Introduction
Connect your servers, sites, and devices into one private, encrypted network — shipped as a single tiny binary that runs anywhere, from a cloud VM to a MikroTik router.
This documentation site is bilingual. Use the 中文 / EN toggle in the top bar to switch languages, or read the 简体中文文档.
What is Subnetra?
Subnetra stitches machines in different places — offices, a data center, roaming laptops, home labs, containers, routers — into a single flat private subnet.
It uses a hub-and-spoke design: a reachable hub relays traffic between spokes, so any node can reach any other by a stable overlay IP even when most of them sit behind NAT. Every packet travels fully encrypted over an ordinary UDP tunnel, and the whole thing is one self-contained binary — nothing else to install, no kernel modules, no daemon zoo.
What you can do with it
- 🏢 Link branch offices to a data center — route whole subnets site-to-site over one private overlay.
- 💻 Give roaming laptops a stable private IP that follows them across Wi-Fi, LTE, and home networks.
- 🧪 Reach home-lab / IoT / container services as if they were on the same LAN.
- 🛰 Publish a LAN from behind NAT — e.g. a MikroTik router exposing
192.168.88.0/24to the mesh. - 📦 Run where heavy VPN stacks won’t fit — constrained containers, BusyBox, small ARM boxes, edge routers.
Highlights
- Runs anywhere, installs as one file — a single static binary (under 512 KB on
Linux) with zero external dependencies. Drops onto cloud VMs, containers, BusyBox,
Raspberry Pi, and MikroTik RouterOS. Builds for
amd64/arm64/armv7/armv5, plus a native macOS spoke. - Encrypted by default, quiet on the wire — every packet is ChaCha20-Poly1305
encrypted with per-link keys and replay protection. There are no magic bytes, and
unauthenticated packets are dropped silently — to a port scanner the tunnel looks like
nothing is listening. Header obfuscation is on by default (
obfuscate, mesh-wide): the 20-byte framing header is XOR-masked per packet so the whole datagram looks random and the NAT keepalive cadence is de-periodized — no protocol fingerprint for a passive observer. Set"obfuscate": falsefor a readable cleartext header (e.g. packet-capture debugging). Obfuscation hides the protocol fingerprint, not packet length or timing. See Wire Protocol → Header obfuscation. - A flat private subnet with policy routing — give every node an overlay IP, route whole subnets site-to-site, and let the hub relay spoke-to-spoke so nodes behind NAT still reach each other.
- Just works behind NAT — spokes keep their own NAT pinhole open with a built-in keepalive, and the hub automatically relearns a spoke that roams to a new address — no external pinger, no manual reconnect.
- Change routes live — inject or update forwarding rules at runtime with zero downtime: no restart, no dropped packets.
- Built to be operated — human-readable and JSON status, per-reason drop counters
that tell you why traffic isn’t flowing, per-peer health/
onlineflags, and a Prometheus exporter for alerting. - Simple, declarative config — describe a node as a
hubor aspokeand the forwarding table is derived for you. Name your peers sostatusreadsbj-office-gw, notid=2.
Quick start
The fastest path is the container image (the hub is typically a public cloud host):
# 1. Create a config — one hub, one or more spokes.
cp config.example.json config.json
# Set a UNIQUE 64-hex key on every peer link: openssl rand -hex 32
# 2. Run it (the tunnel needs the TUN device + NET_ADMIN).
docker run -d --name subnetra \
--cap-add=NET_ADMIN --device=/dev/net/tun \
-v "$PWD/config.json":/etc/subnetra/config.json:ro \
ghcr.io/jamiesun/subnetra:latest
# 3. Check it.
docker exec subnetra subnetra status
Prefer a bare binary? Grab the static build for your architecture from the latest release, then follow the Installation and Quick Start guides. A full hub + two-spoke production walkthrough lives in Production Deployment.
Operate & observe
subnetra status turns the silent, by-design packet drops into countable signals so
you can tell why traffic is or isn’t flowing:
subnetra v0.6.0 [running]
mode=raw_direct local_id=1 udp_port=18020 tun=snr0 peers=2
peers:
id=2 name=bj-office-gw endpoint=203.0.113.2:18020 allowed_src=10.0.0.2/32
id=3 name=alice-laptop endpoint=203.0.113.3:18020 allowed_src=10.0.0.3/32
traffic: tun_rx / udp_tx / udp_rx / tun_tx / relay / keepalive …
drops: unknown_peer / auth_or_invalid / spoof / no_route …
subnetra status --json emits the same data as a stable, versioned JSON object — with a
per-peer last_seen_age_seconds and online flag — and a drop-in Prometheus exporter
turns it into scrapeable metrics. See
Observability & Troubleshooting for the full status
schema, the drop taxonomy, and alerting examples.
How to read these docs
- New here? Start with Installation and the Quick Start.
- Want to understand the design? Read Architecture and the Security Model.
- Setting up a config? See the Configuration Reference and Roles.
- Going to production? Follow Production Deployment, Containers, RouterOS Spoke, or the macOS Spoke runbook.
- Building another implementation? The Wire Protocol is the normative contract.
Project status
The framework, the pure-algorithm layer, and the syscall data path (TUN, the
readiness reactor, AF_UNIX control plane, daemon main loop) are implemented and
exercised end-to-end in the dev container, with a native macOS utun/poll(2)
spoke behind the comptime src/os/ backend. v1 (raw_direct + PSK + anti-replay
- RCU policy) is the deliverable; v2 reliability modes (
kcp_arq,fec_xor) are reserved interface points only — see the Roadmap.
License
MIT © 2026 jettwang.
Installation
Subnetra ships as a single static binary. There is nothing to install system-wide and no shared libraries to manage — pick whichever delivery method fits your environment.
Quick install (interactive)
On Linux or macOS, the fastest path is the install script. It detects your
OS and architecture, resolves the latest release, verifies the download
against the release SHA256SUMS.txt, and installs both subnetra and
subnetrad — pausing for you to confirm before it writes anything:
curl -fsSL https://raw.githubusercontent.com/jamiesun/subnetra/main/install.sh | sh
The script is interactive and only installs the two binaries — it never
touches your network, firewall, or services (Subnetra always leaves the host
plan to you). If Subnetra is already present in the target directory, it shows
the installed version and asks before overwriting. For an unattended run, accept
the defaults with --yes:
curl -fsSL https://raw.githubusercontent.com/jamiesun/subnetra/main/install.sh | sh -s -- --yes
| Flag | Meaning |
|---|---|
--dir <path> | Install location (default /usr/local/bin). |
--version <vX.Y.Z> | Pin a specific release instead of the latest. |
--service | Also install the (disabled) systemd/launchd service unit. |
--yes | Skip every prompt (non-interactive). |
To run Subnetra as a managed service, add --service: it installs the hardened
systemd (Linux) or launchd (macOS) unit disabled — it never starts it and
never touches your network — then prints the steps to finish and enable it. See
Deployment for the full service setup.
Prefer to install by hand, or on a platform the script does not cover? Use the release tarballs below, or browse every asset on the Releases page.
| Method | Best for | Notes |
|---|---|---|
| Install script | One-line Linux / macOS install | Resolves latest, verifies checksums, interactive |
| Container image | Linux hosts, RouterOS / BusyBox containers | Multi-arch amd64 / arm64 / armv7 / armv5 |
| Release tarball | Bare Linux hosts, offline installs | docker load-able image tarballs also provided |
| macOS spoke binary | Apple Silicon / Intel Macs (spoke only) | Runbook-certified, not CI-gated |
| OpenWrt router | MIPS / ARM home & SOHO routers (spoke) | Static musl binary + procd service |
| Build from source | Development, custom targets | Requires Zig 0.16.0+ |
The daemon needs two things at runtime regardless of method: the NET_ADMIN
capability (to create the TUN device) and access to /dev/net/tun.
Container image
Tagged releases publish a multi-arch image to GHCR. Docker automatically selects the right architecture:
docker pull ghcr.io/jamiesun/subnetra:latest
# The daemon needs NET_ADMIN + the TUN device, and a config.json mounted into
# its working directory (/etc/subnetra).
docker run -d --name subnetra \
--cap-add=NET_ADMIN --device=/dev/net/tun \
-v "$PWD/config.json":/etc/subnetra/config.json:ro \
ghcr.io/jamiesun/subnetra:latest
The image ships a Docker HEALTHCHECK (subnetra status), so docker ps /
Compose / Kubernetes report the daemon healthy once it is serving its control
socket and unhealthy if it stops responding.
The amd64, arm64 and arm/v7 images are built FROM busybox:musl (they carry
the two static binaries, config.example.json, and a tiny BusyBox shell for
in-container debugging). The arm/v5 image is built FROM scratch (still static
musl, no debug shell) and stitched into the same :latest / :version manifest.
See Containers for Compose and Kubernetes details.
Release binaries
Browse and download any release from the
Releases page. Each
release (vX.Y.Z) attaches static binary tarballs for amd64, arm64,
armv7, armv5, mipsel, and mips. The Linux binaries are fully static
against musl-libc — ldd reports not a dynamic executable.
MIPS / OpenWrt:
mipselis little-endian (ramips/mt7621/mt7628, most modern OpenWrt devices) andmipsis big-endian (ath79/Atheros). See the OpenWrt Spoke guide for picking the right one and the procd service.
Asset names carry the version, so resolve it once, then download, verify, and install:
ARCH=amd64 # one of: amd64 | arm64 | armv7 | armv5 | mipsel | mips
VER=$(curl -fsSLI -o /dev/null -w '%{url_effective}' \
https://github.com/jamiesun/subnetra/releases/latest | sed 's#.*/tag/##')
curl -fsSLO "https://github.com/jamiesun/subnetra/releases/download/$VER/subnetra-$VER-linux-$ARCH.tar.gz"
curl -fsSLO "https://github.com/jamiesun/subnetra/releases/download/$VER/SHA256SUMS.txt"
sha256sum --ignore-missing -c SHA256SUMS.txt # verify before installing
tar -xzf "subnetra-$VER-linux-$ARCH.tar.gz"
cd "subnetra-$VER-linux-$ARCH"
sudo install -m 0755 subnetrad subnetra /usr/local/bin/
subnetrad --version
The
releases/latest/download/<asset>path always points at the current release, but asset names embed the version — so resolveVERas above, or just use the install script.
Offline / air-gapped install
Devices that cannot reach a container registry can use the per-arch
docker load-able image tarballs attached to each release
(subnetra-image-<version>-<arch>.tar.gz):
docker load < subnetra-image-<version>-arm64.tar.gz # -> ghcr.io/jamiesun/subnetra:<version>
docker run -d --name subnetra \
--cap-add=NET_ADMIN --device=/dev/net/tun \
-v "$PWD/config.json":/etc/subnetra/config.json:ro \
ghcr.io/jamiesun/subnetra:<version>
macOS spoke binary
Each release also attaches native macOS binaries for running Subnetra as a
spoke — subnetra-<version>-macos-arm64.tar.gz (Apple Silicon) and
-amd64.tar.gz (Intel). They are Mach-O binaries that link only libSystem
(zero third-party deps).
The install script works on macOS too and clears the Gatekeeper quarantine attribute for you.
tar -xzf subnetra-<version>-macos-arm64.tar.gz
cd subnetra-<version>-macos-arm64
# Gatekeeper quarantines downloaded binaries — clear it (or build from source):
xattr -d com.apple.quarantine subnetrad subnetra 2>/dev/null || true
./subnetra --print-network-plan --config config.json # preview the host plan
sudo ./subnetrad --config config.json # utun creation needs root
Creating the utun interface and applying the ifconfig / route plan both
require root. macOS is supported as a spoke only (the hub stays
Linux/RouterOS); see the macOS Spoke guide.
Build from source
Requires Zig 0.16.0 or later.
# Native build (defaults to ReleaseSmall; pass -Doptimize=Debug for dev builds)
zig build
# Static cross-compile
zig build -Dtarget=x86_64-linux-musl # amd64
zig build -Dtarget=aarch64-linux-musl # arm64
zig build -Dtarget=arm-linux-musleabihf # armv7 (hard float)
zig build -Dtarget=arm-linux-musleabi # armv5 (soft float)
zig build -Dtarget=mipsel-linux-musl # mipsel (LE: ramips/mt7621 — OpenWrt)
zig build -Dtarget=mips-linux-musl # mips (BE: ath79/Atheros — OpenWrt)
# Run tests
zig build test
# Run the daemon
zig build run
Artifacts are placed in zig-out/bin/: subnetrad (daemon) and subnetra
(control tool).
Tuning the peer cap
The peer registry and parsed config are fixed-capacity, zero-allocation arrays,
so the maximum number of mesh peers a node can hold is a compile-time build
option (-Dmax-peers) — not a runtime config field. It defaults to 32 and
is capped at 128:
zig build -Dmax-peers=64 # raise the local peer cap to 64
zig build -Dmax-peers=128 -Dtarget=aarch64-linux-musl # combine with a target
A hub manages at most this many spokes. This is a per-node sizing knob —
it is never negotiated on the wire, so a spoke that only talks to one hub does
not need the hub’s larger cap. The control-plane policy-table size
(MAX_POLICY_ENTRIES) is derived from this value, so raising the cap grows
the policy capacity automatically (the default 32 yields a 272-entry table).
Raising it much higher trades memory and latency for capacity: the reactor is a
single-threaded, per-packet O(N) scan over peers (and, with obfuscate on, a
trial de-obfuscation per inbound datagram), so very large meshes are better
served by splitting into several hubs than by one hub with hundreds of
spokes.
ARMv5 note: ARMv5 has no hardware atomics, so the standard library’s threaded I/O scaffolding references legacy
__sync_*intrinsics that musl does not provide. Because Subnetra is strictly single-threaded,src/atomic_shim.zigsupplies a provably-correct plain (non-atomic) implementation, gated at comptime and compiled in only for pre-ARMv6 targets — every other architecture is byte-for-byte unaffected.
Verify the install
# Linux: confirm the binary is fully static
ldd ./subnetrad # -> "not a dynamic executable"
ls -lh ./subnetrad # -> < 512 KB
# Any platform: print the version banner
./subnetrad --version
Next: head to the Quick Start to bring up a hub and a spoke.
Quick Start
This walkthrough brings up the smallest useful mesh: one hub and two
spokes, building a virtual 10.0.0.0/24 overlay. Two spokes is what makes the
hub earn its name — it relays traffic between the spokes, which never talk to
each other directly. It assumes you have the subnetrad daemon and subnetra
control tool installed (see Installation).
Throughout: the hub is at the public address 203.0.113.1:18020, spoke A
(overlay 10.0.0.2) at 203.0.113.2, and spoke B (overlay 10.0.0.3) at
203.0.113.3.
1. Generate a per-link key
Every link needs its own 32-byte pre-shared key (64 hex chars). Never reuse one key across peers — so this two-link mesh needs two keys:
openssl rand -hex 32 # → KEY_A, for the hub ↔ spoke-A link
openssl rand -hex 32 # → KEY_B, for the hub ↔ spoke-B link
2. Write the configs
The simplest way is to set a role and let the
daemon derive the forwarding policy at boot.
Hub (config.json on 203.0.113.1) — lists both spokes:
{
"role": "hub",
"virtual_subnet": "10.0.0.0/24",
"local_id": 1,
"listen_ports": [18020, 18023, 18026],
"peers": [
{ "id": 2, "endpoint": "203.0.113.2:18020", "allowed_src": "10.0.0.2/32", "psk": "…KEY_A…" },
{ "id": 3, "endpoint": "203.0.113.3:18020", "allowed_src": "10.0.0.3/32", "psk": "…KEY_B…" }
]
}
Spoke A (config.json on 203.0.113.2):
{
"role": "spoke",
"virtual_subnet": "10.0.0.0/24",
"local_id": 2,
"local_tun_ip": "10.0.0.2/24",
"local_routes": ["10.0.0.2/32"],
"peers": [
{ "id": 1, "endpoint": "203.0.113.1:18020", "allowed_src": "10.0.0.0/24", "psk": "…KEY_A…" }
]
}
Spoke B (config.json on 203.0.113.3) — same shape, its own id and address:
{
"role": "spoke",
"virtual_subnet": "10.0.0.0/24",
"local_id": 3,
"local_tun_ip": "10.0.0.3/24",
"local_routes": ["10.0.0.3/32"],
"peers": [
{ "id": 1, "endpoint": "203.0.113.1:18020", "allowed_src": "10.0.0.0/24", "psk": "…KEY_B…" }
]
}
Each link carries its own PSK: KEY_A is shared by the hub’s peer 2 and
spoke A; KEY_B by the hub’s peer 3 and spoke B — and the two keys differ. The
spokes need no listen_ports (a spoke binds the single default port). See the
Configuration Reference for every field.
3. Validate before running
--check parses the config, runs every sanity rule, and exits without touching
the network:
subnetrad --check --config config.json
# spoke A: subnetra v… (mtu=1452, udp_ports={ 18020 }, mode=raw_direct, local_id=2, peers=1) [config ok]
# hub: subnetra v… (mtu=1452, udp_ports={ 18020, 18023, 18026 }, mode=raw_direct, local_id=1, peers=2) [config ok]
4. Print and apply the host network plan
The daemon creates the TUN device but does not configure host addressing, routes, or MTU (that would break the zero-dependency guarantee). Ask it to print the exact commands instead:
subnetrad --print-network-plan --config config.json
Review the emitted ip link / ip addr / ip route commands and run them (on
macOS the plan emits ifconfig / route). See
Host Network Plan for details, including how
the safe MTU is computed.
Run this on each spoke. The hub has no local_tun_ip, so its plan only
creates the bare TUN device — it is a pure relay with no overlay address.
5. Start the daemons
# On the hub and both spokes (TUN creation needs NET_ADMIN / root):
sudo subnetrad --config config.json
For a real deployment, run it under systemd or launchd instead — see Production Deployment.
6. Verify connectivity
From spoke A, ping spoke B — the packet goes A → hub → B and back,
exercising the hub relay:
ping 10.0.0.3
Then check the live counters on any node:
subnetra status
On the spokes you should see udp_tx / udp_rx climbing and the peer online;
on the hub the relay_* counters increment as it forwards between the spokes. If
traffic is not flowing, the drop counters tell you why — read
Observability & Troubleshooting.
The hub here is a pure relay with no overlay address, so there is nothing at
10.0.0.1to ping. To make the hub itself reachable, see Roles → Reaching the hub itself.
7. Add Site-to-Site routes (optional)
To reach a LAN behind spoke B (e.g. 192.168.3.0/24), the hub needs a relay
rule for that prefix. Inject it at runtime — it hot-updates over the control
socket with no restart:
# On the hub:
subnetra policy add --src 0.0.0.0/0 --dst 192.168.3.0/24 --action forward --target 3
subnetra policy show
subnetra save # persist the active policy back to config.json
Spoke B must also deliver that prefix locally (add it to local_routes) and be
allowed to source it (widen peer 3’s allowed_src on the hub to cover it). The
full Site-to-Site walkthrough is in Production Deployment.
Where to go next
- Roles — auto-derive policy from config.
- Architecture — how the data path works.
- Security Model — keys, epochs, anti-replay.
- Production Deployment — services, secrets, firewall/NAT, upgrades.
Architecture
Subnetra is a Layer-3 overlay: it moves raw IPv4 packets between nodes over an encrypted UDP underlay, arranged as a single-hub hub-and-spoke star. This page explains how a packet travels through the system and how the daemon is structured internally.
Topology
A central hub (typically an overseas relay or colocation node) anchors the
mesh. Each spoke (a branch office, a RouterOS container, a Mac) connects to
the hub over a private UDP tunnel. Spokes do not talk to each other directly —
the hub relays between them by policy. Together they form a virtual subnet
(e.g. 10.0.0.0/24) on top of whatever physical leased line connects them, and
can route between LANs behind each node (Site-to-Site).
flowchart LR
subgraph SiteA["Site A · LAN 192.168.1.0/24"]
S2["Spoke 2<br/>10.0.0.2"]
end
subgraph HubBox["Hub · relay"]
HUB["Hub<br/>10.0.0.1 / id 1<br/>policy + relay"]
end
subgraph SiteB["Site B · LAN 192.168.2.0/24"]
S3["Spoke 3<br/>10.0.0.3"]
end
S2 <-->|"encrypted UDP underlay"| HUB
HUB <-->|"encrypted UDP underlay"| S3
The data path
Every packet crosses the same five stages:
flowchart TD
TUN["1 · TUN ingress<br/>read raw IPv4"] --> POL{"2 · Policy lookup<br/>longest-prefix CIDR"}
POL -->|no match| DROP["DROP · counted"]
POL -->|FORWARD| SEAL["3 · Header + seal<br/>20-byte header · ChaCha20-Poly1305 + 16-byte tag"]
SEAL --> EGR["4 · Egress dispatch<br/>raw_direct over UDP"]
EGR -. encrypted UDP underlay .-> RECV["5 · Receive & verify<br/>key_id · epoch · nonce · anti-replay · inner-src"]
RECV -->|local| DEL["Deliver to local TUN"]
RECV -->|hub relay| RLY["Relay to another spoke<br/>never back to source"]
- TUN ingress. The kernel routes a LAN/overlay packet to the virtual L3 device; the reactor reads the raw IPv4 packet non-blocking.
- Policy lookup. The reactor atomically loads the active policy tree and does
a longest-prefix CIDR match on the destination to decide
FORWARD(and to which peer) orDROP. - Header + seal. It assembles the 20-byte private wire header (version, flags,
key_id, session epoch, sequence number) and encrypts the inner packet with ChaCha20-Poly1305, appending the 16-byte tag. - Egress dispatch. The sealed datagram is sent over the UDP socket to the
peer’s endpoint. v1 uses the
raw_directegress;kcp_arq/fec_xorare reserved for v2. - Receive & deliver. The peer verifies
key_id, epoch, nonce/anti-replay and the inner source, decrypts, and either writes the inner packet to its own TUN (LOCAL) or relays it to another spoke (hub only, never back to the source).
Internal structure
The daemon is a single-threaded, event-driven reactor. One thread multiplexes three file descriptors and never blocks:
| FD | Purpose |
|---|---|
TUN_FD | Raw IPv4 ingress/egress on the virtual L3 device |
UDP_FD | The encrypted underlay socket to/from peers |
UDS_FD | The control-plane Unix domain socket (policy injection, status) |
The readiness primitive is selected at comptime by the OS backend:
- Linux —
epolledge-triggered (EPOLLET), reading untilEWOULDBLOCK. - macOS —
poll(2)(a spoke-only backend;kqueueis a later milestone).
Source modules:
| Module | Responsibility |
|---|---|
config.zig | config.json parsing + sanity checks (MTU range, subnet overlap, role rules) |
policy.zig | CIDR parsing, longest-prefix match, lock-free RCU ActiveTree |
crypto.zig | ChaCha20-Poly1305, monotonic nonce, sliding-window anti-replay |
reactor.zig | Packed wire header, egress dispatch, the readiness loop |
peer.zig | Per-peer endpoint + crypto registry (keys, counters, replay windows) |
os/linux.zig, os/darwin.zig, os/mod.zig | Comptime OS backend (epoll + /dev/net/tun vs poll(2) + utun) |
uds.zig | Control socket + command tokenizer |
stats.zig | Data-plane counters (rx/tx, per-reason drops) for subnetra status |
netplan.zig | --print-network-plan host command emitter |
main.zig / subnetra.zig | Daemon entry point / control tool entry point |
Two memory tiers
Subnetra splits memory by responsibility instead of applying one blanket rule:
- Data plane (
reactor,crypto): strictly zero allocation. All packet buffers are locked into resident memory at startup via a fixed allocator; the hot path never callsalloc/free. Under a saturating large-packet load the RSS line is flat — 0 bytes of jitter. - Control plane / reliability (
uds, policy rebuild, future ARQ/FEC): isolated arenas. These paths may allocate in arenas with independent lifetimes, but must never pollute the data-plane memory line.
Lock-free RCU policy updates
Because the whole daemon is single-threaded, there are no locks anywhere. The
data plane reads the policy tree through a single *const PolicyTree pointer with
an atomic load. When the control plane injects a rule, it builds a brand-new
tree in an arena, then swaps it in with one atomic pointer store (an RCU
pattern). The old tree is reclaimed once the event loop is idle. Hot updates are
therefore zero-copy and jitter-free — an in-flight TCP throughput test sees no
measurable latency spike during a policy swap.
Endpoint learning (NAT / roaming)
Spokes commonly sit behind NAT with changing public addresses. Subnetra carries
the sender’s mesh id (key_id) in every datagram. When an authenticated
packet arrives from a new underlay address, the hub relearns that peer’s endpoint
and replies there — no handshake, no restart. This makes NAT remap and roaming
self-healing. A built-in spoke→hub NAT keepalive keeps idle pinholes open (see
Roles and Security Model).
For the exact byte layout and receiver rules, see the normative Wire Protocol.
Security Model
Subnetra’s transport security is mandatory in v1 and cannot be deferred. This page describes the threat model and every mechanism that enforces it. The byte-exact rules live in the normative Wire Protocol; this page is the conceptual companion.
Threat model
Subnetra assumes a hostile underlay: an attacker can observe, drop, modify, inject, and replay UDP datagrams, and can actively probe the listening port. The design goals are:
- Confidentiality & integrity of every inner packet.
- Stealth — an active prober cannot distinguish a Subnetra endpoint from a host that simply drops traffic.
- No replay of captured ciphertext into the protected network.
- Compartmentalization — compromising one spoke’s key must not forge any other link.
Per-link pre-shared keys
Each peers[] entry carries its own 32-byte PSK (64 hex chars). There is no
mesh-wide shared secret; an old config that still carries a top-level psk is
rejected (InvalidPsk), and reusing one PSK across peers is rejected
(DuplicatePsk).
From each PSK, a directional link key is derived per ordered pair:
link_key(psk, from_id, to_id) =
BLAKE2b-256(key = psk, msg = "subnetra-v1-link" || u32_be(from_id) || u32_be(to_id))
So a node’s transmit key to a peer equals that peer’s receive key for traffic from the node, and the two directions use distinct keys.
Why per-link keys are mandatory: under a shared PSK, two independent per-peer monotonic counters could emit the same
(key, nonce)pair for different plaintexts, which catastrophically breaks ChaCha20-Poly1305. Giving every directional link its own key makes each link’s nonce space disjoint.
Session epoch — stateless, handshake-free sessions
Subnetra establishes no session on the wire. Instead, each daemon lifetime samples
a boot epoch once at startup (wall-clock nanoseconds, u64) and derives a
fresh per-session key:
session_key(link_key, epoch) =
BLAKE2b-256(key = link_key, msg = "subnetra-v1-session" || u64_be(epoch))
The epoch travels in every datagram (8 bytes). The receiver derives the matching key statelessly from the epoch it sees and applies a forward-only rule:
- A larger (later) epoch, once authenticated, supersedes the old session and resets the anti-replay window.
- A smaller (older) epoch is dropped (it would be a cross-epoch replay of a retired session).
Because every restart yields a new key, sequence numbers can safely restart from 1
without ever reproducing a historical (key, nonce) pair — no disk persistence
required.
Fail-closed clock: the epoch must be ≥ 2024-01-01T00:00:00Z in nanoseconds
and non-zero. A node whose clock cannot satisfy this refuses to start, rather than
emit a low/colliding epoch.
Accepted residual limitation: if a node’s wall clock runs backward across a restart (no RTC, not yet NTP-synced), its new epoch may be smaller than the one a peer remembers, and the peer will reject the new session until its clock advances past the old value. This is mitigated operationally (NTP/RTC), never by an in-protocol epoch exchange — there is no handshake by design.
Nonce & anti-replay
The 96-bit AEAD nonce is derived from a 64-bit monotonic counter that each endpoint increments per datagram — it is never fixed or reused. The receiver maintains a sliding-window (bitmap) anti-replay check per session: a sequence number outside the window, or one already seen, is dropped; an in-window out-of-order number is accepted. Without this, historical ciphertext could be replayed into the protected LAN.
Stealth: silent drop, no magic bytes
Subnetra is stateless obfuscation: the ciphertext contains no fixed magic number, and on any authentication or validation failure the packet is dropped silently — never answered with a TCP Reset, an ICMP error, or anything observable. To an active prober sending garbage (or replayed ciphertext) at the UDP port, the endpoint is indistinguishable from a black hole, and its CPU shows no unusual spike.
This defeats active probing. The 20-byte framing header is outside the AEAD, so if
it travels in cleartext a passive on-path observer can fingerprint the protocol by
its constant version, repeated epoch, and low monotonic seq. The deployment-wide
obfuscate setting (on by default)
(Wire Protocol → Header obfuscation)
XOR-masks the header with a per-packet pad so the whole datagram looks random to such
an observer — zero byte overhead, mesh-wide identical — and also
de-periodizes the spoke’s NAT keepalive so its cadence is not a fingerprint. It hides the
protocol fingerprint only, not packet length or general timing. Set obfuscate: false
to opt out (readable cleartext header for packet-capture debugging).
Inner-source binding (anti-spoofing)
Each peer declares an allowed_src CIDR. After decryption, the receiver checks the
inner IPv4 source address against that peer’s allowed_src; a packet whose
inner source falls outside the allowed range is dropped (counted as spoof). This
prevents an authenticated peer from injecting traffic that impersonates another
node’s address space.
No-reflect relay guard
When the hub relays between spokes, it never sends a packet back to the peer it came from. Combined with longest-prefix policy routing, this prevents reflection loops.
NAT keepalive (one-way, never acknowledged)
A role=spoke enables a built-in NAT keepalive by default (keepalive_secs = 20):
it sends one tiny authenticated datagram to its hub each interval so the spoke’s
NAT pinhole stays open and the hub keeps a fresh route back. It is a one-way,
never-acknowledged datagram gated purely by static config — it is not a
handshake and does not weaken the stateless model. Set keepalive_secs to tune it,
or 0 to disable (hub/manual default to 0).
What secrets are never exposed
subnetra status (and --json) deliberately never serialize PSKs or any
derived key. Counters, endpoints, and health are observable; secrets are not.
Cryptographic primitives
| Primitive | Choice | Parameters |
|---|---|---|
| AEAD | ChaCha20-Poly1305 (IETF, 96-bit nonce) | key 32 B, nonce 12 B, tag 16 B |
| KDF / keyed hash | BLAKE2b-256 in native keyed mode (not HMAC) | key = parent key, 32 B digest |
See the Wire Protocol for the exact key schedule, nonce construction, header serialization, and the full receiver decision sequence — all pinned by known-answer test vectors.
Design Principles
Subnetra is built around a small set of non-negotiable constraints. They are not aspirational — they are binding invariants that shape every module. If a change conflicts with one of these, the change is wrong. (Internally these are the project’s “iron laws”.)
1. Zero third-party dependencies
No WireGuard, no ikcp.c, no network frameworks, no external crypto libraries.
Only Zig’s standard library and raw syscalls via std.posix. Even the future v2
reliability layer must be in-house (an arena-based ARQ), never a vendored C
library. The payoff is a single auditable artifact with no supply chain.
2. Layered zero dynamic allocation
Memory is constrained by responsibility, not with one blanket rule:
- Data plane (
reactor,crypto): strictly allocation-free. All packet buffers live in resident memory fixed at startup; the hot path never allocates. - Control plane / reliability: independent arenas. Policy rebuilds and the UDS path may allocate in isolated arenas with their own lifetimes, but must never pollute the data-plane memory line.
The acceptance bar — “0 bytes of RSS jitter under load” — applies to the
raw_direct data plane; control-plane hot updates may briefly use reclaimable
arena memory.
3. Single-threaded, lock-free reactor
One thread; a lock-free, allocation-free readiness loop. No threads, no locks,
ever. Because there is no concurrency between the data and control planes, no
mutex is needed; policy hot-updates happen via an atomic pointer swap (RCU). The
specific readiness primitive is selected at comptime — Linux epoll
edge-triggered, macOS poll(2) — but the single-thread / no-lock / no-per-packet-
allocation invariant is the law.
4. Stateless obfuscation / stealth
ChaCha20-Poly1305 full encryption with no magic numbers in the ciphertext. On authentication failure, drop silently — never reply with a TCP Reset, ICMP, or anything observable. The endpoint is physically invisible to probing.
That covers active probing; a cleartext framing header would still let a passive
observer fingerprint the protocol, which the mesh-wide obfuscate setting (on by
default, Wire Protocol → Header obfuscation)
masks at zero byte overhead — also de-periodizing the keepalive cadence (fingerprint
only — not packet length or timing).
5. Mandatory transport security in v1
A private per-link PSK, a per-endpoint 64-bit monotonic nonce that is never reused, a per-restart session epoch, and a sliding-window anti-replay check. These cannot be deferred to a later version. See the Security Model.
6. A single minimal binary
Default -O ReleaseSmall, zero third-party deps on every platform.
- Linux: fully static against musl-libc;
ldd→not a dynamic executable; target ≤ 512 KB. - macOS: minimal-dynamic — links only
libSystem(Apple ships no static libc), with its own recorded size baseline. Theldd-static check is a Linux-only gate.
7. Test-driven
Pure logic ships with tests; zig build test must stay green before any commit.
The wire protocol is pinned by machine-checkable known-answer vectors (see
Wire Protocol).
8. Stateless, handshake-free transport
Every datagram is self-describing and independently decodable — the per-packet
epoch is the entire session-establishment mechanism. Subnetra performs no
connection-establishment round-trip, no challenge/response, and no in-band session
negotiation — not in v1, and not in v2. Any future transport mode is chosen by
static per-link config (the reserved negotiation_version / flags fields),
never negotiated on the wire.
Two consequences are accepted by design, not deferred:
- An on-path attacker may replay a captured datagram of a not-yet-observed epoch to transiently relocate a peer’s endpoint — it self-heals on the peer’s next genuine packet, and an off-path attacker cannot forge it.
- A node whose wall clock runs backward across a restart is rejected by peers until their clocks advance — mitigated operationally by NTP/RTC, never by an in-protocol epoch exchange.
The
KEEPALIVEflag (bit 0) is a one-way, never-acknowledged spoke→hub NAT-pinhole datagram gated by static config. It is not a handshake and does not weaken this law; bits 1–7 stay reserved for static mode selection.
Scope discipline: v1 vs v2
- v1 (delivered):
raw_directdata plane + PSK encryption + anti-replay + RCU hot-update policy engine. - v2 (roadmap, interface only):
kcp_arqandfec_xor— in-house reliability modes, selected by static per-link config, never an on-wire handshake. v1 only reserves theegressbranch and the headernegotiation_version/flagsfields; v2 branches returnerror.NotImplemented.
There is no handshake on the roadmap. See the Roadmap.
Why these constraints?
The target is a steel pipe for a leased line into the most constrained environments imaginable (a RouterOS / BusyBox container). Determinism, a tiny auditable footprint, and stealth matter more than features. The constraints are what make the result deployable where heavier tools cannot go.
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.
Roles
Instead of hand-injecting subnetra policy add rules, set a role and let the
daemon derive the forwarding table at boot. Three roles are available; role
defaults to "manual".
| Role | Derives policy? | Typical node |
|---|---|---|
manual | No (empty initial policy — inject rules yourself) | Custom setups, backward compatibility |
spoke | Yes — local targets + everything else via the hub | Branch office, RouterOS container, Mac |
hub | Yes — one forward rule per spoke’s allowed_src | The central relay |
You can always layer extra subnetra policy rules on top of a derived table at
runtime.
manual (default)
manual is the original, explicit mode and the default. The daemon derives no
policy at boot — the forwarding table starts empty and you install every rule
yourself over the control socket. Configs that predate roles keep working unchanged.
What manual changes vs. a derived role:
- No derived policy. You build the table with
subnetra policy add. - No role-specific
--check.subnetrad --checkstill runs the universal sanity checks (MTU range, 16-bit ids, host-subnet overlap), but it does not apply thehub/spokestructural rules (per-peerallowed_src, exactly-one-hub, a local target, no0.0.0.0/0local route). A malformed forwarding intent is yours to catch. - Keepalive defaults to
0. If amanualnode sits behind NAT, setkeepalive_secsyourself (aspokedoes this for you).
What manual does not change — security is identical. Role only chooses the
bootstrap policy; it never touches the data plane. Per-link encryption, session-epoch
ordering, anti-replay, and — crucially — the per-peer allowed_src inner-source
check all run exactly the same. Policy match is destination-only (longest-prefix);
each peer’s allowed_src independently binds which inner source addresses that peer
may assert. A hand-built manual table therefore cannot be tricked into accepting a
spoofed inner source — you give up the derived convenience table and the
role-specific guardrails, not the cryptographic guarantees.
When to use manual
- Topologies the
hub/spokeshapes can’t express in a single node — e.g. a node that is a spoke upstream and a relay downstream at the same time (thehub/spokeroles each validate one posture;manuallets one node hold both). This is outside the single-tier model the derived roles validate, so the table — and the upstream hub’sallowed_srcaggregation — is on you. - Reproducing a hand-tuned policy table verbatim, or backward compatibility with a pre-role config.
Building the table by hand
Rules are destination-matched longest-prefix; src is permissive (0.0.0.0/0).
--target 0 delivers to the local TUN, any other target relays to that peer id:
# On Linux the CLI default already matches the daemon, so no SUBNETRA_SOCK needed.
# Deliver this node's own overlay address locally.
sudo subnetra policy add --src 0.0.0.0/0 --dst 10.0.0.9/32 --action forward --target 0
# Relay a downstream prefix to peer 5; send everything else up to the hub (peer 1).
sudo subnetra policy add --src 0.0.0.0/0 --dst 10.0.0.32/27 --action forward --target 5
sudo subnetra policy add --src 0.0.0.0/0 --dst 10.0.0.0/24 --action forward --target 1
sudo subnetra policy show # verify ordering
sudo subnetra save # persist across restarts
Each peer must still carry the right allowed_src for the inner sources it is allowed
to assert — that binding is enforced regardless of these rules.
spoke
A home/office spoke that exposes its own overlay IP and routes everything else through the relay needs only:
{
"role": "spoke",
"virtual_subnet": "10.0.0.0/24",
"local_id": 2,
"local_tun_ip": "10.0.0.2/24",
"local_routes": ["10.0.0.2/32"],
"peers": [
{ "id": 1, "endpoint": "203.0.113.1:18020", "allowed_src": "10.0.0.0/24", "psk": "…64 hex…" }
]
}
This derives, automatically:
10.0.0.2/32 → LOCAL(deliver to this node’s own TUN)10.0.0.0/24 → hub(id 1)(everything else goes through the relay)
To publish a LAN behind the spoke (Site-to-Site), add it to local_routes
(e.g. ["10.0.0.2/32", "192.168.2.0/24"]) so the derived table delivers that
prefix locally.
Built-in NAT keepalive
A spoke turns on the NAT keepalive by default (keepalive_secs = 20). It
sends one tiny authenticated datagram to its hub every interval so an idle spoke’s
NAT pinhole stays open and the hub keeps a fresh route back — no external pinger,
no cron job. Set keepalive_secs explicitly to tune it, or 0 to disable.
Validation rules for spoke
subnetrad --check enforces:
- exactly one hub peer,
- at least one local target (
local_routesorlocal_tun_ip), - no
0.0.0.0/0local route (which would tie the host default route to the tunnel and blackhole it).
hub
The matching hub just lists its spokes; each peer’s allowed_src becomes a forward
rule to that peer:
{
"role": "hub",
"virtual_subnet": "10.0.0.0/24",
"local_id": 1,
"peers": [
{ "id": 2, "endpoint": "203.0.113.2:18020", "allowed_src": "10.0.0.2/32", "psk": "…64 hex…" },
{ "id": 3, "endpoint": "203.0.113.3:18020", "allowed_src": "10.0.0.3/32", "psk": "…64 hex…" }
]
}
This derives 10.0.0.2/32 → peer 2 and 10.0.0.3/32 → peer 3. The hub relays
between spokes by longest-prefix match, and never reflects a packet back to its
source.
Reaching the hub itself
The derived hub table forwards only to spokes — it never delivers to the hub’s own TUN. So by default a hub has no overlay address and is not reachable on the overlay: it is a pure relay. This is usually exactly what you want — the relay exposes nothing addressable on the mesh.
To make the hub itself reachable over the tunnel (to SSH into it, or to host a service on the overlay), do two things:
-
give it an address with
local_tun_ipso the network plan configures its TUN, and -
add a local-delivery rule for that address — either run the node as
manualwith an explicit table, or layer one rule on top of the derived hub table:# deliver the hub's own overlay address to its local TUN sudo -E subnetra policy add --src 0.0.0.0/0 --dst 10.0.0.1/32 --action forward --target 0
Leave local_tun_ip unset (and add no such rule) to keep the hub relay-only, with
nothing on the overlay able to address it.
Validation rules for hub
subnetrad --check rejects:
- a peer with a missing
allowed_src(or the permissive0.0.0.0/0), since the hub could not tell which spoke a packet belongs to; - two peers whose
allowed_srcprefixes overlap, which would make forwarding ambiguous.
A hub’s keepalive defaults to 0 (it does not initiate keepalives to spokes).
Ready-to-edit examples
The repository’s deploy/
directory ships editable hub.json, spoke-a.json, and spoke-b.json, plus the
service units. The full hub + two-spoke walkthrough is in
Production Deployment.
Host Network Plan
subnetrad creates the TUN device but deliberately does not configure host
addressing, routes, or MTU. Auto-applying host networking would mean shelling out
or linking extra libraries, which breaks the zero-dependency single-binary
guarantee. Instead, the daemon prints the exact commands for the loaded config
so you can review and run them.
Print the plan
# Print the host networking plan for this node (defaults to a 1500-byte underlay).
subnetrad --print-network-plan --config config.json
# Override the underlay path MTU (e.g. behind a PPPoE / VPN underlay):
subnetrad --print-network-plan --path-mtu 1420 --config config.json
Output is deterministic and print-only — nothing on the host is modified. The backend is selected at comptime, so the plan matches your platform:
- Linux emits
ip link/ip addr/ip routecommands. - macOS emits
ifconfig/routecommands.
What it emits
For the loaded config the Linux plan emits:
ip link set <tun> mtu <local_tun_mtu> upip addr add <local_tun_ip> dev <tun>— set the optionallocal_tun_ipconfig field (e.g."local_tun_ip": "10.0.0.2/24"); otherwise a placeholder is shown.ip route add <subnet> dev <tun>for each peer’sallowed_src— a permissive0.0.0.0/0is skipped so you never blackhole the default route.- an optional TCP MSS clamp hint (nftables / iptables) to avoid PMTU blackholes.
The MTU calculation
The plan computes the safe tunnel MTU from the real wire overhead:
header 20 + AEAD tag 16 + outer IPv4/UDP 28 = 64 bytes
max tunnel MTU = path_mtu − 64
If the configured local_tun_mtu exceeds that, the plan prints a warning —
this is the classic cause of “small packets work, large transfers stall” (PMTU
blackholing). On a standard 1500-byte path, 1436 is the safe default; on a
1420-byte underlay you would lower local_tun_mtu to 1356.
Measure the real path MTU
--print-network-plan assumes a 1500-byte underlay (override with
--path-mtu). But the real path is often smaller — PPPoE is 1492, and
CGNAT/mobile/tunneled uplinks vary — and the usual way to discover it, kernel
Path MTU Discovery, relies on routers returning ICMP “fragmentation needed”.
That ICMP is frequently filtered (a PMTU black hole), which is exactly the
kind of network an obfuscated overlay runs across.
The mtu-probe tool
measures the path actively, end to end, over plain UDP without trusting
ICMP, then prints the local_tun_mtu to configure. Run the responder on one
node and the prober on the other:
zig build tool:mtu-probe
# on the far node (e.g. the hub, at its public endpoint):
zig-out/tools/mtu-probe --listen 18020
# on the near node — binary-searches the largest datagram that round-trips
# with the Don't-Fragment bit set, then recommends a safe local_tun_mtu:
zig-out/tools/mtu-probe --probe 203.0.113.9:18020
# underlay path MTU : 1492 bytes (largest UDP payload that round-tripped: 1464 + 28 IPv4/UDP)
# subnetra overhead : 64 bytes
# recommended : local_tun_mtu = 1428
Set the printed value as local_tun_mtu (and re-run --print-network-plan to
confirm the warning is gone). On jumbo-frame paths pass --ceil 9000. See
tools/README.md for
details.
Apply it
Review the emitted commands, then run them (most require root):
subnetrad --print-network-plan --config config.json | sudo sh # after reviewing!
Prefer to inspect the output first and paste the commands deliberately, rather than piping straight to a shell — the plan is meant to be auditable.
Why this design
Keeping host networking out of the daemon means:
- the binary stays dependency-free and tiny,
- the same daemon behaves identically across Linux, containers, and macOS,
- operators retain full control and an auditable record of every change to the host’s addressing and routing.
See the Configuration Reference for the fields that feed the plan
(local_tun_ip, local_tun_mtu, each peer’s allowed_src) and
Production Deployment for wiring it into a service.
Production Deployment
This page condenses the full hub + two-spoke production walkthrough. For the
exhaustive version — including traffic shaping, NIC tuning, and benchmarking — see
docs/deployment.md
in the repository. Ready-to-edit artifacts live in
deploy/
(subnetrad.service, net.subnetra.subnetrad.plist, hub.json, spoke-a.json,
spoke-b.json).
0. Components
A deployment has one hub (stable public UDP endpoint) and one or more
spokes (outbound-only, often behind NAT). Each runs the same subnetrad
daemon and subnetra control tool; the difference is configuration
(Roles).
1. Install the binary
Use a release tarball or container image. On a bare host:
sudo install -m 0755 subnetrad subnetra /usr/local/bin/
2. Provision config and secrets
Place config.json where the service expects it (the units use
/etc/subnetra/config.json), owned by root and mode 0600 because it contains
PSKs:
sudo install -d -m 0750 /etc/subnetra
sudo install -m 0600 hub.json /etc/subnetra/config.json
subnetrad --check --config /etc/subnetra/config.json
# subnetra v… (mtu=…, mode=raw_direct, local_id=…, peers=…) [config ok]
Generate each link’s PSK with openssl rand -hex 32 and use a unique value per
link. See the Security Model.
3. Host networking
The daemon prints — but never applies — the host plan. Review and run it (see Host Network Plan):
subnetrad --print-network-plan --config /etc/subnetra/config.json
4. Run as a service
Linux — systemd
sudo install -m 0644 deploy/subnetrad.service /etc/systemd/system/subnetrad.service
sudo systemctl daemon-reload
sudo systemctl enable --now subnetrad
journalctl -u subnetrad -f
The unit requests only CAP_NET_ADMIN, grants /dev/net/tun, runs
subnetrad --check as ExecStartPre, restarts on failure, and is otherwise
sandboxed (ProtectSystem=strict, NoNewPrivileges, restricted address families).
Edit the commented ExecStartPost lines to match your --print-network-plan
output.
macOS — launchd
A macOS spoke runs as a system daemon (creating a utun needs root):
sudo install -m 0644 deploy/net.subnetra.subnetrad.plist \
/Library/LaunchDaemons/net.subnetra.subnetrad.plist
sudo launchctl bootstrap system /Library/LaunchDaemons/net.subnetra.subnetrad.plist
sudo launchctl enable system/net.subnetra.subnetrad
sudo tail -f /var/log/subnetrad.log
# subnetra v… (… mode=raw_direct …) tun=utun4 sock=/var/run/subnetra.sock [ready]
The utunN name is kernel-assigned — read it from the [ready] banner and apply
the plan after the daemon is up. See the macOS Spoke guide.
5. Install the relay policy (hub)
Shortcut: if your config sets
"role": "hub"/"spoke", the daemon derives this whole policy at boot and you can skip this section. See Roles.
For role=manual, install the relay/delivery rules at runtime over the control
socket (hot-swapped, no restart). On Linux the CLI default already matches the
daemon, so no SUBNETRA_SOCK is needed (set it only for a custom path):
# Hub: relay overlay traffic to the right spoke
sudo -E subnetra policy add --src 0.0.0.0/0 --dst 10.0.0.2/32 --action forward --target 2
sudo -E subnetra policy add --src 0.0.0.0/0 --dst 10.0.0.3/32 --action forward --target 3
sudo -E subnetra policy show
sudo -E subnetra save # persist a replayable snapshot
# Spoke: deliver tunnelled traffic for the local overlay address to the local TUN (target 0)
sudo -E subnetra policy add --src 0.0.0.0/0 --dst 10.0.0.2/32 --action forward --target 0
6. Operate
subnetra status shows peers, traffic, and per-reason drops; --json is the
stable schema for monitoring. See
Observability & Troubleshooting.
7. Firewall / NAT
- The hub must accept inbound UDP on all configured
listen_portsfrom the internet. The default is the explicit set18020, 18023, 18026(not a range), avoiding WireGuard’s well-known port fingerprint and keeping the node reachable if one port is blocked or throttled. - Each spoke needs only outbound UDP reachability to the hub — no inbound
port-forwarding (the spoke initiates). A spoke therefore only needs one
listen port (the role-aware default binds just
[18020]); multiplelisten_portsare a hub-side feature, because the hub is the reachable endpoint that spokes target. - If a spoke’s NAT mapping changes, the hub re-learns its new endpoint from the next authenticated datagram. Keep the hub endpoint stable; spokes always initiate.
NAT keepalive (built-in)
An idle spoke’s NAT mapping times out (often ~30 s for UDP), after which inbound
relays would blackhole. A role=spoke runs a built-in keepalive by default
(keepalive_secs = 20): one tiny authenticated datagram per interval holds the
pinhole open and keeps the hub’s learned endpoint fresh. It is allocation-free and
adds no thread or external process. Confirm it with the keepalive tx / keepalive rx counters. Set keepalive_secs = 0 to disable (e.g. a spoke not behind NAT).
Hub on a dynamic IP (DDNS)
Endpoints are numeric IP:port and endpoint learning is one-way — a spoke cannot
discover a hub that moved. Prefer a stable public IP for the hub. If you must
run it behind a dynamic address, solve it operationally on each spoke with a small
DDNS watcher that rewrites the endpoint and restarts the (stateless) daemon — no
daemon changes.
Hub behind NAT (static port-forward)
A hub does not need a public-IP box — it needs a stable, inbound-reachable UDP
endpoint. A host behind NAT qualifies if the edge router has static
port-forwards (DNAT) from fixed public UDP ports to the hub’s internal
IP:listen_ports:
- Spokes dial the external address. Each spoke’s peer
endpointis one publicIP:portfrom the forward (typically the primary:18020), not the hub’s private address. listen_portsare the internal targets. Forward each configured UDP port (for the default, public18020/18023/18026→ internal18020/18023/18026). The singularlisten_portremains a back-compat alias for a one-port deployment and is ignored whenlisten_portsis present.- The mapping must be static. A fixed port-forward, not dynamic PAT that rewrites the source port per flow. If the public IP itself also changes, combine this with the DDNS approach above.
- Same-LAN spokes need the internal endpoint (hairpin). A spoke inside the same
NAT often cannot reach the hub through the public IP unless the router does NAT
hairpin/loopback — give those spokes the hub’s internal primary
IP:portinstead. - CGNAT cannot host a hub. If the “public” address is itself carrier-grade NAT with no inbound port control, you cannot forward to it; that host can only be a spoke.
This is still an ordinary single hub — only its reachability is via DNAT — so it stays inside the validated single-tier model. Endpoint learning remains one-way: keep the external mapping stable; spokes always initiate.
8. High availability
v1 is single-hub by design. The data plane is single-path, stateless, and
handshake-free, and the daemon never probes peer health or auto-switches paths
(an explicit non-goal). Multi-hub and
failover are therefore built around the daemon with ordinary config + OS tooling,
driven by the observe-only health in subnetra status --json (online,
last_seen_age_seconds, a flat auth_or_invalid). Two patterns are sanctioned.
Pattern A — active/standby hub VIP (recommended)
Two hub boxes sit behind one VRRP/keepalived VIP (or an anycast prefix). They
share identical config — same local_id, same per-spoke PSKs, same derived
policy — so every spoke sees exactly one peer (the VIP) and needs no special
config: a normal role=spoke pointing at the VIP as its single hub.
flowchart LR
subgraph Spokes
S2["Spoke · 10.0.0.2"]
S3["Spoke · 10.0.0.3"]
end
VIP(["Hub VIP · 203.0.113.10:18020"])
subgraph HubPair["Hub pair · shared local_id + PSKs"]
HA["hub-a · ACTIVE"]
HB["hub-b · standby"]
end
S2 <-->|"encrypted UDP"| VIP
S3 <-->|"encrypted UDP"| VIP
VIP --- HA
VIP -. takeover .- HB
Minimal keepalived on each hub box, with a notify hook that restarts the
daemon on takeover:
vrrp_instance subnetra {
state BACKUP # BACKUP + nopreempt on both avoids needless flaps
interface eth0
virtual_router_id 51
priority 150 # 150 on hub-a, 100 on hub-b
nopreempt
advert_int 1
virtual_ipaddress { 203.0.113.10/32 }
notify_master "/usr/local/sbin/subnetra-takeover.sh"
}
# /usr/local/sbin/subnetra-takeover.sh — runs when this box wins the VIP.
#!/bin/sh
# Restart so the daemon samples a FRESH boot epoch above the old active's (see below).
systemctl restart subnetrad
Two caveats are load-bearing:
- Epoch ordering (the #1 gotcha). Every datagram carries the sender’s boot
epoch (wall-clock ns at start) and receivers are forward-only: a session
whose epoch is lower than the one a spoke already accepted is dropped before
crypto until wall-clock passes it. A long-idle standby that booted before the
active presents a lower epoch and is silently blackholed. Mitigation: keep
both hubs on NTP and restart the daemon at takeover (the
notify_masterhook) so it stamps a fresh, higher epoch. This is exactly why active/standby beats active/active here. - Endpoint re-learn window. Endpoint learning is one-way, so the new active
starts with no learned spoke endpoints: hub→spoke relay blackholes until each
spoke’s next keepalive re-teaches it (spoke→hub works immediately — the spoke
initiates). Recovery is bounded by
keepalive_secs(spoke default20); lower it on the spokes for faster failover.
Pattern B — static dual-hub (independent identities)
Two fully independent hubs — distinct local_id, distinct PSKs, no shared
secrets and no epoch coupling — both relaying the same overlay. Every spoke is a
peer of both and keeps a primary; you switch by editing the spoke’s policy.
This also enables locality: point regional prefixes at the in-region hub so only
cross-region destinations traverse a long-haul link (each regional hub must carry
the spokes you want locally reachable).
Because role=spoke validates exactly one hub peer, a two-hub spoke runs
role=manual and installs its own longest-prefix policy over the control socket:
# Primary path: the whole overlay via hub-1 (id 1); local delivery for self.
sudo -E subnetra policy add --src 0.0.0.0/0 --dst 10.0.0.0/24 --action forward --target 1
sudo -E subnetra policy add --src 0.0.0.0/0 --dst 10.0.0.5/32 --action forward --target 0
sudo -E subnetra policy show
sudo -E subnetra save
# Fail the overlay over to hub-2 (id 2): two /25s out-specify the /24 (longest
# prefix wins), diverting all overlay traffic without touching the /24 rule.
sudo -E subnetra policy add --src 0.0.0.0/0 --dst 10.0.0.0/25 --action forward --target 2
sudo -E subnetra policy add --src 0.0.0.0/0 --dst 10.0.0.128/25 --action forward --target 2
Keep the two hubs’ prefixes non-overlapping when load-splitting — the same
discipline role=hub enforces on allowed_src — to avoid split-brain (two relays
claiming one destination).
No live
policy replace. The control socket is append-only (policy add/policy show/save— there is noreplace,del, orclear), and longest-prefix only lets a more specific rule win. To move a prefix you therefore either (a) restart the spoke daemon with an updated config/snapshot — it is stateless, so a restart costs only the keepalive re-learn window — or (b) push a more-specific override as above (the table grows; a clean revert still needs a restart). Design the split to be mostly static; do not treat Pattern B as sub-second failover.
Choosing a pattern
| Pattern A — VIP | Pattern B — static dual-hub | |
|---|---|---|
| Goal | Availability (one logical hub) | Locality + availability (two regions) |
| Spoke config | Unchanged (role=spoke, one peer) | role=manual, both hubs, manual policy |
| Hub identity | Shared local_id + PSKs | Distinct local_id + PSKs |
| Switch trigger | Network (VRRP), seconds | Operator restart / prefix override |
| Main gotcha | Epoch ordering + re-learn window | No live replace; keep splits static |
| Anycast | Possible, but riskier than VRRP (a mid-flow POP move churns endpoint learning) | n/a |
Key-material note (Pattern A). Sharing
local_id+ PSKs puts identical secrets on two boxes — secure both to the same standard; a compromise of either is a compromise of every spoke link. The daemon itself makes no failover decision in either pattern.
9. Traffic shaping & tuning
On long cross-ISP links the dominant cause of jitter/loss is the underlay, not
detection. All shaping is done at the OS layer with tc — no daemon or protocol
changes. Cap your egress to ~60–80% of the link’s stable throughput, smooth
bursts, and (optionally) tune socket buffers and IRQ/CPU affinity. The kernel sees
the real inner five-tuples on the cleartext snr0 device. See
docs/deployment.md §9–§10
for the full recipes and the live-overlay benchmark.
Containers
Subnetra is a natural fit for containers: the daemon is a single static binary
with no shared-library dependencies. The published image is multi-arch
(amd64 / arm64 / armv7 / armv5) and Docker selects the right one automatically.
Requirements
A container running subnetrad needs:
- the
NET_ADMINcapability (to create the TUN device), - access to
/dev/net/tun, - a
config.jsonmounted into its working directory (/etc/subnetra).
Docker
docker pull ghcr.io/jamiesun/subnetra:latest
docker run -d --name subnetra \
--cap-add=NET_ADMIN --device=/dev/net/tun \
-v "$PWD/config.json":/etc/subnetra/config.json:ro \
ghcr.io/jamiesun/subnetra:latest
The image ships a Docker HEALTHCHECK that runs subnetra status, so
docker ps reports the container healthy once the daemon is serving its control
socket and unhealthy if it stops responding.
Docker Compose
services:
subnetra:
image: ghcr.io/jamiesun/subnetra:latest
container_name: subnetra
restart: unless-stopped
cap_add: [NET_ADMIN]
devices:
- /dev/net/tun
volumes:
- ./config.json:/etc/subnetra/config.json:ro
Apply the host network plan for the overlay on the host or inside the container’s network namespace as appropriate for your topology.
Kubernetes
Run it as a DaemonSet (or Deployment) with the capability and device. A
minimal container spec:
securityContext:
capabilities:
add: ["NET_ADMIN"]
volumeMounts:
- name: tun
mountPath: /dev/net/tun
- name: config
mountPath: /etc/subnetra/config.json
subPath: config.json
volumes:
- name: tun
hostPath: { path: /dev/net/tun, type: CharDevice }
- name: config
secret: { secretName: subnetra-config } # config.json contains PSKs — use a Secret
The HEALTHCHECK maps cleanly to a liveness/readiness probe:
exec: { command: ["subnetra", "status"] }.
Because
config.jsoncarries per-link PSKs, store it in a KubernetesSecret(or a Compose/host file with0600permissions), never in a plain ConfigMap or image layer.
Image internals
The amd64, arm64, and arm/v7 images are built FROM busybox:musl: they carry
the two static binaries, config.example.json, and a tiny BusyBox shell plus core
utilities for in-container debugging. The daemon itself is fully static and needs
nothing from the base.
Because no musl BusyBox publishes linux/arm/v5, the arm/v5 image is built
independently FROM scratch (still static musl, but without a debug shell) and
stitched into the same :latest / :version manifest. The build cross-compiles
with Zig pinned to $BUILDPLATFORM, so no QEMU emulation is needed. See the
Dockerfile.
Offline / air-gapped
Devices that cannot reach a registry can docker load the per-arch image tarballs
attached to each release — see
Installation → Offline install.
RouterOS containers
MikroTik/RouterOS Container is a special case (it manages the container’s Ethernet
side through a veth, and image import may need a legacy archive layout). It has
its own guide: RouterOS Spoke.
RouterOS Spoke
This guide covers running Subnetra as a spoke inside a RouterOS Container
on a MikroTik device, dialing a public Linux hub. The full reference, including
scripted (.rsc) bring-up/teardown, is
docs/routeros-container.md;
scripts live in
deploy/routeros/.
Why RouterOS is different
RouterOS Container is not a normal Linux host:
- RouterOS manages the container’s Ethernet side through a
veth. - Subnetra creates its own Linux
snr0TUN device inside the container. - RouterOS cannot manage that
snr0directly — it routes to the container through thevethgateway. - RouterOS image import may require a legacy Docker archive layout.
Recommended topology
Put the hub on a public Linux server with a stable UDP endpoint; put NATed RouterOS devices behind it as spokes.
Public Linux Hub
underlay: 203.0.113.10:18020
overlay: 10.66.0.1/24
RouterOS office Spoke
container veth: 172.30.66.2/30
RouterOS veth side: 172.30.66.1/30
subnetra TUN in container: 10.66.0.3/24
published LAN: 192.168.88.0/24
Do not make a NATed RouterOS device the public hub unless its UDP endpoint is stable and reachable from every spoke. LAN addresses here are examples — replace
192.168.88.0/24with your real LAN.
Prerequisites
- RouterOS v7 with the
containerpackage installed. - Device mode allows containers (
/system/device-mode/print). - A writable storage path for container root dirs and image archives.
/dev/net/tunvisible inside RouterOS containers.- Outbound UDP from the RouterOS device to the public hub.
/system/package/print
/system/device-mode/print
/container/print
Outline of the bring-up
- Provision the veth pair and addressing (container side
172.30.66.2/30, RouterOS side172.30.66.1/30). - Import the image (a
docker load-able tarball from a release, or pull from GHCR if the device can reach it) and create the container with the mountedconfig.json,NET_ADMIN, and/dev/net/tun. - Set the spoke config (
role: spoke, the hub as the single peer, the published LAN inlocal_routes) — see Roles. - Apply routing on RouterOS so LAN traffic for the overlay/remote prefixes is
routed to the container’s
vethgateway, and the published LAN is reachable across the tunnel. - Verify with
subnetra statusinside the container and a ping across the overlay.
The scripted .rsc files in deploy/routeros/ automate steps 1–4 (and a matching
teardown). Follow the full guide for the exact commands, including LAN publishing
and the NAT/endpoint notes for NATed spokes.
Endpoint roaming note
Protocol-level endpoint learning means the hub re-learns a NATed spoke’s endpoint
from its next authenticated datagram, and the built-in NAT keepalive
(role=spoke default) keeps the pinhole open — so a RouterOS spoke behind a
changing NAT mapping stays reachable without manual endpoint correction.
OpenWrt Spoke
This guide covers running Subnetra as a spoke on an OpenWrt router (MIPS or ARM home/SOHO CPE) behind NAT, dialing a public Linux hub. OpenWrt is a natural fit: it already ships musl, so the static binary just runs, and a NATed router is exactly what the spoke role is built for.
The ready-to-use procd service is
deploy/openwrt/subnetrad.init.
Why OpenWrt is different
- procd, not systemd. Use the provided
/etc/init.d/subnetradinit script instead of the systemd unit. - TUN is a module. Install
kmod-tunso the daemon can create itssnr0device via/dev/net/tun. - BusyBox userland. The
doctor.shpreflight is BusyBox-friendly and runs as-is. - Small flash. Each binary is well under 512 KB; both fit comfortably in the
overlay (
/usr/sbin,/usr/bin).
Pick the right binary
Subnetra publishes static musl tarballs per architecture. Map your router to one
with opkg print-architecture (or uname -m):
| OpenWrt target (examples) | opkg arch | Release tarball |
|---|---|---|
| ramips (mt7621 / mt7628), most modern MIPS | mipsel_24kc | …-linux-mipsel.tar.gz |
| ath79 / Atheros (big-endian MIPS) | mips_24kc | …-linux-mips.tar.gz |
| mvebu / ipq40xx / sunxi (32-bit ARM) | arm_cortex-a* | …-linux-armv7.tar.gz |
| filogic / ipq807x / bcm27xx (64-bit ARM) | aarch64_cortex-a* | …-linux-arm64.tar.gz |
Endianness matters for MIPS.
mipselis little-endian (ramips and most modern devices);mipsis big-endian (ath79/Atheros). Installing the wrong one fails to exec. When in doubt, checkopkg print-architecture.
Install
# 1. TUN module (one-time)
opkg update && opkg install kmod-tun
# 2. Resolve the latest release + your arch, download, verify, install
ARCH=mipsel # one of: mipsel | mips | armv7 | arm64 (see the table above)
VER=$(uclient-fetch -qO - \
https://api.github.com/repos/jamiesun/subnetra/releases/latest \
| sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p')
base="https://github.com/jamiesun/subnetra/releases/download/$VER"
cd /tmp
uclient-fetch -q "$base/subnetra-$VER-linux-$ARCH.tar.gz"
uclient-fetch -q "$base/SHA256SUMS.txt"
sha256sum -c SHA256SUMS.txt 2>/dev/null | grep "subnetra-$VER-linux-$ARCH.tar.gz: OK"
tar -xzf "subnetra-$VER-linux-$ARCH.tar.gz"
# 3. Place the binaries (daemon in sbin, client in bin)
install -m 0755 "subnetra-$VER-linux-$ARCH/subnetrad" /usr/sbin/subnetrad
install -m 0755 "subnetra-$VER-linux-$ARCH/subnetra" /usr/bin/subnetra
subnetrad --version
Configure the spoke
Put this node’s config (with its PSKs) at /etc/subnetra/config.json,
root-owned and mode 0600. A minimal spoke that publishes the router’s LAN
across the tunnel:
{
"role": "spoke",
"virtual_subnet": "10.0.0.0/24",
"local_id": 7,
"local_tun_ip": "10.0.0.7/24",
"local_routes": ["10.0.0.7/32", "192.168.1.0/24"],
"peers": [
{ "id": 1, "endpoint": "203.0.113.1:18020", "allowed_src": "10.0.0.0/24", "psk": "…64 hex…" }
]
}
See Roles for what the spoke role derives, and
config-gen /
keygen to scaffold a
matching hub + spoke set with fresh per-link PSKs.
mkdir -p /etc/subnetra
# (copy your config in, then lock it down)
chmod 0600 /etc/subnetra/config.json
subnetrad --check --config /etc/subnetra/config.json # validate offline
Install the procd service
# from a checkout, or download the raw file:
uclient-fetch -qO /etc/init.d/subnetrad \
https://raw.githubusercontent.com/jamiesun/subnetra/main/deploy/openwrt/subnetrad.init
chmod 0755 /etc/init.d/subnetrad
/etc/init.d/subnetrad enable
/etc/init.d/subnetrad start
logread -e subnetrad # check it came up (and didn't fail --check)
The init script runs subnetrad --check before starting (so a bad config fails
fast instead of respawn-looping), keeps the daemon respawned, and bounces it when
the network config reloads.
Apply the host network plan
Subnetra never edits routes itself — it only prints the plan. Preview it and
apply the result after the service is up (the snr0 device exists only while the
daemon runs):
subnetra --print-network-plan --config /etc/subnetra/config.json
# then apply the printed ip/route commands, e.g.:
ip link set snr0 mtu 1400 up
ip addr add 10.0.0.7/24 dev snr0
To make it survive reboots, add the equivalent to /etc/config/network (an
interface with proto none bound to snr0, plus static routes) or a small
hotplug script — but keep route application on the OpenWrt side, never in the
daemon.
Verify
subnetra status # peers, traffic, per-reason drops
sh doctor.sh # TUN / capabilities / clock preflight (BusyBox-ok)
ping -c3 10.0.0.1 # across the overlay to the hub
Topology notes
- Behind NAT = ideal spoke. The built-in NAT keepalive (
role=spokedefault,keepalive_secs = 20) holds the pinhole open and keeps the hub’s learned endpoint fresh, so a roaming/CGNAT-changing mapping stays reachable with no manual endpoint correction. - A router with static port-forwards can be a hub. If this OpenWrt box has
stable public UDP ports DNAT’d to its
listen_ports, it can runrole=hubinstead — see Hub behind NAT. - Time sync. The session key uses a
CLOCK_REALTIMEboot epoch ordered forward-only, so runsysntpdand let the clock settle before/at start (the init starts late,START=95). See the time-sync note in Production Deployment.
macOS Spoke
macOS is supported as a spoke that dials an existing Linux/RouterOS hub.
The data path runs natively on utun + poll(2), selected at comptime behind
src/os/. A macOS hub, kqueue, and automatic route mutation are explicitly
out of scope.
Because macOS has no network namespaces and hosted-mac CI runners cannot create a
utun without elevated privileges, the macOS spoke is runbook-certified rather
than CI-gated. The authoritative procedure is
docs/macos-spoke-acceptance.md.
Prerequisites
- A real Mac (Apple Silicon or Intel) with
sudoaccess —utuncreation needs root. - A release macOS binary, or Zig 0.16.0+ to build from source.
- A reachable, already-working Linux/RouterOS hub with a stable underlay endpoint and a per-peer PSK issued for this Mac.
- At least one remote overlay target to ping across the tunnel.
The macOS binary is minimal-dynamic — it links only
libSystem(still zero third-party deps), so it is not a static executable. Do not run the Linux-onlyldd → not a dynamic executablecheck against it.
Install
From a release tarball (subnetra-<version>-macos-arm64.tar.gz or -amd64):
tar -xzf subnetra-<version>-macos-arm64.tar.gz
cd subnetra-<version>-macos-arm64
# Gatekeeper quarantines downloaded binaries — clear it (or build from source):
xattr -d com.apple.quarantine subnetrad subnetra 2>/dev/null || true
Or build locally with zig build (see Installation).
Configure
Write a spoke config.json (see Roles) with the hub
as the single peer and this Mac’s overlay address:
{
"role": "spoke",
"virtual_subnet": "10.0.0.0/24",
"local_id": 4,
"local_tun_ip": "10.0.0.4/24",
"local_routes": ["10.0.0.4/32"],
"peers": [
{ "id": 1, "endpoint": "203.0.113.1:18020", "allowed_src": "10.0.0.0/24", "psk": "…64 hex…" }
]
}
Preview the host plan
On macOS the plan emits ifconfig / route commands (the daemon never applies
them):
./subnetra --print-network-plan --config config.json
Run
sudo ./subnetrad --config config.json
# subnetra v… (… mode=raw_direct …) tun=utun4 sock=/var/run/subnetra.sock [ready]
The utunN interface name is kernel-assigned — read it from the [ready]
banner, then apply the plan with the real name:
sudo ifconfig utun4 inet 10.0.0.4 10.0.0.4 mtu 1400 up
sudo route add -net 10.0.0.0/24 -interface utun4
Run under launchd
For a persistent spoke, install the system daemon plist (it runs as root, restarts
on abnormal exit, and logs to /var/log/subnetrad.log):
sudo install -m 0644 deploy/net.subnetra.subnetrad.plist \
/Library/LaunchDaemons/net.subnetra.subnetrad.plist
sudo launchctl bootstrap system /Library/LaunchDaemons/net.subnetra.subnetrad.plist
sudo launchctl enable system/net.subnetra.subnetrad
Manage it with sudo launchctl kickstart -k system/net.subnetra.subnetrad
(restart) and sudo launchctl bootout system/net.subnetra.subnetrad (stop).
KeepAlivemay restart the daemon onto a differentutunN; re-read the banner and re-apply the plan. Subnetra deliberately leaves routing to you (no automatic route mutation).
Verify
sudo ./subnetra status # peer online, counters climbing
ping 10.0.0.3 # a remote overlay target across the tunnel
Exit Node & Outbound
Subnetra is a Layer-3 encrypted channel, not a proxy or a rule engine. It does not parse domains, resolve DNS, or match GeoIP — by design those belong in tools built for them. What it does well is carry traffic, fully encrypted and NAT-traversed, to a spoke that has the network access you want. That spoke is the exit, and a small proxy on it turns the mesh into a clean outbound.
The recipe is deliberately simple: run a SOCKS5 proxy on the exit spoke, bound to its overlay IP, and point a rule-capable client (Shadowrocket, mihomo, sing-box) at it. Subnetra is only the secure channel that reaches the proxy; the client decides which destinations take the exit.
Why not build the rules into Subnetra? In-daemon DNS, an L7 router, and a path manager are explicit non-goals. Choosing destinations by domain is a DNS + L7 job; keep that in a mature client and let Subnetra be the transport underneath.
Topology
rule-engine client ─► spoke A ─► hub ─► exit spoke B ─► internet
(e.g. Shadowrocket) (10.0.0.2) (relays) (10.0.0.3) (B's uplink)
The hub relays A ↔ B (spokes never relay each other directly). Only B needs
the target access; the client reaches it through B. The device running the client
reaches B’s overlay IP (10.0.0.3) through its local spoke A — A itself, or a
host on A’s LAN with the overlay routed to it.
1. Run a SOCKS5 proxy on the exit spoke
Run a small SOCKS5 server on B, bound to B’s overlay IP so it is reachable
only through the mesh, never on B’s public NIC. zsocks
is a tiny zero-dependency SOCKS5 server (also Zig, same philosophy as Subnetra —
a single static binary, bounded memory, TCP CONNECT + UDP ASSOCIATE, optional
auth):
# Bind to the overlay IP only and require username/password auth.
zsocks --listen 10.0.0.3 --port 1080 --user alice --pass <secret>
Useful flags (see zsocks --help):
| Flag | Purpose |
|---|---|
-l, --listen <host> | Bind address — set to B’s overlay IP (10.0.0.3) |
-p, --port <port> | Listen port (default 1080) |
-u/-P, --user/--pass | Enable RFC1929 auth (recommended) |
--max-conns <n> | Cap concurrent connections (default 256) |
--no-udp | TCP only; drop UDP ASSOCIATE if you don’t need it |
--udp-advertise <h> | UDP relay address — leave default; the overlay IP is directly reachable |
UDP ASSOCIATE is supported, so QUIC / HTTP-3 apps work; the advertised UDP relay
address defaults to the listen host (10.0.0.3), which is directly reachable over
the overlay, so --udp-advertise is not needed here.
Because the proxy binds to the overlay address, all traffic to it stays inside the
encrypted mesh and every overlay packet keeps proper overlay source/destination
IPs — so the hub’s per-peer allowed_src anti-spoofing stays intact. There is no
ip_forward and no masquerade: the proxy re-originates connections from B, and the
return path is just its own sockets.
2. Point a rule engine at it
On the client, send only the destinations you choose through the exit. Below is Shadowrocket / Surge config format; mihomo and sing-box are equivalent. The example routes a well-known international music streaming service (Spotify) through the exit and leaves everything else direct:
[Proxy]
via-exit = socks5, 10.0.0.3, 1080, alice, <secret>
[Rule]
DOMAIN-SUFFIX,spotify.com,via-exit
DOMAIN-SUFFIX,scdn.co,via-exit
FINAL,DIRECT
mihomo equivalent:
proxies:
- name: via-exit
type: socks5
server: 10.0.0.3 # exit spoke B's overlay IP — only reachable via Subnetra
port: 1080
username: alice
password: <secret>
rules:
- DOMAIN-SUFFIX,spotify.com,via-exit
- DOMAIN-SUFFIX,scdn.co,via-exit
- MATCH,DIRECT
Everything not matched stays direct (split-tunnel), so only the chosen
destinations take the A → hub → B path.
DNS
Domain rules still need names to resolve from the right vantage point: if the
client resolves locally it may get endpoints local to A’s region. Let the client
resolve DNS through the exit (Shadowrocket’s proxied DNS, or mihomo fake-ip with
the resolver reached via the proxy) so matched names resolve from B’s location.
Cautions
- B is the exit. Its IP carries the client’s traffic — real responsibility for whoever runs B, which can see destination metadata (SNI, DNS) even though the TLS payload stays end-to-end encrypted. Always enable proxy auth and bind to the overlay IP only.
- Double hop. Traffic goes
client → A → hub → B → targetand back: added latency, and the hub now carries this bandwidth — shape it (see Production Deployment → Traffic shaping). - MTU stacks. You are tunnelling inside a tunnel; size the inner MTU with the Host Network Plan guidance.
Verify
# Through the exit proxy — the IP returned should be B's public IP:
curl -s --socks5-hostname alice:<secret>@10.0.0.3:1080 https://api.ipify.org ; echo
# On the hub — relay counters climb as the traffic flows through:
subnetra status --json | grep relay_
Observability & Troubleshooting
The data plane drops malformed, unauthenticated, replayed, spoofed, unrouted, or
oversized packets silently by design (stealth). subnetra status makes those
silent drops countable, so you can tell why traffic is not flowing without
weakening the stealth property.
subnetra status
subnetrad v0.6.0 [running]
mode=raw_direct local_id=1 udp_port=18020 tun=snr0 peers=2
peers:
id=2 endpoint=203.0.113.2:18020 allowed_src=10.0.0.2/32
id=3 endpoint=203.0.113.3:18020 allowed_src=10.0.0.3/32
traffic:
tun_rx packets=... bytes=...
udp_tx packets=... bytes=...
udp_rx packets=... bytes=...
tun_tx packets=... bytes=...
relay packets=... bytes=...
endpoint_learned=..
keepalive rx=.. tx=..
drops:
tun: not_ipv4=.. no_route=.. drop_rule=.. local_loop=.. unknown_target=.. oversized=.. egress_err=.. send_err=..
udp: unknown_peer=.. auth_or_invalid=.. not_ipv4=.. spoof=.. no_route=.. drop_rule=.. unknown_target=.. no_reflect=.. oversized=.. send_err=..
subnetra status exits non-zero when the daemon is not running, so scripts can
detect it. PSKs and derived keys are never printed.
Reading the drop taxonomy
| Counter | Meaning | Likely cause |
|---|---|---|
udp: unknown_peer | A datagram’s header key_id matches no configured peer | Wrong mesh id at the sender, or unsolicited traffic |
udp: auth_or_invalid | PSK/epoch or wire format does not match | PSK mismatch, key-rotation skew, clock/epoch issue, or a wire-breaking version gap |
udp: spoof | A peer sent an inner source outside its allowed_src | Misconfigured allowed_src, or actual spoofing |
udp: no_route / tun: no_route | No policy rule matches the destination | Missing forward rule / role derivation |
udp: no_reflect | Hub avoided sending a packet back to its source | Normal guard; not an error |
tun: not_ipv4 | A non-IPv4 frame appeared on the TUN | IPv6 or other traffic hitting the L3 device |
*: oversized | Packet exceeds the safe MTU | Lower local_tun_mtu / add an MSS clamp |
Benign signals: a rising endpoint_learned just counts authenticated peers seen at
a new UDP endpoint (roaming / NAT remap). The keepalive rx / tx line counts the
built-in spoke→hub NAT keepalive: tx on the emitting spoke, rx on the receiving
hub.
Machine-readable status (--json)
For monitoring and automation, subnetra status --json emits the same data as a
stable, versioned JSON object — so health can be scraped without parsing
free-form text (and still never serializes secrets):
subnetra status --json | jq .
{
"schema_version": 1, // bumped only on a breaking schema change
"version": "0.5.1",
"mode": "raw_direct",
"local_id": 1,
"listen_port": 18020,
"tun": "snr0",
"peers": [
{
"id": 2,
"endpoint": "203.0.113.7:51822",
"name": "bj-office-gw", // optional operator label ("" when unset)
"allowed_src": "10.66.0.2/32",
"last_seen_age_seconds": 5, // null if the peer has never authenticated
"online": true // last_seen within the freshness window (~90s)
}
],
"counters": { "tun_rx_packets": 3, "udp_tx_packets": 0 /* …every data-plane counter… */ }
}
online/last_seen_age_secondsgive a per-peer heartbeat (the freshness window is ~90 s — long enough to tolerate a few missed keepalives without flapping).counterscarries every counter from the human view, so a scrape never misses a field.- Pin
schema_versionin your monitor; it increments only on a breaking change.
Prometheus textfile exporter
There is deliberately no HTTP server in the daemon (extra attack surface,
against the single-binary ethos). Instead,
deploy/subnetra-textfile-exporter.sh
turns subnetra status --json into node_exporter textfile collector metrics
(the only prerequisite is jq):
sudo install -m 0755 deploy/subnetra-textfile-exporter.sh /usr/local/bin/
sudo install -m 0644 deploy/subnetra-textfile-exporter.service /etc/systemd/system/
sudo install -m 0644 deploy/subnetra-textfile-exporter.timer /etc/systemd/system/
sudo systemctl enable --now subnetra-textfile-exporter.timer
It emits (atomically):
| Metric | Type | Notes |
|---|---|---|
subnetra_up | gauge | 1 if status was read, 0 if down/unbound |
subnetra_build_info{version,mode,tun,local_id,listen_port} | gauge | constant 1; identity in labels |
subnetra_peer_online{id,allowed_src} | gauge | 1 within the freshness window |
subnetra_peer_last_seen_age_seconds{id,allowed_src} | gauge | omitted if never authenticated |
subnetra_<counter>_total | counter | every counters field, drift-proof |
Useful alert expressions: subnetra_up == 0, subnetra_peer_online == 0,
subnetra_peer_last_seen_age_seconds > 120, and a climbing
rate(subnetra_drop_udp_auth_or_invalid_total[5m]) > 0 (PSK/epoch/wire skew) or
rate(subnetra_drop_udp_spoof_total[5m]) > 0.
A troubleshooting checklist
- Daemon up?
subnetra status(non-zero exit ⇒ down). Checkjournalctl -u subnetrad. - Config valid?
subnetrad --check. - Peer online? Look at
online/last_seen_age_seconds. - Large transfers stall but pings work? MTU/PMTU — recheck the host network plan and add an MSS clamp.
auth_or_invalidclimbing? PSK mismatch, key-rotation skew, a backward clock/epoch, or a wire-breaking version gap during a partial upgrade (see Upgrade & Release).spoofclimbing? A peer’s inner source is outside itsallowed_src.no_route? A missing policy rule — check Roles or inject one withsubnetra policy add.
Upgrade & Release
Subnetra is a single static binary with no persistent on-disk data-plane state, so a node upgrade is mechanically “swap the binary and restart.” The real risk is wire compatibility across a mesh, and the procedure below manages it.
Upgrade & rollback runbook
Because the transport is fail-closed — a datagram that fails AEAD
authentication is silently dropped — a mesh that is half-upgraded across a
wire-breaking boundary silently partitions: there is no error, only a climbing
auth_or_invalid drop counter on both sides.
Safe procedure:
- Read the release notes for any wire-breaking change. If the wire is unchanged, nodes interoperate across the upgrade and ordering does not matter.
- Stage a canary. Upgrade one spoke first and watch
subnetra statuson both it and the hub —onlinestays true andauth_or_invalidstays flat. - Roll forward the rest. The binary swap is atomic; restart is stateless (a fresh session epoch is derived each lifetime).
- Rollback is the reverse binary swap + restart. No state migration is involved.
# Per node
sudo install -m 0755 subnetrad subnetra /usr/local/bin/
subnetrad --check --config /etc/subnetra/config.json
sudo systemctl restart subnetrad # stateless restart; a new epoch is derived
subnetra status # confirm peers online, auth_or_invalid flat
Keep the previous binary around for an instant rollback, and watch
auth_or_invalidas your partition alarm during the rollout.
Key rotation runbook
PSKs are per link. To rotate one link’s key without a flag day, exploit that each direction/epoch is keyed independently:
- Generate a new PSK (
openssl rand -hex 32). - Update both ends of the link to the new PSK.
- Restart both daemons (or restart one and accept a brief
auth_or_invalidblip until the other follows). Because there is no shared mesh key, only this one link is affected.
Watch auth_or_invalid during the change: a transient rise as the two ends cross
over is expected; a sustained rise means the two ends disagree on the key.
The full step-by-step is in
docs/deployment.md §6 “Key rotation runbook”.
Cutting a release (maintainers)
The release version lives in exactly one place: the .version field of
build.zig.zon. It
is injected into the daemon banner at build time via the build_options module —
never hard-code a version string in src/.
To publish vX.Y.Z:
- Bump
.versioninbuild.zig.zonto the newX.Y.Z(semantic versioning). - Commit that bump on
mainvia the normal PR flow. - Tag the commit
vX.Y.Z— the tag must equalv+ thebuild.zig.zonversion. A guard job fails the release if they disagree, so a mismatched tag never ships. - Pushing the
v*tag triggers.github/workflows/release.yml, which builds the four-arch static binaries, the GHCR multi-arch image, the offlinedocker load-able per-arch image tarballs, and the macOS spoke binaries, and publishes them all to the GitHub Release with a combinedSHA256SUMS.txt.
Do not create a v* tag without first bumping build.zig.zon to match. The
release process is documented in
docs/release.md.
Verifying downloads
Every release ships a SHA256SUMS.txt. Verify any asset before installing or
docker load-ing it:
sha256sum -c SHA256SUMS.txt 2>/dev/null | grep subnetra-<version>-linux-amd64.tar.gz
Wire Protocol
This page is a readable summary of the Subnetra v1 wire protocol. The
authoritative, normative specification — with RFC 2119 keywords and known-answer
test (KAT) vectors — is
docs/PROTOCOL.md.
If this prose ever disagrees with the spec or its vectors, the spec/vectors win.
Why a normative spec? Any implementation, in any language, that reproduces the behavior below is a conformant Subnetra endpoint and can join a mesh alongside the reference (pure-Zig) implementation. The behavior is pinned by KAT vectors in
tests/protocol-vectors.json, checked against the live code underzig build testand regenerated withzig build vectors.
Model
Subnetra forwards raw IPv4 packets over an encrypted UDP underlay in a
single-hub hub-and-spoke topology. Each node has a numeric mesh id
(0 < id ≤ 65535) that doubles as the on-wire key_id selector. Every
directional link (from_id → to_id) has its own keys. wire_version is 1.
Cryptographic primitives
| Primitive | Choice | Parameters |
|---|---|---|
| AEAD | ChaCha20-Poly1305 (IETF, 96-bit nonce) | key 32 B, nonce 12 B, tag 16 B |
| KDF / keyed hash | BLAKE2b-256, native keyed mode (not HMAC) | key = parent key, 32 B digest |
Key schedule
All integers fed into the KDF are big-endian; labels are ASCII with no NUL.
link_key(psk, from_id, to_id) =
BLAKE2b-256(key = psk, msg = "subnetra-v1-link" || u32_be(from_id) || u32_be(to_id))
session_key(link_key, epoch) =
BLAKE2b-256(key = link_key, msg = "subnetra-v1-session" || u64_be(epoch))
The sender uses link_key(psk, local_id, peer_id); the receiver derives the
matching key with link_key(psk, peer_id, local_id). psk is a per-link 32-byte
secret and MUST NOT be reused across peers.
Nonce
nonce(seq) = u64_le(seq) || 0x00 0x00 0x00 0x00 // 8 bytes LE + 4 zero bytes
The AEAD uses empty AAD; the 16-byte tag follows the ciphertext.
Session epoch
Each daemon lifetime samples a boot epoch once (wall-clock ns, u64), which
MUST be ≥ 2024-01-01T00:00:00Z in ns and non-zero. A node that cannot satisfy
this fails closed (refuses to start). The epoch travels in every datagram, and
the receiver derives the matching session key from it statelessly — there is no
handshake.
Datagram format
+------------------+---------------------------+----------------+
| header (20 B) | ciphertext (len(inner)) | tag (16 B) |
+------------------+---------------------------+----------------+
Header (20 bytes, fixed)
| Offset | Size | Field | Encoding | Meaning |
|---|---|---|---|---|
| 0 | 1 | version | u8 | MUST be 1 |
| 1 | 1 | flags | u8 | bit 0 = KEEPALIVE; bits 1–7 reserved, MUST be 0 |
| 2 | 2 | key_id | u16 LE | sender’s mesh id — the receiver’s peer selector |
| 4 | 8 | epoch | u64 LE | sender boot epoch; never 0 |
| 12 | 8 | seq | u64 LE | per-session monotonic sequence number / nonce basis |
key_idis an unauthenticated selector — it is not covered by the AEAD (AAD is empty). A forgedkey_idjust selects the wrong key, authentication fails, and the datagram is dropped. It lets a roaming/NATed sender be recognized by identity rather than by source endpoint.
Endianness trap: header
epochandseqare little-endian, but the sameepochfed into the session KDF and thefrom_id/to_idfed into the link KDF are big-endian. The KAT vectors exist to catch exactly this mistake.
Keepalive (flags bit 0)
A datagram with KEEPALIVE = 0x01 set is a one-way spoke→hub NAT keepalive sealed
over an empty inner plaintext, so its total length is 20 + 16 = 36 bytes. It
rides the same seq + epoch + anti-replay machinery, is never acknowledged,
and is not a handshake. A receiver predating this bit simply drops keepalives
(strict flags == 0 check) with no effect on data delivery.
Header obfuscation (optional)
The 20-byte header sits outside the AEAD, so if it travels in cleartext a passive
observer can fingerprint the protocol by its constant version, repeated epoch, and
low monotonic seq even with no magic bytes. The deployment-wide obfuscate setting
(on by default) removes this fingerprint at zero byte overhead: the sender
XOR-masks only the header with a per-packet pad
pad = BLAKE2b-256(key = link_key, "subnetra-v1-obfs" || tag)[0..20]
derived from the directional link_key and the datagram’s own cleartext 16-byte
tag; the body is already pseudorandom and is left untouched. XOR is symmetric, so a
receiver reverses it from the public tag. Because the masked key_id can no longer be
read directly, ingress trials each peer’s receive link key (recompute the pad,
check version + key_id, then de-mask); AEAD authentication remains the real gate.
It is not negotiated — every node MUST set obfuscate identically (a mismatch
fails closed) — and it hides the protocol fingerprint only, not packet length or
timing. An obfuscating spoke additionally randomizes its NAT-keepalive interval within
[keepalive_secs/2, keepalive_secs] so the keepalive cadence is not a fixed-period
signature. It is on by default; set obfuscate: false to opt out, leaving the wire
byte-identical to v1 (and the header readable in a capture). Full normative detail and KAT
vectors: docs/PROTOCOL.md §3.4.
Sender (egress)
To send inner IPv4 packet P to peer D:
key = session_key(link_key(psk, local_id, D.id), local_epoch).seq =next value of this link’s monotonic counter (starts at1, strictly increasing, never repeating within a session/epoch).- Emit the header with
version=1, flags=0, key_id=local_id, epoch=local_epoch, seq. - Append
ChaCha20-Poly1305-Seal(key, nonce(seq), "", P). - Send to
D’s UDP endpoint.
On any event that could reset the counter (e.g. a restart), the sender MUST also obtain a fresh epoch (and therefore a fresh session key).
Receiver (ingress)
On a datagram from source endpoint S, in this normative order:
- Identity selection by
key_id(not by endpoint). No matching peer ⇒ drop. (With header obfuscation on, the header is masked, so this step instead trials each peer’s receive link key to de-mask it; steps 2–9 are unchanged.) - Header validation — drop if
len < 20,version != 1, a reservedflagsbit is set, orepoch == 0. - Epoch ordering (forward-only).
epoch < cur⇒ drop before any crypto;epoch == cur⇒ use the cached key;epoch > cur⇒ derive a candidate key but do not commit yet. - Authenticate & decrypt with the link key and
nonce(seq). Failure ⇒ drop. (No state mutated yet — a forged higher epoch or wrongkey_idcannot poison the session.) - Commit a newer epoch only now (set
cur = epoch, cache key, reset the anti-replay window). - Anti-replay — apply the 64-entry sliding window to
seq; replay/too-old ⇒ drop. (Keepalive short-circuits here: it records the endpoint + last-seen, then stops — no inner packet.) - Inner source check — the decrypted source address MUST fall within
P’sallowed_src; otherwise drop (anti-spoofing). - Endpoint learning — only after steps 4–7, optionally record
SasP’s current endpoint (roaming/NAT). Runtime state only; never written to config. - Route — deliver locally or (hub only) relay to another peer; a hub MUST NOT reflect back to the source peer.
The order of steps 3–5 (authenticate before mutating receive state) and step 8 (learn the endpoint only after 4–7) is security-critical.
Anti-replay window
Each receive session keeps a 64-bit sliding window (highest + a 64-bit bitmap,
bit i = “highest − i was seen”): seq > highest shifts the window forward and
accepts; an in-window unseen seq is accepted and marked; a replay or
older-than-window seq is dropped.
Accepted residual risks (by design, no handshake)
- Pre-observation epoch replay. An on-path attacker who captures an
authenticated datagram for epoch
Eand replays it before the receiver has observedEcan transiently relocate the peer’s learned endpoint. It self-heals on the peer’s next genuine packet, and an off-path attacker cannot forge it. - Backward clock across restart. A node whose wall clock regresses emits a lower epoch that peers reject until their clocks advance past the old value. Mitigated operationally (NTP/RTC), never by an in-protocol exchange.
Both follow directly from the stateless, handshake-free design.
CLI Reference
Subnetra ships two binaries:
subnetrad— the daemon (the data plane + control socket).subnetra— the control tool (talks to a running daemon over the control Unix domain socket).
subnetrad (daemon)
subnetrad [--config <path>] [--check] [--print-network-plan] [--path-mtu <n>]
[--version | -V] [--help | -h]
| Flag | Argument | Description |
|---|---|---|
--config | path | Path to config.json. Defaults to config.json in the working directory; falls back to a compiled-in default if absent. |
--check | — | Parse the config, run every sanity rule, print the resolved banner, and exit without touching the network. Use it as a pre-flight (and as systemd’s ExecStartPre). |
--print-network-plan | — | Print the deterministic host networking plan (ip/ifconfig/route commands) for the loaded config and exit. Nothing on the host is modified. See Host Network Plan. |
--path-mtu | integer | Override the assumed underlay path MTU when printing the plan (default 1500). The safe tunnel MTU is path_mtu − 64. |
--version, -V | — | Print the version banner and exit. |
--help, -h | — | Print usage and exit. |
With no action flag, subnetrad runs the daemon: it creates the TUN device, binds
the UDP underlay and the control socket, and enters the reactor loop. Creating the
TUN device requires CAP_NET_ADMIN (Linux) or root (macOS utun).
# Validate, preview the host plan, then run
subnetrad --check --config /etc/subnetra/config.json
subnetrad --print-network-plan --config /etc/subnetra/config.json
sudo subnetrad --config /etc/subnetra/config.json
subnetra (control tool)
subnetra status [--json]
subnetra policy show
subnetra policy add --src <CIDR> --dst <CIDR> --action forward --target <id>
subnetra save
subnetra --version | --help
| Command | Description |
|---|---|
status | Show daemon health, peers, traffic counters, and per-reason drop counters. Exits non-zero if the daemon is not running. |
status --json | Emit the same data as a stable, versioned JSON object for monitoring. Never serializes secrets. See Observability. |
policy show | Print the active policy tree (waits for the daemon’s reply). |
policy add | Inject one rule, hot-swapped via RCU with no restart (fire-and-forget). |
save | Snapshot the active policy back to disk (waits for the daemon’s reply). |
--version / --help | Print version / usage. |
policy add arguments
| Flag | Argument | Description |
|---|---|---|
--src | CIDR | Match on the inner source prefix (e.g. 192.168.1.0/24, or 0.0.0.0/0 for any). |
--dst | CIDR | Match on the inner destination prefix (longest-prefix wins). |
--action | forward | The action. (v1 routes by forwarding to a target; unrouted traffic is dropped.) |
--target | mesh id | Where to send a match: a peer’s mesh id, or 0 for local delivery to this node’s own TUN. |
Examples:
# Site-to-Site: reach LAN 192.168.2.0/24 behind spoke id 3
subnetra policy add --src 192.168.1.0/24 --dst 192.168.2.0/24 --action forward --target 3
# Hub: relay overlay traffic to the right spoke
subnetra policy add --src 0.0.0.0/0 --dst 10.0.0.2/32 --action forward --target 2
# Spoke: deliver tunnelled traffic for the local overlay address to the local TUN
subnetra policy add --src 0.0.0.0/0 --dst 10.0.0.2/32 --action forward --target 0
subnetra policy show
subnetra save
Rules injected at runtime layer on top of any policy derived from a
role. Usesubnetra saveto persist the active tree so it survives a restart.
Environment
| Variable | Description |
|---|---|
SUBNETRA_SOCK | Path to the control Unix domain socket. Defaults to /run/subnetra/subnetra.sock (Linux) / /var/run/subnetra.sock (macOS) — the same path the daemon binds and the systemd unit uses, so subnetra and subnetrad agree out of the box. Set this only to use a non-default path (both processes must match). |
# Usually unnecessary — the default already matches the daemon. Set it only
# when you run the daemon on a custom socket path:
export SUBNETRA_SOCK=/run/subnetra/subnetra.sock
sudo -E subnetra status
Exit codes
subnetra statusreturns non-zero when the daemon is down — convenient for health checks and the DockerHEALTHCHECK.subnetrad --checkreturns non-zero on an invalid config, so it can gate a service start.
Out-of-tree tools
These helpers live under
tools/ and are never
shipped inside the daemon:
| Build step | Tool | Purpose |
|---|---|---|
zig build tool:keygen | keygen | Generate per-link 64-hex PSKs |
zig build tool:config-lint | config-lint | Offline config.json validation (clock-independent) |
zig build tool:wire-decode | wire-decode | Offline, read-only datagram inspector |
| — | tools/doctor.sh | Environment preflight: /dev/net/tun, CAP_NET_ADMIN, ip, clock |
Roadmap
Subnetra deliberately ships a small, finished v1 and reserves a narrow set of v2 interface points. This page describes what is delivered, what is reserved, and — just as importantly — what will never be built.
v1 — delivered
The shipping data plane is raw_direct: a stateless, allocation-free,
handshake-free tunnel with:
- ChaCha20-Poly1305 full encryption with per-link keys + per-restart session epoch,
- 64-bit monotonic nonce + sliding-window anti-replay,
- a CIDR longest-prefix policy engine with lock-free RCU hot updates,
- a single-threaded reactor (Linux
epoll/ macOSpoll(2)), - a normative wire protocol pinned by known-answer vectors.
See the development status table for module-by-module progress.
v2 — reserved interface points only
The PRD reserves two in-house egress modes for lossy/long-haul leased lines.
They are design-only today — the branches return error.NotImplemented and no
code is authorized until the maintainer signs off on the design RFC
(docs/v2-reliability-rfc.md):
| Mode | Idea | Inner MTU |
|---|---|---|
kcp_arq | Arena-based selective-repeat ARQ to absorb small, sporadic leased-line loss (no ikcp.c — in-house) | 1428 |
fec_xor | Forward error correction (naïve 4:1 XOR is known to be inadequate; the real design must do better) | 1428 |
The reservation points that already exist in the tree:
| Reservation | Where it lives |
|---|---|
EgressMode { raw_direct, kcp_arq, fec_xor } | src/reactor.zig (v2 ⇒ error.NotImplemented) |
mtuFor(mode) → 1452 / 1428 / 1428 | src/reactor.zig |
flags header byte (MUST be 0 in v1, except KEEPALIVE) | src/reactor.zig, docs/PROTOCOL.md |
negotiation_version (per-config) | src/config.zig |
Crucially, a v2 mode is selected by static per-link config, never by an on-wire
handshake. The negotiation_version / flags fields exist for static mode
selection only.
Explicit non-goals
These are not “not yet” — they are never, because they would break the design principles:
- No on-wire handshake / challenge-response / capability exchange. The per-packet epoch is session establishment.
- No in-daemon health-probe or auto-switching path manager. The data plane is single-path; failover is an external decision (see Production Deployment → HA).
- No in-tunnel scheduler / adaptive rate controller. Traffic shaping is done at
the OS layer with
tc. - No third-party dependencies. Not even for v2 reliability — the ARQ must be in-house.
- No in-daemon DNS resolver. Endpoints are numeric; a dynamic hub is solved operationally (DDNS watcher) on the spoke.
Changing any non-goal is an RFC that amends the iron laws, not a feature PR — and is intentionally not on the backlog.
The keepalive exception (already in v1)
The only addition under wire_version = 1 is the one-way, never-acknowledged
spoke→hub NAT keepalive (flags bit 0). It is backward-compatible and is not a
handshake — see the Security Model.
Development
Subnetra is written in pure Zig with zero third-party dependencies —
only the standard library and raw syscalls via std.posix. This page covers
building, testing, and the local integration harness.
Prerequisites
- Zig 0.16.0 or later.
- For the privileged integration harness: a Linux host (or the provided dev
container) with
/dev/net/tunand--privileged.
Build & test
# Native build (ReleaseSmall by default; -Doptimize=Debug for dev)
zig build
# Static cross-compile to each shipped target
zig build -Dtarget=x86_64-linux-musl # amd64
zig build -Dtarget=aarch64-linux-musl # arm64
zig build -Dtarget=arm-linux-musleabihf # armv7 (hard float)
zig build -Dtarget=arm-linux-musleabi # armv5 (soft float)
# Unit tests (must stay green before any commit)
zig build test
# Run the daemon
zig build run
Artifacts land in zig-out/bin/: subnetrad and subnetra.
Useful build steps
| Step | What it does |
|---|---|
zig build test | Run the unit tests |
zig build vectors | Print the wire-protocol conformance vectors (JSON) to stdout |
zig build tools-test | Run unit tests for the tools/ utilities |
zig build tool:keygen | Build/run the per-link PSK generator |
zig build tool:config-lint | Build/run the offline config validator |
zig build tool:wire-decode | Build/run the offline datagram inspector |
zig build tool:mtu-probe | Build/run the end-to-end path-MTU prober |
Project layout
| Path | Purpose |
|---|---|
build.zig, build.zig.zon | Dual-binary build (daemon + control tool), static musl cross-compile; version single-sourced in .version |
src/config.zig | Config parse + sanity checks |
src/policy.zig | CIDR longest-prefix match + lock-free RCU ActiveTree |
src/crypto.zig | ChaCha20-Poly1305, monotonic nonce, anti-replay |
src/reactor.zig | Wire header, egress dispatch, readiness reactor |
src/peer.zig | Per-peer endpoint + crypto registry |
src/os/ | Comptime OS backend: linux.zig (epoll + /dev/net/tun), darwin.zig (poll(2) + utun), mod.zig (selector) |
src/uds.zig | Control socket + command tokenizer |
src/stats.zig | Data-plane counters |
src/netplan.zig | --print-network-plan emitter |
src/main.zig, src/subnetra.zig | Daemon / control-tool entry points |
tools/ | Out-of-tree helpers (never shipped in the daemon) |
docs/ | Design docs, the normative protocol, deployment, and RFCs |
Test-driven workflow
Pure logic ships with tests. The PRD’s acceptance tests include JSON/sanity
checks, CIDR overlap/matching, RCU hot-swap safety, crypto invariance (ciphertext
grows by exactly the 16-byte tag), and nonce-monotonic / anti-replay behavior. The
wire protocol is pinned by known-answer vectors generated from the live code
(zig build vectors) with a drift sentinel in zig build test.
Local integration testing (dev container)
The privileged hub-and-spoke harness is Linux-only, so a reproducible Linux
container is provided under
.devcontainer/.
It is a development/test aid only — the shipped artifact stays a single static musl
binary.
# Build the Linux toolchain image (Debian-slim + pinned Zig 0.16.0)
docker build -t subnetra-dev -f .devcontainer/Dockerfile .
# Run the integration / preflight harness
docker run --rm --privileged --device=/dev/net/tun \
-v "$PWD":/workspace subnetra-dev test/integration/run.sh
test/integration/run.sh
builds the binary, enforces the static-link and ≤ 512 KB constraints, smoke-runs
the daemon, cross-builds the other musl arch, runs the unit tests, then runs a
3-node hub-and-spoke end-to-end test across network namespaces: real delivery
spoke-A → Hub(relay) → spoke-B, on-wire encryption (no plaintext leak onto the
underlay), a non-stalling RCU policy hot-update under load, honest drop counters,
resilience under underlay loss (netem) with full recovery, and endpoint roaming /
NAT remap.
For a throughput/PPS baseline, the sibling
test/integration/bench.sh
stands up the same star, builds -Doptimize=ReleaseFast (measurement only), and
reads achieved pps / Gbps / hub-CPU% from each daemon’s own counters.
Contributing principles
Subnetra is governed by a strict operating contract
(AGENT.md). In short:
make surgical, goal-aligned changes; keep zig build test green; preserve the
zero-dependency, single-threaded, allocation-free (data-plane), handshake-free
invariants; and verify the binary still links statically and stays under the size
budget before declaring a task done. See the
Design Principles.
FAQ
What is Subnetra, in one sentence?
A pure-Zig, zero-dependency Layer-3 UDP tunnel that ships as a single static binary under 512 KB and connects sites and devices in a hub-and-spoke overlay with full per-link encryption.
Is it a VPN? How is it different from WireGuard?
It is a Layer-3 encrypted overlay, so it solves a similar problem to WireGuard. The differences are deliberate trade-offs:
- No handshake. Subnetra is stateless and handshake-free; the per-packet epoch plus a static config replaces session negotiation. There is no Noise handshake, no rekey timer, no roaming handshake.
- Single static binary, no kernel module, no third-party libraries — it runs entirely in userspace on a TUN device and cross-compiles to musl targets down to armv5.
- Hub-and-spoke by design, with an in-binary CIDR policy engine for site-to-site routing and hub relay, hot-updated without a restart.
It is not trying to be a drop-in WireGuard replacement; it targets fixed, operator-managed deployments where a tiny auditable binary and static topology are the priority.
How is it different from n2n?
Both build an encrypted overlay, but the designs are near-opposites:
- Star, not P2P. n2n’s signature is supernode-assisted NAT hole-punching so edges talk directly. Subnetra is strict hub-and-spoke and deliberately has no P2P / hole-punching — every spoke-to-spoke packet relays through the hub, for one predictable path (a non-goal).
- Layer 3, not Layer 2. n2n is an Ethernet (TAP) overlay with broadcast, ARP and any protocol. Subnetra routes IPv4 by CIDR only — no broadcast domain.
- Handshake-free, one fixed cipher. n2n has a registration protocol and per-community selectable ciphers. Subnetra has no registration round-trip and one mandatory AEAD (ChaCha20-Poly1305) with a unique key per link.
- Static, not discovered. n2n finds peers dynamically via supernodes. Subnetra uses static numeric endpoints with no in-daemon discovery or DNS.
Pick n2n for plug-and-play P2P and L2 LAN semantics; pick Subnetra for a tiny, auditable binary with a deterministic single-path topology.
Why no handshake? Isn’t that insecure?
No. Every packet is encrypted and authenticated with ChaCha20-Poly1305 under a per-link key. Replay is stopped by a 64-bit monotonic nonce and a sliding-window filter, and a per-restart session epoch is mixed into key derivation so old captures can’t be replayed across a restart. “Handshake-free” means there is no negotiation round-trip — not that packets are unauthenticated. See the Security Model.
Does it hide that it’s a tunnel? Is it “stealth”?
Partly. Obfuscation is stateless and best-effort: a malformed or unauthenticated datagram is silently dropped with no error reply, so the listener does not advertise itself to blind scanners. Subnetra does not claim to defeat a sophisticated DPI adversary, and it is honest about that — see Design Principles → Stateless obfuscation.
The mesh-wide obfuscate setting is on by default: it masks the 20-byte header so
the datagram looks random to a passive observer and de-periodizes the keepalive cadence
(it hides the fingerprint, not packet length or timing). Set obfuscate: false to send a
readable cleartext header (e.g. for packet-capture debugging), which a passive observer
can then fingerprint.
See Wire Protocol → Header obfuscation.
What platforms are supported?
- Linux is the production target:
x86_64,aarch64,armv7(hard float), andarmv5(soft float), all static musl. The hub typically runs on Linux. - macOS is a supported spoke / developer platform via the native
utundevice (minimal-dynamic, linking only libSystem). It is not a production hub target.
How big / fast is it?
The Linux release binary is a single static musl executable under 512 KB. The
data plane is single-threaded, lock-free, and strictly allocation-free on the
hot path, so memory use is bounded and predictable. For a throughput baseline on
your own hardware, run
test/integration/bench.sh.
What MTU should I use?
The fixed wire overhead is 64 bytes (20-byte header + 16-byte tag + 28-byte
outer IPv4/UDP). The safe tunnel MTU is therefore path_mtu − 64 — e.g. 1452 on a
1500-byte underlay. Set local_tun_mtu accordingly, and use
subnetrad --print-network-plan --path-mtu <n> to compute and preview the host
plan. See the Network Plan.
Does Subnetra configure the host network for me?
No — by design it only prints the plan; it never mutates host routing or
firewall state. subnetrad --print-network-plan emits the exact ip/route
commands so you (or your config-management tool) apply them deliberately and
auditable. The daemon does create and manage its own TUN device.
How do PSKs work? Can two links share a key?
Each link (each peer pair, per direction) uses its own 64-hex pre-shared key.
Keys must be unique per link — never reuse a PSK across peers. Generate them
with zig build tool:keygen. Directional keys are derived from the PSK, so the A→B
and B→A directions use different keys.
How do I route a real LAN behind a spoke (site-to-site)?
Set the LAN prefix in remote_routes/local_routes and add policy rules that
forward the destination prefix to the right mesh id. The hub relays between spokes.
See Configuration → Roles and the
policy add examples.
How do I run it on RouterOS / MikroTik?
Via the RouterOS container feature (a static-binary container on the device). See Operations → RouterOS.
The hub has a dynamic IP — now what?
Endpoints are numeric on purpose (no in-daemon DNS). Solve it operationally: run a
small DDNS watcher on the spoke that rewrites the hub endpoint and reloads. The
spoke’s NAT keepalive keeps the path open. See
Security Model → NAT keepalive.
Is there a built-in failover / multi-path?
No. The data plane is intentionally single-path; failover is an external decision (VRRP / health-checked DNS / orchestration). This keeps the daemon small and predictable. See Deployment → High Availability and the Roadmap.
When is v2 (kcp_arq / fec_xor) coming?
Those are reserved interface points, design-only, returning
error.NotImplemented until the maintainer approves the design RFC. v1 ships
raw_direct only. See the Roadmap.
Why Zig, and why zero dependencies?
To get a tiny, statically-linked, auditable binary with predictable memory and no supply chain — the whole data plane is the standard library plus raw syscalls. The Design Principles (“eight iron laws”) explain the reasoning in full.
Where is the authoritative spec?
The normative on-wire contract is
docs/PROTOCOL.md;
the product requirements are
docs/subnetra-develop.md.
This site summarizes and operationalizes them; where they disagree with this site,
they win.