This commit is contained in:
Kirill 2026-07-22 13:15:57 +02:00
parent f00f2cd3ce
commit 3fa4d8a8a7
22 changed files with 938 additions and 221 deletions

4
.gitignore vendored
View file

@ -1,5 +1,6 @@
# Build output
/build/
/build-*/
/out/
*.o
*.obj
@ -8,6 +9,9 @@
# Local logs / scratch
*.log
# Design spec — kept locally, not shipped
tunneling-over-ntp.md
# Editor
.cache/
compile_commands.json

View file

@ -10,28 +10,48 @@ if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE RelWithDebInfo CACHE STRING "Build type" FORCE)
endif()
# The app uses POSIX / Linux system APIs (sockets, TUN, clock_gettime,
# sigaction). Expose them explicitly while keeping a strict language standard.
add_compile_definitions(_GNU_SOURCE)
# The app uses POSIX / Linux system APIs (sockets, TUN, sigaction) on Linux and
# Winsock on Windows. Expose the POSIX feature set explicitly on non-MSVC
# toolchains while keeping a strict language standard.
if(NOT MSVC)
add_compile_definitions(_GNU_SOURCE)
endif()
# ---- Warnings --------------------------------------------------------------
add_library(ntptun_warnings INTERFACE)
target_compile_options(ntptun_warnings INTERFACE
-Wall -Wextra -Wpedantic -Wshadow -Wnon-virtual-dtor -Wcast-align
-Wunused -Wnull-dereference -Wdouble-promotion -Wformat=2)
if(MSVC)
target_compile_options(ntptun_warnings INTERFACE /W4 /permissive-)
# Winsock deprecation/secure-CRT noise is not relevant here.
target_compile_definitions(ntptun_warnings INTERFACE
_CRT_SECURE_NO_WARNINGS _WINSOCK_DEPRECATED_NO_WARNINGS)
else()
target_compile_options(ntptun_warnings INTERFACE
-Wall -Wextra -Wpedantic -Wshadow -Wnon-virtual-dtor -Wcast-align
-Wunused -Wnull-dereference -Wdouble-promotion -Wformat=2)
endif()
# ---- Core library ----------------------------------------------------------
add_library(ntptun_core STATIC
set(ntptun_core_sources
src/Ntp.cpp
src/Carrier.cpp
src/Endpoint.cpp
src/Platform.cpp
src/UdpSocket.cpp
src/TunDevice.cpp
src/Config.cpp
src/Client.cpp
src/Server.cpp)
# The TUN transport is Linux-only; compile it only there.
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
list(APPEND ntptun_core_sources src/TunDevice.cpp)
endif()
add_library(ntptun_core STATIC ${ntptun_core_sources})
target_include_directories(ntptun_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include)
target_link_libraries(ntptun_core PRIVATE ntptun_warnings)
if(WIN32)
target_link_libraries(ntptun_core PUBLIC ws2_32)
endif()
# ---- Executable ------------------------------------------------------------
add_executable(ntptun src/main.cpp)

181
README.md
View file

@ -1,7 +1,8 @@
# ntptun — IP datagrams over NTP
# ntptun — IP over NTP
(and UDP too)
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
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.
@ -10,14 +11,78 @@ admission filtering.
> no authentication**. If you need those properties, run WireGuard or IPsec
> *inside* the tunnel.
- **Target:** Linux (uses `/dev/net/tun`, `poll`, BSD sockets).
- **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](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).
you create and configure out of band (see below), or — in UDP transport — it
needs no interface at all.
## What it does
## 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](examples/client_udp.conf) and
[examples/server_udp.conf](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](https://github.com/go-gost/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`:
>
> ```bash
> # 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
@ -40,21 +105,10 @@ admission filtering.
- **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
```
- 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)
@ -89,7 +143,14 @@ ctest --test-dir build --output-on-failure
The binary is `build/ntptun`.
## Create and configure a TUN interface
### 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
@ -149,38 +210,23 @@ 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:
```bash
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.
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 | Mode | Default | Meaning |
|-----|------|---------|---------|
| `mode` | both | — | `client` or `server` (required) |
| `tun` | both | — | existing TUN interface name (required) |
| 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 size |
| `ntp_payload_cap` | both | `1200` | max NTP UDP payload |
| `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) |
@ -190,11 +236,14 @@ hex characters.
| `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 |
| `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`.
@ -253,6 +302,32 @@ send unsolicited mode‑4 packets, so a stateful observer can distinguish 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

34
examples/client_udp.conf Normal file
View file

@ -0,0 +1,34 @@
# Example ntptun CLIENT configuration — UDP port-tunneling transport.
#
# In this mode the client does NOT use a TUN interface. It listens on a local
# UDP port and tunnels whatever it receives (e.g. a WireGuard client pointed at
# 127.0.0.1:9000). This transport builds and runs on Windows/MSVC too.
mode = client
transport = udp
client_id = 42
server = 203.0.113.10:12300
# Local UDP address the app connects to. Point WireGuard (or any UDP client)
# here; the client remembers the last local peer and returns downstream
# datagrams to it.
udp_listen = 127.0.0.1:9000
ext_type = 0x0F4E
# inner_mtu is the largest UDP payload that fits one NTP carrier. Payloads
# larger than this are discarded with an error in the log.
#
# WireGuard adds a fixed 32-byte overhead to each encapsulated IP packet, so set
# WireGuard's interface MTU to at most inner_mtu - 32. With inner_mtu = 1136
# that is MTU = 1104 (put `MTU = 1104` under [Interface] in the WireGuard conf).
# The client also logs this recommended maximum at startup.
inner_mtu = 1136
ntp_payload_cap = 1200
# push = false
poll_interval_ms = 250
poll_window = 8
keepalive_ms = 1000
log_level = info
default_key = 202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f

35
examples/server_udp.conf Normal file
View file

@ -0,0 +1,35 @@
# Example ntptun SERVER configuration — UDP port-tunneling transport.
#
# In this mode the server does NOT use a TUN interface. It forwards each
# client's decapsulated UDP payloads to a single real target, using a distinct
# source port per client so multiple users are demultiplexed by their outgoing
# port: source_port = udp_base_port + client_id.
#
# Example: udp_base_port = 40000 means client_id 42 sends to the target from
# source port 40042. Ensure udp_base_port + max(client_id) stays <= 65535.
mode = server
transport = udp
listen = 0.0.0.0:12300
# The real destination that every client's payloads are relayed to (e.g. a
# WireGuard server endpoint).
udp_target = 198.51.100.7:51820
udp_base_port = 40000
# Optional: relay ordinary (non-tunnel) NTP requests to a real upstream server.
upstream = 192.168.1.1:123
ext_type = 0x0F4E
# Largest UDP payload (each direction) that fits one NTP carrier. Larger
# datagrams are discarded with an error in the log.
inner_mtu = 1136
ntp_payload_cap = 1200
relay_timeout_ms = 1000
relay_rate_per_sec = 10
log_level = info
# push = false
default_key = 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f
client_key = 42:202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f

View file

@ -19,6 +19,15 @@ constexpr std::uint8_t kCarrierVersion = 1;
// Carrier kinds.
constexpr std::uint8_t kKindIpDatagram = 1;
constexpr std::uint8_t kKindEmptyPoll = 2;
// Raw UDP payload (port-tunneling transport). Carries an opaque application
// datagram rather than a full inner IP packet.
constexpr std::uint8_t kKindUdpDatagram = 3;
// Fixed per-packet overhead a WireGuard data packet adds over the encapsulated
// inner IP packet (4 B type+reserved, 4 B receiver index, 8 B counter, 16 B
// Poly1305 tag). Used to derive the largest WireGuard interface MTU that still
// fits one NTP carrier: MTU_wg <= inner_mtu - kWireGuardOverhead.
constexpr std::size_t kWireGuardOverhead = 32;
// Carrier layout sizes.
constexpr std::size_t kClientIdSize = 2; // clear

View file

@ -1,16 +1,21 @@
// Tunnel client: reads inner IP datagrams from the TUN interface, encapsulates
// them in NTP mode-3 requests, and delivers decapsulated downstream datagrams
// received in mode-4 responses. Sends periodic empty polls to drain the
// server's downstream queue.
// Tunnel client. In TUN transport it reads inner IP datagrams from a TUN
// interface; in UDP transport it listens on a local UDP port (e.g. a WireGuard
// client pointed at 127.0.0.1:9000) and tunnels raw UDP payloads. Either way it
// encapsulates datagrams in NTP mode-3 requests and delivers decapsulated
// downstream datagrams received in mode-4 responses. Sends periodic empty polls
// to drain the server's downstream queue.
#ifndef NTPTUN_CLIENT_HPP
#define NTPTUN_CLIENT_HPP
#include <atomic>
#include <cstdint>
#include <deque>
#include <optional>
#include "ntptun/Bytes.hpp"
#include "ntptun/Config.hpp"
#include "ntptun/Endpoint.hpp"
#include "ntptun/Platform.hpp"
#include "ntptun/TunDevice.hpp"
#include "ntptun/UdpSocket.hpp"
@ -24,18 +29,24 @@ public:
void run(const std::atomic<bool>& stop);
private:
void drain_tun();
void drain_inner(); // read from the local side (TUN or UDP listener)
void drain_socket();
void top_up_polls();
void maybe_keepalive();
bool send_carrier(std::uint8_t kind, ByteSpan inner_ip);
bool send_carrier(std::uint8_t kind, ByteSpan inner);
void handle_response(ByteSpan packet);
void deliver_inner(std::uint8_t kind, ByteSpan payload);
std::uint64_t next_transmit_ts();
bool take_pending(std::uint64_t origin_ts);
ClientConfig cfg_;
Key key_;
TunDevice tun_;
#ifdef NTPTUN_HAVE_TUN
std::optional<TunDevice> tun_;
#endif
std::optional<UdpSocket> local_; // UDP transport: local app listener
Endpoint local_peer_; // UDP transport: last app source address
bool have_local_peer_ = false;
UdpSocket sock_;
std::uint64_t last_transmit_ts_ = 0;

View file

@ -14,7 +14,13 @@ namespace ntptun {
enum class Mode { Client, Server };
// Inner transport carried inside the NTP tunnel.
// Tun: full inner IPv4/IPv6 datagrams via a TUN interface (Linux only).
// Udp: raw UDP port tunneling (no TUN; builds and runs on Windows/MSVC).
enum class Transport { Tun, Udp };
struct CommonConfig {
Transport transport = Transport::Tun;
std::string tun;
std::uint16_t ext_type = 0x0F4E;
std::size_t inner_mtu = 1136;
@ -38,6 +44,9 @@ struct ClientConfig {
// In push mode, how often to send a keepalive so the server keeps the
// client's address/NAT mapping fresh.
unsigned keepalive_ms = 1000;
// UDP transport only: local address the client listens on for the app
// (e.g. a WireGuard client pointed at 127.0.0.1:9000).
Endpoint udp_listen;
};
struct ServerConfig {
@ -47,6 +56,11 @@ struct ServerConfig {
unsigned relay_timeout_ms = 1000;
unsigned relay_rate_per_sec = 10;
std::size_t max_queue_per_client = 64;
// UDP transport only: real target the decapsulated payloads are sent to,
// and the base source port. Each client uses source port udp_base_port +
// client_id so multiple users are demultiplexed by their outgoing port.
Endpoint udp_target;
std::uint16_t udp_base_port = 0;
};
struct Config {

View file

@ -3,14 +3,12 @@
#ifndef NTPTUN_ENDPOINT_HPP
#define NTPTUN_ENDPOINT_HPP
#include <netinet/in.h>
#include <sys/socket.h>
#include <cstdint>
#include <optional>
#include <string>
#include "ntptun/IpPacket.hpp"
#include "ntptun/Platform.hpp"
namespace ntptun {
@ -22,6 +20,10 @@ public:
// When `passive` is true an empty/"*" host resolves to the wildcard address.
static std::optional<Endpoint> parse(const std::string& hostport, bool passive);
// Build a wildcard (INADDR_ANY / in6addr_any) endpoint on a fixed local
// port for the given address family. Used for per-client relay bind ports.
static Endpoint wildcard(int family, std::uint16_t port);
const sockaddr* sockaddr_ptr() const noexcept {
return reinterpret_cast<const sockaddr*>(&storage_);
}

View file

@ -0,0 +1,95 @@
// Cross-platform networking shims. Centralizes the differences between Winsock
// (MSVC/Windows) and POSIX (Linux) so the rest of the code can use one socket
// type, one poll wrapper and one set of error helpers.
//
// TUN support is Linux-only; NTPTUN_HAVE_TUN is defined there and nowhere else.
#ifndef NTPTUN_PLATFORM_HPP
#define NTPTUN_PLATFORM_HPP
#include <cstddef>
#include <cstdint>
#if defined(_WIN32)
# ifndef WIN32_LEAN_AND_MEAN
# define WIN32_LEAN_AND_MEAN
# endif
# ifndef NOMINMAX
# define NOMINMAX
# endif
// WSAPoll, AI_NUMERICSERV and getaddrinfo require Windows Vista (0x0600) or
// later. Raise the target if the toolchain (e.g. MinGW) defaults lower.
# if !defined(_WIN32_WINNT) || (_WIN32_WINNT < 0x0600)
# undef _WIN32_WINNT
# define _WIN32_WINNT 0x0600
# endif
# include <winsock2.h>
# include <ws2tcpip.h>
# include <basetsd.h>
# if !defined(_SSIZE_T_DEFINED) && !defined(NTPTUN_SSIZE_T_DEFINED)
# define NTPTUN_SSIZE_T_DEFINED
using ssize_t = SSIZE_T;
# endif
#else // POSIX
# include <sys/types.h>
# include <sys/socket.h>
# include <netinet/in.h>
# include <arpa/inet.h>
# include <netdb.h>
# include <poll.h>
# include <unistd.h>
# if defined(__linux__)
# define NTPTUN_HAVE_TUN 1
# endif
#endif
namespace ntptun {
#if defined(_WIN32)
using socket_t = SOCKET;
inline constexpr socket_t kInvalidSocket = INVALID_SOCKET;
#else
using socket_t = int;
inline constexpr socket_t kInvalidSocket = -1;
#endif
// Poll flag for "readable"; translated to the native value inside net_poll.
inline constexpr short kPollIn = 0x0001;
// A portable pollfd equivalent used with net_poll.
struct PollFd {
socket_t fd = kInvalidSocket;
short events = 0;
short revents = 0;
};
// Initialize the networking stack (WSAStartup on Windows; a no-op elsewhere).
// Idempotent and safe to call from any entry point.
void net_startup();
// Close a socket handle (closesocket / close).
void socket_close(socket_t s) noexcept;
// True when the most recent socket operation failed only because it would have
// blocked (EAGAIN/EWOULDBLOCK / WSAEWOULDBLOCK).
bool socket_would_block() noexcept;
// Throw std::system_error describing the most recent socket error.
[[noreturn]] void socket_throw(const char* what);
// Put a socket into (non)blocking mode.
void socket_set_nonblocking(socket_t s, bool enabled);
// Wait for readability/writability on the given descriptors. Semantics match
// ::poll: returns the number of ready descriptors, 0 on timeout, <0 on error
// (with errno/WSAGetLastError set, EINTR possible on POSIX).
int net_poll(PollFd* fds, std::size_t n, int timeout_ms);
} // namespace ntptun
#endif // NTPTUN_PLATFORM_HPP

View file

@ -1,7 +1,9 @@
// Tunnel server: classifies incoming NTP packets into the tunnel path or the
// real-NTP relay path, forwards decapsulated client datagrams onto its TUN
// interface, queues downstream datagrams per client, and answers each request
// with exactly one response.
// real-NTP relay path. In TUN transport it forwards decapsulated client
// datagrams onto its TUN interface and source-learns downstream routes; in UDP
// transport it forwards raw payloads to a configured target from a per-client
// source port (udp_base_port + client_id). Queues downstream datagrams per
// client and answers each request with exactly one response.
#ifndef NTPTUN_SERVER_HPP
#define NTPTUN_SERVER_HPP
@ -17,6 +19,7 @@
#include "ntptun/Config.hpp"
#include "ntptun/IpPacket.hpp"
#include "ntptun/Ntp.hpp"
#include "ntptun/Platform.hpp"
#include "ntptun/TunDevice.hpp"
#include "ntptun/UdpSocket.hpp"
@ -51,10 +54,14 @@ private:
void drain_udp();
void drain_tun();
void drain_upstream();
void drain_relay(std::uint16_t client_id, UdpSocket& sock);
void process_request(ByteSpan packet, const Endpoint& from);
void handle_tunnel(std::uint64_t request_transmit_ts, const DecodedCarrier& carrier,
const Endpoint& from);
void queue_downstream(std::uint16_t client_id, std::uint8_t kind, ByteSpan payload);
// UDP transport: get (creating if needed) the relay socket for a client.
UdpSocket* relay_for(std::uint16_t client_id);
void send_response(std::uint16_t client_id, std::uint64_t request_transmit_ts,
std::uint8_t kind, ByteSpan payload, const Endpoint& from);
void relay(ByteSpan packet, const NtpHeader& header, const Endpoint& from);
@ -63,12 +70,18 @@ private:
std::uint64_t next_transmit_ts();
ServerConfig cfg_;
#ifdef NTPTUN_HAVE_TUN
TunDevice tun_;
std::map<ip::Address, std::uint16_t> routes_; // learned inner dst -> client id
#endif
UdpSocket sock_;
std::optional<UdpSocket> upstream_;
std::map<std::uint16_t, ClientState> clients_;
std::map<ip::Address, std::uint16_t> routes_; // learned inner dst -> client id
// UDP transport: per-client relay sockets to the target and their last-use
// time (for LRU eviction when the table is full).
std::map<std::uint16_t, UdpSocket> relay_socks_;
std::map<std::uint16_t, std::chrono::steady_clock::time_point> relay_last_;
std::deque<PendingRelay> relays_;
std::map<ip::Address, RateBucket> rate_;

View file

@ -5,12 +5,11 @@
#ifndef NTPTUN_TUNDEVICE_HPP
#define NTPTUN_TUNDEVICE_HPP
#include <sys/types.h>
#include <cstddef>
#include <string>
#include "ntptun/Bytes.hpp"
#include "ntptun/Platform.hpp"
namespace ntptun {

View file

@ -1,14 +1,14 @@
// RAII UDP socket wrapper. Throws std::system_error on fatal errors; recv/send
// return -1 on EAGAIN so callers can drive them from a poll() loop.
// return -1 when the operation would block so callers can drive them from a
// poll() loop. Cross-platform over Winsock and POSIX (see Platform.hpp).
#ifndef NTPTUN_UDPSOCKET_HPP
#define NTPTUN_UDPSOCKET_HPP
#include <sys/types.h>
#include <cstddef>
#include "ntptun/Bytes.hpp"
#include "ntptun/Endpoint.hpp"
#include "ntptun/Platform.hpp"
namespace ntptun {
@ -31,20 +31,20 @@ public:
void connect(const Endpoint& remote);
void set_nonblocking(bool enabled);
int fd() const noexcept { return fd_; }
bool valid() const noexcept { return fd_ >= 0; }
socket_t fd() const noexcept { return fd_; }
bool valid() const noexcept { return fd_ != kInvalidSocket; }
// Return bytes transferred, or -1 on EAGAIN/EWOULDBLOCK.
// Return bytes transferred, or -1 when the operation would block.
ssize_t recv_from(Byte* buf, std::size_t len, Endpoint& from);
ssize_t send_to(const Byte* buf, std::size_t len, const Endpoint& to);
ssize_t recv(Byte* buf, std::size_t len);
ssize_t send(const Byte* buf, std::size_t len);
private:
explicit UdpSocket(int fd) noexcept : fd_(fd) {}
explicit UdpSocket(socket_t fd) noexcept : fd_(fd) {}
void close_fd() noexcept;
int fd_ = -1;
socket_t fd_ = kInvalidSocket;
};
} // namespace ntptun

View file

@ -121,7 +121,8 @@ CarrierStatus decode_carrier_value(ByteSpan value, const KeyStore& keys,
if (flags != 0) {
return CarrierStatus::Invalid;
}
if (kind != kKindIpDatagram && kind != kKindEmptyPoll) {
if (kind != kKindIpDatagram && kind != kKindEmptyPoll &&
kind != kKindUdpDatagram) {
return CarrierStatus::Invalid;
}
@ -146,6 +147,19 @@ CarrierStatus decode_carrier_value(ByteSpan value, const KeyStore& keys,
return CarrierStatus::Ok;
}
if (kind == kKindUdpDatagram) {
// Raw UDP port-tunneling payload: opaque application bytes, not an IP
// datagram, so no inner-IP length validation applies.
if (payload_len == 0) {
return CarrierStatus::Invalid;
}
out.client_id = client_id;
out.kind = kind;
out.inner_ip.assign(buf.data() + kProtectedHeaderSize,
buf.data() + kProtectedHeaderSize + payload_len);
return CarrierStatus::Ok;
}
// kind == kKindIpDatagram
if (payload_len == 0) {
return CarrierStatus::Invalid;

View file

@ -1,18 +1,16 @@
#include "ntptun/Client.hpp"
#include <poll.h>
#include <algorithm>
#include <array>
#include <chrono>
#include <cerrno>
#include <stdexcept>
#include <system_error>
#include "ntptun/Carrier.hpp"
#include "ntptun/IpPacket.hpp"
#include "ntptun/Logging.hpp"
#include "ntptun/Ntp.hpp"
#include "ntptun/Platform.hpp"
namespace ntptun {
namespace {
@ -30,53 +28,94 @@ std::uint64_t now_ms() {
Client::Client(ClientConfig config)
: cfg_(std::move(config)),
tun_(TunDevice::open(cfg_.common.tun)),
sock_(UdpSocket::create(cfg_.server.family())) {
const auto key = cfg_.common.keys.resolve(cfg_.client_id);
if (!key) {
throw std::runtime_error("no key resolves for configured client_id");
}
key_ = *key;
if (cfg_.common.transport == Transport::Udp) {
local_ = UdpSocket::bind(cfg_.udp_listen);
} else {
#ifdef NTPTUN_HAVE_TUN
tun_ = TunDevice::open(cfg_.common.tun);
#else
throw std::runtime_error("tun transport is not supported on this platform");
#endif
}
sock_.connect(cfg_.server);
}
void Client::run(const std::atomic<bool>& stop) {
tun_.set_nonblocking(true);
sock_.set_nonblocking(true);
std::array<pollfd, 2> fds{};
fds[0].fd = tun_.fd();
fds[0].events = POLLIN;
socket_t inner_fd = kInvalidSocket;
if (cfg_.common.transport == Transport::Udp) {
local_->set_nonblocking(true);
inner_fd = local_->fd();
} else {
#ifdef NTPTUN_HAVE_TUN
tun_->set_nonblocking(true);
inner_fd = tun_->fd();
#endif
}
PollFd fds[2]{};
fds[0].fd = inner_fd;
fds[0].events = kPollIn;
fds[1].fd = sock_.fd();
fds[1].events = POLLIN;
fds[1].events = kPollIn;
last_transmit_ts_ = 0;
const bool push = cfg_.common.push_mode;
const int timeout_ms =
static_cast<int>(push ? cfg_.keepalive_ms : cfg_.poll_interval_ms);
const char* xport = cfg_.common.transport == Transport::Udp ? "udp" : "tun";
const std::string inner_desc =
cfg_.common.transport == Transport::Udp
? ("listen=" + cfg_.udp_listen.to_string())
: ("tun=" + cfg_.common.tun);
if (push) {
log_info("client ", cfg_.client_id, " up: tun=", tun_.name(),
log_info("client ", cfg_.client_id, " up: transport=", xport, " ", inner_desc,
" server=", cfg_.server.to_string(), " mode=push keepalive_ms=",
cfg_.keepalive_ms);
} else {
log_info("client ", cfg_.client_id, " up: tun=", tun_.name(),
log_info("client ", cfg_.client_id, " up: transport=", xport, " ", inner_desc,
" server=", cfg_.server.to_string(), " mode=poll poll_window=",
cfg_.poll_window);
}
if (cfg_.common.transport == Transport::Udp) {
// Largest inner app payload is inner_mtu; a WireGuard data packet adds a
// fixed overhead on top of its encapsulated IP packet, so the WireGuard
// interface MTU must be at most inner_mtu - kWireGuardOverhead.
if (cfg_.common.inner_mtu > kWireGuardOverhead) {
log_info("udp: max inner UDP payload ", cfg_.common.inner_mtu,
" bytes; set WireGuard MTU <= ",
cfg_.common.inner_mtu - kWireGuardOverhead);
} else {
log_warn("udp: inner_mtu ", cfg_.common.inner_mtu,
" is too small to carry WireGuard (needs > ",
kWireGuardOverhead, " bytes)");
}
}
while (!stop.load()) {
const int rc = ::poll(fds.data(), fds.size(), timeout_ms);
const int rc = net_poll(fds, 2, timeout_ms);
if (rc < 0) {
#if !defined(_WIN32)
if (errno == EINTR) {
continue;
}
throw std::system_error(errno, std::generic_category(), "poll");
#endif
socket_throw("poll");
}
if (rc > 0) {
if (fds[0].revents & POLLIN) {
drain_tun();
if (fds[0].revents & kPollIn) {
drain_inner();
}
if (fds[1].revents & POLLIN) {
if (fds[1].revents & kPollIn) {
drain_socket();
}
}
@ -89,10 +128,33 @@ void Client::run(const std::atomic<bool>& stop) {
log_info("client shutting down");
}
void Client::drain_tun() {
void Client::drain_inner() {
std::array<Byte, kBufSize> buf{};
if (cfg_.common.transport == Transport::Udp) {
Endpoint peer;
for (;;) {
const ssize_t n = local_->recv_from(buf.data(), buf.size(), peer);
if (n < 0) {
break; // no more packets
}
local_peer_ = peer;
have_local_peer_ = true;
if (static_cast<std::size_t>(n) > cfg_.common.inner_mtu) {
log_error("udp: dropping oversized datagram of ", n,
" bytes (max payload is ", cfg_.common.inner_mtu,
"); lower the inner app MTU, or for QUIC/Hysteria2 "
"raise inner_mtu >= 1200 and ntp_payload_cap >= "
"inner_mtu+64");
continue;
}
send_carrier(kKindUdpDatagram,
ByteSpan(buf.data(), static_cast<std::size_t>(n)));
}
return;
}
#ifdef NTPTUN_HAVE_TUN
for (;;) {
const ssize_t n = tun_.read_packet(buf.data(), buf.size());
const ssize_t n = tun_->read_packet(buf.data(), buf.size());
if (n < 0) {
break; // EAGAIN: no more packets
}
@ -118,9 +180,10 @@ void Client::drain_tun() {
}
send_carrier(kKindIpDatagram, pkt);
}
#endif
}
bool Client::send_carrier(std::uint8_t kind, ByteSpan inner_ip) {
bool Client::send_carrier(std::uint8_t kind, ByteSpan inner) {
const std::uint64_t ts = next_transmit_ts();
NtpHeader header;
@ -129,14 +192,14 @@ bool Client::send_carrier(std::uint8_t kind, ByteSpan inner_ip) {
header.precision = -20;
header.transmit_ts = ts;
const ByteVector value = encode_carrier_value(cfg_.client_id, kind, inner_ip,
const ByteVector value = encode_carrier_value(cfg_.client_id, kind, inner,
key_, Direction::ClientToServer, ts);
const ByteVector packet =
build_packet_with_extension(header, cfg_.common.ext_type, value);
if (packet.size() > cfg_.common.ntp_payload_cap) {
log_warn("dropping datagram: encoded size ", packet.size(),
" exceeds NTP payload cap ", cfg_.common.ntp_payload_cap);
log_error("dropping datagram: encoded size ", packet.size(),
" exceeds NTP payload cap ", cfg_.common.ntp_payload_cap);
return false;
}
@ -226,11 +289,35 @@ void Client::handle_response(ByteSpan packet) {
if (decoded.client_id != cfg_.client_id) {
return; // belongs to another logical association
}
if (decoded.kind == kKindIpDatagram && !decoded.inner_ip.empty()) {
tun_.write_packet(decoded.inner_ip.data(), decoded.inner_ip.size());
if ((decoded.kind == kKindIpDatagram || decoded.kind == kKindUdpDatagram) &&
!decoded.inner_ip.empty()) {
deliver_inner(decoded.kind, decoded.inner_ip);
}
}
void Client::deliver_inner(std::uint8_t kind, ByteSpan payload) {
if (payload.empty()) {
return;
}
if (cfg_.common.transport == Transport::Udp) {
if (kind != kKindUdpDatagram) {
return;
}
if (!have_local_peer_) {
log_debug("udp: no local peer yet; dropping downstream datagram");
return;
}
local_->send_to(payload.data(), payload.size(), local_peer_);
return;
}
#ifdef NTPTUN_HAVE_TUN
if (kind != kKindIpDatagram) {
return;
}
tun_->write_packet(payload.data(), payload.size());
#endif
}
std::uint64_t Client::next_transmit_ts() {
std::uint64_t ts = ntp_now();
if (ts <= last_transmit_ts_) {

View file

@ -144,7 +144,25 @@ Config load_config(const std::string& path) {
Config cfg;
CommonConfig common;
common.tun = require("tun");
if (const auto* v = get("transport")) {
if (*v == "tun") {
common.transport = Transport::Tun;
} else if (*v == "udp") {
common.transport = Transport::Udp;
} else {
fail("transport must be 'tun' or 'udp', got: " + *v);
}
}
#if !defined(NTPTUN_HAVE_TUN)
if (common.transport == Transport::Tun) {
fail("transport 'tun' is not supported on this platform; use 'udp'");
}
#endif
if (common.transport == Transport::Tun) {
common.tun = require("tun");
} else if (const auto* v = get("tun")) {
common.tun = *v; // optional/ignored in udp transport
}
if (const auto* v = get("ext_type")) {
common.ext_type = static_cast<std::uint16_t>(parse_uint("ext_type", *v, 65535));
}
@ -198,6 +216,10 @@ Config load_config(const std::string& path) {
cc.keepalive_ms =
static_cast<unsigned>(parse_uint("keepalive_ms", *v, 3600000));
}
if (cc.common.transport == Transport::Udp) {
cc.udp_listen =
parse_endpoint("udp_listen", require("udp_listen"), true);
}
cfg.client = std::move(cc);
} else if (mode_str == "server") {
cfg.mode = Mode::Server;
@ -218,6 +240,14 @@ Config load_config(const std::string& path) {
if (const auto* v = get("max_queue_per_client")) {
sc.max_queue_per_client = parse_uint("max_queue_per_client", *v, 1000000);
}
if (sc.common.transport == Transport::Udp) {
sc.udp_target = parse_endpoint("udp_target", require("udp_target"), false);
sc.udp_base_port = static_cast<std::uint16_t>(
parse_uint("udp_base_port", require("udp_base_port"), 65535));
if (sc.udp_base_port == 0) {
fail("udp_base_port must be between 1 and 65535");
}
}
cfg.server = std::move(sc);
} else {
fail("mode must be 'client' or 'server', got: " + mode_str);

View file

@ -1,13 +1,13 @@
#include "ntptun/Endpoint.hpp"
#include <arpa/inet.h>
#include <netdb.h>
#include <cstring>
#include "ntptun/Platform.hpp"
namespace ntptun {
std::optional<Endpoint> Endpoint::parse(const std::string& hostport, bool passive) {
net_startup();
std::string host;
std::string port;
@ -50,11 +50,29 @@ std::optional<Endpoint> Endpoint::parse(const std::string& hostport, bool passiv
Endpoint ep;
std::memcpy(&ep.storage_, result->ai_addr, result->ai_addrlen);
ep.length_ = result->ai_addrlen;
ep.length_ = static_cast<socklen_t>(result->ai_addrlen);
freeaddrinfo(result);
return ep;
}
Endpoint Endpoint::wildcard(int family, std::uint16_t port) {
Endpoint ep;
if (family == AF_INET6) {
auto* in6 = reinterpret_cast<sockaddr_in6*>(&ep.storage_);
in6->sin6_family = AF_INET6;
in6->sin6_addr = in6addr_any;
in6->sin6_port = htons(port);
ep.length_ = sizeof(sockaddr_in6);
} else {
auto* in4 = reinterpret_cast<sockaddr_in*>(&ep.storage_);
in4->sin_family = AF_INET;
in4->sin_addr.s_addr = htonl(INADDR_ANY);
in4->sin_port = htons(port);
ep.length_ = sizeof(sockaddr_in);
}
return ep;
}
ip::Address Endpoint::ip_key() const noexcept {
ip::Address key;
if (storage_.ss_family == AF_INET) {

View file

@ -1,7 +1,7 @@
#include "ntptun/Ntp.hpp"
#include <chrono>
#include <cstring>
#include <ctime>
namespace ntptun {
@ -86,12 +86,12 @@ bool parse_ntp_extensions(ByteSpan packet, std::vector<NtpExtension>& out) {
}
std::uint64_t ntp_now() {
timespec ts{};
clock_gettime(CLOCK_REALTIME, &ts);
const std::uint64_t seconds =
static_cast<std::uint64_t>(ts.tv_sec) + kNtpUnixEpochOffset;
const std::uint64_t frac =
(static_cast<std::uint64_t>(ts.tv_nsec) << 32) / 1000000000ULL;
using namespace std::chrono;
const auto now = system_clock::now().time_since_epoch();
const std::uint64_t whole_ns =
static_cast<std::uint64_t>(duration_cast<nanoseconds>(now).count());
const std::uint64_t seconds = whole_ns / 1000000000ULL + kNtpUnixEpochOffset;
const std::uint64_t frac = ((whole_ns % 1000000000ULL) << 32) / 1000000000ULL;
return (seconds << 32) | (frac & 0xFFFFFFFFULL);
}

134
src/Platform.cpp Normal file
View file

@ -0,0 +1,134 @@
#include "ntptun/Platform.hpp"
#include <system_error>
#include <vector>
#if !defined(_WIN32)
# include <cerrno>
# include <fcntl.h>
#endif
namespace ntptun {
#if defined(_WIN32)
void net_startup() {
static const bool ok = [] {
WSADATA data{};
const int rc = WSAStartup(MAKEWORD(2, 2), &data);
if (rc != 0) {
throw std::system_error(rc, std::system_category(), "WSAStartup");
}
return true;
}();
(void)ok;
}
void socket_close(socket_t s) noexcept {
if (s != kInvalidSocket) {
::closesocket(s);
}
}
bool socket_would_block() noexcept {
const int e = WSAGetLastError();
return e == WSAEWOULDBLOCK;
}
void socket_throw(const char* what) {
throw std::system_error(WSAGetLastError(), std::system_category(), what);
}
void socket_set_nonblocking(socket_t s, bool enabled) {
u_long mode = enabled ? 1u : 0u;
if (::ioctlsocket(s, FIONBIO, &mode) != 0) {
socket_throw("ioctlsocket(FIONBIO)");
}
}
int net_poll(PollFd* fds, std::size_t n, int timeout_ms) {
std::vector<WSAPOLLFD> native(n);
for (std::size_t i = 0; i < n; ++i) {
native[i].fd = fds[i].fd;
native[i].events = (fds[i].events & kPollIn) ? POLLRDNORM : 0;
native[i].revents = 0;
}
const int rc = ::WSAPoll(native.empty() ? nullptr : native.data(),
static_cast<ULONG>(n), timeout_ms);
if (rc > 0) {
for (std::size_t i = 0; i < n; ++i) {
short r = 0;
if (native[i].revents & (POLLRDNORM | POLLHUP | POLLERR)) {
r |= kPollIn;
}
fds[i].revents = r;
}
} else {
for (std::size_t i = 0; i < n; ++i) {
fds[i].revents = 0;
}
}
return rc;
}
#else // POSIX
void net_startup() {}
void socket_close(socket_t s) noexcept {
if (s != kInvalidSocket) {
::close(s);
}
}
bool socket_would_block() noexcept {
return errno == EAGAIN || errno == EWOULDBLOCK;
}
void socket_throw(const char* what) {
throw std::system_error(errno, std::generic_category(), what);
}
void socket_set_nonblocking(socket_t s, bool enabled) {
int flags = ::fcntl(s, F_GETFL, 0);
if (flags < 0) {
socket_throw("fcntl(F_GETFL)");
}
if (enabled) {
flags |= O_NONBLOCK;
} else {
flags &= ~O_NONBLOCK;
}
if (::fcntl(s, F_SETFL, flags) < 0) {
socket_throw("fcntl(F_SETFL)");
}
}
int net_poll(PollFd* fds, std::size_t n, int timeout_ms) {
std::vector<pollfd> native(n);
for (std::size_t i = 0; i < n; ++i) {
native[i].fd = fds[i].fd;
native[i].events = (fds[i].events & kPollIn) ? POLLIN : 0;
native[i].revents = 0;
}
const int rc = ::poll(native.empty() ? nullptr : native.data(),
static_cast<nfds_t>(n), timeout_ms);
if (rc > 0) {
for (std::size_t i = 0; i < n; ++i) {
short r = 0;
if (native[i].revents & (POLLIN | POLLHUP | POLLERR)) {
r |= kPollIn;
}
fds[i].revents = r;
}
} else {
for (std::size_t i = 0; i < n; ++i) {
fds[i].revents = 0;
}
}
return rc;
}
#endif
} // namespace ntptun

View file

@ -1,16 +1,15 @@
#include "ntptun/Server.hpp"
#include <poll.h>
#include <algorithm>
#include <array>
#include <cerrno>
#include <cstddef>
#include <stdexcept>
#include <system_error>
#include <utility>
#include <vector>
#include "ntptun/Logging.hpp"
#include "ntptun/Platform.hpp"
namespace ntptun {
namespace {
@ -19,7 +18,9 @@ constexpr std::size_t kBufSize = 65536;
constexpr std::size_t kMaxPendingRelays = 4096;
constexpr std::size_t kMaxRateEntries = 8192;
constexpr std::size_t kMaxRoutes = 100000;
constexpr std::size_t kMaxRelaySockets = 4096;
constexpr int kPollTimeoutMs = 200;
constexpr std::size_t kNoIndex = static_cast<std::size_t>(-1);
bool plausible_request(const NtpHeader& header) {
const std::uint8_t version = header.version();
@ -30,9 +31,14 @@ bool plausible_request(const NtpHeader& header) {
} // namespace
Server::Server(ServerConfig config)
: cfg_(std::move(config)),
tun_(TunDevice::open(cfg_.common.tun)),
sock_(UdpSocket::bind(cfg_.listen)) {
: cfg_(std::move(config)), sock_(UdpSocket::bind(cfg_.listen)) {
if (cfg_.common.transport == Transport::Tun) {
#ifdef NTPTUN_HAVE_TUN
tun_ = TunDevice::open(cfg_.common.tun);
#else
throw std::runtime_error("tun transport is not supported on this platform");
#endif
}
if (cfg_.upstream) {
UdpSocket up = UdpSocket::create(cfg_.upstream->family());
up.connect(*cfg_.upstream);
@ -41,49 +47,98 @@ Server::Server(ServerConfig config)
}
void Server::run(const std::atomic<bool>& stop) {
tun_.set_nonblocking(true);
const bool tun_xport = cfg_.common.transport == Transport::Tun;
#ifdef NTPTUN_HAVE_TUN
if (tun_xport) {
tun_.set_nonblocking(true);
}
#endif
sock_.set_nonblocking(true);
if (upstream_) {
upstream_->set_nonblocking(true);
}
log_info("server up: tun=", tun_.name(), " listen=", cfg_.listen.to_string(),
upstream_ ? " relay=on" : " relay=off",
const char* xport = tun_xport ? "tun" : "udp";
const std::string inner_desc =
tun_xport ? ("tun=" + cfg_.common.tun)
: ("target=" + cfg_.udp_target.to_string() + " base_port=" +
std::to_string(cfg_.udp_base_port));
log_info("server up: transport=", xport, " ", inner_desc,
" listen=", cfg_.listen.to_string(), upstream_ ? " relay=on" : " relay=off",
cfg_.common.push_mode ? " mode=push" : " mode=poll");
if (!tun_xport) {
// In UDP transport each client relays from source port
// udp_base_port + client_id, so the largest usable client_id is
// (65535 - udp_base_port). Clients above that cannot be served and are
// rejected per-request in relay_for().
const unsigned max_client_id = 65535u - cfg_.udp_base_port;
log_info("udp relay: source ports ", cfg_.udp_base_port, "..65535; ",
"usable client_id range 1..", max_client_id,
" (udp_base_port + client_id must be <= 65535)");
if (max_client_id < 65535u) {
log_warn("udp relay: client_id > ", max_client_id,
" cannot be served with udp_base_port=", cfg_.udp_base_port);
}
}
while (!stop.load()) {
std::array<pollfd, 3> fds{};
nfds_t nfds = 0;
fds[nfds].fd = tun_.fd();
fds[nfds].events = POLLIN;
const std::size_t tun_idx = nfds++;
fds[nfds].fd = sock_.fd();
fds[nfds].events = POLLIN;
const std::size_t udp_idx = nfds++;
std::size_t up_idx = 0;
std::vector<PollFd> fds;
std::size_t up_idx = kNoIndex;
fds.push_back(PollFd{sock_.fd(), kPollIn, 0});
const std::size_t udp_idx = 0;
#ifdef NTPTUN_HAVE_TUN
std::size_t tun_idx = kNoIndex;
if (tun_xport) {
tun_idx = fds.size();
fds.push_back(PollFd{tun_.fd(), kPollIn, 0});
}
#endif
if (upstream_) {
fds[nfds].fd = upstream_->fd();
fds[nfds].events = POLLIN;
up_idx = nfds++;
up_idx = fds.size();
fds.push_back(PollFd{upstream_->fd(), kPollIn, 0});
}
const int rc = ::poll(fds.data(), nfds, kPollTimeoutMs);
std::vector<std::uint16_t> relay_ids;
if (!tun_xport) {
relay_ids.reserve(relay_socks_.size());
for (auto& entry : relay_socks_) {
relay_ids.push_back(entry.first);
fds.push_back(PollFd{entry.second.fd(), kPollIn, 0});
}
}
const std::size_t relay_base = fds.size() - relay_ids.size();
const int rc = net_poll(fds.data(), fds.size(), kPollTimeoutMs);
if (rc < 0) {
#if !defined(_WIN32)
if (errno == EINTR) {
continue;
}
throw std::system_error(errno, std::generic_category(), "poll");
#endif
socket_throw("poll");
}
if (rc > 0) {
if (fds[udp_idx].revents & POLLIN) {
if (fds[udp_idx].revents & kPollIn) {
drain_udp();
}
if (fds[tun_idx].revents & POLLIN) {
#ifdef NTPTUN_HAVE_TUN
if (tun_idx != kNoIndex && (fds[tun_idx].revents & kPollIn)) {
drain_tun();
}
if (upstream_ && (fds[up_idx].revents & POLLIN)) {
#endif
if (up_idx != kNoIndex && (fds[up_idx].revents & kPollIn)) {
drain_upstream();
}
for (std::size_t i = 0; i < relay_ids.size(); ++i) {
if (fds[relay_base + i].revents & kPollIn) {
const auto it = relay_socks_.find(relay_ids[i]);
if (it != relay_socks_.end()) {
drain_relay(relay_ids[i], it->second);
}
}
}
}
expire_relays();
}
@ -148,32 +203,119 @@ void Server::handle_tunnel(std::uint64_t request_transmit_ts,
state.has_src = true;
state.last_request_ts = request_transmit_ts;
if (carrier.kind == kKindIpDatagram) {
if (tun_.write_packet(carrier.inner_ip.data(), carrier.inner_ip.size()) < 0) {
log_debug("tun write would block; inner datagram dropped");
}
// Source-learn a route back to this client for downstream traffic.
const auto src = ip::source(carrier.inner_ip);
if (src) {
if (routes_.size() >= kMaxRoutes && routes_.find(*src) == routes_.end()) {
log_warn("route table full; not learning new route");
} else {
routes_[*src] = carrier.client_id;
if (cfg_.common.transport == Transport::Udp) {
if (carrier.kind == kKindUdpDatagram && !carrier.inner_ip.empty()) {
UdpSocket* r = relay_for(carrier.client_id);
if (r && r->send(carrier.inner_ip.data(), carrier.inner_ip.size()) < 0) {
log_debug("relay send would block; payload dropped");
}
}
} else {
#ifdef NTPTUN_HAVE_TUN
if (carrier.kind == kKindIpDatagram) {
if (tun_.write_packet(carrier.inner_ip.data(), carrier.inner_ip.size()) < 0) {
log_debug("tun write would block; inner datagram dropped");
}
// Source-learn a route back to this client for downstream traffic.
const auto src = ip::source(carrier.inner_ip);
if (src) {
if (routes_.size() >= kMaxRoutes && routes_.find(*src) == routes_.end()) {
log_warn("route table full; not learning new route");
} else {
routes_[*src] = carrier.client_id;
}
}
}
#endif
}
// Answer with exactly one response, carrying a queued datagram if available.
std::uint8_t kind = kKindEmptyPoll;
ByteVector payload;
if (!state.downstream.empty()) {
kind = kKindIpDatagram;
kind = (cfg_.common.transport == Transport::Udp) ? kKindUdpDatagram
: kKindIpDatagram;
payload = std::move(state.downstream.front());
state.downstream.pop_front();
}
send_response(carrier.client_id, request_transmit_ts, kind, payload, from);
}
UdpSocket* Server::relay_for(std::uint16_t client_id) {
const auto existing = relay_socks_.find(client_id);
if (existing != relay_socks_.end()) {
relay_last_[client_id] = std::chrono::steady_clock::now();
return &existing->second;
}
const std::uint32_t port =
static_cast<std::uint32_t>(cfg_.udp_base_port) + client_id;
if (port > 65535u) {
log_error("relay: source port ", port, " out of range for client ", client_id);
return nullptr;
}
// Evict the least-recently-used relay socket when the table is full.
if (relay_socks_.size() >= kMaxRelaySockets && !relay_last_.empty()) {
const auto lru = std::min_element(
relay_last_.begin(), relay_last_.end(),
[](const auto& a, const auto& b) { return a.second < b.second; });
const std::uint16_t victim = lru->first;
relay_socks_.erase(victim);
relay_last_.erase(victim);
log_debug("relay: evicted LRU socket for client ", victim);
}
try {
UdpSocket s = UdpSocket::bind(
Endpoint::wildcard(cfg_.udp_target.family(), static_cast<std::uint16_t>(port)));
s.connect(cfg_.udp_target);
s.set_nonblocking(true);
const auto inserted = relay_socks_.emplace(client_id, std::move(s));
relay_last_[client_id] = std::chrono::steady_clock::now();
return &inserted.first->second;
} catch (const std::exception& e) {
log_error("relay: cannot open socket for client ", client_id, " on port ",
port, ": ", e.what());
return nullptr;
}
}
void Server::drain_relay(std::uint16_t client_id, UdpSocket& sock) {
std::array<Byte, kBufSize> buf{};
for (;;) {
const ssize_t n = sock.recv(buf.data(), buf.size());
if (n < 0) {
break;
}
relay_last_[client_id] = std::chrono::steady_clock::now();
if (static_cast<std::size_t>(n) > cfg_.common.inner_mtu) {
log_error("relay: dropping oversized reply of ", n, " bytes for client ",
client_id, " (max payload is ", cfg_.common.inner_mtu,
"); raise inner_mtu (and ntp_payload_cap >= inner_mtu+64), "
"e.g. inner_mtu >= 1200 for QUIC/Hysteria2");
continue;
}
queue_downstream(client_id, kKindUdpDatagram,
ByteSpan(buf.data(), static_cast<std::size_t>(n)));
}
}
void Server::queue_downstream(std::uint16_t client_id, std::uint8_t kind,
ByteSpan payload) {
ClientState& state = clients_[client_id];
if (cfg_.common.push_mode && state.has_src) {
// Push immediately to the client's last known address instead of waiting
// for a poll (breaks strict NTP request/response semantics).
send_response(client_id, state.last_request_ts, kind, payload, state.last_src);
return;
}
state.downstream.emplace_back(payload.begin(), payload.end());
while (state.downstream.size() > cfg_.max_queue_per_client) {
state.downstream.pop_front();
}
}
void Server::send_response(std::uint16_t client_id, std::uint64_t request_transmit_ts,
std::uint8_t kind, ByteSpan payload, const Endpoint& from) {
const auto key = cfg_.common.keys.resolve(client_id);
@ -207,6 +349,7 @@ void Server::send_response(std::uint16_t client_id, std::uint64_t request_transm
}
void Server::drain_tun() {
#ifdef NTPTUN_HAVE_TUN
std::array<Byte, kBufSize> buf{};
for (;;) {
const ssize_t n = tun_.read_packet(buf.data(), buf.size());
@ -240,19 +383,9 @@ void Server::drain_tun() {
log_debug("no route for inner destination; dropping downstream datagram");
continue;
}
ClientState& state = clients_[route->second];
if (cfg_.common.push_mode && state.has_src) {
// Push immediately to the client's last known address instead of
// waiting for a poll (breaks strict NTP request/response semantics).
send_response(route->second, state.last_request_ts, kKindIpDatagram,
pkt, state.last_src);
continue;
}
state.downstream.emplace_back(pkt.begin(), pkt.end());
while (state.downstream.size() > cfg_.max_queue_per_client) {
state.downstream.pop_front();
}
queue_downstream(route->second, kKindIpDatagram, pkt);
}
#endif
}
void Server::relay(ByteSpan packet, const NtpHeader& header, const Endpoint& from) {

View file

@ -1,48 +1,38 @@
#include "ntptun/UdpSocket.hpp"
#include <fcntl.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cerrno>
#include <cstring>
#include <system_error>
#include "ntptun/Platform.hpp"
namespace ntptun {
namespace {
[[noreturn]] void throw_errno(const char* what) {
throw std::system_error(errno, std::generic_category(), what);
}
} // namespace
UdpSocket::~UdpSocket() { close_fd(); }
UdpSocket::UdpSocket(UdpSocket&& other) noexcept : fd_(other.fd_) {
other.fd_ = -1;
other.fd_ = kInvalidSocket;
}
UdpSocket& UdpSocket::operator=(UdpSocket&& other) noexcept {
if (this != &other) {
close_fd();
fd_ = other.fd_;
other.fd_ = -1;
other.fd_ = kInvalidSocket;
}
return *this;
}
void UdpSocket::close_fd() noexcept {
if (fd_ >= 0) {
::close(fd_);
fd_ = -1;
if (fd_ != kInvalidSocket) {
socket_close(fd_);
fd_ = kInvalidSocket;
}
}
UdpSocket UdpSocket::create(int family) {
const int fd = ::socket(family, SOCK_DGRAM, IPPROTO_UDP);
if (fd < 0) {
throw_errno("socket");
net_startup();
const socket_t fd = ::socket(family, SOCK_DGRAM, IPPROTO_UDP);
if (fd == kInvalidSocket) {
socket_throw("socket");
}
return UdpSocket(fd);
}
@ -50,44 +40,35 @@ UdpSocket UdpSocket::create(int family) {
UdpSocket UdpSocket::bind(const Endpoint& local) {
UdpSocket sock = create(local.family());
const int one = 1;
::setsockopt(sock.fd_, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
::setsockopt(sock.fd_, SOL_SOCKET, SO_REUSEADDR,
reinterpret_cast<const char*>(&one), sizeof(one));
if (::bind(sock.fd_, local.sockaddr_ptr(), local.length()) != 0) {
throw_errno("bind");
socket_throw("bind");
}
return sock;
}
void UdpSocket::connect(const Endpoint& remote) {
if (::connect(fd_, remote.sockaddr_ptr(), remote.length()) != 0) {
throw_errno("connect");
socket_throw("connect");
}
}
void UdpSocket::set_nonblocking(bool enabled) {
int flags = ::fcntl(fd_, F_GETFL, 0);
if (flags < 0) {
throw_errno("fcntl(F_GETFL)");
}
if (enabled) {
flags |= O_NONBLOCK;
} else {
flags &= ~O_NONBLOCK;
}
if (::fcntl(fd_, F_SETFL, flags) < 0) {
throw_errno("fcntl(F_SETFL)");
}
socket_set_nonblocking(fd_, enabled);
}
ssize_t UdpSocket::recv_from(Byte* buf, std::size_t len, Endpoint& from) {
sockaddr_storage src{};
socklen_t src_len = sizeof(src);
const ssize_t n = ::recvfrom(fd_, buf, len, 0,
reinterpret_cast<sockaddr*>(&src), &src_len);
const ssize_t n =
::recvfrom(fd_, reinterpret_cast<char*>(buf), static_cast<int>(len), 0,
reinterpret_cast<sockaddr*>(&src), &src_len);
if (n < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
if (socket_would_block()) {
return -1;
}
throw_errno("recvfrom");
socket_throw("recvfrom");
}
std::memcpy(from.sockaddr_ptr(), &src, src_len);
from.set_length(src_len);
@ -95,34 +76,38 @@ ssize_t UdpSocket::recv_from(Byte* buf, std::size_t len, Endpoint& from) {
}
ssize_t UdpSocket::send_to(const Byte* buf, std::size_t len, const Endpoint& to) {
const ssize_t n = ::sendto(fd_, buf, len, 0, to.sockaddr_ptr(), to.length());
const ssize_t n =
::sendto(fd_, reinterpret_cast<const char*>(buf), static_cast<int>(len), 0,
to.sockaddr_ptr(), to.length());
if (n < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
if (socket_would_block()) {
return -1;
}
throw_errno("sendto");
socket_throw("sendto");
}
return n;
}
ssize_t UdpSocket::recv(Byte* buf, std::size_t len) {
const ssize_t n = ::recv(fd_, buf, len, 0);
const ssize_t n =
::recv(fd_, reinterpret_cast<char*>(buf), static_cast<int>(len), 0);
if (n < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
if (socket_would_block()) {
return -1;
}
throw_errno("recv");
socket_throw("recv");
}
return n;
}
ssize_t UdpSocket::send(const Byte* buf, std::size_t len) {
const ssize_t n = ::send(fd_, buf, len, 0);
const ssize_t n =
::send(fd_, reinterpret_cast<const char*>(buf), static_cast<int>(len), 0);
if (n < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
if (socket_would_block()) {
return -1;
}
throw_errno("send");
socket_throw("send");
}
return n;
}

View file

@ -32,12 +32,17 @@ int main(int argc, char** argv) {
: cfg.server->common.log_level;
log_level() = level;
#if defined(_WIN32)
std::signal(SIGINT, on_signal);
std::signal(SIGTERM, on_signal);
#else
struct sigaction sa {};
sa.sa_handler = on_signal;
sigemptyset(&sa.sa_mask);
sigaction(SIGINT, &sa, nullptr);
sigaction(SIGTERM, &sa, nullptr);
std::signal(SIGPIPE, SIG_IGN);
#endif
if (cfg.mode == Mode::Client) {
Client client(std::move(*cfg.client));