The Dockerfile's builder stage already runs the unit suite, so the workflow deliberately has no separate test job -- a red test cannot produce an image. After pushing, the published artifact is smoke-tested by digest: `--version` covers a runtime stage missing a shared library, and `--check` against a throwaway credentials file covers the config baked into the image. Both were failure modes a green build would not have caught. The `--check` invocation is verified locally against docker/openvpngate.conf. `latest` follows the newest v* tag rather than the branch head; master head is published as `master`. Registry paths are lowercased explicitly rather than relying on metadata-action, since the same value is reused for the smoke test. GHCR_TOKEN / GHCR_USER / GHCR_IMAGE override the built-ins so this still works from a mirror or a Gitea/Forgejo runner, where the ambient token authenticates to the wrong registry. On github.com none of them need to be set. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8.9 KiB
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 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.
./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 3–4 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_refusedis false for asio errors. They live in theasio.systemcategory, whosedefault_error_conditiondoesn't map tostd::errc. Normalise throughDirectEgress::map_ec(and the errno fallback insocks5_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-armedsignal_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 toApp::begin_shutdownin 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 callsrun()once and waits for it to return. Seestack_stop_lets_the_io_context_drainfor 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 usesretry_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/headthe 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
SKIPfor exactly this reason; - a local OpenVPN server is not an option either: no
/dev/net/tun,TUNSETIFFgives 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).
.github/workflows/publish-image.yml publishes that image to GHCR. It has no test job on purpose —
the builder stage runs ovg_tests, so a red test cannot produce an image. If you ever make the
Dockerfile skip the suite by default, CI stops testing anything and nothing will say so.
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.