Files
ovgate/etc/openvpngate.conf
iceBear67andClaude Opus 5 b2ba45c9f8 OpenVPN client with an authenticated SOCKS5 front door
A userspace VPN gateway: builds an OpenVPN tunnel to a VPNGate node with
the OpenVPN 3 core, terminates it in-process with lwIP, and serves SOCKS5
(RFC 1928/1929, CONNECT and UDP ASSOCIATE) over it. No root, no tun
device, no routing table changes.

Layout follows the module boundaries in docs/ARCHITECTURE.md:

  vpngate/   directory fetch + CSV parse (lines run to ~13.5 KB, so the
             parser streams rather than splitting on newlines)
  selector/  two-phase pick: cheap prior over the whole list, then real
             TCP handshake timing of the top K
  ovpn/      openvpn3 driven through TunBuilder, packets over a socketpair
  netstack/  lwIP: the TCP/IP stack that makes "no root" possible
  egress/    the swappable way out, and make-before-break switching
  socks5/    the front door
  health/    per-window scoring, and the decision to move
  app/       wiring, admin HTTP, signals

docs/FEASIBILITY.md is the analysis this was built from, including the
one requirement that is not physically possible -- carrying established
TCP connections across a node switch -- and what is done instead
(zero-progress redial, UDP re-homing, grace-period drain).

Tests: 155 without the tunnel egress, 172 with it. The seam is the egress
factory; selection, scoring, history and probing all run for real.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 04:38:39 +00:00

221 lines
8.2 KiB
Plaintext

# openvpngate -- sample configuration
#
# Every key below is optional and shown at its default unless noted. Durations
# take a unit suffix ("250ms", "30s", "5m", "2h"); a bare number means seconds.
# Comments run from '#' to end of line.
#
# openvpngate -c etc/openvpngate.conf --check validate and print, no start
# openvpngate -c etc/openvpngate.conf run
#
# Nothing here needs root. The tunnel is terminated in userspace (lwIP), so
# there is no tun device, no routing table change, and no capability to grant.
# ---------------------------------------------------------------------------
[socks5]
# ---------------------------------------------------------------------------
listen_address = 127.0.0.1
listen_port = 1080
# Leave this on. With it off the proxy is an open relay to anyone who can reach
# the listen address.
require_auth = true
# Credentials. Two ways, and they merge:
# * the [users] section at the bottom of this file (plaintext, dev only)
# * auth_file, which also accepts pre-hashed lines and is reloaded on SIGHUP
# auth_file = etc/socks5.auth
# SOCKS5 UDP ASSOCIATE. Supported, with the caveats in docs/FEASIBILITY.md §5:
# fragmented datagrams (FRAG != 0) are dropped and counted, never reassembled.
udp_associate = true
# The address handed back in the UDP ASSOCIATE reply. It has to be reachable
# *by the client*, which is why it cannot be derived from listen_address when
# that is 0.0.0.0. Empty = use the control connection's local address.
# advertise_address =
# Admission control. Refused at accept, before a session object exists: this is
# the only thing standing between a burst and lwIP's allocator.
max_sessions = 1200
handshake_timeout = 10s
connect_timeout = 20s
idle_timeout = 5m
udp_idle_timeout = 60s
relay_buffer_size = 16384
# 0 = min(hardware_concurrency, 4). More than four buys little: every tunnelled
# byte passes through lwIP's single strand regardless.
io_threads = 0
# ---------------------------------------------------------------------------
[vpngate]
# ---------------------------------------------------------------------------
# The public node directory. Lines in this CSV can be ~10 KB each (the whole
# .ovpn profile is base64 in the last column), which the parser handles by
# streaming rather than by splitting the response into lines.
api_urls = http://www.vpngate.net/api/iphone/
refresh_interval = 30m
http_timeout = 30s
# A stale cache still beats no nodes at all when the API is down.
cache_path = var/vpngate_cache.csv
cache_max_age = 6h
max_response_bytes = 33554432
# ---------------------------------------------------------------------------
[selector]
# ---------------------------------------------------------------------------
# Two-phase: rank everything cheaply from the API metrics, then actively probe
# only the survivors. VPNGate's own numbers are measured from their
# infrastructure, not from yours, so they are a prior and nothing more.
# country_allow = JP, KR, SG
# country_deny = RU
prefer_udp = true
probe_candidates = 12
probe_samples = 3
probe_timeout = 3s
probe_concurrency = 8
# Prior weights (API-derived).
w_score = 0.35
w_speed = 0.30
w_sessions = 0.20
w_uptime = 0.15
# Final blend. Our own measured RTT outweighs anything the directory claims.
w_rtt = 0.45
w_prior = 0.30
w_history = 0.25
# Per-node outcome history. This is what stops the selector walking into the
# same broken node every time it appears with a flattering score.
history_path = var/node_history.tsv
failure_backoff_initial = 60s
failure_backoff_max = 1h
# ---------------------------------------------------------------------------
[switch]
# ---------------------------------------------------------------------------
# graceful = make-before-break (new tunnel is fully up before the old one is
# replaced); hard = promote and close everything at once.
mode = graceful
# How long a replaced egress keeps serving the sessions still on it.
drain_grace = 2m
max_draining = 2
# Anti-flap: no switch within this window of the last one, and a candidate must
# beat the incumbent by improvement_margin to be worth the disruption.
min_interval = 60s
improvement_margin = 0.20
backoff_initial = 30s
backoff_max = 8m
# What survives a switch. A session that has moved no bytes carries no TCP
# state, so it is re-dialled on the new egress transparently; a UDP association
# has no sequence state at all, so only its egress-side socket is replaced and
# the client never sees the port change. Everything else drains and is closed
# when the grace window expires -- see docs/ARCHITECTURE.md §5.3.
retry_zero_progress = true
rehome_udp = true
# Go looking for a *better* node while the current one is healthy. Off by
# default: a scan probes a dozen volunteer-run servers and a switch costs every
# session that has moved bytes. Degradation-driven switching covers the
# requirement; this is the optional upgrade path.
opportunistic_interval = 0
# ---------------------------------------------------------------------------
[health]
# ---------------------------------------------------------------------------
interval = 15s
# Consecutive bad windows before a switch is requested. One bad sample on a
# volunteer tunnel in another country is weather, not a failure.
unhealthy_windows = 3
# The probe dials this through the egress and drops the stream immediately. A
# TCP handshake rather than a DNS lookup on purpose: a lookup can be answered
# from cache without a byte crossing the tunnel, which would report a dead
# tunnel as the healthiest node in the fleet.
probe_domain = www.google.com
probe_port = 80
probe_timeout = 5s
min_score = 0.40
max_connect_failure_rate = 0.50
stall_threshold = 45s
# ---------------------------------------------------------------------------
[ovpn]
# ---------------------------------------------------------------------------
# VPNGate nodes are overwhelmingly AES-128-CBC + SHA1. Turning this off is
# principled and will fail to connect to most of the directory.
allow_legacy_algorithms = true
# Most VPNGate nodes ignore credentials; the ones that ask want "vpn"/"vpn".
username = vpn
password = vpn
connect_timeout = 30
tunnel_up_timeout = 45
compression = true
# Socket buffers for the tun-side socketpair. Too small drops IP packets under
# burst -- recoverable, but it costs throughput.
packet_socket_buffer = 2097152
# ---------------------------------------------------------------------------
[dns]
# ---------------------------------------------------------------------------
# Used inside the tunnel when the server pushes no resolver of its own. Must be
# literals: resolving a resolver needs a resolver.
fallback_servers = 1.1.1.1, 8.8.8.8
timeout = 5s
cache_entries = 4096
min_ttl = 5s
max_ttl = 1h
prefer_ipv4 = true
# ---------------------------------------------------------------------------
[admin]
# ---------------------------------------------------------------------------
# GET /status /nodes /sessions /health /metrics /healthz, POST /switch.
# There is NO authentication here. Keep it on loopback.
enabled = true
listen_address = 127.0.0.1
listen_port = 9080
# ---------------------------------------------------------------------------
[log]
# ---------------------------------------------------------------------------
level = info
file = - # "-" is stderr
[log.modules]
# Per-module overrides; the tag is the one in square brackets in each log line.
# socks5 = debug
# netstack = warn
# health = debug
# ---------------------------------------------------------------------------
[egress]
# ---------------------------------------------------------------------------
# Which way out to build:
#
# tunnel OpenVPN + lwIP -- the real thing
# direct host sockets, NO VPN -- for testing the proxy in isolation. A build
# configured with -DOVG_WITH_TUNNEL=OFF refuses to start in any other
# mode rather than silently proxying in the clear.
mode = tunnel
# ---------------------------------------------------------------------------
[users]
# ---------------------------------------------------------------------------
# user = password, hashed with a random salt at load time. Fine for a laptop;
# for anything shared use socks5.auth_file, which takes pre-hashed lines and
# reloads on SIGHUP without dropping a single live session.
alice = changeme