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, PreDown and PostDown stop 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 local said 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 --wireguard file 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.