Files
gatuna/PROTOCOL.md
T
mute f09028b135 add payload_len field to wire protocol header
Ethernet pads frames to 60 bytes minimum; without an explicit length
field the receiver cannot distinguish real payload from zero padding
(e.g. a 6-byte DISCOVER becomes 46 bytes after padding, failing the
'payload must be empty' check).

Header is now 8 bytes: [ver:1][type:1][session_id:4][payload_len:2]
(both multi-byte fields big-endian). The receiver slices exactly
payload_len bytes and ignores trailing padding.

Updated PROTOCOL.md, Rust frame.rs, and C# Frame.cs.
2026-08-13 07:51:39 +00:00

6.8 KiB
Raw Blame History

gatuna wire protocol

All frames ride Ethernet with ethertype 0x6969. The ethertype is the sole discriminator; there is no magic number inside the payload.

Frame layout

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+---------------+---------------+-------------------------------+
| version       | type          | session_id                    |
+---------------+---------------+-------------------------------+
| payload_len (big-endian)      | payload ...                   |
+-------------------------------+                               +
|                                                               |
+---------------------------------------------------------------+
  • version (u8): protocol version. Currently 1.
  • type (u8): frame type, see table below.
  • session_id (u32, big-endian): 0 for non-session frames; the server-assigned ID for session-scoped frames.
  • payload_len (u16, big-endian): number of payload bytes that follow. The receiver reads exactly this many bytes and ignores any trailing Ethernet padding (frames under 60 bytes are zero-padded by the NIC to meet the minimum Ethernet frame size).
  • payload (payload_len bytes): type-dependent.

No CRC, no retransmit, no ordering at this layer. TCP reliability is handled by the endpoints' TCP stacks; UDP (reserved) will rely on application-level mechanisms.

Maximum payload: 1500 (Ethernet MTU) 8 (our header) = 1492 bytes. In practice we cap at 1480 to stay conservative. Larger payloads are not emitted in v1.

Frame types

Type Name Direction session_id Payload
0x01 DISCOVER C → broadcast 0 empty
0x02 MANIFEST S → C 0 id:1, proto:1, port:2 × N
0x03 OPEN C → S 0 upstream_id:1
0x04 OPEN_ACK S → C assigned upstream_id:1
0x05 OPEN_NAK S → C 0 upstream_id:1, reason:1
0x06 DATA both session raw bytes (≤1480)
0x07 CLOSE both session optional reason:1

Reserved (unimplemented in v1; parse returns Err, encode unimplemented):

Type Name
0x08 UDP_OPEN
0x09 UDP_DATA
0x0A UDP_CLOSE

Payload field encodings

MANIFEST payload

Variable-length entries, parsed sequentially until the payload is consumed.

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+---------------+---------------+-------------------------------+
| id            | proto         | port (big-endian)             |
+---------------+---------------+-------------------------------+
| label_len     | label (UTF-8, label_len bytes) ...           |
+---------------+-----------------------------------------------+
  • id (u8): upstream identifier (1-based positional index from gatunad cmdline).
  • proto (u8): 1 = TCP, 2 = UDP (reserved; not emitted in v1).
  • port (u16, big-endian): the real port on the server's 127.0.0.1.
  • label_len (u8): length in bytes of the label that follows. 0 means no label.
  • label (label_len bytes, UTF-8): human-readable name for the upstream, taken from the PORT[:label] cmdline argument. Maximum 255 bytes.

To parse: read the 5-byte fixed prefix, then label_len bytes, and repeat until the payload is exhausted. The number of entries is not carried explicitly.

OPEN payload

+---------------+
| upstream_id   |
+---------------+
  • upstream_id (u8): which MANIFEST entry to open.

OPEN_ACK payload

+---------------+
| upstream_id   |
+---------------+
  • upstream_id (u8): echoes the requested upstream. The session is identified by the session_id field in the header, not the payload.

OPEN_NAK payload

+---------------+---------------+
| upstream_id   | reason        |
+---------------+---------------+
  • upstream_id (u8): echoes the requested upstream.
  • reason (u8): see reason codes.

DATA payload

Raw application bytes. Up to 1480 bytes per frame. The session_id header field identifies which session the bytes belong to.

CLOSE payload

+---------------+
| reason?       |
+---------------+
  • reason (u8, optional): present iff payload length ≥ 1. See reason codes.

Reason codes

Value Meaning
0 unspecified
1 unknown_upstream
2 connect_failed
3 oversize
4 unknown_session

Discovery flow

client                            server
  |                                 |
  | DISCOVER (dst = broadcast)      |
  |-------------------------------->|
  |                                 |
  |        MANIFEST (unicast)       |
  |<--------------------------------|
  |                                 |

The server learns the client's MAC from the DISCOVER frame's source address and unicasts the MANIFEST back. The server never speaks unsolicited.

TCP session lifecycle

client                            server
  |                                 |
  | OPEN { upstream_id }            |
  |-------------------------------->|
  |                                 | TcpStream::connect(127.0.0.1:port)
  |                                 |
  |   OPEN_ACK { session_id }       |
  |<--------------------------------|   (on success)
  |        OR                       |
  |   OPEN_NAK { reason }           |
  |<--------------------------------|   (on failure)
  |                                 |
  | DATA { session_id, bytes }      |
  |<------------------------------->| DATA { session_id, bytes }
  |                                 |
  | CLOSE { session_id, reason? }   |
  |<------------------------------->|   (on EOF, RST, or error)
  |                                 |
  • session_id is allocated by the server as a monotonically increasing u32 (starting at 1) from an atomic counter. Collision by wraparound is ignored.
  • Either side may send CLOSE. The side receiving CLOSE tears down its half and stops emitting frames for that session.
  • The server's socket→tunnel pump reads TcpStream in 1480-byte chunks and emits one DATA frame per chunk. On read returning 0 (FIN) or an error, it emits CLOSE and exits.
  • The server's tunnel→socket path writes DATA payloads to the TcpStream with write_all. On error it emits CLOSE and drops the session.