diff --git a/.gitignore b/.gitignore index 1352564..13f53e6 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CMakeLists.txt b/CMakeLists.txt index be0492d..cfe0785 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) diff --git a/README.md b/README.md index 2ea8d87..5ad7d08 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/examples/client_udp.conf b/examples/client_udp.conf new file mode 100644 index 0000000..78e754b --- /dev/null +++ b/examples/client_udp.conf @@ -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 diff --git a/examples/server_udp.conf b/examples/server_udp.conf new file mode 100644 index 0000000..3925aca --- /dev/null +++ b/examples/server_udp.conf @@ -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 diff --git a/include/ntptun/Carrier.hpp b/include/ntptun/Carrier.hpp index 6e5f886..bd3fe7f 100644 --- a/include/ntptun/Carrier.hpp +++ b/include/ntptun/Carrier.hpp @@ -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 diff --git a/include/ntptun/Client.hpp b/include/ntptun/Client.hpp index 3d63046..9605c29 100644 --- a/include/ntptun/Client.hpp +++ b/include/ntptun/Client.hpp @@ -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 #include #include +#include #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& 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 tun_; +#endif + std::optional 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; diff --git a/include/ntptun/Config.hpp b/include/ntptun/Config.hpp index e681442..ba0f4d6 100644 --- a/include/ntptun/Config.hpp +++ b/include/ntptun/Config.hpp @@ -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 { diff --git a/include/ntptun/Endpoint.hpp b/include/ntptun/Endpoint.hpp index bd4b498..1f2afad 100644 --- a/include/ntptun/Endpoint.hpp +++ b/include/ntptun/Endpoint.hpp @@ -3,14 +3,12 @@ #ifndef NTPTUN_ENDPOINT_HPP #define NTPTUN_ENDPOINT_HPP -#include -#include - #include #include #include #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 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(&storage_); } diff --git a/include/ntptun/Platform.hpp b/include/ntptun/Platform.hpp new file mode 100644 index 0000000..b2b4f0d --- /dev/null +++ b/include/ntptun/Platform.hpp @@ -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 +#include + +#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 +# include +# include + +# if !defined(_SSIZE_T_DEFINED) && !defined(NTPTUN_SSIZE_T_DEFINED) +# define NTPTUN_SSIZE_T_DEFINED +using ssize_t = SSIZE_T; +# endif + +#else // POSIX + +# include +# include +# include +# include +# include +# include +# include + +# 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 diff --git a/include/ntptun/Server.hpp b/include/ntptun/Server.hpp index 86af429..3eef1eb 100644 --- a/include/ntptun/Server.hpp +++ b/include/ntptun/Server.hpp @@ -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 routes_; // learned inner dst -> client id +#endif UdpSocket sock_; std::optional upstream_; std::map clients_; - std::map 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 relay_socks_; + std::map relay_last_; std::deque relays_; std::map rate_; diff --git a/include/ntptun/TunDevice.hpp b/include/ntptun/TunDevice.hpp index cecf2c2..560bc35 100644 --- a/include/ntptun/TunDevice.hpp +++ b/include/ntptun/TunDevice.hpp @@ -5,12 +5,11 @@ #ifndef NTPTUN_TUNDEVICE_HPP #define NTPTUN_TUNDEVICE_HPP -#include - #include #include #include "ntptun/Bytes.hpp" +#include "ntptun/Platform.hpp" namespace ntptun { diff --git a/include/ntptun/UdpSocket.hpp b/include/ntptun/UdpSocket.hpp index 32f1cf5..b8b717d 100644 --- a/include/ntptun/UdpSocket.hpp +++ b/include/ntptun/UdpSocket.hpp @@ -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 - #include #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 diff --git a/src/Carrier.cpp b/src/Carrier.cpp index 4474e66..0309f37 100644 --- a/src/Carrier.cpp +++ b/src/Carrier.cpp @@ -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; diff --git a/src/Client.cpp b/src/Client.cpp index 6e5d6f4..3ab0145 100644 --- a/src/Client.cpp +++ b/src/Client.cpp @@ -1,18 +1,16 @@ #include "ntptun/Client.hpp" -#include - #include #include #include #include #include -#include #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& stop) { - tun_.set_nonblocking(true); sock_.set_nonblocking(true); - std::array 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(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& stop) { log_info("client shutting down"); } -void Client::drain_tun() { +void Client::drain_inner() { std::array 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(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(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_) { diff --git a/src/Config.cpp b/src/Config.cpp index 9e83c13..ea0157f 100644 --- a/src/Config.cpp +++ b/src/Config.cpp @@ -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(parse_uint("ext_type", *v, 65535)); } @@ -198,6 +216,10 @@ Config load_config(const std::string& path) { cc.keepalive_ms = static_cast(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( + 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); diff --git a/src/Endpoint.cpp b/src/Endpoint.cpp index 8451ff7..2c7dd88 100644 --- a/src/Endpoint.cpp +++ b/src/Endpoint.cpp @@ -1,13 +1,13 @@ #include "ntptun/Endpoint.hpp" -#include -#include - #include +#include "ntptun/Platform.hpp" + namespace ntptun { std::optional Endpoint::parse(const std::string& hostport, bool passive) { + net_startup(); std::string host; std::string port; @@ -50,11 +50,29 @@ std::optional 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(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(&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(&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) { diff --git a/src/Ntp.cpp b/src/Ntp.cpp index f65e316..0acb44c 100644 --- a/src/Ntp.cpp +++ b/src/Ntp.cpp @@ -1,7 +1,7 @@ #include "ntptun/Ntp.hpp" +#include #include -#include namespace ntptun { @@ -86,12 +86,12 @@ bool parse_ntp_extensions(ByteSpan packet, std::vector& out) { } std::uint64_t ntp_now() { - timespec ts{}; - clock_gettime(CLOCK_REALTIME, &ts); - const std::uint64_t seconds = - static_cast(ts.tv_sec) + kNtpUnixEpochOffset; - const std::uint64_t frac = - (static_cast(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(duration_cast(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); } diff --git a/src/Platform.cpp b/src/Platform.cpp new file mode 100644 index 0000000..3218a90 --- /dev/null +++ b/src/Platform.cpp @@ -0,0 +1,134 @@ +#include "ntptun/Platform.hpp" + +#include +#include + +#if !defined(_WIN32) +# include +# include +#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 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(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 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(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 diff --git a/src/Server.cpp b/src/Server.cpp index ed0fa1c..58b0a21 100644 --- a/src/Server.cpp +++ b/src/Server.cpp @@ -1,16 +1,15 @@ #include "ntptun/Server.hpp" -#include - #include #include #include +#include #include -#include #include #include #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(-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& 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 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 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 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(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(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 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(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(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 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) { diff --git a/src/UdpSocket.cpp b/src/UdpSocket.cpp index 9cf486d..5264d30 100644 --- a/src/UdpSocket.cpp +++ b/src/UdpSocket.cpp @@ -1,48 +1,38 @@ #include "ntptun/UdpSocket.hpp" -#include -#include -#include - -#include #include -#include + +#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(&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(&src), &src_len); + const ssize_t n = + ::recvfrom(fd_, reinterpret_cast(buf), static_cast(len), 0, + reinterpret_cast(&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(buf), static_cast(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(buf), static_cast(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(buf), static_cast(len), 0); if (n < 0) { - if (errno == EAGAIN || errno == EWOULDBLOCK) { + if (socket_would_block()) { return -1; } - throw_errno("send"); + socket_throw("send"); } return n; } diff --git a/src/main.cpp b/src/main.cpp index 597e5fa..c59306b 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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));