Compare commits

...

2 Commits

Author SHA1 Message Date
mute d730028af9 show all adapter details in full-width dropdown
Display FriendlyName — Description — MAC for each device. Dropdown
spans full form width; layout simplified to vertical stack.
2026-08-13 07:54:06 +00:00
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
4 changed files with 99 additions and 66 deletions
+11 -5
View File
@@ -11,7 +11,9 @@ discriminator; there is no magic number inside the payload.
+---------------+---------------+-------------------------------+ +---------------+---------------+-------------------------------+
| version | type | session_id | | version | type | session_id |
+---------------+---------------+-------------------------------+ +---------------+---------------+-------------------------------+
| payload ... | | payload_len (big-endian) | payload ... |
+-------------------------------+ +
| |
+---------------------------------------------------------------+ +---------------------------------------------------------------+
``` ```
@@ -19,15 +21,19 @@ discriminator; there is no magic number inside the payload.
- **type** (u8): frame type, see table below. - **type** (u8): frame type, see table below.
- **session_id** (u32, big-endian): `0` for non-session frames; the - **session_id** (u32, big-endian): `0` for non-session frames; the
server-assigned ID for session-scoped frames. server-assigned ID for session-scoped frames.
- **payload** (variable): type-dependent. No length field is carried — the - **payload_len** (u16, big-endian): number of payload bytes that follow.
Ethernet frame length from the capture gives the payload extent. 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 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 the endpoints' TCP stacks; UDP (reserved) will rely on application-level
mechanisms. mechanisms.
Maximum payload: 1500 (Ethernet MTU) 14 (eth header) 6 (our header) = Maximum payload: 1500 (Ethernet MTU) 8 (our header) = **1492 bytes**. In
**1480 bytes**. Larger payloads are not emitted in v1. practice we cap at **1480** to stay conservative. Larger payloads are not
emitted in v1.
## Frame types ## Frame types
+34 -28
View File
@@ -3,6 +3,7 @@
pub const ETHERTYPE: u16 = 0x6969; pub const ETHERTYPE: u16 = 0x6969;
pub const ETH_HEADER_LEN: usize = 14; pub const ETH_HEADER_LEN: usize = 14;
pub const VERSION: u8 = 1; pub const VERSION: u8 = 1;
pub const HEADER_LEN: usize = 8;
pub const MAX_PAYLOAD: usize = 1480; pub const MAX_PAYLOAD: usize = 1480;
pub const TYPE_DISCOVER: u8 = 0x01; pub const TYPE_DISCOVER: u8 = 0x01;
@@ -76,50 +77,50 @@ fn encode_entry(buf: &mut Vec<u8>, e: &UpstreamEntry) {
buf.extend_from_slice(&label_bytes[..label_len as usize]); buf.extend_from_slice(&label_bytes[..label_len as usize]);
} }
/// Build the 8-byte header + payload. The payload_len field records the
/// exact payload length so the receiver can ignore Ethernet padding.
fn build(type_byte: u8, session_id: u32, payload: Vec<u8>) -> Vec<u8> {
let len = payload.len() as u16;
let mut buf = Vec::with_capacity(HEADER_LEN + payload.len());
buf.push(VERSION);
buf.push(type_byte);
buf.extend_from_slice(&session_id.to_be_bytes());
buf.extend_from_slice(&len.to_be_bytes());
buf.extend_from_slice(&payload);
buf
}
impl Frame { impl Frame {
pub fn encode(&self) -> Vec<u8> { pub fn encode(&self) -> Vec<u8> {
let mut buf = Vec::new();
match self { match self {
Frame::Discover => { Frame::Discover => build(TYPE_DISCOVER, 0, Vec::new()),
buf.extend_from_slice(&[VERSION, TYPE_DISCOVER, 0, 0, 0, 0]);
}
Frame::Manifest(entries) => { Frame::Manifest(entries) => {
buf.extend_from_slice(&[VERSION, TYPE_MANIFEST, 0, 0, 0, 0]); let mut payload = Vec::new();
for e in entries { for e in entries {
encode_entry(&mut buf, e); encode_entry(&mut payload, e);
} }
build(TYPE_MANIFEST, 0, payload)
} }
Frame::Open { upstream_id } => { Frame::Open { upstream_id } => build(TYPE_OPEN, 0, vec![*upstream_id]),
buf.extend_from_slice(&[VERSION, TYPE_OPEN, 0, 0, 0, 0, *upstream_id]);
}
Frame::OpenAck { session_id, upstream_id } => { Frame::OpenAck { session_id, upstream_id } => {
buf.extend_from_slice(&[VERSION, TYPE_OPEN_ACK]); build(TYPE_OPEN_ACK, *session_id, vec![*upstream_id])
buf.extend_from_slice(&session_id.to_be_bytes());
buf.push(*upstream_id);
} }
Frame::OpenNak { upstream_id, reason } => { Frame::OpenNak { upstream_id, reason } => {
buf.extend_from_slice(&[VERSION, TYPE_OPEN_NAK, 0, 0, 0, 0]); build(TYPE_OPEN_NAK, 0, vec![*upstream_id, *reason])
buf.push(*upstream_id);
buf.push(*reason);
}
Frame::Data { session_id, payload } => {
buf.extend_from_slice(&[VERSION, TYPE_DATA]);
buf.extend_from_slice(&session_id.to_be_bytes());
buf.extend_from_slice(payload);
} }
Frame::Data { session_id, payload } => build(TYPE_DATA, *session_id, payload.clone()),
Frame::Close { session_id, reason } => { Frame::Close { session_id, reason } => {
buf.extend_from_slice(&[VERSION, TYPE_CLOSE]); let p = match reason {
buf.extend_from_slice(&session_id.to_be_bytes()); Some(r) => vec![*r],
if let Some(r) = reason { None => Vec::new(),
buf.push(*r); };
build(TYPE_CLOSE, *session_id, p)
} }
} }
} }
buf
}
pub fn parse(buf: &[u8]) -> Result<Frame, DecodeError> { pub fn parse(buf: &[u8]) -> Result<Frame, DecodeError> {
if buf.len() < 6 { if buf.len() < HEADER_LEN {
return Err(DecodeError::Short); return Err(DecodeError::Short);
} }
let version = buf[0]; let version = buf[0];
@@ -128,7 +129,12 @@ impl Frame {
} }
let typ = buf[1]; let typ = buf[1];
let session_id = u32::from_be_bytes([buf[2], buf[3], buf[4], buf[5]]); let session_id = u32::from_be_bytes([buf[2], buf[3], buf[4], buf[5]]);
let payload = &buf[6..]; let payload_len = u16::from_be_bytes([buf[6], buf[7]]) as usize;
if buf.len() < HEADER_LEN + payload_len {
return Err(DecodeError::BadPayload("payload_len exceeds available data"));
}
// Slice exactly payload_len bytes, ignoring any trailing Ethernet padding.
let payload = &buf[HEADER_LEN..HEADER_LEN + payload_len];
match typ { match typ {
TYPE_DISCOVER => { TYPE_DISCOVER => {
if !payload.is_empty() { if !payload.is_empty() {
+33 -24
View File
@@ -7,6 +7,7 @@ static class Proto
public const ushort EtherType = 0x6969; public const ushort EtherType = 0x6969;
public const int EthHeaderLen = 14; public const int EthHeaderLen = 14;
public const byte Version = 1; public const byte Version = 1;
public const int HeaderLen = 8;
public const int MaxPayload = 1480; public const int MaxPayload = 1480;
public const byte TypeDiscover = 0x01; public const byte TypeDiscover = 0x01;
@@ -49,34 +50,49 @@ abstract record Frame
static class FrameCodec static class FrameCodec
{ {
/// Build the 8-byte header + payload. payload_len records the exact
/// payload length so the receiver can ignore Ethernet padding.
static byte[] Build(byte type, uint sessionId, byte[] payload)
{
var buf = new byte[Proto.HeaderLen + payload.Length];
buf[0] = Proto.Version;
buf[1] = type;
buf[2] = (byte)(sessionId >> 24);
buf[3] = (byte)(sessionId >> 16);
buf[4] = (byte)(sessionId >> 8);
buf[5] = (byte)(sessionId & 0xFF);
buf[6] = (byte)(payload.Length >> 8);
buf[7] = (byte)(payload.Length & 0xFF);
Buffer.BlockCopy(payload, 0, buf, Proto.HeaderLen, payload.Length);
return buf;
}
public static byte[] Encode(Frame frame) public static byte[] Encode(Frame frame)
{ {
return frame switch return frame switch
{ {
Frame.Discover => Frame.Discover =>
Header(Proto.TypeDiscover, 0), Build(Proto.TypeDiscover, 0, []),
Frame.Manifest manifest => Frame.Manifest manifest =>
BuildManifest(manifest.Entries), Build(Proto.TypeManifest, 0, BuildManifestPayload(manifest.Entries)),
Frame.Open open => Frame.Open open =>
[.. Header(Proto.TypeOpen, 0), open.UpstreamId], Build(Proto.TypeOpen, 0, [open.UpstreamId]),
Frame.OpenAck ack => Frame.OpenAck ack =>
[.. Header(Proto.TypeOpenAck, ack.SessionId), ack.UpstreamId], Build(Proto.TypeOpenAck, ack.SessionId, [ack.UpstreamId]),
Frame.OpenNak nak => Frame.OpenNak nak =>
[.. Header(Proto.TypeOpenNak, 0), nak.UpstreamId, nak.Reason], Build(Proto.TypeOpenNak, 0, [nak.UpstreamId, nak.Reason]),
Frame.Data data => Frame.Data data =>
[.. Header(Proto.TypeData, data.SessionId), .. data.Payload], Build(Proto.TypeData, data.SessionId, data.Payload),
Frame.Close close => Frame.Close close =>
close.Reason.HasValue Build(Proto.TypeClose, close.SessionId,
? [.. Header(Proto.TypeClose, close.SessionId), close.Reason.Value] close.Reason.HasValue ? [close.Reason.Value] : []),
: Header(Proto.TypeClose, close.SessionId),
_ => throw new InvalidOperationException($"unknown frame type: {frame.GetType()}"), _ => throw new InvalidOperationException($"unknown frame type: {frame.GetType()}"),
}; };
} }
static byte[] BuildManifest(UpstreamEntry[] entries) static byte[] BuildManifestPayload(UpstreamEntry[] entries)
{ {
using var ms = new MemoryStream(); using var ms = new MemoryStream();
ms.Write(Header(Proto.TypeManifest, 0));
foreach (var e in entries) foreach (var e in entries)
{ {
var labelBytes = Encoding.UTF8.GetBytes(e.Label ?? ""); var labelBytes = Encoding.UTF8.GetBytes(e.Label ?? "");
@@ -92,26 +108,19 @@ static class FrameCodec
return ms.ToArray(); return ms.ToArray();
} }
static byte[] Header(byte type, uint sessionId)
{
return [
Proto.Version, type,
(byte)(sessionId >> 24),
(byte)(sessionId >> 16),
(byte)(sessionId >> 8),
(byte)(sessionId & 0xFF),
];
}
public static Frame? Parse(ReadOnlySpan<byte> buf) public static Frame? Parse(ReadOnlySpan<byte> buf)
{ {
if (buf.Length < 6) if (buf.Length < Proto.HeaderLen)
return null; return null;
if (buf[0] != Proto.Version) if (buf[0] != Proto.Version)
return null; return null;
var type = buf[1]; var type = buf[1];
var sessionId = (uint)(buf[2] << 24 | buf[3] << 16 | buf[4] << 8 | buf[5]); var sessionId = (uint)(buf[2] << 24 | buf[3] << 16 | buf[4] << 8 | buf[5]);
var payload = buf[6..]; var payloadLen = (ushort)(buf[6] << 8 | buf[7]);
if (buf.Length < Proto.HeaderLen + payloadLen)
return null;
// Slice exactly payloadLen bytes, ignoring any trailing Ethernet padding.
var payload = buf.Slice(Proto.HeaderLen, payloadLen);
return type switch return type switch
{ {
+21 -9
View File
@@ -26,26 +26,37 @@ public partial class MainForm : Form
_sessions.ManifestReceived += entries => this.Invoke(() => PopulateList(entries)); _sessions.ManifestReceived += entries => this.Invoke(() => PopulateList(entries));
foreach (var d in TunnelLink.ListDevices()) foreach (var d in TunnelLink.ListDevices())
_deviceBox.Items.Add($"{d.Name} — {d.Interface?.FriendlyName ?? d.Interface?.Description}"); {
var friendly = d.Interface?.FriendlyName ?? "";
var desc = d.Interface?.Description ?? "";
var mac = d.MacAddress?.GetAddressBytes();
var macStr = mac != null
? string.Join(":", mac.Select(b => b.ToString("X2")))
: "??-??-??-??-??-??";
_deviceBox.Items.Add($"{friendly} — {desc} — {macStr}");
}
if (_deviceBox.Items.Count > 0) if (_deviceBox.Items.Count > 0)
_deviceBox.SelectedIndex = 0; _deviceBox.SelectedIndex = 0;
} }
void InitializeComponents() void InitializeComponents()
{ {
Controls.Add(new Label { Text = "Adapter:", Left = 12, Top = 12, AutoSize = true }); const int pad = 12;
_deviceBox.Left = 70; _deviceBox.Top = 9; _deviceBox.Left = pad; _deviceBox.Top = 12;
_deviceBox.Width = 330; _deviceBox.DropDownStyle = ComboBoxStyle.DropDownList; _deviceBox.Width = ClientSize.Width - pad * 2;
_deviceBox.DropDownStyle = ComboBoxStyle.DropDownList;
Controls.Add(_deviceBox); Controls.Add(_deviceBox);
_discoverBtn.Text = "Discover"; _discoverBtn.Text = "Discover";
_discoverBtn.Left = 410; _discoverBtn.Top = 8; _discoverBtn.Width = 80; _discoverBtn.Left = pad; _discoverBtn.Top = _deviceBox.Bottom + 8;
_discoverBtn.Width = 80;
_discoverBtn.Click += OnDiscover; _discoverBtn.Click += OnDiscover;
Controls.Add(_discoverBtn); Controls.Add(_discoverBtn);
_listView.Left = 12; _listView.Top = 40; _listView.Left = pad; _listView.Top = _discoverBtn.Bottom + 8;
_listView.Width = 478; _listView.Height = 250; _listView.Width = ClientSize.Width - pad * 2;
_listView.Height = 220;
_listView.View = View.Details; _listView.View = View.Details;
_listView.FullRowSelect = true; _listView.FullRowSelect = true;
_listView.CheckBoxes = true; _listView.CheckBoxes = true;
@@ -57,8 +68,9 @@ public partial class MainForm : Form
_listView.ItemChecked += OnItemChecked; _listView.ItemChecked += OnItemChecked;
Controls.Add(_listView); Controls.Add(_listView);
_statusLabel.Left = 12; _statusLabel.Top = 304; _statusLabel.Left = pad; _statusLabel.Top = _listView.Bottom + 8;
_statusLabel.Width = 478; _statusLabel.AutoEllipsis = true; _statusLabel.Width = ClientSize.Width - pad * 2;
_statusLabel.AutoEllipsis = true;
Controls.Add(_statusLabel); Controls.Add(_statusLabel);
} }