| examples | ||
| include/ntptun | ||
| src | ||
| tests | ||
| third_party/portable8439 | ||
| .gitignore | ||
| CMakeLists.txt | ||
| LICENSE | ||
| README.md | ||
ntptun — IP over NTP
(and UDP too)
A small, dependency-free C++17 implementation of the IP-over-NTP tunnel and UDP-over-NTP: it carries one complete inner IPv4/IPv6 or UDP datagram inside one NTPv4 packet, using an NTP extension field sealed with ChaCha20‑Poly1305 (RFC 8439) authenticated encryption.
- Targets: Linux (full features) and Windows/MSVC (UDP transport only — see
below). The TUN transport uses
/dev/net/tunand is Linux-only; sockets and the event loop are portable over Winsock and BSD sockets. - Dependencies: none. Only the C++17 standard library. The carrier's
ChaCha20‑Poly1305 (RFC 8439) AEAD is a vendored, public‑domain (CC0) reference
implementation under
third_party/portable8439/; no external crypto library is required. - Interface model: the app attaches to an existing TUN interface that you create and configure out of band (see below), or — in UDP transport — it needs no interface at all.
Transports
ntptun carries one of two inner payload types inside the NTP carrier, selected
by transport = in the config:
-
tun(default, Linux only): full inner IPv4/IPv6 datagrams read from and written to a TUN interface. This is the original IP-over-NTP mode. -
udp(portable, incl. Windows/MSVC): raw UDP port tunneling. The client listens on a local UDP port (udp_listen, e.g.127.0.0.1:9000) and tunnels whatever it receives; point WireGuard or any UDP client at that address. The server releases each client's payloads to a single real target (udp_target) using a distinct source port per user:udp_base_port + client_id, so multiple clients are demultiplexed by their outgoing port. Downstream replies from the target are returned to the originating client.Because the source port is
udp_base_port + client_id, the sum must stay within the 16-bit port space:udp_base_port + max(client_id) <= 65535. The server logs the usableclient_idrange at startup and rejects (with an error) any request whose computed relay port would exceed 65535.A UDP payload that will not fit one NTP carrier (larger than
inner_mtu) is discarded with an error logged — keep the inner app's MTU low enough.
Sizing the inner protocol's MTU
Every datagram the inner app sends must fit one NTP carrier: its UDP payload
must be ≤ inner_mtu, or ntptun drops it (with an error logged). The carrier's
own overhead is a fixed 80 bytes (48 NTP header + 4 ext type/len + 2
Client ID + 10 protected header + 16 Poly1305 tag), so the outer packet is
inner_mtu + 80; keep ntp_payload_cap ≥ inner_mtu + 80. Configure each
protocol so its packets stay under inner_mtu:
| Protocol | Hard min packet? | Path MTU discovery | What you configure |
|---|---|---|---|
| WireGuard | no | no | interface MTU = inner_mtu − 32 (e.g. 1104 at default) |
| KCP / kcptun | no | no | KCP mtu ≤ inner_mtu (e.g. --mtu 1136; header is inside the MTU) |
| DTLS | no (fragments handshake) | app-driven, no auto PMTUD | DTLS link MTU ≤ inner_mtu |
Notes: WireGuard's 32-byte overhead is added on top of the inner packet, so you subtract it; KCP and DTLS account for their own header inside the MTU, so you don't.
Recommended: pair ntptun with GOST. GOST speaks KCP and DTLS natively over UDP, so you get a proven proxy/tunnel (HTTP, SOCKS5, relay, port-forwarding, TLS) inside the NTP carrier with one tool on both ends. Point GOST's KCP/DTLS dialer at the ntptun client's
udp_listenand its listener at the ntptun server'sudp_target:# server: gost KCP/DTLS listener that ntptun releases traffic to gost -L 'http+kcp://:8443?kcp.mtu=1250' # (or http+dtls://:8443) # client: gost dials the ntptun client's local UDP port gost -L http://127.0.0.1:18080 -F 'http+kcp://127.0.0.1:9000?kcp.mtu=1250'KCP and DTLS have both been tested end-to-end through the tunnel. DTLS runs at the default
inner_mtu = 1136; KCP just needskcp.mtu ≤ inner_mtu.
What it does (example in TUN mode)
client host server host
┌───────────────────┐ ┌───────────────────┐
app ─▶ tun0 ─▶ ntptun ── NTP mode-3 req ───▶ ntptun ─▶ tun0 ─▶ app/kernel
app ◀─ tun0 ◀─ ntptun ◀── NTP mode-4 resp ── ntptun ◀─ tun0 ◀─ app/kernel
└───────────────────┘ └───────────────────┘
│ (non-tunnel NTP)
▼
real upstream NTP
- The client reads inner IP datagrams from its TUN interface, encapsulates each in an NTP mode‑3 request, and writes any datagram returned in the mode‑4 response back to the TUN interface. When idle it sends periodic empty polls so the server can hand back queued downstream datagrams.
- The server shares one UDP socket among many clients, keyed by a clear
16‑bit Client ID. It:
- forwards decapsulated client datagrams onto its own TUN interface;
- source‑learns routes (inner source IP → Client ID) so it knows which client a downstream datagram belongs to — no extra routing config needed;
- queues downstream datagrams per client and returns exactly one per request;
- relays anything that isn't one of its own valid carriers — ordinary non‑tunnel NTP and packets that fail the carrier's authentication check — to a real upstream server, so an active probe still gets a genuine answer (with amplification and rate‑limit guards).
Build (GCC or Clang)
Prerequisites (Ubuntu/Debian):
sudo apt-get update
sudo apt-get install -y build-essential cmake # GCC toolchain
sudo apt-get install -y clang # optional, for Clang
Configure and build with GCC:
cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo
cmake --build build -j
…or with Clang:
cmake -S . -B build-clang -DCMAKE_BUILD_TYPE=RelWithDebInfo \
-DCMAKE_CXX_COMPILER=clang++
cmake --build build-clang -j
Run the unit tests:
ctest --test-dir build --output-on-failure
The binary is build/ntptun.
Windows (Visual Studio, UDP transport)
On Windows, install the Desktop development with C++ workload, then open the
repo folder in Visual Studio (File ▸ Open ▸ Folder) — it auto-detects the
CMake project and builds ntptun.exe with MSVC. Only the udp transport is
available (the TUN transport is Linux-only).
Create and configure a TUN interface (for TUN mode)
ntptun does not create the interface; it attaches to an existing one by
name (IFF_TUN | IFF_NO_PI). Create a persistent TUN owned by your user so the
daemon can run unprivileged:
# Run once (per host). Replace $USER as needed.
sudo ip tuntap add dev tun0 mode tun user "$USER"
sudo ip link set tun0 up mtu 1136
Then give each side an inner address. For a point‑to‑point demo on 10.9.0.0/24:
# On the SERVER host
sudo ip addr add 10.9.0.1/24 dev tun0
# On the CLIENT host
sudo ip addr add 10.9.0.2/24 dev tun0
Notes:
- MTU. With
ntp_payload_cap = 1216the largest inner datagram is 1136 bytes. Set the TUN MTU toinner_mtu(1136) or lower. - Port 123. Binding the standard NTP port needs root or
CAP_NET_BIND_SERVICE. For unprivileged testing use a high port (the example configs use12300). To grant the capability instead:sudo setcap cap_net_bind_service=+ep ./build/ntptun. - Routing / NAT (optional). To send other traffic through the tunnel, add
routes toward
tun0on the client, and on the server enable forwarding and NAT:sudo sysctl -w net.ipv4.ip_forward=1 sudo iptables -t nat -A POSTROUTING -s 10.9.0.0/24 -o eth0 -j MASQUERADE - Remove the interface when done:
sudo ip tuntap del dev tun0 mode tun.
Run
Server:
./build/ntptun examples/server.conf
Client (edit server = <server-ip>:12300 and use a matching key first):
./build/ntptun examples/client.conf
Then, from the client host, traffic to the server's inner address flows through the tunnel:
ping 10.9.0.1
Configuration reference
Line-based key = value; # starts a comment. Keys are 32 bytes, given as 64
hex characters. mode selects the endpoint role, while transport selects
what is carried through the tunnel. In the default tun transport, tun is
required on both endpoints. In the udp transport, no TUN interface is used;
the client instead requires udp_listen, and the server requires udp_target
and udp_base_port.
| Key | Applies to | Default | Meaning |
|---|---|---|---|
mode |
both | — | endpoint role: client or server (required) |
transport |
both | tun |
inner transport: tun (Linux only) or udp; must match on both ends |
tun |
TUN, both | — | existing TUN interface name (required when transport = tun) |
ext_type |
both | 0x0F4E |
NTP extension type used for the tunnel |
inner_mtu |
both | 1136 |
max inner IP datagram (tun) or UDP payload (udp) size |
ntp_payload_cap |
both | 1216 |
max NTP UDP payload; must accommodate inner_mtu + 80 bytes of framing |
replay_window |
both | 1024 |
anti‑replay window: recent carrier timestamps remembered per association to reject replays (0 disables) |
log_level |
both | info |
error/warn/info/debug |
default_key |
both | — | shared 32‑byte key (hex) |
client_key |
both | — | id:hex per‑client override (repeatable) |
push |
both | false |
downstream delivery mode (see below); must match on both ends |
client_id |
client | — | this client's ID 1..65535 (required) |
server |
client | — | host:port of the server (required) |
poll_interval_ms |
client | 250 |
poll mode: idle empty‑poll interval / loop wakeup |
poll_window |
client | 1 |
poll mode: empty polls kept outstanding (downstream throughput lever) |
keepalive_ms |
client | 1000 |
push mode: keepalive interval to keep the server's address/NAT fresh |
udp_listen |
UDP client | — | local host:port that receives inner UDP datagrams (required for udp) |
listen |
server | — | outer NTP host:port to bind (required) |
upstream |
server | — | real NTP server for the non-tunnel relay path (optional) |
relay_timeout_ms |
server | 1000 |
how long a relayed ordinary NTP request awaits a reply |
relay_rate_per_sec |
server | 10 |
per-source ordinary NTP relay rate limit |
max_queue_per_client |
server | 64 |
max queued downstream datagrams per client |
udp_target |
UDP server | — | real host:port that receives decapsulated UDP payloads (required for udp) |
udp_base_port |
UDP server | — | base relay source port (required for udp); client I uses udp_base_port + I |
At least one of default_key / client_key must be set. IPv6 literals in
host:port must be bracketed, e.g. [2001:db8::1]:123.
Downstream delivery: poll mode vs push mode
The upstream direction (client → server) is always immediate and unrestricted.
The downstream direction (server → client) is what differs between the two
modes, selected by push (which must match on both ends).
Poll mode (push = false, default) keeps genuine NTP request/response
semantics: the server sends at most one datagram per client request and
never sends unsolicited packets. Because the client drives every
exchange, it must keep sending requests to receive downstream traffic — it holds
poll_window empty polls outstanding at all times. This is what makes the
traffic look like ordinary NTP polling, but it means the client is always
transmitting (~poll_window packets per RTT) even with no data to move, so it
costs idle CPU and bandwidth.
Push mode (push = true) drops that constraint: when the server has a
downstream datagram it sends it immediately to the client's last known
address as an unsolicited mode‑4 packet. The client no longer polls; it only
sends a keepalive every keepalive_ms so the server keeps its address/NAT
mapping fresh. This is efficient but not NTP‑realistic — real servers never
send unsolicited mode‑4 packets, so a stateful observer can distinguish it.
| Poll mode | Push mode | |
|---|---|---|
| NTP realism | high (looks like polling) | low (unsolicited mode‑4) |
| Idle CPU / bandwidth | grows with poll_window (≈3% at 256) |
~0 (one keepalive/sec) |
| Downstream latency | up to one poll interval of extra delay | immediate, low jitter |
| Downstream throughput | needs a large poll_window to fill the pipe |
fills the pipe on its own |
| Tuning knob | poll_window (and poll_interval_ms) |
keepalive_ms |
Recommendations
- Default / when blending in matters: poll mode with a small
poll_window(1–8). Lowest footprint, most NTP‑like. Downstream is slow/bursty — fine for interactive or low‑rate traffic. - Need downstream throughput but must stay NTP‑like: poll mode with a large
poll_window(64–512). Accept the idle CPU/bandwidth; size it to your bandwidth‑delay product (throughput × RTT ÷ ~1.1 KB). - Efficiency over camouflage (trusted path, WireGuard/IPsec on top, etc.):
push mode. Near‑zero idle cost and the best latency; leave
keepalive_msat ~1000 (lower it only if the NAT mapping times out sooner). - Throughput note: a single TCP flow is limited by inner‑TCP over the tunnel RTT, not by the delivery mode — both modes measure the same. Use multiple parallel flows to get aggregate throughput (see the test results above).
Key policy
- Shared: set only
default_key. Simplest; every client uses one key. - Per‑client: set only
client_keylines. A client that claims another client's ID resolves the wrong key, so its carrier fails to validate and it cannot hijack that association. - Hybrid: a
default_keyplusclient_keyoverrides for selected IDs.
Non‑tunnel relay (anti‑active‑probing)
An adversary who suspects your listen address hides a tunnel can actively
probe it with an ordinary NTP mode‑3 request: a real time server answers, a
bespoke tunnel stays silent — and that difference is the fingerprint. Set
upstream to a real NTP server and the box forwards anything that isn't one
of its own valid carriers upstream, relaying the genuine reply back. That
covers both non‑tunnel packets and failed‑authentication carriers (wrong
key, tampered, etc.), so a probe can't tell it apart from a stock time source.
The path is conservative: it only fires for plausible NTPv3/v4 requests, is
rate‑limited per source (relay_rate_per_sec), expires after
relay_timeout_ms, and caps replies to the request size so it can't be used as
a reflection amplifier.
Recommendations
- Turn it on for any internet‑facing server. With
upstreamunset, non‑tunnel and failed‑auth requests are silently dropped — the exact "speaks nothing back" tell a prober wants. Off only makes sense on a private/allow‑listed path. - Pick a plausible, reachable upstream — a public pool server or the box's own
time source, believable for where your server appears to live; tune
relay_rate_per_sec/relay_timeout_msto the probe volume and upstream RTT. - Know the residual tell. Relayed answers are genuine (real stratum/refid), tunnel answers are synthesized (stratum 2). This defeats naive probing, not an analyst comparing both — for that, layer WireGuard/IPsec inside the tunnel.
Live integration test (RU → US)
End-to-end test carrying real internet traffic over the tunnel: a client in
Moscow (Debian 12) tunnels through a server in New Jersey (Ubuntu 22.04) which
NATs the decapsulated traffic out to the internet. Inner MTU 1136, base link
RTT ~140 ms. Downloads used curl --interface tun0 against an external file
host, so every byte traversed client → tunnel → server NAT → internet.
| Test | Poll mode (poll_window=256) |
Push mode |
|---|---|---|
| Tunnel ping (avg / jitter) | 182 ms / 32 ms | 146 ms / 1.0 ms |
| NAT egress | reported the server's public IP ✓ | same ✓ |
| Latency: 100 KB × 12 | all HTTP 200; connect ~0.24 s, TTFB ~0.49 s, total ~1.3 s | — |
| Throughput: single 100 MB | HTTP 200, full 100 MB, avg ~0.24 MB/s | ~0.25 MB/s |
| Throughput: 4 parallel flows | — | ~1.0 MB/s aggregate |
Idle CPU (ntptun process / system) |
~3% / 6–9% | ~0% / ~2.6% |
Takeaways:
- Correctness and stability were solid: 0% ping loss, all downloads completed with correct byte counts, no client/server errors.
- Single-flow throughput (~0.25 MB/s) is bounded by inner-TCP over the long intercontinental RTT, not by the tunnel — 4 parallel flows aggregate ~4× (~1 MB/s), showing the tunnel itself has headroom.
- Poll mode's idle CPU is the price of a large
poll_window(continuous polling ≈ 1400 pkt/s each way). Push mode removes it (idle CPU ~0) and also lowers latency/jitter, at the cost of NTP realism.
Design notes
- Carrier & encryption — src/Carrier.cpp: a clear 2‑byte
Client ID followed by a ChaCha20‑Poly1305 (RFC 8439) seal of the
Magic/Version/Kind/Payload‑Length/Flags header, the inner datagram and 4‑byte
alignment padding, plus the 16‑byte Poly1305 tag. The clear Client ID is the
AEAD associated data and the 96‑bit nonce is
direction ‖ Client ID ‖ Transmit Timestamp; decode authenticates before applying all validation rules. - NTP framing — src/Ntp.cpp: 48‑byte header and 4‑byte‑aligned extension‑field parsing/assembly.
- Classification & relay — src/Server.cpp: tunnel vs.
real‑NTP path, request/response correlation (
Origin == Transmit), non‑blocking relay with amplification and rate‑limit guards. - Transmit‑timestamp uniqueness — each endpoint uses a monotonic generator so
a
T(and therefore an AEAD nonce) is never reused in a direction; responses keepReceive ≤ Transmit.
Credits
The carrier's ChaCha20‑Poly1305 (RFC 8439) AEAD uses the vendored, public‑domain
(CC0) portable8439 by Davy Landman —
https://github.com/DavyLandman/portable8439 — which bundles the
chacha-portable (Davy Landman) and poly1305-donna
(Andrew Moon) reference
implementations. The vendored sources live under
third_party/portable8439/; their upstream license is
third_party/portable8439/LICENSE.
ntptun itself is licensed under the GNU GPL v2 (see LICENSE).