Skip to content

Configuration ​

mqvpn supports both INI and JSON config files. If the file content starts with {, it is parsed as JSON; otherwise as INI. CLI arguments override config values.

UTF-8 files with or without a BOM and Windows (CRLF) line endings are supported. Files containing a NUL byte are rejected. INI section and key names are case-insensitive. A malformed section header produces a warning; its keys are ignored until the next valid section header. On/off keys take true or false (any case; JSON uses true/false without quotes); any other value, such as on, yes or 1, is an error and mqvpn does not start.

INI Format ​

Server ​

ini
# /etc/mqvpn/server.conf
[Interface]
Listen = 0.0.0.0:443
Subnet = 10.0.0.0/24
Subnet6 = 2001:db8:1::/112
# MTU = 1280

[TLS]
Cert = /etc/mqvpn/server.crt
Key = /etc/mqvpn/server.key

[Auth]
Key = mPyVpoQWcp/5gr404xvS19aRC03o0XS2mrb2tZJ1Ii4=
User = alice:<ALICE_PSK>
User = bob:<BOB_PSK>

[Multipath]
Scheduler = wlb
# CC = bbr2                     # Congestion control (bbr2|bbr|cubic|none)

[Hybrid]
Enabled = true

[Reorder]
Enabled = true

When you use a CA-issued certificate, Cert (cert_file in JSON) must contain the full chain: the server certificate followed by its intermediates (for Let's Encrypt, fullchain.pem, not cert.pem). Clients verify the chain exactly as the server presents it and do not fetch missing intermediates, so a leaf-only file fails verification on any client that does not already trust the intermediate.

Client ​

ini
# /etc/mqvpn/client.conf
[Server]
Address = 203.0.113.1:443
# ServerName = vpn.example.com  # TLS SNI / cert verify name (default: use Address host)

[Auth]
Key = mPyVpoQWcp/5gr404xvS19aRC03o0XS2mrb2tZJ1Ii4=

[Interface]
TunName = mqvpn0
DNS = 1.1.1.1, 8.8.8.8
LogLevel = info
# MTU = 1280

[Multipath]
Scheduler = wlb
# CC = bbr2                     # Congestion control (bbr2|bbr|cubic|none)
Path = eth0
Path = wlan0

# [Hybrid]                      # opt in to carry inner TCP over QUIC streams; see the [Hybrid] section
# Enabled = true

# [Reorder]                     # opt in for bulk inner QUIC; see the [Reorder] section
# Enabled = true

JSON Format ​

JSON config is useful for structured management and automation tooling. The file must be strict JSON (RFC 8259): no comments, no trailing commas, and keys and strings in double quotes.

Server ​

json
{
  "mode": "server",
  "listen": "0.0.0.0:443",
  "tun_name": "mqvpn0",
  "log_level": "info",
  "subnet": "10.0.0.0/24",
  "subnet6": "2001:db8:1::/112",
  "cert_file": "/etc/mqvpn/server.crt",
  "key_file": "/etc/mqvpn/server.key",
  "auth_key": "<YOUR_PSK_HERE>",
  "users": [
    { "name": "alice", "key": "<ALICE_PSK>" },
    { "name": "bob", "key": "<BOB_PSK>" }
  ],
  "max_clients": 64,
  "scheduler": "wlb",
  "cc": "bbr2",
  "hybrid": { "enabled": true },
  "reorder": { "enabled": true }
}

cert_file takes the full chain as well (see the note under the INI server example).

Client ​

json
{
  "mode": "client",
  "server_addr": "203.0.113.1:443",
  "tls_server_name": "vpn.example.com",
  "tun_name": "mqvpn0",
  "log_level": "info",
  "auth_key": "<YOUR_PSK_HERE>",
  "insecure": false,
  "dns": ["1.1.1.1", "8.8.8.8"],
  "kill_switch": false,
  "reconnect": true,
  "reconnect_interval": 5,
  "scheduler": "wlb",
  "cc": "bbr2",
  "paths": ["eth0", "wlan0"]
}

Multi-User Authentication ​

The server can authenticate multiple users, each with their own PSK. In JSON config, add a users array where each entry is either an object ({"name":"alice","key":"..."}) or a shorthand string ("alice:key"). In INI config, use repeatable User = NAME:KEY lines in the [Auth] section. You can also use the Control API to manage users at runtime.

Config usernames can be at most 63 bytes. Longer names are rejected instead of shortened: INI skips the entry with a warning; JSON and --user return an error.

When both auth_key (global key) and users are set, clients can authenticate with either. To restrict access to named users only, remove auth_key from the config.

Removing a user via the Control API also disconnects any active sessions authenticated with that username.

Monitoring requires per-user keys

Sharing a single auth_key across multiple clients works for the VPN data plane, but the Control API and the Prometheus exporter identify clients by their user label. Sessions authenticated with the global auth_key are reported as user="(global)", so multiple clients collide on the same label and the Prometheus scrape is dropped. For multi-client monitoring give each client its own entry under users (or register them at runtime via add_user).

Running with Config Files ​

bash
sudo mqvpn --config /etc/mqvpn/server.conf
sudo mqvpn --config /etc/mqvpn/server.json

Config Reference ​

[Server] (client only) ​

KeyDescriptionDefault
AddressServer address (HOST:PORT, e.g. [2001:db8::1]:443 for IPv6)Required
ServerNameTLS SNI and certificate verification name. Use when connecting by IP but verifying against a domain certificateAddress host
InsecureSkip TLS certificate verification (self-signed test setups only). With false the certificate is verified against the system store (/etc/ssl; override with SSL_CERT_FILE / SSL_CERT_DIR); on Linux/macOS (and on Windows with a custom trust path), when connecting by IP address set ServerName to the certificate's DNS name; with the Windows certificate store an IP literal matches an iPAddress SAN directly. On Windows the certificate is verified against the Windows certificate stores; if SSL_CERT_FILE (a PEM bundle) or SSL_CERT_DIR (a hashed certificate directory) is set, that custom trust path is used instead, as on Linux. On Android the certificate is verified against the device's CA store and the app's network security config; tlsServerName (the SDK counterpart of ServerName) is the name it must match, and it must be a bare host name or IP literal (no brackets).false

[Interface] ​

KeyDescriptionDefault
ListenListen address (HOST:PORT, server only)0.0.0.0:443
SubnetClient IPv4 pool (server only)10.0.0.0/24
Subnet6Client IPv6 pool (server only). Each client gets the same position in it as in Subnet, so it must have room for as many addresses: a prefix length of /120 or shorter for a /24.—
TunNameTUN device namemqvpn0
DNSDNS servers (comma-separated)—
LogLevelLog level (debug, info, warn, error)info
KillSwitchBlock traffic outside the VPN tunnel (client only). Also stops applications that bind to a specific network adapter from bypassing the tunnel.false
ReconnectEnable automatic reconnection (client only)true
ReconnectIntervalSeconds between reconnection attempts5
ManageRoutesManage the host routing table (VPN routes and server pin route). Set to false (or pass --no-manage-routes) to handle routing yourself. Routing alone does not stop applications that bind their sockets to a specific adapter — see the note below.true
MTUTUN MTU (1280–9000). Client: cap — if the negotiated MTU is lower, the negotiated value is used. Server: sets the TUN MTU directly.auto (client ~1382 negotiated, server 1382)
PathMTULargest IP packet the network between client and server carries without fragmenting (1280–9000). mqvpn sizes its QUIC packets to fit, assuming an IPv6 header so the value is safe for IPv4 too. Set the same value on both ends. See Using a larger tunnel MTU.0 (1400-byte packets)
PMTUDDiscover the path MTU instead of trusting a fixed size. Packets start small and grow while larger probes get through; PathMTU, when set, is the upper limit. Both ends must enable it. See Discovering the path MTU.false

Note: route management does not cover interface-bound applications

mqvpn adds a split default route (0.0.0.0/1 + 128.0.0.0/1) instead of replacing the system default route, so the original default route stays in place. Applications that pick a network adapter by looking up the system default route — rather than the route to the destination — keep using the physical adapter and bypass the tunnel. Tailscale on Windows does this for its own sockets.

This is independent of ManageRoutes. To stop such applications, set KillSwitch = true. Note that they are then blocked outright rather than routed through the tunnel.

[TLS] (server only) ​

KeyDescriptionDefault
CertTLS certificate path (PEM)Required
KeyTLS private key path (PEM)Required

[Auth] ​

KeyDescriptionDefault
KeyPre-shared key (base64, generate with mqvpn --genkey)Required unless User is set
UserPer-user PSK in NAME:KEY format (repeatable)—
MaxClientsMaximum concurrent clients (server only)64

In JSON, use auth_key on both client and server (as in the examples above).

[Multipath] ​

KeyDescriptionDefault
SchedulerScheduler algorithm (minrtt, wlb, wlb_udp_pin, or backup_fec)wlb
AllowClientSchedulerServer only (allow_client_scheduler in JSON, or pass --allow-client-scheduler). When true, a client that asks for a scheduler gets it for its own connection's download direction; other clients keep the server's Scheduler. The request is not applied, and the connection keeps the scheduler it already has (at first, the server's Scheduler), when the client asks for backup_fec, when the server's Scheduler is backup_fec, when the same connection already changed its scheduler once, or when the connection already uses more than one path.false
RequestServerSchedulerClient only (request_server_scheduler in JSON, or pass --request-server-scheduler). When true, the client asks the server to use this client's Scheduler for its download direction too. Setting Scheduler alone still affects only the upload direction, as before. Takes effect only on servers with AllowClientScheduler = true.false
CCCongestion control algorithm (bbr2, bbr, cubic, or none)bbr2
PathNetwork interface to bind (repeatable)Default interface
InitMaxPathIdMP-QUIC draft-21 test knob: initial Maximum Path Identifier advertised in transport parameters (1–4294967295; 0 = xquic default 8)0
ReinjectionSpeculative duplication mode (off, deadline, or idle). dgram is no longer supported; a config file that sets it gets a warning and runs with off. Use redundant mode (Redundant = true) to duplicate packets.off
ReinjectionSrttFactorPctdeadline mode: duplicate unacked packets older than factor × min_srtt, in percent (100–1000)110
ReinjectionHardDeadlineMsdeadline mode: upper clamp on the duplication deadline (1–60000)500
ReinjectionDeadlineLowerBoundMsdeadline mode: lower clamp; values above the hard deadline are clamped down to it (1–60000)20
RedundantClient only. When true, copies of outgoing UDP packets are sent over more than one path at once, which helps live streams keep going when a path drops. That traffic uses up to twice the data with two paths, three times with three. Every connection should be fast enough to carry that traffic on its own (recommended); if your connections are often slower, leave it off. See Redundant mode.false
RedundantPortsClient only. Comma-separated destination ports (at most 16 entries) to duplicate when Redundant = true; empty duplicates all UDP. With Redundant = true, an invalid list stops the client at startup.empty

A server of this version tells each connected client which scheduler it uses for that client, even when AllowClientScheduler is false. A client that sets RequestServerScheduler logs server scheduler: <name> (requested <name>) after it connects.

See Multipath for scheduler details and Reinjection for per-mode guidance.

backup_fec is experimental and requires both peers to run mqvpn ≥ 0.4.0 with FEC build enabled (-DXQC_ENABLE_FEC=ON -DXQC_ENABLE_XOR=ON). See Multipath.

CC = none (no congestion control) requires xquic built with -DXQC_ENABLE_UNLIMITED=ON.

[Reorder] ​

A flow-aware reorder buffer for inner UDP traffic. It targets a single inner connection (e.g. inner QUIC) that is itself spread across multiple paths by mqvpn's multipath aggregation: by holding briefly out-of-order datagrams and delivering them in order, it reduces the reordering the inner endpoint sees. Disabled by default (Enabled = false); when off the section has no effect and packets are forwarded unchanged. It only takes effect when both the server and the client set Enabled = true; the client log then shows peer advertised mqvpn-reorder.

Scope: the reorder buffer applies to inner UDP flows only. Inner TCP is not handled by the reorder buffer. For inner TCP, enable hybrid mode ([Hybrid] below) instead — the QUIC stream layer restores ordering.

KeyDescriptionDefault
EnabledMaster switch (true / false)false
MaxWaitMsHow long to hold a gap before skipping the missing datagram (ms)30
CapPacketsMax buffered datagrams per flow (power of two)1024
MaxBytesPerFlowMax buffered bytes per flow1572864
ClassifyWindowDatagrams observed to classify a flow's direction (0 disables ACK-direction demotion)64
AckDemoteMaxLargeLarge-packet count at or below which a flow is judged ACK-direction3
SmallPacketThresholdInner payload bytes splitting small vs. large200
ResetMarkPacketsFLOW_RESET marks emitted when a flow restarts8
ResetIdleGraceMsHonor a FLOW_RESET only after the flow has been idle this long (ms)10000
MaxFlowsMax tracked flows65536
GlobalMaxBytesShared buffer byte budget across all flows67108864
IngressIdleSecReceiver idle eviction timeout (must be < EgressIdleSec)30
EgressIdleSecSender idle eviction timeout300

Demotion in the ACK direction is automatic: a flow that looks like an ACK/control stream (mostly small packets) is moved to pass-through so it is never delayed. This is internal behaviour, not a configurable knob — tune it indirectly via ClassifyWindow / AckDemoteMaxLarge / SmallPacketThreshold.

Reorder is a throughput-vs-latency trade-off — it helps bulk transfers but delays every flow it touches: MaxWaitMs is how long the receiver waits for a missing datagram before skipping it; a gap that opens while another is already being waited for is deferred to the next wait, so a hold is nominally at most twice MaxWaitMs (buffer-limit flushes can release a gap earlier, and because the deadline is checked periodically, a wait can run slightly past it). Datagrams that arrive in order are never delayed. Since one tunnel usually carries both kinds of traffic, per-port [ReorderRule] sections let you enable it where it helps (bulk inner QUIC) and pass latency-sensitive traffic through (DNS, NTP, real-time UDP). Unmatched UDP is pass-through by default, so you only need rules for the ports you want reordered:

ini
[Reorder]
Enabled = true

[ReorderRule]          # bulk inner QUIC: reorder on
Proto = udp
Port = 443
Profile = fiber_lte

[ReorderRule]          # DNS: never add latency
Proto = udp
Port = 53
Profile = default_udp

The same works in JSON. The reorder object uses snake_case keys mapping 1:1 to the INI keys above, and reorder_rules is an array of {proto, port, profile} objects (each rule may also carry optional max_wait_ms / cap_packets overrides):

json
{
  "reorder": {
    "enabled": true,
    "max_wait_ms": 30,
    "cap_packets": 1024,
    "max_bytes_per_flow": 1572864,
    "classify_window": 64,
    "ack_demote_max_large": 3,
    "small_packet_threshold": 200,
    "reset_mark_packets": 8,
    "reset_idle_grace_ms": 10000,
    "max_flows": 65536,
    "global_max_bytes": 67108864,
    "ingress_idle_sec": 30,
    "egress_idle_sec": 300
  },
  "reorder_rules": [
    { "proto": "udp", "port": 443, "profile": "cellular_bond" },
    { "proto": "udp", "port": 4500, "profile": "fiber_lte", "max_wait_ms": 50, "cap_packets": 2048 }
  ]
}

Profile presets ​

Each profile carries an empirically-tuned (MaxWaitMs, CapPackets) preset. The values were chosen from a multipath netem sweep across 16 link environments — full methodology and per-environment data are in the reorder-only multipath report:

ProfileMaxWaitMsCapPacketsNotes
cellular_bond501024Cellular bonding (e.g. dual-LTE)
fiber_lte502048Mixed fiber + LTE; larger cap for higher BDP
quic_bulk501024Back-compat alias of cellular_bond
low_latency——Reserved; no preset (inert)
default_udp——Matched but not reordered (pass-through / OFF)

Precedence for a rule's effective (MaxWaitMs, CapPackets), highest first:

  1. The rule's own explicit MaxWaitMs / CapPackets key.
  2. A global [Reorder] explicit MaxWaitMs / CapPackets.
  3. The rule's profile preset (above).
  4. The builtin default (MaxWaitMs = 30, CapPackets = 1024).

In short, a number written explicitly always beats the profile. This is why a config that sets a global [Reorder] MaxWaitMs alongside Profile = quic_bulk keeps using the explicit global value unchanged.

When to enable reorder ​

Reorder is off by default and is meant to be opt-in only within its useful range. Empirically that range is an RTT spread of roughly 15–100 ms, paths with jitter, or asymmetric bandwidth.

  • Strong bandwidth asymmetry (≳ 8:1): consider raising MaxWaitMs to 150–200 (unverified — treat as a follow-up to validate on your link).
  • Extreme RTT spread (≥ 285 ms, GEO-satellite class): reorder is a net loss here; leave it off for that traffic with Profile = default_udp.

[ReorderRule] (repeatable) ​

KeyDescriptionDefault
ProtoMatched L4 protocol (udp)udp
PortMatched port (source or destination)—
Profilecellular_bond, fiber_lte, quic_bulk, low_latency, or default_udpquic_bulk
MaxWaitMsPer-rule override of the hold time (ms). 0 is rejected with a warning — to pass a port through untouched use Profile = default_udp insteadprofile preset
CapPacketsPer-rule override of the per-flow buffer cap. Must be a non-zero power of two, or it is rejected with a warningprofile preset

[Hybrid] ​

Terminates inner TCP locally and relays it over an HTTP/3 request stream so a single TCP flow can aggregate multiple paths. Disabled by default. See Hybrid Mode for the lane diagram, egress ACL semantics, and known limitations.

KeyDescriptionApplies toDefault
EnabledMaster switchclient + serverfalse
TcpPer-flow TCP lane policy: stream (always), raw (never — byte-identical to hybrid disabled), or auto (TCP lane once ≥2 paths are active at SYN time; latched for the flow's lifetime)clientauto
TcpMaxFlowsConcurrent TCP-lane flow cap. The client and the server enforce it on different machinery and fail differently — see Notes on TcpMaxFlows right below this tableclient + server256
TcpIdleTimeoutSecIdle-eviction timeout for TCP-lane flows; 0 disables idle evictionclient + server300
TcpConnectTimeoutSecTimeout for the server's egress connect(); on expiry the client gets HTTP 504server10
TcpMaxGlobalFlowsWhole-server cap on concurrent egress TCP flows across all sessionsserver4096
EgressAllowCIDR allowed through the default-deny egress ACL for private ranges (repeatable, up to 32)server—
EgressDenyAdditional CIDR to block, evaluated after EgressAllow (repeatable, up to 32)server—

Notes on TcpMaxFlows ​

TcpMaxFlows behaves differently on the client and the server. The two sides share the key name and nothing else: what is counted, and what happens when the cap is hit, both differ.

clientserver
Counted inthe lane's own flow tableper client session
Checkedbefore lwIP ever sees the SYNwhen the CONNECT-TCP request arrives
Inner TCP terminatedyes, by lwIP inside the laneno — relayed over ordinary kernel sockets
Over the capdegraded to the RAW lane — the connection still worksHTTP 503, and the client then resets that inner connection; no RAW fallback at that point
Memory per flow~0.75 MiB (uplink-queue ceiling)~32 KiB (two lazily-allocated 16 KiB relay chunks) plus one fd
Binding resourceRAM — worst case TcpMaxFlows × 0.75 MiB (≈ 192 MiB at the default 256, ≈ 3.0 GiB at 4096)the fd budget — TcpMaxGlobalFlows is checked first, but size the RAM too: worst case TcpMaxGlobalFlows × 32 KiB (≈ 128 MiB at 4096) once every admitted flow has paused once in each direction
Clampedto half the lwIP TCP pcb pool (below)not clamped (no lwIP involved)

Client-side clamp: the honored value is capped at half the lwIP TCP pcb pool — 4096 on desktop/router builds (Linux, Windows, macOS), 256 on Android, 64 with the iOS lwIP profile.

It does not halve your quota. You may run the full honored number of flows at once. The other pool half is reserved as headroom for pcbs the flow table has stopped counting — TIME_WAIT, LAST_ACK and CLOSING outlive the flow itself.

In JSON, the section is a "hybrid" object with snake_case keys (enabled, tcp, tcp_max_flows, tcp_idle_timeout_sec, tcp_connect_timeout_sec, tcp_max_global_flows, egress_allow, egress_deny).

The egress ACL default-denies RFC1918, loopback, and link-local targets even with no EgressAllow/EgressDeny configured — a safety default against a compromised client using the server as an internal-network pivot.

[Advanced] ​

KeyDescriptionDefault
RecvRateLimitConn-level receive-rate cap in bytes/sec; bounds the aggregate QUIC receive window to rate x RTT. Client-side only — the server ignores it (a server-side cap would throttle client upload). Maximum 10000000000 (10 GB/s); larger values are rejected with a warning and the key falls back to 0. Leave 0 unless memory-constrained (mobile clients set this internally)0 (off)
UdpGsoBatches UDP sends with kernel GSO for lower CPU use at high throughput. Automatically falls back to regular batched sends on kernels without support (< 4.18). Linux and Android only; no effect elsewhere. Set to false to restore the previous per-packet send pathtrue
UdpGroLets the kernel hand over received packets in batches, lowering CPU use at high throughput. Automatically falls back to one-at-a-time receives on kernels without support (< 5.0). Linux only; no effect elsewhere. Set to false to restore the previous receive pathtrue
TunOffloadLets the kernel hand large batches of traffic to the tunnel interface in one read; mqvpn splits them, lowering CPU use at high throughput. Automatically falls back to one packet per read when the kernel does not support it. Linux only; no effect elsewhere. Set to false to restore the previous tunnel interfacetrue
TunGroCombines consecutive TCP packets, and consecutive UDP datagrams of the same size, headed for the tunnel interface into one larger write, lowering CPU use at high throughput. UDP datagrams are combined only on kernels 6.2 and later; older kernels keep writing them one at a time. No effect when TunOffload is false. Linux only; no effect elsewhere. Set to false to write packets one at a time as beforetrue

In JSON, the section is an "advanced" object with snake_case keys (recv_rate_limit, udp_gso, udp_gro, tun_offload, tun_gro).

MTU Guidelines ​

Default (auto) ​

For most setups, leave MTU unset. The auto-negotiated value (~1382) works on standard Ethernet (1500), PPPoE (1492), and mobile networks.

When to set MTU explicitly ​

ScenarioRecommendation
Standard Ethernet / mobileLeave unset (auto ~1382)
Deeply nested tunnels (mqvpn → WG → another tunnel)Calculate remaining MTU; set if near 1280
Both ends on a clean 1500-byte path, larger tunnel MTU wantedPathMTU = 1500 on both ends (TUN MTU 1418)
Path MTU unknown or changing (mobile, PPPoE, tunnels, ICMP filtered)PMTUD = true on both ends

On the client, if MTU is set, mqvpn uses min(config MTU, negotiated MTU); a warning is logged when the config value exceeds the negotiated value. On the server, MTU sets the TUN MTU directly (default 1382).

TIP

On the client, setting MTU above the negotiated value (~1382) has no effect — the negotiated value is always the upper bound. On the server, the configured value is applied to the TUN device as-is; packets exceeding a client's negotiated MSS are answered with ICMP Packet Too Big so that the sender's Path MTU Discovery can adjust.

How mqvpn determines TUN MTU ​

mqvpn negotiates the TUN MTU from the QUIC DATAGRAM Maximum Segment Size (MSS) at connection time. With the default max_pkt_out_size of 1400, the overhead breakdown is:

max_pkt_out_size           1400 bytes
 − QUIC short header         13 bytes
 − DATAGRAM frame header      3 bytes
 − MASQUE datagram header      2 bytes
                           ─────────
 = TUN MTU                  1382 bytes

This negotiation happens at connection time on the client, and the client TUN MTU follows it. The server sets its TUN MTU once at startup (1382 by default, or the configured value). When a packet exceeds a particular client's negotiated MSS, the server returns ICMP Packet Too Big to the original sender, carrying that client's MSS as the MTU value; the sender's Path MTU Discovery then lowers its packet size. The shared server TUN MTU never needs to shrink for individual clients.

Using a larger tunnel MTU ​

The default 1400-byte packet size leaves room for PPPoE and most mobile networks without any tuning. If you know the network between client and server carries larger packets, set PathMTU to that size on both ends and mqvpn sizes its packets to fit:

PathMTU                    1500 bytes
 − IPv6 + UDP header         48 bytes
 − encryption tag            16 bytes
                           ─────────
 = max_pkt_out_size         1436 bytes (upper limit)
 − QUIC, DATAGRAM and MASQUE headers  18 bytes
                           ─────────
 = TUN MTU                  1418 bytes

A packet at the upper limit never carries an ACK frame. Below it, mqvpn also leaves 16 bytes of room for one, so PathMTU = 1492 gives 1412-byte packets and a TUN MTU of 1394. The QUIC library caps packets at 1436 bytes, so values above 1500 give the same result. If PathMTU is larger than what the network really carries, full-size packets get fragmented or dropped, so only set it when you know the path.

When an explicit MTU exceeds the available inner packet size, the client warns and limits it. The server also warns, but keeps the configured TUN MTU; oversized tunnel packets may be dropped.

IPv6 inside the tunnel requires room for a complete 1280-byte packet. With IPv6 enabled, a nonzero PathMTU must be at least 1378, or 1386 when reorder is enabled. The server rejects a smaller setting at startup; the client closes the connection with an error if its available packet size is too small when IPv6 is assigned. Setting MTU = 1280 does not increase the available capacity. IPv4-only tunnels can still use smaller values. The default PathMTU = 0 remains supported.

Discovering the path MTU ​

With PMTUD = true on both ends, mqvpn finds the packet size the path carries instead of relying on a fixed one:

  • Packets start at the smallest size that still gives a 1280-byte inner MTU (IPv6's minimum): 1358 bytes on the path over IPv4, 1378 over IPv6, and 8 bytes more with [Reorder] on, for its header.
  • If the path is smaller than that, mqvpn goes down to QUIC's minimum packet size (1200 bytes) and searches up from there, so an IPv4-only tunnel keeps working with a TUN MTU below 1280. A tunnel that carries IPv6 cannot: the client closes the connection with an error saying IPv6 needs an inner MTU of 1280.
  • Larger probe packets are sent at increasing sizes. A probe that gets through raises the packet size; a lost one only means it does not fit, and does not slow the connection down. The search takes a few round trips per size tried and stops within 4 bytes of the largest size that fits, at most the PathMTU size if one is set, or 1436 bytes (TUN MTU 1418).
  • The UDP sockets send with the Don't Fragment bit set, so packets are never fragmented on the way. Discovery does not need ICMP: it works on networks that filter it.
  • If the path gets smaller during a connection, larger packets stop arriving while smaller ones still do. mqvpn checks with a few probes at the current size, and if they are lost too, goes back to the start size within a few round trips (about a second on a 100 ms path) and searches again. Lost data sent at the old size is resent in packets that fit.
  • Ten minutes after a search ends below the limit, a new one starts from the top, in case the path got larger.

The client's TUN MTU follows the discovered size, down as well as up. The server keeps its TUN MTU and answers packets that are too large for one client with ICMP Packet Too Big carrying that client's size.

With multipath, every path is probed, and the connection uses the size of the smallest one. A path that is added later starts from the start size, so the connection goes back down to it until the new path is probed. A path that is still being validated does not count, so one that never gets through does not hold the connection back.

PMTUD works on Linux, macOS and Windows clients and on Linux servers. In the Android SDK, set pmtud = true (and pathMtu for the upper limit) in MqvpnConfig. Android sets the TUN MTU only when the tunnel comes up, so there it stays at 1280: PMTUD still keeps the tunnel working when the path gets smaller, but uploads do not use a larger discovered size.

Running other tunnels inside mqvpn ​

When running a tunnel protocol (WireGuard, IPsec, GRE, etc.) inside the mqvpn tunnel, the inner tunnel's overhead reduces the effective MTU. Verify that the remaining MTU meets the inner protocol's requirements.

Example: WireGuard inside mqvpn

mqvpn TUN MTU                    1382 bytes
 − WireGuard overhead (IPv6)        80 bytes
                                 ─────────
 = WireGuard inner MTU            1302 bytes
   → IPv6 minimum (1280)            ✓
   → QUIC/HTTP3 UDP payload        1254 bytes > 1200  ✓

Constraints ​

ConstraintValueSource
Config minimum1280IPv6 minimum MTU (RFC 8200)
Config maximum9000Jumbo frame MTU
QUIC minimum UDP payload1200RFC 9000 §14 (handshake requirement)
Auto value~1382 (client: negotiated; server: fixed default)Derived from max_pkt_out_size (1400)

Control API ​

A running server can be managed at runtime over a local TCP socket using JSON commands. This is useful for adding or removing users without restarting the server.

Enable ​

bash
sudo mqvpn --mode server ... --control-port 9090

The control API binds to 127.0.0.1 by default. It has no authentication, so only bind to trusted interfaces.

Enable from config file ​

The control API can also be enabled from /etc/mqvpn/server.conf:

ini
[Control]
Listen = 127.0.0.1:9090

…or from the JSON equivalent:

json
{
  "control_listen": "127.0.0.1:9090"
}

CLI flags (--control-port, --control-addr) override the config-file values per field. --control-port 0 explicitly disables the API even if [Control] Listen is set in the config.

Commands ​

Add a user:

bash
echo '{"cmd":"add_user","name":"carol","key":"carol-secret"}' | nc 127.0.0.1 9090

Remove a user:

bash
echo '{"cmd":"remove_user","name":"carol"}' | nc 127.0.0.1 9090

Removing a user also disconnects any active sessions authenticated with that username.

List users:

bash
echo '{"cmd":"list_users"}' | nc 127.0.0.1 9090

Get stats:

bash
echo '{"cmd":"get_stats"}' | nc 127.0.0.1 9090

Get detailed status (per-client, per-path):

bash
echo '{"cmd":"get_status"}' | nc 127.0.0.1 9090

Or use the built-in status command for human-readable output:

bash
mqvpn --status --control-port 9090

All commands return a JSON response with an "ok" field. Each connection handles one command, then the server closes the connection.

systemd ​

If you installed via the deb package or install.sh, the systemd units are already in place. For source builds, install manually:

bash
sudo cmake --install build --prefix /usr/local

Server ​

If you used install.sh, /etc/mqvpn/server.conf is already generated. To configure manually, copy the example:

bash
sudo cp /etc/mqvpn/server.conf.example /etc/mqvpn/server.conf
sudo vi /etc/mqvpn/server.conf   # edit cert paths, auth key, etc.
sudo systemctl enable --now mqvpn-server

Client (template unit) ​

The client uses a template unit — the instance name maps to the config file:

bash
sudo cp /etc/mqvpn/client.conf.example /etc/mqvpn/client-home.conf
sudo vi /etc/mqvpn/client-home.conf   # edit server address, auth key, etc.
sudo systemctl enable --now mqvpn-client@home
# → reads /etc/mqvpn/client-home.conf

INFO

The systemd units use the .conf paths shown above, which can contain INI or JSON. The server's NAT helper uses the same configuration parser as mqvpn at startup. At shutdown, it removes its firewall rules and restores saved forwarding settings even if the configuration has been changed or deleted. When installing manually, keep mqvpn-server-nat.sh and mqvpn-nat-config together in the installation's lib/mqvpn directory.

Released under the Apache License 2.0 · Privacy Policy