Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Configuration Reference

The daemon reads a single config.json from its working directory (override with --config <path>). If the file is missing it falls back to a compiled-in default. The parser is strict: unknown shapes, invalid CIDRs, or values out of range cause a fail-closed startup. Validate any change with subnetrad --check before deploying.

A minimal example (config.example.json):

{
  "negotiation_version": 1,
  "local_tun_mtu": 1452,
  "listen_ports": [18020, 18023, 18026],
  "virtual_subnet": "10.0.0.0/24",
  "local_id": 1,
  "obfuscate": true,
  "peers": [
    { "id": 2, "endpoint": "203.0.113.2:18020", "allowed_src": "10.0.0.2/32", "name": "bj-office-gw", "psk": "…64 hex…" },
    { "id": 3, "endpoint": "203.0.113.3:18020", "allowed_src": "10.0.0.3/32", "name": "colo-hub",     "psk": "…64 hex…" }
  ]
}

Top-level fields

FieldTypeDefaultDescription
negotiation_versioninteger1Wire/config version, fixed to 1 in v1. Reserved for future static per-link transport-mode selection — never an on-wire handshake.
local_tun_mtuinteger1452Tunnel MTU. Must be in 68–1500. The default leaves room for the 64-byte wire overhead on a 1500-byte underlay.
listen_portsarray of integerrole-aware (see note)The UDP ports the daemon binds for the underlay — listed explicitly (never a range). The node accepts datagrams on all of them and returns each peer’s traffic out the socket it was last heard on (NAT-correct), so a single blocked port does not take the node offline. 1–8 ports, each non-zero and distinct. When omitted, the default is role-aware: a hub/manual node binds the full set [18020, 18023, 18026] (it is the reachable endpoint, so several ports give single-blocked-port fault tolerance), while a spoke binds only [18020] — a spoke sits behind NAT and the hub always replies to its source port, so extra listen ports would just sit idle. Override it explicitly with the same array key either way (e.g. [18020] on a spoke, [18020, 18023, 18026] on a hub). The default is deliberately not 51820 (WireGuard’s well-known port is itself a fingerprint).
listen_portinteger(unset)Single-port convenience / back-compat: a scalar equivalent to "listen_ports": [<port>]. Ignored when listen_ports is present. When both are omitted the role-aware default set applies.
virtual_subnetCIDR10.0.0.0/24The overlay subnet this mesh builds.
local_tun_ipCIDR(unset)This node’s own overlay address (host + prefix), e.g. 10.0.0.2/24. Emitted in the host network plan to address the TUN; the daemon never configures host addressing itself. role=spoke needs a local target (this or local_routes) and uses it as the spoke’s reachable IP. For a role=hub it is optional — the derived table forwards only to spokes, so leaving it unset keeps the hub a pure relay that is not reachable on the overlay; set it (and add a local-delivery rule) only if you need to reach the hub itself (details).
local_idinteger0This node’s mesh id. Must be non-zero, fit a u16 (1–65535), and be distinct from every peer id when peers is non-empty. 0 means “single-node / no mesh”.
peersarray[]The configured mesh peers (fixed capacity, zero-allocation). See Peer fields.
rolestring"manual"manual, hub, or spoke. Controls bootstrap-policy derivation — see Roles.
local_routesarray of CIDR[]role=spoke: subnets this node delivers locally (to its own TUN/host). When empty, local_tun_ip (as a /32) is used.
remote_routesarray of CIDR[]role=spoke: subnets reachable through the hub. When empty, the spoke routes virtual_subnet to the hub.
keepalive_secsintegerrole defaultBuilt-in spoke→hub NAT keepalive interval. 0 disables it (hub/manual default). A NATed spoke defaults to 20. With obfuscate on, each interval is randomized within [secs/2, secs] so the cadence is not a fingerprint.
obfuscatebooleantrueHeader obfuscation, on by default: XOR-mask the 20-byte header per packet so the datagram is indistinguishable from random to a passive observer, and randomize the spoke keepalive cadence. Set false to opt out (readable cleartext header, e.g. for packet-capture debugging). Must be set identically on every node in the mesh (it is not negotiated; a mismatch fails closed). Hides the protocol fingerprint only, not packet length or timing.

Peer fields

Each entry of peers[]:

FieldTypeDefaultDescription
idintegerThe peer’s mesh id (non-zero, u16). Used to derive directional link keys and as the on-wire key_id selector.
endpointstringThe peer’s underlay address as host:port, e.g. 203.0.113.2:18020 (use one of the peer’s listen_ports). For a hub on dynamic DNS, see the deployment guide.
allowed_srcCIDR0.0.0.0/0The inner-source range this peer is permitted to send. A decrypted packet whose inner IPv4 source falls outside is dropped (spoof). Set this explicitly — the permissive default disables anti-spoofing.
pskhex stringThis link’s private 32-byte pre-shared key (64 hex chars). Required, non-zero, and unique per link. Generate with openssl rand -hex 32.
namestring""Optional human-readable label. Over-long or non-printable values are rejected.

There is no mesh-wide psk. Every link carries its own key. A config that still has a top-level psk is rejected with InvalidPsk; reusing one PSK across peers is rejected with DuplicatePsk.

Peer cap. peers[] has a fixed, zero-allocation capacity chosen at build time by -Dmax-peers (default 32, max 128); a hub manages at most this many spokes, and the policy-table size scales with it. See Tuning the peer cap.

Sanity checks

config.zig enforces these at load (and under --check); any failure aborts startup:

  • MTU range: local_tun_mtu must be 68–1500.
  • Subnet overlap: the virtual subnet must not collide with the host’s physical subnet in a way that would blackhole traffic.
  • Mesh ids: local_id is non-zero and distinct from every peer id.
  • Unique PSKs: no PSK is shared across peers (DuplicatePsk); no top-level psk (InvalidPsk).
  • Role rules (see Roles):
    • hub rejects a peer whose allowed_src is missing/0.0.0.0/0 or overlaps another peer’s allowed_src.
    • spoke requires exactly one hub peer, at least one local target (local_routes or local_tun_ip), and no 0.0.0.0/0 local route.

MTU and wire overhead

The fixed per-packet overhead is 64 bytes: a 20-byte private header + a 16-byte AEAD tag + 28 bytes of outer IPv4/UDP. The safe tunnel MTU is therefore path_mtu − 64. The default local_tun_mtu = 1452 assumes a 1500-byte underlay. On a smaller path (PPPoE, a VPN underlay), lower it — the host network plan computes and warns about this for you.

Where config meets the rest of the docs