From 279af33fd851a9912d40b8f238d2fc991cf627ab Mon Sep 17 00:00:00 2001 From: Mute Date: Thu, 13 Aug 2026 08:28:59 +0000 Subject: [PATCH] add network test feature (PING/PONG with latency stats) New frame types PING (0x0B) and PONG (0x0C), each carrying an 8-byte nonce. Server echoes PING nonce verbatim in PONG. Client sends pings at random 10-100ms intervals, correlates nonces to measure RTT. Stats shown live: sent/recv counts, loss %, average latency, jitter (mean absolute delta of consecutive RTTs). Test button toggles on/off. Updated both Rust server (echo in main.rs) and C# client (PingTest.cs, SessionManager routing, MainForm Test button + stats label). --- PROTOCOL.md | 14 ++++ gatunad/src/frame.rs | 20 ++++++ gatunad/src/main.rs | 7 +- win/gatuna-client/Frame.cs | 30 +++++++++ win/gatuna-client/MainForm.cs | 49 +++++++++++++- win/gatuna-client/PingTest.cs | 101 ++++++++++++++++++++++++++++ win/gatuna-client/SessionManager.cs | 25 +++++++ 7 files changed, 242 insertions(+), 4 deletions(-) create mode 100644 win/gatuna-client/PingTest.cs diff --git a/PROTOCOL.md b/PROTOCOL.md index ed00c28..138fcab 100644 --- a/PROTOCOL.md +++ b/PROTOCOL.md @@ -46,6 +46,8 @@ emitted in v1. | 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` | +| 0x0B | PING | C → S | 0 | `nonce:8` | +| 0x0C | PONG | S → C | 0 | `nonce:8` (echoed) | Reserved (unimplemented in v1; parse returns Err, encode unimplemented): @@ -140,6 +142,18 @@ field identifies which session the bytes belong to. - **reason** (u8, optional): present iff payload length ≥ 1. See reason codes. +### PING / PONG payload + +``` ++ + +| nonce (big-endian, 8 bytes) | ++ + +``` + +- **nonce** (u64, big-endian): arbitrary value chosen by the client. The + server echoes it verbatim in the PONG reply. Used to correlate RTT + measurements. + ## Reason codes | Value | Meaning | diff --git a/gatunad/src/frame.rs b/gatunad/src/frame.rs index 428ee50..702ef29 100644 --- a/gatunad/src/frame.rs +++ b/gatunad/src/frame.rs @@ -16,6 +16,8 @@ pub const TYPE_CLOSE: u8 = 0x07; pub const TYPE_UDP_OPEN: u8 = 0x08; pub const TYPE_UDP_DATA: u8 = 0x09; pub const TYPE_UDP_CLOSE: u8 = 0x0A; +pub const TYPE_PING: u8 = 0x0B; +pub const TYPE_PONG: u8 = 0x0C; pub const PROTO_TCP: u8 = 1; pub const PROTO_UDP: u8 = 2; @@ -45,6 +47,8 @@ pub enum Frame { OpenNak { upstream_id: u8, reason: u8 }, Data { session_id: u32, payload: Vec }, Close { session_id: u32, reason: Option }, + Ping { nonce: u64 }, + Pong { nonce: u64 }, } #[derive(Debug)] @@ -120,6 +124,8 @@ impl Frame { }; build(TYPE_CLOSE, *session_id, p) } + Frame::Ping { nonce } => build(TYPE_PING, 0, nonce.to_be_bytes().to_vec()), + Frame::Pong { nonce } => build(TYPE_PONG, 0, nonce.to_be_bytes().to_vec()), } } @@ -212,6 +218,20 @@ impl Frame { }; Ok(Frame::Close { session_id, reason }) } + TYPE_PING => { + if payload.len() != 8 { + return Err(DecodeError::BadPayload("PING payload must be 8 bytes")); + } + let nonce = u64::from_be_bytes(payload.try_into().unwrap()); + Ok(Frame::Ping { nonce }) + } + TYPE_PONG => { + if payload.len() != 8 { + return Err(DecodeError::BadPayload("PONG payload must be 8 bytes")); + } + let nonce = u64::from_be_bytes(payload.try_into().unwrap()); + Ok(Frame::Pong { nonce }) + } TYPE_UDP_OPEN | TYPE_UDP_DATA | TYPE_UDP_CLOSE => { Err(DecodeError::BadPayload("UDP frame types not implemented in v1")) } diff --git a/gatunad/src/main.rs b/gatunad/src/main.rs index 985209c..4035a44 100644 --- a/gatunad/src/main.rs +++ b/gatunad/src/main.rs @@ -207,7 +207,12 @@ async fn handle_frame( Frame::Close { session_id, reason: _ } => { store.lock().expect("store poisoned").remove(&session_id); } + Frame::Ping { nonce } => { + let pong = Frame::Pong { nonce }; + let _ = tx.send((src, pong.encode())).await; + } // Not expected from a client; ignore. - Frame::Manifest { .. } | Frame::OpenAck { .. } | Frame::OpenNak { .. } => {} + Frame::Manifest { .. } | Frame::OpenAck { .. } | Frame::OpenNak { .. } + | Frame::Pong { .. } => {} } } diff --git a/win/gatuna-client/Frame.cs b/win/gatuna-client/Frame.cs index dbbbeaf..34b662b 100644 --- a/win/gatuna-client/Frame.cs +++ b/win/gatuna-client/Frame.cs @@ -17,6 +17,8 @@ static class Proto public const byte TypeOpenNak = 0x05; public const byte TypeData = 0x06; public const byte TypeClose = 0x07; + public const byte TypePing = 0x0B; + public const byte TypePong = 0x0C; public const byte ProtoTcp = 1; public const byte ProtoUdp = 2; @@ -46,6 +48,8 @@ abstract record Frame internal record OpenNak(byte UpstreamId, byte Reason) : Frame; internal record Data(uint SessionId, byte[] Payload) : Frame; internal record Close(uint SessionId, byte? Reason) : Frame; + internal record Ping(ulong Nonce) : Frame; + internal record Pong(ulong Nonce) : Frame; } static class FrameCodec @@ -86,6 +90,10 @@ static class FrameCodec Frame.Close close => Build(Proto.TypeClose, close.SessionId, close.Reason.HasValue ? [close.Reason.Value] : []), + Frame.Ping ping => + Build(Proto.TypePing, 0, EncodeNonce(ping.Nonce)), + Frame.Pong pong => + Build(Proto.TypePong, 0, EncodeNonce(pong.Nonce)), _ => throw new InvalidOperationException($"unknown frame type: {frame.GetType()}"), }; } @@ -138,12 +146,34 @@ static class FrameCodec new Frame.Data(sessionId, payload.ToArray()), Proto.TypeClose when payload.Length is 0 or 1 => new Frame.Close(sessionId, payload.Length == 1 ? payload[0] : null), + Proto.TypePong when payload.Length == 8 => + new Frame.Pong(ParseNonce(payload)), + Proto.TypePing when payload.Length == 8 => + new Frame.Ping(ParseNonce(payload)), Proto.TypeDiscover when payload.Length == 0 => new Frame.Discover(), _ => null, }; } + static ulong ParseNonce(ReadOnlySpan payload) + { + ulong nonce = 0; + for (int i = 0; i < 8; i++) + nonce = (nonce << 8) | payload[i]; + return nonce; + } + + static byte[] EncodeNonce(ulong nonce) + { + return [ + (byte)(nonce >> 56), (byte)(nonce >> 48), + (byte)(nonce >> 40), (byte)(nonce >> 32), + (byte)(nonce >> 24), (byte)(nonce >> 16), + (byte)(nonce >> 8), (byte)(nonce & 0xFF), + ]; + } + static Frame.Manifest? ParseManifest(ReadOnlySpan payload) { if (payload.Length < 1) diff --git a/win/gatuna-client/MainForm.cs b/win/gatuna-client/MainForm.cs index e682985..ff8e9bb 100644 --- a/win/gatuna-client/MainForm.cs +++ b/win/gatuna-client/MainForm.cs @@ -7,16 +7,19 @@ public partial class MainForm : Form readonly SessionManager _sessions = new(); readonly ComboBox _deviceBox = new(); readonly Button _discoverBtn = new(); + readonly Button _testBtn = new(); readonly Label _serverLabel = new(); + readonly Label _pingStatsLabel = new(); readonly ListView _listView = new(); readonly Label _statusLabel = new(); TunnelLink? _link; + PingTest? _pingTest; public MainForm() { Text = "gatuna"; Width = 520; - Height = 380; + Height = 420; StartPosition = FormStartPosition.CenterScreen; Icon = SystemIcons.GetStockIcon(StockIconId.NetworkConnect, 32); InitializeComponents(); @@ -54,15 +57,27 @@ public partial class MainForm : Form _discoverBtn.Click += OnDiscover; Controls.Add(_discoverBtn); + _testBtn.Text = "Test"; + _testBtn.Left = _discoverBtn.Right + 8; _testBtn.Top = _discoverBtn.Top; + _testBtn.Width = 60; + _testBtn.Click += OnTest; + Controls.Add(_testBtn); + _serverLabel.Left = pad; _serverLabel.Top = _discoverBtn.Bottom + 8; _serverLabel.Width = ClientSize.Width - pad * 2; _serverLabel.AutoEllipsis = true; _serverLabel.Text = "Server: not connected"; Controls.Add(_serverLabel); - _listView.Left = pad; _listView.Top = _serverLabel.Bottom + 8; + _pingStatsLabel.Left = pad; _pingStatsLabel.Top = _serverLabel.Bottom + 4; + _pingStatsLabel.Width = ClientSize.Width - pad * 2; + _pingStatsLabel.AutoEllipsis = true; + _pingStatsLabel.Text = ""; + Controls.Add(_pingStatsLabel); + + _listView.Left = pad; _listView.Top = _pingStatsLabel.Bottom + 8; _listView.Width = ClientSize.Width - pad * 2; - _listView.Height = 200; + _listView.Height = 180; _listView.View = View.Details; _listView.FullRowSelect = true; _listView.CheckBoxes = true; @@ -106,6 +121,34 @@ public partial class MainForm : Form _statusLabel.Text = "discovering..."; } + void OnTest(object? s, EventArgs e) + { + if (_pingTest != null && _pingTest.Running) + { + _sessions.StopPing(); + _pingTest = null; + _testBtn.Text = "Test"; + return; + } + + _pingTest = _sessions.StartPing(); + if (_pingTest == null) + { + MessageBox.Show("Discover a server first."); + return; + } + _pingTest.StatsUpdated += stats => this.Invoke(() => + { + _pingStatsLabel.Text = + $"sent: {stats.Sent} recv: {stats.Received} " + + $"loss: {stats.LossPct:F1}% " + + $"avg: {stats.AvgLatencyMs:F1}ms " + + $"jitter: {stats.JitterMs:F1}ms"; + }); + _testBtn.Text = "Stop"; + _pingStatsLabel.Text = "pinging..."; + } + void PopulateList(string hostname, byte[] mac, UpstreamEntry[] entries) { var macStr = string.Join(":", mac.Select(b => b.ToString("X2"))); diff --git a/win/gatuna-client/PingTest.cs b/win/gatuna-client/PingTest.cs new file mode 100644 index 0000000..82300f7 --- /dev/null +++ b/win/gatuna-client/PingTest.cs @@ -0,0 +1,101 @@ +using System.Collections.Concurrent; + +namespace gatuna_client; + +sealed class PingTest +{ + readonly TunnelLink _link; + readonly byte[] _serverMac; + readonly CancellationTokenSource _cts = new(); + readonly ConcurrentDictionary _outstanding = new(); + readonly ConcurrentQueue _rtts = new(); + long _sent; + long _received; + double _lastRttMs; + double _jitterSum; + long _jitterCount; + + public event Action? StatsUpdated; + public event Action? Log; + + public bool Running { get; private set; } + + public PingTest(TunnelLink link, byte[] serverMac) + { + _link = link; + _serverMac = serverMac; + } + + public void Start() + { + Running = true; + _ = RunLoop(); + } + + public void Stop() + { + Running = false; + _cts.Cancel(); + } + + async Task RunLoop() + { + var rng = new Random(); + var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(10)); + while (!_cts.IsCancellationRequested) + { + var nonce = (ulong)Interlocked.Increment(ref _sent); + var ticks = DateTime.UtcNow.Ticks; + _outstanding[nonce] = ticks; + + _link.SendTo(_serverMac, new Frame.Ping(nonce)); + + var interval = rng.Next(10, 101); + try + { + await Task.Delay(interval, _cts.Token); + } + catch { break; } + } + Running = false; + } + + public void HandlePong(ulong nonce) + { + if (_outstanding.TryRemove(nonce, out var sentTicks)) + { + var rttMs = (DateTime.UtcNow.Ticks - sentTicks) / (double)TimeSpan.TicksPerMillisecond; + _rtts.Enqueue(rttMs); + Interlocked.Increment(ref _received); + + if (_jitterCount > 0) + { + _jitterSum += Math.Abs(rttMs - _lastRttMs); + } + _lastRttMs = rttMs; + Interlocked.Increment(ref _jitterCount); + + EmitStats(); + } + } + + void EmitStats() + { + var sent = Interlocked.Read(ref _sent); + var recv = Interlocked.Read(ref _received); + var loss = sent > 0 ? (1.0 - (double)recv / sent) * 100.0 : 0; + + var rttList = _rtts.ToArray(); + var avg = rttList.Length > 0 ? rttList.Average() : 0; + var jitter = _jitterCount > 1 ? _jitterSum / (_jitterCount - 1) : 0; + + StatsUpdated?.Invoke(new PingStats(sent, recv, loss, avg, jitter)); + } +} + +readonly record struct PingStats( + long Sent, + long Received, + double LossPct, + double AvgLatencyMs, + double JitterMs); diff --git a/win/gatuna-client/SessionManager.cs b/win/gatuna-client/SessionManager.cs index 9d6dcf8..7b59d3e 100644 --- a/win/gatuna-client/SessionManager.cs +++ b/win/gatuna-client/SessionManager.cs @@ -14,6 +14,7 @@ sealed class SessionManager : IDisposable byte[]? _serverMac; string _serverHostname = ""; UpstreamEntry[] _upstreams = []; + PingTest? _pingTest; // Serialized OPEN: only one outstanding at a time. readonly object _openLock = new(); @@ -47,6 +48,22 @@ sealed class SessionManager : IDisposable _link.SendBroadcast(new Frame.Discover()); } + public PingTest? StartPing() + { + if (_link == null || _serverMac == null) + return null; + _pingTest?.Stop(); + _pingTest = new PingTest(_link, _serverMac); + _pingTest.Start(); + return _pingTest; + } + + public void StopPing() + { + _pingTest?.Stop(); + _pingTest = null; + } + public void HandleFrame(Frame frame, byte[] srcMac) { switch (frame) @@ -85,6 +102,13 @@ sealed class SessionManager : IDisposable if (_sessions.TryRemove(close.SessionId, out var s)) s.Dispose(); break; + + case Frame.Pong pong: + _pingTest?.HandlePong(pong.Nonce); + break; + + case Frame.Ping _: + break; } } @@ -227,6 +251,7 @@ sealed class SessionManager : IDisposable public void StopAll() { + StopPing(); foreach (var kv in _listeners) kv.Value.Listener.Stop(); _listeners.Clear();