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

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 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.

  • Targets: Linux (full features) and Windows/MSVC (UDP transport only — see below). The TUN transport uses /dev/net/tun and is Linux-only; sockets and the event loop are portable over Winsock and 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), 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 usable client_id range 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.

    See examples/client_udp.conf and examples/server_udp.conf.

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 64 bytes (48 NTP header + 4 ext type/len + 12 carrier header), so the outer packet is inner_mtu + 64; keep ntp_payload_cap ≥ inner_mtu + 64. 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_listen and its listener at the ntptun server's udp_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 needs kcp.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 = 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

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 1200 max NTP UDP payload; must accommodate inner_mtu + 64 bytes of framing
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_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.

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 upstream unset, 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_ms to 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 & 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.