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 |
+---------------+---------------+-------------------------------+
| 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.
- **session_id** (u32, big-endian): `0` for non-session frames; the
server-assigned ID for session-scoped frames.
- **payload** (variable): type-dependent. No length field is carried — the
Ethernet frame length from the capture gives the payload extent.
- **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) 14 (eth header) 6 (our header) =
**1480 bytes**. Larger payloads are not emitted in v1.
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
+34 -28
View File
@@ -3,6 +3,7 @@
pub const ETHERTYPE: u16 = 0x6969;
pub const ETH_HEADER_LEN: usize = 14;
pub const VERSION: u8 = 1;
pub const HEADER_LEN: usize = 8;
pub const MAX_PAYLOAD: usize = 1480;
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]);
}
/// 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 {
pub fn encode(&self) -> Vec<u8> {
let mut buf = Vec::new();
match self {
Frame::Discover => {
buf.extend_from_slice(&[VERSION, TYPE_DISCOVER, 0, 0, 0, 0]);
}
Frame::Discover => build(TYPE_DISCOVER, 0, Vec::new()),
Frame::Manifest(entries) => {
buf.extend_from_slice(&[VERSION, TYPE_MANIFEST, 0, 0, 0, 0]);
let mut payload = Vec::new();
for e in entries {
encode_entry(&mut buf, e);
encode_entry(&mut payload, e);
}
build(TYPE_MANIFEST, 0, payload)
}
Frame::Open { upstream_id } => {
buf.extend_from_slice(&[VERSION, TYPE_OPEN, 0, 0, 0, 0, *upstream_id]);
}
Frame::Open { upstream_id } => build(TYPE_OPEN, 0, vec![*upstream_id]),
Frame::OpenAck { session_id, upstream_id } => {
buf.extend_from_slice(&[VERSION, TYPE_OPEN_ACK]);
buf.extend_from_slice(&session_id.to_be_bytes());
buf.push(*upstream_id);
build(TYPE_OPEN_ACK, *session_id, vec![*upstream_id])
}
Frame::OpenNak { upstream_id, reason } => {
buf.extend_from_slice(&[VERSION, TYPE_OPEN_NAK, 0, 0, 0, 0]);
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);
build(TYPE_OPEN_NAK, 0, vec![*upstream_id, *reason])
}
Frame::Data { session_id, payload } => build(TYPE_DATA, *session_id, payload.clone()),
Frame::Close { session_id, reason } => {
buf.extend_from_slice(&[VERSION, TYPE_CLOSE]);
buf.extend_from_slice(&session_id.to_be_bytes());
if let Some(r) = reason {
buf.push(*r);
}
let p = match reason {
Some(r) => vec![*r],
None => Vec::new(),
};
build(TYPE_CLOSE, *session_id, p)
}
}
buf
}
pub fn parse(buf: &[u8]) -> Result<Frame, DecodeError> {
if buf.len() < 6 {
if buf.len() < HEADER_LEN {
return Err(DecodeError::Short);
}
let version = buf[0];
@@ -128,7 +129,12 @@ impl Frame {
}
let typ = buf[1];
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 {
TYPE_DISCOVER => {
if !payload.is_empty() {
+33 -24
View File
@@ -7,6 +7,7 @@ static class Proto
public const ushort EtherType = 0x6969;
public const int EthHeaderLen = 14;
public const byte Version = 1;
public const int HeaderLen = 8;
public const int MaxPayload = 1480;
public const byte TypeDiscover = 0x01;
@@ -49,34 +50,49 @@ abstract record Frame
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)
{
return frame switch
{
Frame.Discover =>
Header(Proto.TypeDiscover, 0),
Build(Proto.TypeDiscover, 0, []),
Frame.Manifest manifest =>
BuildManifest(manifest.Entries),
Build(Proto.TypeManifest, 0, BuildManifestPayload(manifest.Entries)),
Frame.Open open =>
[.. Header(Proto.TypeOpen, 0), open.UpstreamId],
Build(Proto.TypeOpen, 0, [open.UpstreamId]),
Frame.OpenAck ack =>
[.. Header(Proto.TypeOpenAck, ack.SessionId), ack.UpstreamId],
Build(Proto.TypeOpenAck, ack.SessionId, [ack.UpstreamId]),
Frame.OpenNak nak =>
[.. Header(Proto.TypeOpenNak, 0), nak.UpstreamId, nak.Reason],
Build(Proto.TypeOpenNak, 0, [nak.UpstreamId, nak.Reason]),
Frame.Data data =>
[.. Header(Proto.TypeData, data.SessionId), .. data.Payload],
Build(Proto.TypeData, data.SessionId, data.Payload),
Frame.Close close =>
close.Reason.HasValue
? [.. Header(Proto.TypeClose, close.SessionId), close.Reason.Value]
: Header(Proto.TypeClose, close.SessionId),
Build(Proto.TypeClose, close.SessionId,
close.Reason.HasValue ? [close.Reason.Value] : []),
_ => throw new InvalidOperationException($"unknown frame type: {frame.GetType()}"),
};
}
static byte[] BuildManifest(UpstreamEntry[] entries)
static byte[] BuildManifestPayload(UpstreamEntry[] entries)
{
using var ms = new MemoryStream();
ms.Write(Header(Proto.TypeManifest, 0));
foreach (var e in entries)
{
var labelBytes = Encoding.UTF8.GetBytes(e.Label ?? "");
@@ -92,26 +108,19 @@ static class FrameCodec
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)
{
if (buf.Length < 6)
if (buf.Length < Proto.HeaderLen)
return null;
if (buf[0] != Proto.Version)
return null;
var type = buf[1];
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
{
+21 -9
View File
@@ -26,26 +26,37 @@ public partial class MainForm : Form
_sessions.ManifestReceived += entries => this.Invoke(() => PopulateList(entries));
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)
_deviceBox.SelectedIndex = 0;
}
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.Width = 330; _deviceBox.DropDownStyle = ComboBoxStyle.DropDownList;
_deviceBox.Left = pad; _deviceBox.Top = 12;
_deviceBox.Width = ClientSize.Width - pad * 2;
_deviceBox.DropDownStyle = ComboBoxStyle.DropDownList;
Controls.Add(_deviceBox);
_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;
Controls.Add(_discoverBtn);
_listView.Left = 12; _listView.Top = 40;
_listView.Width = 478; _listView.Height = 250;
_listView.Left = pad; _listView.Top = _discoverBtn.Bottom + 8;
_listView.Width = ClientSize.Width - pad * 2;
_listView.Height = 220;
_listView.View = View.Details;
_listView.FullRowSelect = true;
_listView.CheckBoxes = true;
@@ -57,8 +68,9 @@ public partial class MainForm : Form
_listView.ItemChecked += OnItemChecked;
Controls.Add(_listView);
_statusLabel.Left = 12; _statusLabel.Top = 304;
_statusLabel.Width = 478; _statusLabel.AutoEllipsis = true;
_statusLabel.Left = pad; _statusLabel.Top = _listView.Bottom + 8;
_statusLabel.Width = ClientSize.Width - pad * 2;
_statusLabel.AutoEllipsis = true;
Controls.Add(_statusLabel);
}