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>
221 lines
8.2 KiB
Plaintext
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
|