Files
iceBear67andClaude Opus 5 40b47a8f66 CI: build and publish the container image to GHCR
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>
2026-07-28 06:04:51 +00:00

147 lines
8.9 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```
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.