Reference
Every command, option, file, rule and event in vpnw 0.8.0. The same reference ships with the code as docs/reference.md; plugin authors want docs/plugins.md.
Commands
| Command | What it does |
|---|---|
vpnw run [options] -- CMD [ARGS] |
runs a program through a chosen path |
vpnw trace [options] -- CMD [ARGS] |
the same, printing every connection |
vpnw guard [options] -- CMD [ARGS] |
the same, under a policy, enforced |
vpnw learn [--from last|FILE] [--name NAME] [--ports] [--wildcards] [-o FILE] |
drafts a policy from traces, with the built-in Learn plugin |
vpnw advise PLUGIN [--from FILE] [--set KEY=VALUE] [-o FILE] |
runs an Advisor plugin on traces |
vpnw verify LEDGER [TRACE]... [--head HASH] |
checks a ledger written by the Ledger plugin; with no trace given it also reads last. Exit 0 intact, 1 changed or cut short |
vpnw exit serve|check|token|cert |
runs an exit, the server end of a path, and makes what it needs; see Exit below |
vpnw plugin list|info|add|remove|keygen|sign|trust|keys |
manages plugins |
vpnw doctor |
checks what this machine supports |
vpnw version |
Options for run, trace and guard
| Option | Meaning |
|---|---|
--policy FILE |
a file with a [policy] table, and optionally paths |
--config FILE |
a file with [network] and [paths.NAME] tables |
--via NAME |
a named path from --config or --policy |
--direct |
connect from this machine (the default) |
--wireguard FILE |
through a WireGuard tunnel, from a wg-quick file; vpnw runs the tunnel itself |
--tunnel NAME |
through WireGuard tunnels whose configs come from Tunnel plugin NAME, as the built-in rotate; see Tunnel Plugins below |
--proxy URL |
socks5://, socks5h://, http://, https:// or socks5+tls://; given more than once, a list of exits with failover |
--ca FILE, --token-file FILE |
for proxies over TLS |
--dns MODE |
local or remote for --proxy; tunnel or local for --wireguard and --tunnel |
--allow RULE, --deny RULE |
rules on the command line |
--deny-private |
deny loopback, private and link-local addresses, cloud metadata included |
--default allow|deny |
when no rule matches |
--plugin NAME |
an Observer or Guard plugin alongside the run |
--set NAME.KEY=VALUE |
a setting for a plugin, as --set ledger.every=50 |
--plugin-file NAME=PATH |
the file vpnw creates for a plugin that asks for one (file.write); it must not exist yet |
--plugin-folder NAME=PATH |
the folder a plugin that asks for one reads (folder.read); by default ~/.config/vpnw/NAME |
--out FILE, --json |
events to a file, or JSON on the console |
-v, -q, --no-save |
more, less, and don’t keep this trace as last |
--backend sealed|env |
sealed (Linux, enforced) or env (proxy settings only) |
--allow-unix-sockets |
let a sealed program use Unix sockets |
--no-deny-exit |
keep the program’s exit code when something was denied |
A policy or a Guard plugin needs the sealed backend: the env backend only sets proxy variables, so it can’t stop a program that ignores them.
Exit Codes
| Code | Meaning |
|---|---|
| the program’s own | it ran and nothing was denied |
| 120 | something was denied and the program exited with 0 |
| 121 | usage or configuration error |
| 122 | no sealed backend on this machine |
| 123 | the path can’t be used, so the program never started |
| 124 | vpnw itself failed |
| 126, 127 | the program can’t be run, or wasn’t found |
Files
version = 1
name = "agent"
[network] # the path used unless --via names another
type = "wireguard"
config = "office.conf" # a wg-quick file, next to this one
[paths.home]
type = "proxy"
url = "socks5h://127.0.0.1:1080"
[policy]
default = "deny"
deny_private = true
allow = ["api.github.com", "*.githubusercontent.com", "pypi.org:443"]
deny = ["telemetry.example"]
A strict subset of TOML. Anything vpnw doesn’t know is an error with its line number, never skipped.
WireGuard Config Files
The usual wg-quick format. [Interface] takes PrivateKey, Address, DNS, MTU and ListenPort; Table, SaveConfig and FwMark are accepted and do nothing, since the tunnel has no routing table or interface. Each [Peer] takes PublicKey, Endpoint, AllowedIPs, PresharedKey and PersistentKeepalive. IPv6 endpoints are written [address]:port.
- Shell hooks are refused.
PreUp,PostUp,PreDownandPostDownstop the run: vpnw runs no commands from a config file. - DNS goes through the tunnel, to the config’s DNS servers. A config with no DNS server needs
--dns localsaid out loud, and one whose DNS server is outside every peer’s AllowedIPs is refused. - The tunnel must be up before the program starts. vpnw waits up to 12 seconds for a handshake with every peer (WireGuard retries after 5) and stops with exit code 123 if one doesn’t answer.
- AllowedIPs hold. A connection to an address outside every peer’s AllowedIPs is refused with that reason.
- Never loopback or link-local. A connection to a loopback, link-local (169.254.0.0/16, fe80::/10) or multicast address is refused through any tunnel, always, a full tunnel (
AllowedIPs = 0.0.0.0/0) included. Through a tunnel those could only reach the peer’s own machine or its network segment, where cloud metadata lives, so a lying DNS answer from the tunnel can’t send a program there. - Errors keep keys out. An error names the line, and shows what’s on it only when that’s a key’s name or an address, so a mistyped line never puts a private key in the record.
Tunnel Plugins
A Tunnel plugin hands vpnw WireGuard configs and says how long to keep each; vpnw runs every tunnel itself. The built-in rotate hands over the .conf files in its folder, ~/.config/vpnw/rotate or --plugin-folder rotate=PATH, reading the folder each time it’s asked.
vpnw guard --tunnel rotate --set rotate.every=10m --policy scraper.toml -- ./scraper
vpnw run --tunnel rotate --plugin-folder rotate=configs/sweden --set rotate.order=random -- ./crawler
| Setting | Value |
|---|---|
rotate.every |
how long to keep each config, from 5s to 720h; unset, until its tunnel stops answering |
rotate.order |
name (the default) or random, shuffled once per run |
- Asked at three moments. When the run starts (up to 8 configs, each with 12 seconds for its handshake; if none comes up the program never starts, exit 123), when the config in use is due, and when its tunnel stops answering: data went out and nothing WireGuard accepts came back for 20 seconds.
- Make before break. The next tunnel is up before anything moves. New connections go through it; open ones finish on the tunnel they started on, which closes after its last one. At most 4 tunnels wind down at once.
- One key, one tunnel. A peer talks to only one tunnel of a key, so a config handed over again while its tunnel still carries connections gets that tunnel back, if the config is unchanged and the tunnel still answers. A config with the same key and peer as the one in use is refused.
- Checked, then skipped. Each config is checked as a
--wireguardfile is, with at most 16 peers; one refused or whose tunnel doesn’t come up is recorded with the reason and skipped, and the plugin is told why. - Never direct. If no other config comes up, the tunnel in use stays, a silent one included, and the record says so.
- Trust. A Tunnel plugin decides where the program’s traffic goes: choose one as you would a VPN provider.
When Connections End
On every path, a connection ends when both sides are done. When the program has stopped sending (it closed its side, say after giving up on a timeout) and the destination then sends nothing for 2 minutes, vpnw closes the connection and records it. A destination that keeps sending, however slowly, keeps it open. When the run ends, connections still open are closed and recorded, and lookups or dials still waiting are recorded as cut off by the end of the run.
Policy Rules
A rule is a host (api.github.com), every subdomain of one (*.github.com, not github.com itself), an address (10.0.0.7) or a range (10.0.0.0/8), each with an optional port. Rules never match more than they say.
Every connection is decided in the same order: deny rules on the name, deny rules on every address it resolved to, deny_private on every address, allow rules, then the default. With an allow list and no default, the default is deny. A name is looked up only when an address could change the answer. If a name resolves to several addresses, all must pass. Guard plugins come after the policy, and can only refuse.
Events
One JSON object per line. Every event has v, ts, type and run; most have pid, path and conn.
| Type | Fields |
|---|---|
run.start |
mode, backend, path_kind, path, policy, plugins; with --tunnel, tunnel: the plugin, the config in use, keep, and the configs skipped with why |
connection.attempt |
host or ip, port, proto; at an exit also client, source, peer, client_run, client_conn |
dns.query, dns.result |
host, then ips and ms, or error |
policy.allow, policy.deny |
rule, reason; a Guard plugin’s rule is plugin:NAME |
path.switch |
from, to, error, ms |
connection.open, connection.close |
ip, exit, tunnel (the config that carried it), ms; then bytes_up, bytes_down |
tunnel.switch, tunnel.error, tunnel.close |
with --tunnel: new connections moved to another config’s tunnel (from, to, why, error, ms, peer, again); a config not used (config, error), or the one in use kept (error); a tunnel closed (config, why, cut, by) |
connection.error |
error, exit |
auth.deny |
at an exit, a request without a valid token: peer, proto, reason; never the token |
plugin.error |
plugin, type, error |
process.start, process.exit, run.end |
the program, its exit code, the totals |
plugin.NAME.TYPE |
whatever plugin NAME emitted; Ledger’s checkpoint copies are plugin.ledger.checkpoint, with cp, n, h |
Exit
vpnw exit is the server end of a path, for a machine with fixed addresses. Clients reach it over TLS with --proxy https:// or socks5+tls://, each with a token of its own. The exit decides every request again with that client’s own policy, by the same code that decides a run’s connections, and connects from the client’s fixed address.
# a private CA and the exit's certificate
vpnw exit cert --host 198.51.100.2 --name exit-de
# a client's token, and its hash
vpnw exit token --out agent-1.token
# what each client may do
vpnw exit check --config exit-de.toml
vpnw exit serve --config exit-de.toml --out exit.jsonl
version = 1
name = "exit-de"
listen = "198.51.100.2:8443"
cert = "exit-de.crt"
key = "exit-de.key"
[clients.agent-1]
token_sha256 = "1045911b…" # vpnw exit token prints it
source = "198.51.100.10" # an address of its own, or pool = "NAME"
policy = "agent-1.toml" # its own policy file, or the policy keys here
[pools.shared]
addresses = ["198.51.100.20", "198.51.100.21"]
| On the wire | HTTP CONNECT | SOCKS5 |
|---|---|---|
| token | the proxy password | the login password |
| the client’s run and connection IDs | VPNW-Run, VPNW-Conn |
the user name, RUN/CONN |
| refused | 403, VPNW-Rule, VPNW-Reason |
reply 2 |
| wrong token | 407, after a second | login refused |
| health check | GET /health |
a login |
Whatever a client’s policy says, the exit never connects to its own machine unless the client has allow_exit_host = true, and never to link-local, multicast or cloud metadata addresses. A source address is one address of the machine, never a wildcard. Before a client shows its token it gets 10 seconds, and one address can hold 32 waiting connections; once logged in, a client can have 1,024 open. Plugins run at the exit too. The whole of it, with the exit’s record, is in docs/reference.md.
Ledger Files
JSON Lines. A header, then one line per event with its hash, a checkpoint every 100 events (--set ledger.every=N) and at the end, and an end line. Each hash covers the line before it, so checkpoints and the end line are links in the chain too.
{"vpnw-ledger":1,"run":"r-3f9a1c"}
{"n":1,"ev":{"v":1,"type":"run.start",…},"h":"7e3b…"}
{"n":7,"lost":12,"h":"…"}
{"cp":1,"n":100,"h":"…"}
{"end":143,"cp":2,"h":"…"}
A lost line marks events that never reached Ledger because it fell behind the run. The hashes, byte by byte, are in docs/reference.md.
Where Things Are Kept
| What | Where |
|---|---|
| the last trace | ~/.local/state/vpnw/last.jsonl |
| a plugin’s file, as Ledger’s | ~/.local/state/vpnw/NAME/RUN.NAME, as ledger/r-3f9a1c.ledger |
| a plugin’s folder, as rotate’s | ~/.config/vpnw/NAME/, as rotate/ |
| installed plugins | ~/.local/share/vpnw/plugins/ |
| trusted signing keys | ~/.config/vpnw/trusted-keys |
| compiled plugins | ~/.cache/vpnw/wasm/, safe to delete |
Each follows $XDG_STATE_HOME, $XDG_DATA_HOME, $XDG_CONFIG_HOME and $XDG_CACHE_HOME when set.