A packet parser can avoid copying its input and still return a result that is safe to hand to another layer. The important question is where borrowed bytes stop and owned bytes begin.

I built TUNNELFORGE as a constrained C++20 packet-processing lab around that boundary. It accepts a narrow GTP-U user-plane path, applies a static session and forwarding decision, then moves an accepted inner IP packet toward a Linux TUN device. The goal is to make the path inspectable—not to present a complete 5G user-plane function.

TUNNELFORGE / UPLINK OWNERSHIP

Borrow the input. Own the forwarded packet.
SOURCE-DERIVED
OFFLINE REPLAY VECTOR36-byte GTP-U payload
GTP-U HEADER8 B
INNER IPv4 / UDP28 B

GTP-U bytes only; the outer IP and UDP headers are not included.

  1. 01 / RECEIVEReusable input bufferstd::array · 65,536 B

    The UDP socket writes the datagram into the gateway’s shared buffer.

  2. 02 / DECODEBorrowed packet viewstd::span → payload subspan

    Length, flags and extension bounds are checked without copying packet bytes.

  3. 03 / DECIDESession and policy checksTEID · peer · UE IP · MTU · QER

    A rejected packet returns a reason and stops before TUN output.

  4. 04 / OWNAccepted IP packet copiedPacketDecision.packet · vector

    The result now owns the inner bytes that the gateway writes to TUN.

RETURN PATHTUN IPUE destination lookupGTP-U encodeUDP to configured gNB
A source-derived path from the checked-in gateway and packet engine. The sample size comes from the repository’s offline replay vector; this is not a live packet capture or a throughput test.

Borrow bytes while the receive buffer is alive

The Linux gateway receives a UDP datagram into a reusable 65,536-byte array. decode_gtpu accepts a std::span<const std::byte> over the received portion of that array. After validating the GTP-U header, it returns a GtpuView; the inner payload is another span into the same input memory.

That avoids copying packet bytes just to describe where the header ends and the payload begins. The view is intentionally temporary in meaning even though C++ does not enforce a lifetime on std::span: it is valid only while the backing receive buffer remains unchanged and alive. A later UDP receive reuses that buffer.

“Zero-copy” needs a narrow definition here. The payload view does not copy bytes, but the parser still creates ordinary metadata objects, and extension-header types are collected in a vector. More importantly, the complete accepted-packet path does make an owned output buffer. The project avoids one unnecessary copy; it does not claim zero allocation across the dataplane.

Reject malformed input before forwarding

The decoder first checks the mandatory eight-byte header, GTP version and protocol bits, reserved flag, and whether the declared GTP length exactly matches the datagram. If optional fields are present, their four-byte area must fit. Extension headers are walked using their declared four-octet-unit lengths; a zero length or a truncated chain is rejected. The packet engine then accepts only message type 255, the G-PDU used by this narrow data path.

For a G-PDU, the packet engine looks up the uplink TEID, checks the configured gNB peer when one is supplied, and requires an active session with a forwarding action. It parses the inner IPv4 or IPv6 packet, enforces the configured MTU and the UE source address, then applies the session’s token bucket. Every rejection returns a reason such as unknown_teid, ue_source_mismatch, or qos_exceeded; rejected bytes never reach the TUN write in this path.

This ordering gives the parser and policy engine clear jobs. The parser decides whether the bytes form a supported, bounded message. The session path decides whether that message is allowed for a configured peer and UE. Neither decision is hidden inside the Linux socket adapter.

Copy once at the ownership boundary

After the checks pass, PacketEngine::uplink copies the validated inner IP length into PacketDecision.packet, a std::vector<std::byte>. The Linux loop writes that owned vector to TUN before it receives another packet. The returned decision therefore carries the accepted payload independently of the reusable UDP input buffer.

The repository’s offline replay example contains a 28-byte inner IPv4/UDP packet. With the mandatory eight-byte GTP-U header, that is a 36-byte GTP-U payload for UDP; outer IP and UDP headers are outside that count. This small example makes the boundary tangible: decode points into the input, while the accepted result owns exactly the inner packet bytes selected for forwarding.

The downlink starts from a packet read from TUN, validates and looks up its destination UE IP, applies the same per-session policy bucket, then serializes a new GTP-U packet for the configured gNB endpoint. That direction necessarily builds an output vector containing the header and payload. The two directions share the session model, but their ownership transitions are not identical.

Keep the live adapter deliberately simple

The Linux runtime uses one poll() loop for the UDP socket, TUN device and optional local metrics listener. It handles one datagram per receive call and writes the resulting packet before returning to the loop. Static sessions load at startup; there is no live control API or session-churn path.

That single-threaded shape keeps buffer lifetime and packet order easy to follow while the core is exercised separately by the CLI, correctness checks and benchmark. It is not evidence that a single loop is the fastest design. The repository’s benchmark measures the in-process packet engine; it excludes UDP and TUN syscalls, kernel routing, scheduling and NIC queues. I do not use it to claim end-to-end throughput or latency.

What the evidence does and does not establish

I reviewed TUNNELFORGE at commit c2d619c. Its GitHub Actions run 38598544845 completed successfully across Ubuntu, Windows and macOS, building the cross-platform core and running the correctness executable. The checks cover golden and malformed GTP-U inputs, optional and extension headers, session decisions, MTU and QoS cases, downlink encoding, counters and the example configuration.

That CI run does not exercise the Linux network-namespace lab or prove interoperability with a real gNB. No end-to-end TUN or benchmark result is part of the recorded evidence. The README also explicitly excludes PFCP and control-plane procedures, mobility and handover, radio behavior, several GTP-U control and extension features, and full 3GPP conformance. The configured peer-address comparison is a lab guard, not cryptographic authentication. The 3GPP TS 29.281 record describes the GTPv1-U specification; TUNNELFORGE should be read as a deliberately limited learning implementation against that larger protocol scope.

The Linux TUN/TAP documentation describes TUN as a userspace interface for IP packets. That is the boundary this project uses: the packet engine returns an IP packet, and the adapter writes it to the TUN device so the configured kernel routes can handle it. A successful write is not proof that the later route delivered the packet.

The next useful measurements would be a reproducible namespace run with packet counts and failure injection, then a separate profile of parsing, policy checks and the output copy. Only after those baselines would I compare batching or a more complex I/O backend. For another example of making rejection behavior explicit, read how Sentinel-DB classifies unsupported SQL as unknown.

TUNNELFORGE is a private educational prototype. This note describes the reviewed source at a high level and does not publish its code or assets.

← All notes