| examples | ||
| include/ntptun | ||
| src | ||
| tests | ||
| .gitignore | ||
| CMakeLists.txt | ||
| LICENSE | ||
| README.md | ||
ntptun — IP datagrams over NTP
A small, dependency-free C++17 implementation of the IP-over-NTP tunnel: it carries one complete inner IPv4/IPv6 datagram inside one NTPv4 packet, using an NTP extension field with a lightweight SHA‑256‑based XOR obfuscation layer for traffic classification and admission filtering.
Scope and honesty. This is a datagram tunnel and an obfuscation layer, not a security layer. The XOR construction provides no confidentiality and no authentication. If you need those properties, run WireGuard or IPsec inside the tunnel.
- Target: Linux (uses
/dev/net/tun,poll, BSD sockets). - Dependencies: none. Only the C++17 standard library. SHA‑256 is a self-contained header (include/ntptun/Sha256.hpp, FIPS 180‑4).
- Interface model: the app attaches to an existing TUN interface that you create and configure out of band (see below).
What it does
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 ordinary (non‑tunnel) NTP requests to a real upstream server so a plain NTP probe still gets a genuine answer, with amplification and rate‑limit guards.
Project layout
include/ntptun/ public headers (Bytes, Sha256, Ntp, Carrier, KeyStore,
IpPacket, Endpoint, UdpSocket, TunDevice, Config, Client,
Server, Logging)
src/ implementation (.cpp) + main.cpp
tests/ dependency-free unit tests (SHA-256 vectors, carrier
round-trip/validation, NTP header/extension parsing)
examples/ sample server.conf / client.conf
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.
Create and configure a TUN interface
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 = 1200the 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
Quick single-host smoke test with network namespaces
You can exercise both ends on one machine using two network namespaces, each
with its own tun0, connected by a veth pair. Outline:
sudo ip netns add srv
sudo ip netns add cli
sudo ip link add veth-s type veth peer name veth-c
sudo ip link set veth-s netns srv
sudo ip link set veth-c netns cli
sudo ip -n srv addr add 192.0.2.1/24 dev veth-s
sudo ip -n cli addr add 192.0.2.2/24 dev veth-c
sudo ip -n srv link set veth-s up
sudo ip -n cli link set veth-c up
# In each namespace: create tun0, assign 10.9.0.1/.2, set mtu 1136, run ntptun
# (server listen on 0.0.0.0:12300; client server=192.0.2.1:12300).
Configuration reference
Line-based key = value; # starts a comment. Keys are 32 bytes, given as 64
hex characters.
| Key | Mode | Default | Meaning |
|---|---|---|---|
mode |
both | — | client or server (required) |
tun |
both | — | existing TUN interface name (required) |
ext_type |
both | 0x0F4E |
NTP extension type used for the tunnel |
inner_mtu |
both | 1136 |
max inner IP datagram size |
ntp_payload_cap |
both | 1200 |
max NTP UDP payload |
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 |
listen |
server | — | host:port to bind (required) |
upstream |
server | — | real NTP server for the relay path (optional) |
relay_timeout_ms |
server | 1000 |
how long a relayed request awaits a reply |
relay_rate_per_sec |
server | 10 |
per‑source relay rate limit |
max_queue_per_client |
server | 64 |
max queued downstream datagrams per client |
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.
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 & obfuscation — src/Carrier.cpp: 12‑byte carrier
header (clear Client ID + obfuscated Magic/Version/Kind/Payload‑Length/Flags),
per‑offset SHA‑256 mask
M_j = SHA256("ntp-xor-v1" ‖ K_I ‖ I ‖ D ‖ T ‖ j), 4‑byte alignment padding, and all decode‑side 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 XOR mask) is never reused in a direction; responses keepReceive ≤ Transmit.
Limitations
No cryptographic confidentiality or authentication, no integrity beyond UDP/IP
checksums, no replay protection, no retransmission, no ordering, no congestion
control, and no tunnel‑level fragmentation. An inner datagram larger than
inner_mtu is dropped, not fragmented. The private extension type, packet
sizes and traffic pattern remain visible to a passive observer.