Files
ovgate/CLAUDE.md
T
iceBear67andClaude Opus 5 f37cd0a125 Add container build, compose example and deployment docs
Multi-stage Dockerfile on debian:trixie-slim. openvpn3 and lwIP are cloned
at pinned refs and handed to CMake through OVG_OPENVPN3_DIR/OVG_LWIP_DIR
rather than left to FetchContent, whose GIT_TAG master would make the same
Dockerfile build a different VPN client each week. The unit suite runs in
the builder stage.

docker/openvpngate.conf overrides only the keys whose host default is wrong
inside a container -- loopback listen addresses, which make a published port
reach nothing, and relative state paths, which put the node failure history
on a layer that gets thrown away. Everything else stays absent and takes the
compiled-in default so the file cannot drift from the code.

The compose example drops every capability, runs read-only as uid 10001 and
publishes both ports to host loopback: the admin endpoint has no auth and
includes POST /switch. That configuration is the design constraint of this
project (no root, no tun device) turned into something testable.

docs/DOCKER.md 8 records what was checked against the source and what was
not: this sandbox has no docker daemon, so neither the image build nor the
compose file has actually been run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 05:54:44 +00:00

8.6 KiB
Raw Blame History

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:

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 donenetstack 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.

./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

./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

There is a container build too (Dockerfile, docker-compose.yml, docs/DOCKER.md). Two things about it are load-bearing: it clones openvpn3/lwIP at pinned refs and passes them via OVG_OPENVPN3_DIR/OVG_LWIP_DIR, because FetchContent's GIT_TAG master would otherwise make the image a different VPN client every week; and docker/openvpngate.conf overrides the two host defaults that are silently wrong in a container — loopback listen addresses (a published port then reaches nothing) and relative state paths (the node history dies with the container).

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.