Find a file
2026-07-18 12:51:55 +02:00
examples push mode 2026-07-15 17:36:21 +02:00
include/ntptun push mode 2026-07-15 17:36:21 +02:00
src push mode 2026-07-15 17:36:21 +02:00
tests First commit 2026-07-14 20:46:28 +02:00
.gitignore First commit 2026-07-14 20:46:28 +02:00
CMakeLists.txt First commit 2026-07-14 20:46:28 +02:00
LICENSE add LICENSE file 2026-07-18 12:50:24 +02:00
README.md Edit README.md 2026-07-18 12:51:55 +02:00

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 = 1200 the largest inner datagram is 1136 bytes. Set the TUN MTU to inner_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 use 12300). 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 tun0 on 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_ms at ~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_key lines. 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_key plus client_key overrides 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 keep Receive ≤ 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.