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
| Field | Type | Default | Description |
|---|---|---|---|
negotiation_version | integer | 1 | Wire/config version, fixed to 1 in v1. Reserved for future static per-link transport-mode selection — never an on-wire handshake. |
local_tun_mtu | integer | 1452 | Tunnel MTU. Must be in 68–1500. The default leaves room for the 64-byte wire overhead on a 1500-byte underlay. |
listen_ports | array of integer | role-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_port | integer | (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_subnet | CIDR | 10.0.0.0/24 | The overlay subnet this mesh builds. |
local_tun_ip | CIDR | (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_id | integer | 0 | This 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”. |
peers | array | [] | The configured mesh peers (fixed capacity, zero-allocation). See Peer fields. |
role | string | "manual" | manual, hub, or spoke. Controls bootstrap-policy derivation — see Roles. |
local_routes | array 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_routes | array of CIDR | [] | role=spoke: subnets reachable through the hub. When empty, the spoke routes virtual_subnet to the hub. |
keepalive_secs | integer | role default | Built-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. |
obfuscate | boolean | true | Header 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[]:
| Field | Type | Default | Description |
|---|---|---|---|
id | integer | — | The peer’s mesh id (non-zero, u16). Used to derive directional link keys and as the on-wire key_id selector. |
endpoint | string | — | The 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_src | CIDR | 0.0.0.0/0 | The 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. |
psk | hex string | — | This link’s private 32-byte pre-shared key (64 hex chars). Required, non-zero, and unique per link. Generate with openssl rand -hex 32. |
name | string | "" | 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-levelpskis rejected withInvalidPsk; reusing one PSK across peers is rejected withDuplicatePsk.
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_mtumust 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_idis non-zero and distinct from every peer id. - Unique PSKs: no PSK is shared across peers (
DuplicatePsk); no top-levelpsk(InvalidPsk). - Role rules (see Roles):
hubrejects a peer whoseallowed_srcis missing/0.0.0.0/0or overlaps another peer’sallowed_src.spokerequires exactly one hub peer, at least one local target (local_routesorlocal_tun_ip), and no0.0.0.0/0local 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
- Bootstrap policy from
role: Roles. - Turning config into host commands: Host Network Plan.
- What the keys/epoch/
allowed_srcdefend against: Security Model. - Runtime policy injection that overlays this config: CLI Reference.