Add CLAUDE.md

Distils the things that took a debugging session to learn and cannot be
read off a single file: the two-build-tree workflow, the one-way module
dependency chain, the invariants around the single lwIP stack, and the
traps (asio error categories, self-re-arming io work blocking shutdown,
slice-driven tests being blind to that class of bug).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
iceBear67
2026-07-28 05:29:10 +00:00
co-authored by Claude Opus 5
parent b2ba45c9f8
commit 5782207744
+135
View File
@@ -0,0 +1,135 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
A userspace VPN gateway: OpenVPN 3 core builds a tunnel to a VPNGate node, lwIP terminates it
in-process, and a SOCKS5 server (RFC 1928/1929) serves traffic over it. **No root, no tun device,
no routing table changes** — that constraint is why the netstack exists at all, and it is not
negotiable.
`docs/FEASIBILITY.md` and `docs/ARCHITECTURE.md` (both Chinese) are the design record. Read
FEASIBILITY §1 before touching anything switch-related: carrying established TCP connections
across a node switch is physically impossible, and the code is shaped around that fact.
## Build and test
Two build trees by convention — the same source with the tunnel egress off and on:
```sh
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DOVG_WITH_TUNNEL=OFF
cmake -S . -B build-tunnel -DCMAKE_BUILD_TYPE=Release -DOVG_WITH_TUNNEL=ON
cmake --build build -j8 && cmake --build build-tunnel -j8
```
`OFF` compiles in seconds and is the right loop for everything except netstack/ovpn work; `ON`
drags in openvpn3 and lwIP. **Run both suites before declaring anything done**`netstack` tests
only exist in the tunnel build, and app wiring is `#if OVG_WITH_TUNNEL`'d in places.
openvpn3 and lwIP are source dependencies. This working copy points at `/tmp/ovpn3` and `/tmp/lwip`
via `OVG_OPENVPN3_DIR` / `OVG_LWIP_DIR`; if `/tmp` was cleared, re-configure without those and
FetchContent re-downloads.
```sh
./build/tests/ovg_tests # all
./build/tests/ovg_tests manager_walks # substring filter on the test name — this is the only filter
ctest --test-dir build # wraps the whole binary as one test
```
Tests are `OVG_TEST(name) { ... }` with `CHECK`/`CHECK_EQ`/`SKIP(reason)` from `tests/harness.h`.
No gtest. Expected: 155 passed / 2 skipped (no tunnel), 172 passed / 3 skipped (tunnel).
There is no linter or formatter config. Match the surrounding style: comments explain *why*, and
several load-bearing ones exist specifically to stop a future reader "simplifying" a subtlety back
out. Don't delete them to make a diff tidy.
## Architecture: the parts that span files
Dependency direction is strictly one-way and enforced by review, not by tooling:
`app → {health, socks5, egress, selector} → {netstack, ovpn, vpngate} → common`.
`socks5` must never learn that `ovpn`/`netstack` exist — it only holds an `Egress`. `netstack`
must never learn `ovpn` exists — it only gets an fd.
**`egress::Egress` is the seam.** `TunnelEgress` and `DirectEgress` implement it; everything above
is written against the interface, which is what makes `egress_mode = direct` (host sockets, no VPN)
a usable test path and what makes hot-swapping a tunnel possible at all.
**Refcount is the drain.** `shared_ptr<Egress>::use_count()` *is* the live session count. A replaced
egress is kept alive by the sessions still on it and disappears when the last one lets go. There is
no separate session registry to keep consistent — don't add one, and don't stash an `EgressPtr`
anywhere that outlives a session (a `promotions` vector in a test that holds egresses instead of
labels will hang the drain forever).
**Switching is make-before-break** (`EgressManager`, `SwitchController`): the new tunnel is fully up
before the old one is replaced. New sessions land on the new egress; zero-progress TCP sessions are
re-dialled transparently; UDP associations are re-homed in place; everything else drains under
`switch.drain_grace` and is then closed. The promote hook fans out in `app/`, **socks5 first** (it
re-homes while the old egress is still whole), then the switch controller.
**lwIP has one Stack per process** (`g_stack_live` throws otherwise — its state is in globals) and
every lwIP call happens on `Stack::strand_`. Public `TcpStream`/`UdpSocket` methods post themselves
there, so callers may use them from any thread; completions post back to the caller's executor.
Objects a raw lwIP callback can still reach are destroyed through `common/strand_deleter.h`, not by
the last `shared_ptr` dropping.
**Selection is two-phase** (`selector/`): a cheap prior over the whole ~95-node list, then real TCP
handshake timing of only the top K. Probing needs a TCP remote — a UDP "connect" completes locally
and measures nothing — and VPNGate is mostly UDP-only, so a top-10 routinely yields 34 real probe
targets. That is expected, and the selector logs it so it doesn't read as a broken filter.
**Health probing must stay a TCP handshake** through the egress (`health/`). A DNS lookup can be
answered from cache without a byte crossing the tunnel, which reports a dead tunnel as the healthiest
node in the fleet. Scores drop terms whose inputs are unavailable and renormalise the remaining
weights: *missing signal ≠ bad signal*.
## Traps this codebase has already been bitten by
- **`ec == std::errc::connection_refused` is false for asio errors.** They live in the `asio.system`
category, whose `default_error_condition` doesn't map to `std::errc`. Normalise through
`DirectEgress::map_ec` (and the errno fallback in `socks5_reply_for`) rather than comparing raw.
- **Anything that re-arms on the io_context prevents shutdown.** Dropping the work guard cannot
retire pending work. The lwIP timer (`Stack::stop()`) and the re-armed `signal_set`
(`signals_.cancel()`) both had to be shut down explicitly; without them the process logged a
flawless graceful shutdown and then hung forever. If you add a self-rescheduling timer, add its
teardown to `App::begin_shutdown` in the same commit.
- **The test suite is structurally blind to that class of bug**: tests drive the loop in
`io.run_for(2ms)` slices, while the service calls `run()` once and waits for it to return. See
`stack_stop_lets_the_io_context_drain` for the shape of a test that actually checks it.
- **A cold start with no directory yet is not a switch failure.** Charging it to the backoff ladder
cost 30s of dead service on every start and could exhaust the startup budget. `Selector::directory_loaded()`
distinguishes "nothing has arrived yet" from "everything was rejected"; the former uses
`retry_startup`, which touches neither backoff nor failure counters.
- **Phase transitions must be claimed atomically.** `claim_switch_phase()` exists because releasing
the phase during a retry let the tunnel-down watchdog and the startup poll both start selections
and build two tunnels for one slot.
- **Never `cat`/`head` the VPNGate CSV.** Lines run to ~13.5 KB because the whole .ovpn profile is
base64 in the last column. Decode field 15 in a script and print only what you need.
## This sandbox
Outbound TCP is transparently intercepted: connects to unroutable addresses (e.g. `192.0.2.1:1213`)
"succeed" in ~0.9 ms and then return nothing. Consequences, all environmental rather than bugs —
do not go chasing them in the code:
- every latency probe reports ~1 ms, so selection here is effectively arbitrary;
- OpenVPN handshakes die with `NETWORK_EOF_ERROR`, so the tunnel **data plane cannot be verified
here**. The control path (selection, connect attempts, reconnect, graceful shutdown) can be, and is;
- three tests `SKIP` for exactly this reason;
- a local OpenVPN server is not an option either: no `/dev/net/tun`, `TUNSETIFF` gives EPERM, no
kernel modules.
`tools/tunnel_smoke.cpp` (built as `ovg_tunnel_smoke`, meaningful only in the tunnel build) brings up
one tunnel, pings through it, and exits — the first thing to run on a machine with real network access.
## Running it
```sh
./build/src/openvpngate -c etc/openvpngate.conf --check # validate + print config, don't start
./build/src/openvpngate -c etc/openvpngate.conf --egress direct --listen 127.0.0.1:1080
```
`etc/openvpngate.conf` documents every key with its default and the reasoning. Admin HTTP (default
`127.0.0.1:9080`, **no auth**) exposes `/status /nodes /sessions /health /metrics /healthz` and
`POST /switch`; `/nodes` explains each node's score, which is the fastest way to understand a
selection decision. `SIGHUP` reloads credentials and the node list without dropping sessions.