Compare commits
3
Commits
b2ba45c9f8
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40b47a8f66 | ||
|
|
f37cd0a125 | ||
|
|
5782207744 |
@@ -0,0 +1,36 @@
|
||||
# The build context is COPYied wholesale into the builder stage. Without this
|
||||
# the two local build trees (~1 GB with the openvpn3 objects) would be shipped
|
||||
# to the daemon on every `docker build`, only to be overwritten by the
|
||||
# container's own cmake run.
|
||||
build/
|
||||
build-tunnel/
|
||||
build*/
|
||||
|
||||
# The image builds from the working tree, not from git history.
|
||||
.git/
|
||||
.gitignore
|
||||
.dockerignore
|
||||
|
||||
# CI definitions cannot affect the build, and leaving them in means editing a
|
||||
# workflow invalidates the COPY layer and recompiles openvpn3 for nothing.
|
||||
.github/
|
||||
|
||||
# Runtime state. The node cache and the outcome history belong to whichever
|
||||
# machine produced them; the container gets its own in a volume.
|
||||
var/
|
||||
|
||||
# Local operator config and credentials. The container reads its config from a
|
||||
# mount (see docker-compose.yml), never from something baked into the layer.
|
||||
etc/
|
||||
# Patterns match the whole context-relative path, so a bare *.auth would only
|
||||
# cover the top level -- and the credential file lives in docker/.
|
||||
**/*.auth
|
||||
|
||||
# Editor and OS noise
|
||||
*.swp
|
||||
*~
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
.cache/
|
||||
compile_commands.json
|
||||
@@ -0,0 +1,149 @@
|
||||
# Build the container image and publish it to GitHub Container Registry.
|
||||
#
|
||||
# The unit test suite runs *inside* the image build (the Dockerfile's builder
|
||||
# stage ends with ./build/tests/ovg_tests), so a failing test fails the publish.
|
||||
# There is deliberately no separate test job duplicating that.
|
||||
#
|
||||
# Tags produced:
|
||||
# push to master -> master, sha-<short>
|
||||
# push tag v1.2.3 -> 1.2.3, 1.2, latest, sha-<short>
|
||||
# pull request -> built and smoke-tested, never pushed
|
||||
#
|
||||
# `latest` follows the newest release tag, not the branch head. Until the first
|
||||
# v* tag exists, the tag to pull is `master`.
|
||||
name: publish image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
# Superseding a PR build is free; killing a release build half way through is
|
||||
# not.
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
|
||||
jobs:
|
||||
image:
|
||||
runs-on: ubuntu-latest
|
||||
# A cold build compiles the openvpn3 core and lwIP from source. Configure +
|
||||
# compile alone measured 1m39s on 4 cores (same core count as a
|
||||
# GitHub-hosted runner); on top of that the image build also does an apt
|
||||
# install, two git clones, and the test suite. The ceiling is set well above
|
||||
# any of that because the failure mode it guards against -- a cache miss on
|
||||
# a runner that is also being slow -- is the one where a tight limit turns a
|
||||
# slow build into a red one.
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Registry paths must be lowercase and github.repository is not
|
||||
# guaranteed to be. GHCR_IMAGE overrides the whole owner/name, which is
|
||||
# what you need when this repository does not live on github.com -- see
|
||||
# the login step.
|
||||
- name: Resolve image name
|
||||
id: img
|
||||
run: |
|
||||
printf 'name=%s\n' \
|
||||
"$(printf '%s' "${{ vars.GHCR_IMAGE || github.repository }}" | tr '[:upper:]' '[:lower:]')" \
|
||||
>> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
# Skipped on pull requests: a PR from a fork has no write credentials, and
|
||||
# nothing is pushed from a PR anyway.
|
||||
#
|
||||
# On github.com the built-in GITHUB_TOKEN is enough. Running this from a
|
||||
# mirror or a self-hosted Actions runner (Gitea/Forgejo) means that token
|
||||
# authenticates to the wrong registry, so set GHCR_TOKEN to a GitHub PAT
|
||||
# with write:packages, and GHCR_USER/GHCR_IMAGE if the account name there
|
||||
# differs from the one here.
|
||||
- name: Log in to ghcr.io
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ vars.GHCR_USER || github.actor }}
|
||||
password: ${{ secrets.GHCR_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Derive tags and labels
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ steps.img.outputs.name }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha
|
||||
# The default flavor (latest=auto) adds `latest` on a semver tag push
|
||||
# and nowhere else, which is the intent stated at the top of the file.
|
||||
|
||||
- name: Build and push
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
annotations: ${{ steps.meta.outputs.annotations }}
|
||||
# amd64 only, matching the project's stated target. arm64 is not known
|
||||
# to be broken -- it is untested, and cross-building the openvpn3 core
|
||||
# under QEMU costs the better part of an hour per run. Enabling it
|
||||
# means adding docker/setup-qemu-action above and verifying lwIP's
|
||||
# unaligned-access assumptions, not just editing this line.
|
||||
platforms: linux/amd64
|
||||
build-args: |
|
||||
OVG_WITH_TUNNEL=ON
|
||||
OVG_RUN_TESTS=1
|
||||
# Without this every run recompiles openvpn3 from scratch.
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
|
||||
# Verifies the artifact that was actually published, by digest rather than
|
||||
# by tag. Cheap, and it covers the two things a green build still would
|
||||
# not: that the runtime stage carries the shared libraries the binary
|
||||
# needs, and that the config baked into the image parses.
|
||||
#
|
||||
# --check needs the credentials file, which is deliberately not in the
|
||||
# image; a throwaway one is enough to get the config validated.
|
||||
- name: Smoke test the published image
|
||||
if: github.event_name != 'pull_request'
|
||||
env:
|
||||
IMAGE: ${{ env.REGISTRY }}/${{ steps.img.outputs.name }}@${{ steps.build.outputs.digest }}
|
||||
run: |
|
||||
set -eux
|
||||
docker run --rm "$IMAGE" --version
|
||||
printf 'ci:changeme\n' > "$RUNNER_TEMP/socks5.auth"
|
||||
docker run --rm \
|
||||
-v "$RUNNER_TEMP/socks5.auth:/etc/openvpngate/socks5.auth:ro" \
|
||||
"$IMAGE" -c /etc/openvpngate/openvpngate.conf --check
|
||||
|
||||
- name: Summary
|
||||
if: github.event_name != 'pull_request'
|
||||
run: |
|
||||
{
|
||||
echo "### Published"
|
||||
echo
|
||||
echo '```'
|
||||
echo "${{ steps.meta.outputs.tags }}"
|
||||
echo '```'
|
||||
echo
|
||||
echo "digest: \`${{ steps.build.outputs.digest }}\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -0,0 +1,146 @@
|
||||
# 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.
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
#
|
||||
# openvpngate as a container.
|
||||
#
|
||||
# The interesting property of this image is what it does *not* need: no
|
||||
# --privileged, no --cap-add NET_ADMIN, no --device /dev/net/tun, no
|
||||
# --sysctl. The tunnel is terminated in userspace by lwIP, so from the kernel's
|
||||
# point of view this is an ordinary unprivileged process that listens on a
|
||||
# socket. docker-compose.yml drops every capability to make that testable
|
||||
# rather than merely claimed.
|
||||
#
|
||||
# See docs/DOCKER.md for the container-specific configuration traps -- the
|
||||
# defaults in etc/openvpngate.conf are written for a host, and two of them
|
||||
# (loopback listen addresses, relative state paths) are actively wrong here.
|
||||
|
||||
# Pinned rather than :latest, and pinned to *trixie* specifically: the runtime
|
||||
# package names below are release-specific (bookworm ships libssl3 and libfmt9,
|
||||
# trixie ships libssl3t64 and libfmt10). Moving this tag means re-checking them.
|
||||
ARG DEBIAN_TAG=trixie-slim
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 1 -- build
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM debian:${DEBIAN_TAG} AS build
|
||||
|
||||
# openvpn3 and lwIP are source dependencies. Left to itself, CMake's
|
||||
# FetchContent pulls openvpn3 at GIT_TAG master, which makes this image
|
||||
# un-reproducible: the same Dockerfile and the same commit of this repo would
|
||||
# build a different VPN client next week. So clone them here at pinned refs and
|
||||
# hand the checkouts to CMake through OVG_OPENVPN3_DIR / OVG_LWIP_DIR, the knob
|
||||
# it already has for a pre-existing checkout.
|
||||
#
|
||||
# The openvpn3 default is the commit this tree was developed and tested against.
|
||||
# Bump it deliberately, and re-run the suite when you do.
|
||||
ARG OPENVPN3_REF=1512c16622288f3c01da09d3278ac61a86dca26d
|
||||
ARG LWIP_REF=STABLE-2_2_1_RELEASE
|
||||
|
||||
# OFF builds without openvpn3/lwIP: `direct` egress only, host sockets, no VPN.
|
||||
# Useful for testing the SOCKS5 layer in isolation, useless as a gateway -- and
|
||||
# the binary refuses to start in tunnel mode rather than proxying in the clear.
|
||||
ARG OVG_WITH_TUNNEL=ON
|
||||
|
||||
# The unit tests are the only thing between "it compiled" and "it works", and
|
||||
# they cost seconds against a build measured in minutes. Turn off with
|
||||
# --build-arg OVG_RUN_TESTS=0 on a builder without loopback networking: a dozen
|
||||
# tests bind and connect on 127.0.0.1.
|
||||
ARG OVG_RUN_TESTS=1
|
||||
|
||||
ARG BUILD_JOBS=
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
cmake \
|
||||
pkg-config \
|
||||
git \
|
||||
ca-certificates \
|
||||
libasio-dev \
|
||||
libssl-dev \
|
||||
liblz4-dev \
|
||||
libfmt-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Fetched before the source is copied, so editing a .cpp does not re-clone the
|
||||
# dependencies. --filter=blob:none keeps the clone small while still allowing a
|
||||
# checkout of an arbitrary ref -- which a --depth 1 clone cannot do for a bare
|
||||
# commit id, and the openvpn3 pin above is one.
|
||||
WORKDIR /deps
|
||||
RUN git clone --filter=blob:none --no-checkout \
|
||||
https://github.com/OpenVPN/openvpn3.git openvpn3 \
|
||||
&& git -C openvpn3 checkout --detach "${OPENVPN3_REF}" \
|
||||
&& git clone --filter=blob:none --no-checkout \
|
||||
https://github.com/lwip-tcpip/lwip.git lwip \
|
||||
&& git -C lwip checkout --detach "${LWIP_REF}"
|
||||
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
|
||||
RUN cmake -S . -B build \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DOVG_WITH_TUNNEL="${OVG_WITH_TUNNEL}" \
|
||||
-DOVG_BUILD_TESTS=ON \
|
||||
-DOVG_OPENVPN3_DIR=/deps/openvpn3 \
|
||||
-DOVG_LWIP_DIR=/deps/lwip \
|
||||
&& cmake --build build -j"${BUILD_JOBS:-$(nproc)}"
|
||||
|
||||
RUN if [ "${OVG_RUN_TESTS}" = "1" ]; then ./build/tests/ovg_tests; fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 2 -- runtime
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM debian:${DEBIAN_TAG} AS runtime
|
||||
|
||||
LABEL org.opencontainers.image.title="openvpngate" \
|
||||
org.opencontainers.image.description="OpenVPN client with an authenticated SOCKS5 front door, terminated in userspace" \
|
||||
org.opencontainers.image.version="0.1.0" \
|
||||
org.opencontainers.image.licenses="NOASSERTION"
|
||||
|
||||
# Release-specific names -- see the DEBIAN_TAG comment at the top.
|
||||
# curl is here only for the HEALTHCHECK below; drop both together if you run
|
||||
# with admin.enabled = false.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libssl3t64 \
|
||||
liblz4-1 \
|
||||
libfmt10 \
|
||||
ca-certificates \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# A fixed uid, not a distro-assigned one: a bind-mounted state directory has to
|
||||
# be chown'd to a number the host knows in advance.
|
||||
RUN groupadd --system --gid 10001 ovg \
|
||||
&& useradd --system --uid 10001 --gid 10001 \
|
||||
--home-dir /var/lib/openvpngate --shell /usr/sbin/nologin ovg
|
||||
|
||||
COPY --from=build /src/build/src/openvpngate /usr/local/bin/openvpngate
|
||||
# Brings up one tunnel, pings through it, exits. The first thing worth running
|
||||
# on a host with real network access, and the fastest way to tell "the image is
|
||||
# broken" from "this network cannot reach VPNGate".
|
||||
COPY --from=build /src/build/src/ovg_tunnel_smoke /usr/local/bin/ovg_tunnel_smoke
|
||||
|
||||
# Baked in so `docker run` alone works; docker-compose.yml mounts over it so the
|
||||
# config can be edited without a rebuild.
|
||||
COPY docker/openvpngate.conf /etc/openvpngate/openvpngate.conf
|
||||
|
||||
# The one writable path the service needs: the node cache and the per-node
|
||||
# outcome history. An empty named volume mounted here inherits this ownership,
|
||||
# which is what makes `read_only: true` on the rest of the rootfs work.
|
||||
RUN install -d -o ovg -g ovg -m 0750 /var/lib/openvpngate
|
||||
|
||||
# So that a relative path in a user-supplied config resolves somewhere writable
|
||||
# instead of failing at the first cache write.
|
||||
WORKDIR /var/lib/openvpngate
|
||||
|
||||
# 1080 SOCKS5, 9080 admin. The admin endpoint has NO authentication; publish it
|
||||
# to host loopback or not at all.
|
||||
EXPOSE 1080 9080
|
||||
|
||||
USER ovg
|
||||
|
||||
# Liveness only -- /healthz answers 200 as soon as the admin server is up and
|
||||
# says nothing about whether a tunnel is established. Tunnel state is
|
||||
# GET /status, and the service already reacts to a degraded tunnel by switching
|
||||
# nodes on its own; see docs/DOCKER.md before wiring restart-on-unhealthy.
|
||||
# start-period covers a cold start: fetch the directory, probe candidates,
|
||||
# negotiate with a volunteer-run server on the other side of the world.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=3 \
|
||||
CMD curl -fsS http://127.0.0.1:9080/healthz || exit 1
|
||||
|
||||
# Explicit because the second one means something different: SIGTERM starts a
|
||||
# graceful shutdown, a second SIGTERM/SIGINT during it stops immediately. The
|
||||
# binary runs as pid 1 (exec form, no shell wrapper), so it receives them.
|
||||
STOPSIGNAL SIGTERM
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/openvpngate"]
|
||||
CMD ["-c", "/etc/openvpngate/openvpngate.conf"]
|
||||
@@ -18,6 +18,7 @@ SOCKS5 客户端 ──► socks5::Server ──► egress::Egress ──► lwI
|
||||
| [docs/FEASIBILITY.md](docs/FEASIBILITY.md) | 动手前的可行性结论。**需求中唯一不可能的部分在 §1**;UDP ASSOCIATE 的明确表态在 §5;1000 并发的真实天花板在 §4 |
|
||||
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | 模块边界、线程模型、切换状态机、选点与健康检查的具体算法 |
|
||||
| [etc/openvpngate.conf](etc/openvpngate.conf) | 全部配置项,每一项都带默认值和「为什么是这个默认值」 |
|
||||
| [docs/DOCKER.md](docs/DOCKER.md) | 容器部署。**容器里有三处宿主机默认值是错的**(§3);`cap_drop: ALL` 为什么能成立(§5);GHCR 发布与 CI(§9) |
|
||||
|
||||
---
|
||||
|
||||
@@ -125,6 +126,20 @@ curl -sS --socks5-hostname alice:changeme@127.0.0.1:1080 https://example.com -o
|
||||
`--socks5-hostname` 让 curl 把域名交给代理解析(DNS 不泄漏路径);`--socks5` 则是本地解析。
|
||||
两条路径都支持。
|
||||
|
||||
### 用容器跑
|
||||
|
||||
```sh
|
||||
cp docker/socks5.auth.example docker/socks5.auth # 改口令
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
镜像以 uid 10001、`cap_drop: ALL`、只读 rootfs 运行——不需要 `NET_ADMIN`,不需要
|
||||
`/dev/net/tun`,这正是用户态终结隧道换来的东西,而容器让这句话第一次可以当场验证。
|
||||
|
||||
**但宿主机的默认配置在容器里有两处是静默错误的**(监听环回口、状态文件用相对路径),
|
||||
所以镜像自带一份 `docker/openvpngate.conf` 覆盖它们。细节、暴露面取舍、以及这套编排
|
||||
**哪些部分没有被真正跑过**,见 [docs/DOCKER.md](docs/DOCKER.md)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 管理接口
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
# Example deployment. See docs/DOCKER.md for what each block is protecting
|
||||
# against; the short version is that a userspace VPN client should be able to
|
||||
# run with every capability dropped, and this file is where that gets proven
|
||||
# rather than asserted.
|
||||
#
|
||||
# cp docker/socks5.auth.example docker/socks5.auth # then edit it
|
||||
# docker compose up -d --build
|
||||
# curl -x socks5h://alice:...@127.0.0.1:1080 https://ifconfig.me
|
||||
#
|
||||
name: openvpngate
|
||||
|
||||
services:
|
||||
openvpngate:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
# OFF drops openvpn3 and lwIP: `direct` egress only, no VPN. Builds in
|
||||
# seconds, useful for exercising the SOCKS5 layer, useless as a gateway.
|
||||
OVG_WITH_TUNNEL: "ON"
|
||||
image: openvpngate:0.1.0
|
||||
restart: unless-stopped
|
||||
|
||||
# Both published to host loopback, and that is doing real work in each case:
|
||||
# 1080 the proxy is only as private as who can reach it;
|
||||
# 9080 the admin endpoint has NO authentication and includes POST /switch.
|
||||
# Change these to 0.0.0.0 only after reading docs/DOCKER.md 4.
|
||||
ports:
|
||||
- "127.0.0.1:1080:1080"
|
||||
- "127.0.0.1:9080:9080"
|
||||
|
||||
volumes:
|
||||
# Long syntax with create_host_path: false on purpose. Docker's default is
|
||||
# to silently create a *directory* when a bind mount's source is missing,
|
||||
# which for the auth file means the proxy starts and refuses every login,
|
||||
# and for the config means it starts on defaults that listen on loopback
|
||||
# inside the container and are reachable by nobody. Fail at `up` instead.
|
||||
- type: bind
|
||||
source: ./docker/openvpngate.conf
|
||||
target: /etc/openvpngate/openvpngate.conf
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
|
||||
- type: bind
|
||||
source: ./docker/socks5.auth
|
||||
target: /etc/openvpngate/socks5.auth
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
|
||||
# The node cache and the per-node failure history. Worth persisting for
|
||||
# more than startup speed: the history is the record of which volunteer
|
||||
# nodes have already failed on you, and throwing it away on every `up`
|
||||
# means walking back into the same one.
|
||||
- ovg-state:/var/lib/openvpngate
|
||||
|
||||
# ---- the point of the exercise ----------------------------------------
|
||||
# No NET_ADMIN, no /dev/net/tun, no --privileged, no sysctls, uid 10001.
|
||||
# The tunnel is terminated by lwIP inside the process, so nothing here
|
||||
# needs kernel networking privileges. If a change to this project ever
|
||||
# makes one of these lines necessary, the change is wrong.
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
read_only: true
|
||||
tmpfs:
|
||||
# Nothing is known to need it; it is here so that a library that decides
|
||||
# to spool to /tmp fails loudly at write time rather than at connect time.
|
||||
- /tmp:size=16m,mode=1777
|
||||
|
||||
# SIGTERM starts a graceful shutdown (stop accepting, drain, tear the tunnel
|
||||
# down); it normally completes in well under a second. The window is wide
|
||||
# because the alternative when it does not is SIGKILL in the middle of
|
||||
# writing the node history.
|
||||
stop_grace_period: 30s
|
||||
|
||||
# socks5.max_sessions defaults to 1200 and each session costs a client-side
|
||||
# fd (plus an egress-side one in direct mode). The Docker default is usually
|
||||
# far higher than this, but it is not guaranteed to be.
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 8192
|
||||
hard: 8192
|
||||
|
||||
# A gateway logs one line per session at info. Unrotated json-file logging
|
||||
# is how a container quietly fills a host disk.
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "5"
|
||||
|
||||
# The image's HEALTHCHECK probes admin /healthz. It is liveness only -- it
|
||||
# says the process is answering, not that a tunnel is up. Do NOT wire an
|
||||
# unhealthy-triggers-restart supervisor on top: the service already responds
|
||||
# to a degrading tunnel by switching nodes, and a restart throws away the
|
||||
# drain, the session, and the freshly-learned reason the node was bad.
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Proxy without a VPN, for splitting "is the SOCKS5 implementation correct"
|
||||
# from "is the tunnel up". Egress is a plain host socket -- traffic through
|
||||
# this one is NOT tunnelled, which is why it is behind a profile and on its
|
||||
# own port.
|
||||
#
|
||||
# docker compose --profile test up openvpngate-direct
|
||||
# -------------------------------------------------------------------------
|
||||
openvpngate-direct:
|
||||
profiles: [test]
|
||||
# Same image and same build inputs as above, so this resolves to the one
|
||||
# already built rather than trying to pull it from a registry.
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
OVG_WITH_TUNNEL: "ON"
|
||||
image: openvpngate:0.1.0
|
||||
command:
|
||||
- "-c"
|
||||
- "/etc/openvpngate/openvpngate.conf"
|
||||
- "--egress"
|
||||
- "direct"
|
||||
- "--no-admin"
|
||||
ports:
|
||||
- "127.0.0.1:1081:1080"
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./docker/openvpngate.conf
|
||||
target: /etc/openvpngate/openvpngate.conf
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
- type: bind
|
||||
source: ./docker/socks5.auth
|
||||
target: /etc/openvpngate/socks5.auth
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
# Inherited from the image, but --no-admin means there is nothing listening
|
||||
# on 9080 to answer it; left armed it would report this container unhealthy
|
||||
# forever.
|
||||
healthcheck:
|
||||
disable: true
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=16m,mode=1777
|
||||
# This one still fetches and caches the node directory even though it
|
||||
# never dials a node, so it needs its state directory writable. Throwaway
|
||||
# rather than a volume: nothing produced by a no-VPN test run is worth
|
||||
# keeping, least of all a failure history for nodes it never contacted.
|
||||
- /var/lib/openvpngate:size=64m,mode=1777
|
||||
|
||||
volumes:
|
||||
ovg-state:
|
||||
@@ -0,0 +1,71 @@
|
||||
# openvpngate -- configuration baked into the container image.
|
||||
#
|
||||
# This file sets ONLY the keys whose host default is wrong inside a container.
|
||||
# Everything else is left absent and takes the compiled-in default, so this
|
||||
# file cannot drift from the code the way a copied-and-edited full config does.
|
||||
#
|
||||
# etc/openvpngate.conf in the source tree is the annotated reference: every key,
|
||||
# its default, and why that is the default. Read that one to change behaviour,
|
||||
# then add the key here.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
[socks5]
|
||||
# ---------------------------------------------------------------------------
|
||||
# The default is 127.0.0.1, which inside a container means "reachable from this
|
||||
# container only" -- a published port would connect to nothing. Bind everywhere
|
||||
# and let the container runtime decide who gets to reach it: docker-compose.yml
|
||||
# publishes this to host loopback, not to the LAN.
|
||||
listen_address = 0.0.0.0
|
||||
listen_port = 1080
|
||||
|
||||
require_auth = true
|
||||
|
||||
# Credentials stay out of the image. Mount a file here (docker-compose.yml
|
||||
# does) -- SIGHUP re-reads it without dropping a live session:
|
||||
#
|
||||
# docker compose kill -s HUP openvpngate
|
||||
#
|
||||
# If this path is missing the process exits at startup with
|
||||
# "config: cannot open auth file"; if it is a *directory* -- which is what
|
||||
# Docker silently creates for a bind mount whose source does not exist -- the
|
||||
# file parses as empty and startup warns "every login will be refused". The
|
||||
# compose file uses create_host_path: false so that case fails loudly instead.
|
||||
auth_file = /etc/openvpngate/socks5.auth
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
[vpngate]
|
||||
# ---------------------------------------------------------------------------
|
||||
# Absolute. The default is relative (var/vpngate_cache.csv) and would resolve
|
||||
# against the working directory; inside the image that is /var/lib/openvpngate,
|
||||
# which happens to be right, but only by accident. Say it explicitly, because
|
||||
# this is also the one path that survives a container replacement and the one
|
||||
# directory a read-only rootfs still permits writing to.
|
||||
cache_path = /var/lib/openvpngate/vpngate_cache.csv
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
[selector]
|
||||
# ---------------------------------------------------------------------------
|
||||
# Same reasoning. Worth persisting for a different reason though: this is the
|
||||
# record of which nodes have failed on you, and losing it on every `up` means
|
||||
# walking back into the same broken node with a flattering API score.
|
||||
history_path = /var/lib/openvpngate/node_history.tsv
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
[admin]
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bound everywhere for the same reason as socks5 above -- but this endpoint has
|
||||
# NO authentication and includes POST /switch, so whoever can reach it can force
|
||||
# your gateway onto another node. It is protected here by *publishing* rules,
|
||||
# not by the bind address: keep it on 127.0.0.1 on the host side, or set
|
||||
# enabled = false and drop the HEALTHCHECK from the Dockerfile, which probes it.
|
||||
enabled = true
|
||||
listen_address = 0.0.0.0
|
||||
listen_port = 9080
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
[log]
|
||||
# ---------------------------------------------------------------------------
|
||||
# stderr, so that `docker logs` and the compose log driver see it. Logging to a
|
||||
# file inside a container puts the record on the layer that gets thrown away.
|
||||
level = info
|
||||
file = -
|
||||
@@ -0,0 +1,32 @@
|
||||
# SOCKS5 credentials, one per line: <user>:<secret>
|
||||
#
|
||||
# cp docker/socks5.auth.example docker/socks5.auth
|
||||
# $EDITOR docker/socks5.auth
|
||||
#
|
||||
# docker-compose.yml mounts docker/socks5.auth read-only into the container.
|
||||
# Reloaded on SIGHUP without dropping a live session:
|
||||
#
|
||||
# docker compose kill -s HUP openvpngate
|
||||
#
|
||||
# Two accepted forms.
|
||||
#
|
||||
# 1. Plaintext. Hashed with a random salt when the file is read, so it is never
|
||||
# held in memory in the clear -- but it is sitting in the clear right here,
|
||||
# which is the part that matters on a shared host.
|
||||
#
|
||||
# alice:changeme
|
||||
#
|
||||
# 2. Pre-hashed: sha256$<salt_hex>$<sha256_hex(salt_hex + password)>. Note the
|
||||
# salt is concatenated as its *hex text*, not as raw bytes. Generate one:
|
||||
#
|
||||
# salt=$(openssl rand -hex 16)
|
||||
# printf 'alice:sha256$%s$%s\n' "$salt" \
|
||||
# "$(printf '%s' "${salt}${PASSWORD}" | sha256sum | cut -d' ' -f1)"
|
||||
#
|
||||
# Lines starting with '#' and blank lines are ignored. A malformed line is a
|
||||
# hard startup error, not a skipped entry: half-loaded credentials are worse
|
||||
# than none.
|
||||
#
|
||||
# The entry below is an example and will be rejected by anyone paying
|
||||
# attention. Replace it.
|
||||
alice:changeme
|
||||
+298
@@ -0,0 +1,298 @@
|
||||
# 容器部署
|
||||
|
||||
这份文档存在的理由有两个,都不是「怎么打个包」:
|
||||
|
||||
1. **`cap_drop: ALL` 在这里是可以成立的。** 全项目最核心的约束是「不需要 root、不需要 tun
|
||||
设备、不需要改路由表」(README 开头那句),在宿主机上这句话只能靠读代码相信;在容器里
|
||||
它变成一条可以当场验证的断言——把所有 capability 丢掉、rootfs 只读、非 root uid,服务
|
||||
照常工作。`docker-compose.yml` 里那几行安全选项不是装饰,是这个设计的验收条件。
|
||||
2. **宿主机的默认配置在容器里有两处是错的**,而且都是「静默地错」:监听环回口会让发布的
|
||||
端口连不到任何东西,相对路径的状态文件会写到一个随容器一起消失的层里。
|
||||
|
||||
先读哪一份:镜像与编排的**为什么**在这里,全部配置项的含义在
|
||||
[etc/openvpngate.conf](../etc/openvpngate.conf),设计本身在
|
||||
[ARCHITECTURE.md](ARCHITECTURE.md)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 快速开始
|
||||
|
||||
```sh
|
||||
cp docker/socks5.auth.example docker/socks5.auth
|
||||
$EDITOR docker/socks5.auth # 换掉 alice:changeme
|
||||
|
||||
docker compose up -d --build
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
冷启动要花几十秒,这是正常的:抓 1.3 MB 的节点目录 → 对候选节点做真实 TCP 握手计时 →
|
||||
和地球另一端一台志愿者跑的服务器完成 OpenVPN 握手。镜像里 `HEALTHCHECK` 的
|
||||
`--start-period=90s` 就是按这个量级给的。日志里出现 `egress ready on <节点>` 才算真正可用。
|
||||
|
||||
验证:
|
||||
|
||||
```sh
|
||||
curl -x socks5h://alice:口令@127.0.0.1:1080 https://ifconfig.me
|
||||
```
|
||||
|
||||
`socks5h` 让 curl 把域名交给代理去解析(DNS 走隧道,不泄漏);`socks5` 是本地解析后只把
|
||||
IP 交过来。两条路径都支持,但只有前者是你部署 VPN 网关想要的那条。
|
||||
|
||||
不想在本机编译 openvpn3 的话,[§9](#9-ci-与-ghcr-发布) 说了怎么改成拉现成镜像。
|
||||
|
||||
---
|
||||
|
||||
## 2. 镜像里有什么
|
||||
|
||||
| 路径 | 内容 |
|
||||
|---|---|
|
||||
| `/usr/local/bin/openvpngate` | 主程序,以 uid/gid `10001` 运行,ENTRYPOINT |
|
||||
| `/usr/local/bin/ovg_tunnel_smoke` | 只建一条隧道、ping、退出的诊断工具。**接手一台新机器后第一个该跑的东西**:它能把「镜像坏了」和「这个网络到不了 VPNGate」分开 |
|
||||
| `/etc/openvpngate/openvpngate.conf` | 容器默认配置,compose 会挂载覆盖它 |
|
||||
| `/var/lib/openvpngate` | 唯一需要可写的目录:节点缓存 + 节点历史。工作目录也是这里 |
|
||||
|
||||
构建参数:
|
||||
|
||||
| `--build-arg` | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `DEBIAN_TAG` | `trixie-slim` | **改它要同时改运行时包名**:bookworm 是 `libssl3`/`libfmt9`,trixie 是 `libssl3t64`/`libfmt10` |
|
||||
| `OPENVPN3_REF` | `1512c166…` | openvpn3 的固定 commit |
|
||||
| `LWIP_REF` | `STABLE-2_2_1_RELEASE` | lwIP 的 tag |
|
||||
| `OVG_WITH_TUNNEL` | `ON` | `OFF` 只编 `direct` 出口,不拉 openvpn3/lwIP,几秒编完;此时二进制拒绝以 tunnel 模式启动 |
|
||||
| `OVG_RUN_TESTS` | `1` | 在构建阶段跑完整单元测试。构建机没有环回网络时关掉(十几个测试要 bind 127.0.0.1) |
|
||||
| `BUILD_JOBS` | `$(nproc)` | 编译并发度 |
|
||||
|
||||
### 为什么要钉住 openvpn3 的 commit
|
||||
|
||||
`cmake/Dependencies.cmake` 里 openvpn3 的 `FetchContent` 用的是 `GIT_TAG master`。放着不管的
|
||||
话,同一个 Dockerfile、同一个本仓库 commit,下周构建出来的是一个不同的 VPN 客户端——这在
|
||||
本地开发里只是有点烦,在镜像里等于没有可复现构建。所以 Dockerfile 自己按固定 ref clone,
|
||||
再通过 `OVG_OPENVPN3_DIR` / `OVG_LWIP_DIR` 交给 CMake,用的是它本来就有的那个开关。
|
||||
|
||||
默认那个 commit 就是本仓库开发和测试时用的那个。升级它是个需要**主动做**的决定,做完请重跑
|
||||
测试套件。
|
||||
|
||||
---
|
||||
|
||||
## 3. 容器里必须改的三处配置
|
||||
|
||||
`docker/openvpngate.conf` 只写了这三类键,其余全部留空走编译内置默认值——这样它不会像
|
||||
「复制一份完整配置再改几行」那样慢慢和代码脱节。
|
||||
|
||||
| 键 | 宿主机默认 | 容器里 | 为什么 |
|
||||
|---|---|---|---|
|
||||
| `socks5.listen_address` | `127.0.0.1` | `0.0.0.0` | 环回口在容器里的意思是「只有本容器能连」,`-p` 发布出去的端口会连到一个没人监听的地址。安全性改由**发布规则**提供:compose 把它发布到宿主机环回口 |
|
||||
| `admin.listen_address` | `127.0.0.1` | `0.0.0.0` | 同上。但这个接口**没有认证**,见 §4 |
|
||||
| `vpngate.cache_path`<br>`selector.history_path` | `var/…`(相对) | `/var/lib/…`(绝对) | 相对路径按工作目录解析,写进容器可写层就随容器一起没了。节点历史尤其不该丢:它是「哪些节点已经坑过你」的记录,丢掉就会顶着一个好看的 API 分数再走进同一个坑 |
|
||||
| `socks5.auth_file` | 未设置 | `/etc/openvpngate/socks5.auth` | 凭据不进镜像层。挂载进来,`SIGHUP` 热重载 |
|
||||
|
||||
### 凭据文件那个 Docker 陷阱
|
||||
|
||||
bind mount 的源文件不存在时,Docker 的默认行为是**在宿主机上建一个同名目录**。之后:
|
||||
|
||||
- 配置文件被挂成目录 → 服务读到空配置,用内置默认值启动,监听容器内环回口,谁也连不上;
|
||||
- 认证文件被挂成目录 → 解析出零条凭据,启动时打印
|
||||
`warning: socks5.require_auth is on but no credentials are configured; every login will be refused`,
|
||||
然后拒绝所有人。
|
||||
|
||||
所以 `docker-compose.yml` 里这两个挂载用的是长语法加 `create_host_path: false`:宁可在
|
||||
`up` 的时候直接失败。而如果 `auth_file` 指向的路径**完全不存在**,进程会在启动时以
|
||||
`config: cannot open auth file: …` 退出——这是好事,是快速失败。
|
||||
|
||||
---
|
||||
|
||||
## 4. 端口与暴露面
|
||||
|
||||
| 端口 | 内容 | 建议 |
|
||||
|---|---|---|
|
||||
| 1080 | SOCKS5,需要用户名/口令 | 发布到宿主机环回口,或者放进一个只有客户端在的 docker 网络 |
|
||||
| 9080 | 管理 HTTP | **没有任何认证**,而且包含 `POST /switch` |
|
||||
|
||||
把 9080 发布到 `0.0.0.0` 意味着:能访问到它的人可以读到你在线会话的列表,也可以随时强制
|
||||
你的网关换节点(换节点会掐掉所有已经传过字节的连接)。这不是理论风险,是一个 HTTP POST。
|
||||
|
||||
不想要它的话,三处一起改:`docker/openvpngate.conf` 里 `admin.enabled = false`、命令行加
|
||||
`--no-admin`、并把 Dockerfile 里的 `HEALTHCHECK` 去掉(它探的就是 `/healthz`)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 权限:验证那句「不需要 root」
|
||||
|
||||
compose 里这几行是断言,也是测试:
|
||||
|
||||
```yaml
|
||||
cap_drop: [ALL]
|
||||
security_opt: [no-new-privileges:true]
|
||||
read_only: true
|
||||
```
|
||||
|
||||
跑起来之后自己查:
|
||||
|
||||
```sh
|
||||
docker compose exec openvpngate cat /proc/self/status | grep -E 'Cap(Eff|Prm)|^Uid'
|
||||
# Uid: 10001 10001 10001 10001
|
||||
# CapPrm: 0000000000000000
|
||||
# CapEff: 0000000000000000
|
||||
|
||||
docker compose exec openvpngate ls /dev/net/tun # No such file or directory
|
||||
```
|
||||
|
||||
零 capability、非 root、没有 tun 设备,SOCKS5 照常出流量。对照绝大多数 VPN 客户端容器
|
||||
需要的 `--cap-add NET_ADMIN --device /dev/net/tun`——省掉它们的代价是进程内自带了一个
|
||||
用户态 TCP/IP 栈,这笔账 [FEASIBILITY.md](FEASIBILITY.md) §2 算过。
|
||||
|
||||
**如果哪天某个改动让上面任何一行不得不放开,那个改动是错的。**
|
||||
|
||||
---
|
||||
|
||||
## 6. 日常运维
|
||||
|
||||
```sh
|
||||
# 换了凭据 / 想立刻刷新节点列表;不断开任何在途连接
|
||||
docker compose kill -s HUP openvpngate
|
||||
|
||||
# 手动换节点(被拒绝时返回具体原因,不是一句 false)
|
||||
curl -s -XPOST 127.0.0.1:9080/switch
|
||||
|
||||
# 为什么选了这个节点
|
||||
curl -s 127.0.0.1:9080/nodes | jq
|
||||
curl -s 127.0.0.1:9080/status | jq
|
||||
```
|
||||
|
||||
**退出语义。** `docker stop` 发 SIGTERM:停止 accept → 排空 → 拆隧道 → 退出,正常在一秒内
|
||||
完成。宽限期给到 30s 不是因为它慢,而是因为超时之后 Docker 发的是 SIGKILL,而那有可能落在
|
||||
写节点历史的中间。容器里手动再发一次 SIGTERM 会立即退出(第二次信号的语义就是「别排空了」)。
|
||||
|
||||
**healthcheck 是存活探针,不是就绪探针。** `/healthz` 只要管理服务在监听就回 200,它不表示
|
||||
隧道是通的;隧道状态在 `/status`。特别地:**不要**在它上面接「unhealthy 就重启容器」的
|
||||
监工。节点变差时服务自己会换节点,重启会把排空、在线会话、以及刚刚学到的「这个节点不行」
|
||||
一起扔掉——恰好是在它已经在正确处理问题的时候。
|
||||
|
||||
**日志**走 stderr,由 compose 的 json-file driver 收,配了 10 MB × 5 的轮转。一个繁忙的网关
|
||||
每条会话至少一行 info,不轮转就是在慢慢填满宿主机磁盘。
|
||||
|
||||
---
|
||||
|
||||
## 7. 卷与文件属主
|
||||
|
||||
状态目录用的是命名卷 `ovg-state`。空的命名卷会继承镜像里该目录的属主(Dockerfile 里
|
||||
`install -d -o ovg -g ovg`),所以开箱即用。
|
||||
|
||||
换成 bind mount 就没这个待遇,宿主机目录的属主说了算:
|
||||
|
||||
```sh
|
||||
mkdir -p ./state && sudo chown 10001:10001 ./state
|
||||
```
|
||||
|
||||
uid 写死成 10001 而不是让发行版随便分配,就是为了这条命令里能有一个提前知道的数字。
|
||||
|
||||
`read_only: true` 之下,容器里唯一可写的是这个卷和 `/tmp`(16 MB tmpfs)。目前没有已知的
|
||||
东西需要 `/tmp`,它在那里是为了让某个决定往 `/tmp` 写东西的库在写的时候就报错,而不是在
|
||||
连接的时候才莫名其妙失败。
|
||||
|
||||
---
|
||||
|
||||
## 8. 这份编排验证到了什么、没验证到什么
|
||||
|
||||
延续 README §6 的规矩:**没跑过就是没跑过。**
|
||||
|
||||
开发这套文件的沙箱里**没有 docker daemon**,所以 `docker build` 和 `docker compose up`
|
||||
**一次都没有真正执行过**。下面把「照着源码核对过的」和「没跑过的」分开列。
|
||||
|
||||
**已核对(对着本仓库源码或在本机实测)**
|
||||
|
||||
- 构建依赖与运行时共享库:`ldd` 实测二进制只依赖 `liblz4.so.1 / libfmt.so.10 /
|
||||
libssl.so.3 / libcrypto.so.3` 加 libc 三件套,对应的 trixie 包名逐个 `apt-cache policy`
|
||||
查过;
|
||||
- 产物路径 `build/src/openvpngate`、`build/src/ovg_tunnel_smoke`、`build/tests/ovg_tests`;
|
||||
- `OVG_OPENVPN3_DIR` / `OVG_LWIP_DIR` 确实能跳过 FetchContent(`cmake/Dependencies.cmake`),
|
||||
钉住的两个 ref 就是本机构建通过的那两个;
|
||||
- 配置键名、相对路径默认值、`auth_file` 缺失时的失败方式(`config: cannot open auth file`)、
|
||||
空凭据时的警告文案,全部来自 `src/common/config.cpp` 与 `src/app/main.cpp`;
|
||||
- CLI 参数与信号语义来自 `src/app/main.cpp` 的 usage 与 `App::begin_shutdown`;
|
||||
- **预哈希凭据格式是实测的**:用文档里那条 `openssl rand -hex 16` + `sha256sum` 生成一条
|
||||
`sha256$salt$hash`,起 direct 模式代理,正确口令拿到 200,错误口令和不存在的用户都被
|
||||
`auth rejected` 挡下;
|
||||
- 管理路由清单来自 `src/app/admin_server.cpp`;
|
||||
- **`docker/openvpngate.conf` 本身是实测能解析的**:拿它配一个临时凭据文件跑
|
||||
`openvpngate -c docker/openvpngate.conf --check`,输出 `config ok` 且
|
||||
`socks5 0.0.0.0:1080 auth=required users=1`——即 §3 那三处覆盖确实生效了。CI 里对镜像跑的
|
||||
就是同一条命令;
|
||||
- `docker-compose.yml` 与 `.github/workflows/publish-image.yml` 的 YAML 结构解析通过。
|
||||
|
||||
**没有验证**
|
||||
|
||||
- 镜像构建本身:apt 装包、两个 clone、CMake 配置与编译、构建阶段跑测试;
|
||||
- `HEALTHCHECK` 是否真的能命中 `/healthz`;
|
||||
- `read_only: true` 下有没有哪个库偷偷要写别处(`/tmp` 的 tmpfs 是按这个可能性预留的);
|
||||
- `create_host_path: false` 的报错行为;
|
||||
- 容器里 `CapEff` 是否真是全零(按 Docker 语义应当如此,但没实跑)。
|
||||
|
||||
上面前两条会在 CI 第一次跑通时自动补上(§9 的 smoke 步骤),剩下三条只能在真机上验。
|
||||
|
||||
接手后按顺序跑一遍就能补齐:
|
||||
|
||||
```sh
|
||||
docker compose build # 构建 + 构建阶段的单元测试
|
||||
docker compose run --rm --entrypoint ovg_tunnel_smoke openvpngate # 数据面
|
||||
docker compose up -d && docker compose ps # 看 healthy
|
||||
curl -x socks5h://用户:口令@127.0.0.1:1080 https://ifconfig.me # 出口 IP 应是节点 IP
|
||||
docker compose exec openvpngate cat /proc/self/status | grep CapEff # 应为全零
|
||||
docker compose stop # 应在一秒内退出,不是等满 30s
|
||||
```
|
||||
|
||||
最后一条尤其值得看:本项目已经被「优雅退出日志打得漂漂亮亮然后永远不退出」这类 bug 咬过
|
||||
一次(README §5 末尾那段),`docker stop` 卡满宽限期然后被 SIGKILL,就是它在容器里的样子。
|
||||
|
||||
---
|
||||
|
||||
## 9. CI 与 GHCR 发布
|
||||
|
||||
`.github/workflows/publish-image.yml` 构建镜像并推到 GitHub Container Registry。
|
||||
|
||||
**它没有单独的测试 job,这是故意的**:Dockerfile 的构建阶段最后一步就是跑完整单元测试
|
||||
(`OVG_RUN_TESTS=1`),测试挂了镜像就构建不出来,也就发布不了。再加一个跑同一套测试的 job
|
||||
只是把同样的编译做第二遍。
|
||||
|
||||
推完之后还有一步 smoke:按 **digest**(不是 tag)把刚发布的那个镜像拉回来,跑
|
||||
`--version`,再挂一个临时凭据文件跑 `--check`。这两下覆盖的正是「构建绿了也不代表能跑」的
|
||||
两件事——运行时阶段有没有漏装共享库,以及镜像里烤进去的那份配置能不能解析。
|
||||
|
||||
产出的 tag:
|
||||
|
||||
| 触发 | tag |
|
||||
|---|---|
|
||||
| push 到 `master` | `master`、`sha-<短 hash>` |
|
||||
| push tag `v1.2.3` | `1.2.3`、`1.2`、`latest`、`sha-<短 hash>` |
|
||||
| pull request | 照常构建并跑测试,**不推** |
|
||||
|
||||
`latest` 跟的是最新的 release tag,不是分支头。在打出第一个 `v*` 之前,要拉的是 `master`。
|
||||
|
||||
拉现成镜像而不是本地编译,把 `docker-compose.yml` 里的 `build:` 整块删掉,`image:` 改成:
|
||||
|
||||
```yaml
|
||||
image: ghcr.io/<owner>/<repo>:master
|
||||
```
|
||||
|
||||
`docker compose pull && docker compose up -d` 即可。注意 `docker/openvpngate.conf` 和
|
||||
`docker/socks5.auth` 仍然要从本仓库挂进去(§3),镜像里那份只是兜底默认值。
|
||||
|
||||
### 这个仓库的 remote 不是 GitHub
|
||||
|
||||
`origin` 指向 `git.sfclub.cc`。`.github/workflows/` 只有在下面两种情况下会跑:
|
||||
|
||||
1. **镜像到 github.com**:什么都不用配。内置的 `GITHUB_TOKEN` 配合 workflow 里的
|
||||
`permissions: packages: write` 就能推 ghcr.io,包会挂在镜像仓库名下。
|
||||
2. **在 Gitea/Forgejo Actions 上跑**:它们认这个 workflow 语法,但它们发的 `GITHUB_TOKEN`
|
||||
认证的是**自己那个 registry**,推不了 ghcr.io。需要在仓库里配:
|
||||
|
||||
| 名字 | 类型 | 值 |
|
||||
|---|---|---|
|
||||
| `GHCR_TOKEN` | secret | GitHub PAT,勾 `write:packages` |
|
||||
| `GHCR_USER` | variable | 那个 PAT 对应的 GitHub 用户名 |
|
||||
| `GHCR_IMAGE` | variable | 目标镜像名,如 `icybear/openvpngate`。这里的仓库名和 GitHub 上想要的名字不一定一样 |
|
||||
|
||||
三个都是「有就用、没有就退回内置值」,所以在 github.com 上不配也不会碍事。
|
||||
|
||||
首次推送后包默认是私有的。要让别人 `docker pull` 得先在 GitHub 的 Package settings 里改成
|
||||
public,或者让对方 `docker login ghcr.io`。
|
||||
Reference in New Issue
Block a user