publish image / image (push) Canceled after 0s
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>
147 lines
8.9 KiB
Markdown
147 lines
8.9 KiB
Markdown
# 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 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_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.
|