It's normal to do a little hacking on New Year's Eve, right? Well, maybe not but being focused on the time this evening I noticed some of my devices clocks had drifted noticeably -- and that just can't be ignored.
The problem: Full WireGuard Tunnel
One of the things I've been trying to demonstrate with Proxylity's UDP Gateway is that adding a new UDP-based service should feel uneventful. It should look like application code, not feel like infrastructure archaeology.
This article walks through a real example: adding a minimal but functional NTP server to an existing Lambda destination that already handles WireGuard-encapsulated traffic.
The motivation came from a very practical problem.
I was testing an ESP32 Cheap Yellow Display using Wireguard-ESP32, connected to a WireGuard Listener on UDP Gateway. The device acts as a full tunnel client.
That worked well, except the clock never synced.
Once the tunnel is established, all traffic goes through WireGuard, including NTP. The ESP32 was still trying to reach public NTP servers on UDP port 123, but those packets were now being routed to the Listener with very narrow functionality (which doesn't include proxying to the public internet).
Ideally, this would be solved with a partial tunnel configuration so that NTP bypasses WireGuard. Unfortunately, the ESP32 library does not support that today.
Rather than fighting the library, I leaned into the UDP Gateway side: if NTP packets are already arriving in Lambda, it is straightforward to simply answer them there.
What this Lambda already does
Before adding NTP, this destination Lambda already handled:
- Decapsulated UDP packets from a WireGuard Listener
- ICMP echo, so
pingworks through the tunnel - A simple UDP echo service for testing
This is implemented by subclassing DecapsulatedHandler from the Proxylity Lambda SDK. Each
protocol handler receives a fully parsed PacketDotNet packet along with metadata about the remote peer.
There are no raw sockets, no custom crypto, and no kernel-level configuration.
Dispatching NTP traffic
NTP is UDP on port 123. The dispatch logic inside the UDP handler is intentionally simple:
if (packet.DestinationPort == 123)
{
return await HandleNtpAsync(packet, remote, logger);
}
If the packet arrives, the Lambda decides whether to respond. No listener reconfiguration or routing changes are required.
Validating the request
NTP packets are always 48 bytes. The first byte encodes the leap indicator, version number, and mode.
For this implementation, only two checks matter:
- The packet must be client mode
- The version must be at least 3
byte liVnMode = request[0];
byte version = (byte)((liVnMode >> 3) & 0b111);
byte mode = (byte)(liVnMode & 0b111);
if (mode != ClientMode || version < 3)
return null;
Returning null causes the packet to be dropped. This is often the correct behavior for UDP
services.
Constructing the response
An NTP response is just another 48-byte buffer with timestamps filled in. There is no session state and no handshake.
The Lambda uses DateTime.UtcNow as its clock source and converts it to the NTP epoch, which
starts on January 1, 1900.
private static readonly DateTime NtpEpoch =
new(1900, 1, 1, 0, 0, 0, DateTimeKind.Utc);
The transmit, receive, and reference timestamps are all derived from the same value. This is sufficient for embedded devices that need a stable and reasonable clock.
A small helper writes timestamps in NTP's fixed-point format:
static void write_timestamp(byte[] buffer, int offset, DateTime utc)
{
ulong seconds = (ulong)(utc - NtpEpoch).TotalSeconds;
ulong fraction = (ulong)(
(utc.Ticks % TimeSpan.TicksPerSecond) *
((double)(1UL << 32) / TimeSpan.TicksPerSecond));
BinaryPrimitives.WriteUInt32BigEndian(buffer.AsSpan(offset), (uint)seconds);
BinaryPrimitives.WriteUInt32BigEndian(buffer.AsSpan(offset + 4), (uint)fraction);
}
The client's transmit timestamp is copied into the originate field, as required by the protocol.
Payload-only responses
Unlike the UDP echo handler, the NTP handler returns only the UDP payload, not a full IP packet.
The Gateway already knows the source and destination IPs and ports. When a raw payload is returned, it reconstructs the UDP and IP headers automatically.
This keeps the handler focused on protocol logic rather than packet plumbing.
Restoring a basic assumption
The immediate goal was to set the ESP32's clock, but the impact was broader.
Many subsystems quietly assume time is roughly correct:
- TLS certificate validation depends on valid time windows
- Logs become difficult to reason about without sane timestamps
- Scheduling, retries, and backoff depend on time progressing normally
Before adding NTP, failures appeared as unrelated symptoms: TLS errors, missing logs, and odd retry behavior. After the device could sync its clock through the tunnel, those issues disappeared without any other changes.
The NTP handler itself is small, but it restores a foundational assumption the system depends on.
Why this matters
NTP is just one example.
The same approach applies to DNS, DHCP helpers, TFTP, discovery protocols, or custom UDP services that are inconvenient to run on traditional infrastructure.
Once UDP packets land in Lambda in a structured form, adding a network service is no harder than adding a request handler.
That is the point: not that NTP was easily implemented, but that doing so was boring and doable in minutes to ensure all my devices enjoy the ball drop on time tonight.