README
¶
mv
A small, dependency-free Go CLI for Mullvad: browse the relay catalog, manage the WireGuard devices on an account, and generate ready-to-use WireGuard configs.
- No third-party dependencies — standard library only.
- Servers by default — with no account argument, it lists relay servers.
- Config-first device commands —
-new/-add/-rotateregister/rotate a device and print a working config (OpenBSD by default,-linuxfor Linux). - Human-readable by default —
-grepfor greppable output (one|-delimited line per record,cut -d'|' -fN);-jsonfor raw API output.
Install
go install eio.dev/x/mv/cmd/mvctl
Build
go build -o mvctl ./cmd/mvctl
The Go module is mv; the command it builds is mvctl.
Environment
| Var | Meaning |
|---|---|
MV_ACCT |
account number (used by device/account/config commands) |
MV_CC_EXIT |
default exit country for generated configs |
MV_CC_ENTRY |
default entry country; if set (like -entry), the tunnel is multihop |
In the examples below, $ACCT is your 16-digit account number.
Usage
mvctl list WireGuard relays (-all for bridges + all columns)
mvctl -country se -owned -daita ... filter (-country -city -bridge -down -socks -owned -daita)
mvctl -countries | -cities list country / city indexes
mvctl <acct> show account info + devices
mvctl -new [-exit c] [-entry c] [-linux] <acct> gen key, register, print config
mvctl -add <privatekey> [...] <acct> register a key, print config
mvctl -rotate <device-id-or-name> [...] <acct> rotate a device key, print config
mvctl -del <device-id-or-name> <acct> revoke a device
Mode selection: a positional <acct> (or an action flag like -new)
selects device mode; otherwise it lists servers. MV_ACCT supplies the account
to action flags, e.g. MV_ACCT=… mvctl -new. To show your account + devices,
pass the account positionally (mvctl $ACCT).
Servers
No account needed.
By default only WireGuard relays are shown, with a slim column set that
includes the public key and multihop port. -all adds bridges and the extended
columns (type, provider, speed, daita, socks). Use -bridge for bridge relays
instead of WireGuard; -all shows both types (and ignores -bridge).
# default: human-readable table, WireGuard only
mvctl
mvctl -all # everything: bridges + all columns
# filters (combine with AND)
mvctl -country us -city nyc # WireGuard in NYC
mvctl -owned -daita -country se # Mullvad-owned, DAITA-capable, in Sweden
mvctl -bridge # bridge relays only
mvctl -all -down # down/maintenance, both relay types
mvctl -socks -country se # relays with a SOCKS5 proxy
# greppable (-grep): one |-delimited line per relay
# slim fields: host|status|cc|city|owned|ipv4|ipv6|multihop|pubkey
# -all fields: host|status|type|cc|city|owned|provider|ipv4|ipv6|speed|daita|multihop|socks|pubkey
mvctl -grep
mvctl -grep | grep '|DOWN|' # down servers
mvctl -grep -down | cut -d'|' -f1 # just hostnames
mvctl -grep | cut -d'|' -f9 # public keys (field 9, slim view)
mvctl -grep -all -socks | cut -d'|' -f13 # SOCKS5 host:port (field 13, -all view)
# raw JSON
mvctl -json
Indexes
mvctl -countries # cc|country
mvctl -cities -country se # cc|city_code|city (filters apply)
Notes: the public relay feed only marks servers
active true/false(it doesn't distinguish down vs maintenance, nor expose live load). SOCKS5 proxies (thesockscolumn) are reachable only over the Mullvad tunnel; there is also a fixed in-tunnel proxy at10.64.0.1:1080.
Devices
mvctl <acct> shows the account info first, then the devices.
mvctl $ACCT # human: account block, then device blocks
mvctl -json $ACCT # {"account": {...}, "devices": [...]}
# greppable: account is one line prefixed account|…, then the device lines
# account|id|expiry|max_devices|can_add_devices
# <pubkey>|name|id|created|ipv4|ipv6|hijack_dns
mvctl -grep $ACCT
mvctl -grep $ACCT | grep -v '^account|' | cut -d'|' -f5 # device IPv4s
# revoke (by name or ID)
mvctl -del "my-laptop" $ACCT
Flags must come before the account argument (
mvctl -grep $ACCT, notmvctl $ACCT -grep) — Go's flag parser stops at the first positional.
Generate configs
-new, -add, and -rotate all register/rotate a device and print a
WireGuard config to stdout — OpenBSD hostname.if(5) by default, -linux for
wg-quick. By default they print nothing but the config (not even on
stderr); add -v to log the device and chosen relay to stderr. Errors are
always shown.
# new key pair + register, OpenBSD config, exit in Sweden
mvctl -new -exit se $ACCT > /etc/hostname.wg0
# Linux config, multihop (entry Switzerland -> exit Sweden)
mvctl -new -linux -exit se -entry ch $ACCT > wg0.conf
# register a private key you already hold
mvctl -add "$(cat key.txt)" -exit us $ACCT
# rotate an existing device's key in place, get a fresh config for it
mvctl -rotate "my-laptop" -exit se $ACCT
# OpenBSD: route everything through the tunnel, in routing table 1
mvctl -new -exit se -gw -rtable 1 $ACCT > /etc/hostname.wg0
# OpenBSD nested multihop -> two hostname.if files in one shot
# (exit hop -> stdout -> wg0, entry hop -> stderr -> wg1)
mvctl -new -nested -entry ch -exit se -rtable 1 -gw $ACCT 1>/etc/hostname.wg0 2>/etc/hostname.wg1
- Exit relay:
-exit→$MV_CC_EXIT→ random global. A random active WireGuard relay is chosen in the resolved country. - Multihop (default, single-peer): set
-entry <country>or$MV_CC_ENTRY. The endpoint becomes the entry relay's IP on the exit'smultihop_port; the peer key is the exit's. This is Mullvad's documented static-config method. On OpenBSD, multihop requires-rtable(the tunnel's routing table). - Nested multihop (
-nested, OpenBSD only): the two-peer scheme the desktop app uses, emitted as twohostname.iffiles.-rtable Ris the rdomain that already has internet access; the entry hop runs onwg1in a new rdomainR+1withwgrtable R(reaches the entry relay over the internet), and the exit hop (wg0, rdomain 0) useswgrtable R+1to route through the entry tunnel. Both hops use port 51820 (notmultihop_port). Requires-entryand-rtable >= 1. The exit hop goes to stdout and the entry hop to stderr, so… 1>/etc/hostname.wg0 2>/etc/hostname.wg1writes both files at once (-vis unused in this mode).
MTU (nested): each WireGuard layer adds ~60 bytes (IPv4 outer), so nesting wraps user packets twice. The exit interface uses MTU 1280 (safe: 1280 + 120 = 1400 ≤ 1500); the entry interface uses MTU 1420 so it can carry the exit tunnel's wrapped packets (1280 + 60 = 1340). On a non-1500 uplink (PPPoE, already-tunneled) or IPv6 relay legs (~80 bytes/layer), lower these.
- Node constraints:
-ownedand/or-daitarestrict entry/exit selection to Mullvad-owned and/or DAITA-capable relays (e.g.-new -owned -daita). - Description: the device name and chosen node(s) are written to the config —
OpenBSD
description "<…>", Linux a leading# <…>comment. Single-hop =<device>:<exit>, multihop =<device>:<entry>:<exit>. - OpenBSD-only (
-linuxrejects them):-rtable <n>sets the tunnel's routing table (wgrtable) — optional for single-hop, required for multihop and nested (for nested it's the internet rdomainR);-gwadds inet/inet6 default routes via the tunnel IP (!route add -label mvd default <ip>). - The generated private key is embedded in the config (no separate key file
is written). Revoke the device with
-del <id|name>when done.
Note:
-addtakes the key on the command line, visible in shell history /ps. Prefer-add "$(cat key.txt)"with the file at mode0600.
Multihop: single-peer vs nested
Two ways to build a multihop tunnel to the same pair of relays. They differ most in what an on-path observer can see, plus speed and setup.
Single-peer (-entry) |
Nested (-nested) |
|
|---|---|---|
| Mechanism | one tunnel to the exit; the entry relay UDP-forwards on the exit's multihop_port |
two stacked tunnels; the client encrypts twice |
| On your link, packets go to | entry_ip : <exit's multihop_port> |
entry_ip : 51820 |
| A passive on-path observer learns | entry and exit | entry only (looks like a plain single-hop) |
| OS support | Linux and OpenBSD | OpenBSD only |
| Config files | 1 | 2 (wg0 + wg1, two rdomains) |
| Encryption / overhead | 1 layer, ~60 B | 2 layers, ~120 B → smaller MTU (1280), more CPU |
| Speed | faster | slower (double crypto; entry relay decrypts + re-encrypts) |
| Setup | one command, one file | rdomains + mandatory -rtable + two files + MTU care |
Adversarial perspective
A passive observer on your link (ISP, Wi-Fi, a tap) — the key difference.
Single-peer sends your packets to entry_ip : <port>, where <port> is the
exit relay's multihop_port, and that port→relay mapping is public (it's in
the relay catalog). So an on-path observer learns both your entry and your exit
relay. Nested sends everything to entry_ip : 51820 (the ordinary WireGuard
port) with the exit encrypted inside the outer tunnel: the observer learns only
the entry, and the connection is indistinguishable from a plain single-hop.
Your exit is revealed only inside Mullvad — to the entry relay, which unwraps
the outer layer.
Mullvad's own relays. In both schemes only the entry sees your real IP and only the exit sees your plaintext, and both collapse if entry and exit collude. One nuance: in nested the entry relay terminates a WireGuard session with your device key, so both relays see your device public key (a shared identifier); in single-peer the entry is a dumb forwarder and never sees your key.
Either way: the entry can't read or modify your real traffic (only drop/delay), and neither scheme defeats a global passive adversary correlating timing and volume across both hops.
Rule of thumb: choose nested when you don't want an on-path observer to learn your exit — at the cost of speed, OpenBSD-only setup, and exposing your key to both relays. Choose single-peer for speed, simplicity, and cross-platform use when hiding the exit from your local network isn't the concern.
Nested is what the official app already does
Mullvad's official app uses the nested (two-peer) scheme for all multihop, on
every platform including Linux — it has no single-peer multihop_port mode.
So nested isn't exotic; it's the standard Mullvad multihop. The app just does it
at runtime inside its daemon (two WireGuard peers / cryptokey routing) rather
than as static config files.
No Linux nested (in mvctl)
mvctl does not generate Linux nested configs — -nested is OpenBSD-only and
errors with -linux. Our nesting relies on OpenBSD routing domains (rdomain +
wgrtable), which have no clean static wg-quick equivalent (Linux would need a
second interface plus policy routing or a network namespace wired up at runtime,
which is exactly what the app's daemon does). So on Linux with mvctl, use
single-peer multihop — or the official app, which gives you nested.
Output formats
| Flag | Output |
|---|---|
| (none) | Human-readable: labeled blocks (devices/account), table with header (relays). |
-grep |
Greppable: one |-delimited line per record (positional). |
-json |
Raw API response as indented JSON. |
With -grep, | is used because it appears in no field, so IPv6 (colons), names
(spaces), and CIDRs (slashes) parse cleanly with cut -d'|' -fN / awk -F'|'.
Empty fields render as -. Column order:
account: account|id|expiry|max_devices|can_add_devices
device: pubkey|name|id|created|ipv4|ipv6|hijack_dns
relay (slim): host|status|cc|city|owned|ipv4|ipv6|multihop|pubkey
relay (-all): host|status|type|cc|city|owned|provider|ipv4|ipv6|speed|daita|multihop|socks|pubkey
(mvctl <acct> emits the account line then the device lines.)
The config commands (-new/-add/-rotate) print only the config to
stdout and nothing else unless -v is given — so mvctl -new … > file
captures exactly the config. Errors always go to stderr.
Notes
- Each invocation performs its own token exchange; rapid repeated calls can trip
Mullvad's
429 THROTTLEDrate limit on the auth endpoint. - Accounts are limited to 5 devices.
Documentation
CHANGELOG.md— what changed and when.doc/GO_API.md— the internal Go package API (internal/api,internal/wg).doc/MULLVAD_API.md— the Mullvad HTTP API the tool talks to.doc/CLAUDE.md— orientation for contributors: design, build/test, and how to patch.
Version
mvctl -version prints the version (currently 0.1.0).
License
BSD 3-Clause — Copyright (c) 2026, Eric Auge <eau (at) ermites (dot) io>. See LICENSE.
Directories
¶
| Path | Synopsis |
|---|---|
|
cmd
|
|
|
mvctl
command
Command mvctl manages WireGuard devices on a Mullvad account and generates configs.
|
Command mvctl manages WireGuard devices on a Mullvad account and generates configs. |
|
internal
|
|
|
api
Package api is a small client for Mullvad's app API.
|
Package api is a small client for Mullvad's app API. |
|
wg
Package wg holds the WireGuard primitives the tool needs.
|
Package wg holds the WireGuard primitives the tool needs. |