mv

module
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 27, 2026 License: BSD-3-Clause

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/-rotate register/rotate a device and print a working config (OpenBSD by default, -linux for Linux).
  • Human-readable by default-grep for greppable output (one |-delimited line per record, cut -d'|' -fN); -json for 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 (the socks column) are reachable only over the Mullvad tunnel; there is also a fixed in-tunnel proxy at 10.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, not mvctl $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's multihop_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 two hostname.if files. -rtable R is the rdomain that already has internet access; the entry hop runs on wg1 in a new rdomain R+1 with wgrtable R (reaches the entry relay over the internet), and the exit hop (wg0, rdomain 0) uses wgrtable R+1 to route through the entry tunnel. Both hops use port 51820 (not multihop_port). Requires -entry and -rtable >= 1. The exit hop goes to stdout and the entry hop to stderr, so … 1>/etc/hostname.wg0 2>/etc/hostname.wg1 writes both files at once (-v is 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: -owned and/or -daita restrict 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 (-linux rejects 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 rdomain R); -gw adds 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: -add takes the key on the command line, visible in shell history / ps. Prefer -add "$(cat key.txt)" with the file at mode 0600.

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 THROTTLED rate limit on the auth endpoint.
  • Accounts are limited to 5 devices.

Documentation

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL