Compare commits

...

6 Commits

Author SHA1 Message Date
mute 279af33fd8 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).
2026-08-13 08:28:59 +00:00
mute a2d3643d69 use Windows NetworkConnect stock icon for form and tray
SystemIcons.GetStockIcon(StockIconId.NetworkConnect, 32) — no
external files needed.
2026-08-13 08:14:11 +00:00
mute d1e71f0323 restore standard window controls, minimize-to-tray, close-exits
- Restore default FormBorderStyle (resizable with min/max/close buttons)
- Minimize button hides to tray (WindowState=Minimized -> Hide())
- Close button and tray Exit both call Shutdown() then close normally
- Tray Show/DoubleClick restores window from tray
2026-08-13 08:11:16 +00:00
mute 54c804f81f deterministic mirror ports derived from server MAC + upstream port
mirror = (upstream_port ^ (mac[0]<<8 | mac[5])); clamped to >=1024.
Same server+upstream always yields the same local port so the user
knows where to connect without checking the UI each time. Falls back
to OS-assigned port if the deterministic one is already in use.
2026-08-13 08:09:47 +00:00
mute 3336a08543 announce server hostname in MANIFEST, display in client
Server reads its hostname via gethostname(2) and includes it as a
length-prefixed UTF-8 string at the start of the MANIFEST payload.

Client displays 'Server: <hostname> — <MAC>' in a label above the
upstream list. Both sides updated for the new MANIFEST format:
  [hostname_len:1][hostname:N][entries...]

Protocol version unchanged (still 1); MANIFEST payload layout change
is backward-incompatible but both sides ship together.
2026-08-13 08:08:05 +00:00
mute 27e15452ee fix: filter out own outgoing frames on Windows client
Npcap in promiscuous mode loops back our own sent frames to the
capture callback. Without filtering, every DATA frame the client
sent was also processed as an incoming frame, duplicating the data
stream. This corrupted SSH sessions (Bad packet length 0x5353482D
= 'SSH-' — the version banner received twice).

Fix: compare source MAC in captured frames against our own MAC and
skip matches. Mirrors the PACKET_OUTGOING check on the Rust side.
2026-08-13 08:02:28 +00:00
10 changed files with 406 additions and 53 deletions
+27 -3
View File
@@ -46,6 +46,8 @@ emitted in v1.
| 0x05 | OPEN_NAK | S → C | 0 | `upstream_id:1, reason:1` | | 0x05 | OPEN_NAK | S → C | 0 | `upstream_id:1, reason:1` |
| 0x06 | DATA | both | session | raw bytes (≤1480) | | 0x06 | DATA | both | session | raw bytes (≤1480) |
| 0x07 | CLOSE | both | session | optional `reason:1` | | 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): Reserved (unimplemented in v1; parse returns Err, encode unimplemented):
@@ -59,18 +61,27 @@ Reserved (unimplemented in v1; parse returns Err, encode unimplemented):
### MANIFEST payload ### MANIFEST payload
Variable-length entries, parsed sequentially until the payload is consumed. A hostname prefix followed by variable-length entries, parsed sequentially
until the payload is consumed.
``` ```
0 1 2 3 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 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
+---------------+---------------+-------------------------------+ +---------------+-----------------------------------------------+
| hostname_len | hostname (UTF-8, hostname_len bytes) ... |
+---------------+-----------------------------------------------+
| id | proto | port (big-endian) | | id | proto | port (big-endian) |
+---------------+---------------+-------------------------------+ +---------------+---------------+-------------------------------+
| label_len | label (UTF-8, label_len bytes) ... | | label_len | label (UTF-8, label_len bytes) ... |
+---------------+-----------------------------------------------+ +---------------+-----------------------------------------------+
| ... repeated ... |
+---------------------------------------------------------------+
``` ```
- **hostname_len** (u8): length in bytes of the server's hostname. `0` is valid
(unknown hostname).
- **hostname** (`hostname_len` bytes, UTF-8): the server's hostname, read via
`gethostname(2)` at startup. Maximum 255 bytes.
- **id** (u8): upstream identifier (1-based positional index from `gatunad` - **id** (u8): upstream identifier (1-based positional index from `gatunad`
cmdline). cmdline).
- **proto** (u8): `1 = TCP`, `2 = UDP` (reserved; not emitted in v1). - **proto** (u8): `1 = TCP`, `2 = UDP` (reserved; not emitted in v1).
@@ -80,7 +91,8 @@ Variable-length entries, parsed sequentially until the payload is consumed.
- **label** (`label_len` bytes, UTF-8): human-readable name for the upstream, - **label** (`label_len` bytes, UTF-8): human-readable name for the upstream,
taken from the `PORT[:label]` cmdline argument. Maximum 255 bytes. 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 To parse: read `hostname_len`, then `hostname_len` bytes of hostname, then read
5-byte fixed entry prefixes + `label_len` bytes of label each, repeating until
the payload is exhausted. The number of entries is not carried explicitly. the payload is exhausted. The number of entries is not carried explicitly.
### OPEN payload ### OPEN payload
@@ -130,6 +142,18 @@ field identifies which session the bytes belong to.
- **reason** (u8, optional): present iff payload length ≥ 1. See reason codes. - **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 ## Reason codes
| Value | Meaning | | Value | Meaning |
+44 -11
View File
@@ -16,6 +16,8 @@ pub const TYPE_CLOSE: u8 = 0x07;
pub const TYPE_UDP_OPEN: u8 = 0x08; pub const TYPE_UDP_OPEN: u8 = 0x08;
pub const TYPE_UDP_DATA: u8 = 0x09; pub const TYPE_UDP_DATA: u8 = 0x09;
pub const TYPE_UDP_CLOSE: u8 = 0x0A; 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_TCP: u8 = 1;
pub const PROTO_UDP: u8 = 2; pub const PROTO_UDP: u8 = 2;
@@ -39,12 +41,14 @@ pub struct UpstreamEntry {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub enum Frame { pub enum Frame {
Discover, Discover,
Manifest(Vec<UpstreamEntry>), Manifest { hostname: String, entries: Vec<UpstreamEntry> },
Open { upstream_id: u8 }, Open { upstream_id: u8 },
OpenAck { session_id: u32, upstream_id: u8 }, OpenAck { session_id: u32, upstream_id: u8 },
OpenNak { upstream_id: u8, reason: u8 }, OpenNak { upstream_id: u8, reason: u8 },
Data { session_id: u32, payload: Vec<u8> }, Data { session_id: u32, payload: Vec<u8> },
Close { session_id: u32, reason: Option<u8> }, Close { session_id: u32, reason: Option<u8> },
Ping { nonce: u64 },
Pong { nonce: u64 },
} }
#[derive(Debug)] #[derive(Debug)]
@@ -94,8 +98,12 @@ impl Frame {
pub fn encode(&self) -> Vec<u8> { pub fn encode(&self) -> Vec<u8> {
match self { match self {
Frame::Discover => build(TYPE_DISCOVER, 0, Vec::new()), Frame::Discover => build(TYPE_DISCOVER, 0, Vec::new()),
Frame::Manifest(entries) => { Frame::Manifest { hostname, entries } => {
let mut payload = Vec::new(); let mut payload = Vec::new();
let hn_bytes = hostname.as_bytes();
let hn_len = hn_bytes.len().min(255) as u8;
payload.push(hn_len);
payload.extend_from_slice(&hn_bytes[..hn_len as usize]);
for e in entries { for e in entries {
encode_entry(&mut payload, e); encode_entry(&mut payload, e);
} }
@@ -116,6 +124,8 @@ impl Frame {
}; };
build(TYPE_CLOSE, *session_id, p) 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()),
} }
} }
@@ -143,29 +153,38 @@ impl Frame {
Ok(Frame::Discover) Ok(Frame::Discover)
} }
TYPE_MANIFEST => { TYPE_MANIFEST => {
if payload.is_empty() {
return Err(DecodeError::BadPayload("MANIFEST missing hostname prefix"));
}
let hn_len = payload[0] as usize;
if 1 + hn_len > payload.len() {
return Err(DecodeError::BadPayload("MANIFEST hostname truncated"));
}
let hostname = String::from_utf8_lossy(&payload[1..1 + hn_len]).into_owned();
let rest = &payload[1 + hn_len..];
let mut entries = Vec::new(); let mut entries = Vec::new();
let mut i = 0; let mut i = 0;
while i < payload.len() { while i < rest.len() {
if i + 5 > payload.len() { if i + 5 > rest.len() {
return Err(DecodeError::BadPayload("MANIFEST entry truncated")); return Err(DecodeError::BadPayload("MANIFEST entry truncated"));
} }
let id = payload[i]; let id = rest[i];
let proto = payload[i + 1]; let proto = rest[i + 1];
let port = u16::from_be_bytes([payload[i + 2], payload[i + 3]]); let port = u16::from_be_bytes([rest[i + 2], rest[i + 3]]);
let label_len = payload[i + 4] as usize; let label_len = rest[i + 4] as usize;
i += 5; i += 5;
if i + label_len > payload.len() { if i + label_len > rest.len() {
return Err(DecodeError::BadPayload("MANIFEST label truncated")); return Err(DecodeError::BadPayload("MANIFEST label truncated"));
} }
let label = if label_len == 0 { let label = if label_len == 0 {
None None
} else { } else {
Some(String::from_utf8_lossy(&payload[i..i + label_len]).into_owned()) Some(String::from_utf8_lossy(&rest[i..i + label_len]).into_owned())
}; };
i += label_len; i += label_len;
entries.push(UpstreamEntry { id, proto, port, label }); entries.push(UpstreamEntry { id, proto, port, label });
} }
Ok(Frame::Manifest(entries)) Ok(Frame::Manifest { hostname, entries })
} }
TYPE_OPEN => { TYPE_OPEN => {
if payload.len() != 1 { if payload.len() != 1 {
@@ -199,6 +218,20 @@ impl Frame {
}; };
Ok(Frame::Close { session_id, reason }) 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 => { TYPE_UDP_OPEN | TYPE_UDP_DATA | TYPE_UDP_CLOSE => {
Err(DecodeError::BadPayload("UDP frame types not implemented in v1")) Err(DecodeError::BadPayload("UDP frame types not implemented in v1"))
} }
+14 -3
View File
@@ -52,6 +52,8 @@ async fn main() -> ExitCode {
} }
}; };
let hostname = Arc::new(upstream::get_hostname());
let link = match Link::open(&args.iface) { let link = match Link::open(&args.iface) {
Ok(l) => Arc::new(l), Ok(l) => Arc::new(l),
Err(e) => { Err(e) => {
@@ -112,7 +114,7 @@ async fn main() -> ExitCode {
continue; continue;
} }
}; };
handle_frame(frame, src, &tx, &store, &next_id, &table).await; handle_frame(frame, src, &tx, &store, &next_id, &table, &hostname).await;
} }
Ok(None) => { Ok(None) => {
// Ignorable frame (outgoing/short/mismatch) or transient; do not // Ignorable frame (outgoing/short/mismatch) or transient; do not
@@ -135,10 +137,14 @@ async fn handle_frame(
store: &SessionStore, store: &SessionStore,
next_id: &Arc<AtomicU32>, next_id: &Arc<AtomicU32>,
table: &Arc<crate::upstream::UpstreamTable>, table: &Arc<crate::upstream::UpstreamTable>,
hostname: &Arc<String>,
) { ) {
match frame { match frame {
Frame::Discover => { Frame::Discover => {
let manifest = Frame::Manifest(table.entries()); let manifest = Frame::Manifest {
hostname: (**hostname).clone(),
entries: table.entries(),
};
let _ = tx.send((src, manifest.encode())).await; let _ = tx.send((src, manifest.encode())).await;
} }
Frame::Open { upstream_id } => { Frame::Open { upstream_id } => {
@@ -201,7 +207,12 @@ async fn handle_frame(
Frame::Close { session_id, reason: _ } => { Frame::Close { session_id, reason: _ } => {
store.lock().expect("store poisoned").remove(&session_id); 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. // Not expected from a client; ignore.
Frame::Manifest(_) | Frame::OpenAck { .. } | Frame::OpenNak { .. } => {} Frame::Manifest { .. } | Frame::OpenAck { .. } | Frame::OpenNak { .. }
| Frame::Pong { .. } => {}
} }
} }
+15 -1
View File
@@ -1,6 +1,20 @@
//! Upstream table: cmdline parsing and MANIFEST entry construction. //! Upstream table: cmdline parsing and MANIFEST entry construction.
use crate::frame::{UpstreamEntry, PROTO_TCP, PROTO_UDP}; use crate::frame::{Frame, UpstreamEntry, PROTO_TCP, PROTO_UDP};
/// Read the system hostname via `gethostname(2)`.
pub fn get_hostname() -> String {
let mut buf = [0u8; 256];
let ret = unsafe {
libc::gethostname(buf.as_mut_ptr() as *mut libc::c_char, buf.len())
};
if ret == 0 {
let len = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
String::from_utf8_lossy(&buf[..len]).into_owned()
} else {
String::new()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[allow(dead_code)] #[allow(dead_code)]
+57 -12
View File
@@ -17,6 +17,8 @@ static class Proto
public const byte TypeOpenNak = 0x05; public const byte TypeOpenNak = 0x05;
public const byte TypeData = 0x06; public const byte TypeData = 0x06;
public const byte TypeClose = 0x07; public const byte TypeClose = 0x07;
public const byte TypePing = 0x0B;
public const byte TypePong = 0x0C;
public const byte ProtoTcp = 1; public const byte ProtoTcp = 1;
public const byte ProtoUdp = 2; public const byte ProtoUdp = 2;
@@ -40,12 +42,14 @@ readonly record struct UpstreamEntry(
abstract record Frame abstract record Frame
{ {
internal record Discover : Frame; internal record Discover : Frame;
internal record Manifest(UpstreamEntry[] Entries) : Frame; internal record Manifest(string Hostname, UpstreamEntry[] Entries) : Frame;
internal record Open(byte UpstreamId) : Frame; internal record Open(byte UpstreamId) : Frame;
internal record OpenAck(uint SessionId, byte UpstreamId) : Frame; internal record OpenAck(uint SessionId, byte UpstreamId) : Frame;
internal record OpenNak(byte UpstreamId, byte Reason) : Frame; internal record OpenNak(byte UpstreamId, byte Reason) : Frame;
internal record Data(uint SessionId, byte[] Payload) : Frame; internal record Data(uint SessionId, byte[] Payload) : Frame;
internal record Close(uint SessionId, byte? Reason) : Frame; internal record Close(uint SessionId, byte? Reason) : Frame;
internal record Ping(ulong Nonce) : Frame;
internal record Pong(ulong Nonce) : Frame;
} }
static class FrameCodec static class FrameCodec
@@ -74,7 +78,7 @@ static class FrameCodec
Frame.Discover => Frame.Discover =>
Build(Proto.TypeDiscover, 0, []), Build(Proto.TypeDiscover, 0, []),
Frame.Manifest manifest => Frame.Manifest manifest =>
Build(Proto.TypeManifest, 0, BuildManifestPayload(manifest.Entries)), Build(Proto.TypeManifest, 0, BuildManifestPayload(manifest.Hostname, manifest.Entries)),
Frame.Open open => Frame.Open open =>
Build(Proto.TypeOpen, 0, [open.UpstreamId]), Build(Proto.TypeOpen, 0, [open.UpstreamId]),
Frame.OpenAck ack => Frame.OpenAck ack =>
@@ -86,13 +90,22 @@ static class FrameCodec
Frame.Close close => Frame.Close close =>
Build(Proto.TypeClose, close.SessionId, Build(Proto.TypeClose, close.SessionId,
close.Reason.HasValue ? [close.Reason.Value] : []), 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()}"), _ => throw new InvalidOperationException($"unknown frame type: {frame.GetType()}"),
}; };
} }
static byte[] BuildManifestPayload(UpstreamEntry[] entries) static byte[] BuildManifestPayload(string hostname, UpstreamEntry[] entries)
{ {
using var ms = new MemoryStream(); using var ms = new MemoryStream();
var hnBytes = Encoding.UTF8.GetBytes(hostname);
var hnLen = (byte)Math.Min(hnBytes.Length, 255);
ms.WriteByte(hnLen);
if (hnLen > 0)
ms.Write(hnBytes, 0, hnLen);
foreach (var e in entries) foreach (var e in entries)
{ {
var labelBytes = Encoding.UTF8.GetBytes(e.Label ?? ""); var labelBytes = Encoding.UTF8.GetBytes(e.Label ?? "");
@@ -133,33 +146,65 @@ static class FrameCodec
new Frame.Data(sessionId, payload.ToArray()), new Frame.Data(sessionId, payload.ToArray()),
Proto.TypeClose when payload.Length is 0 or 1 => Proto.TypeClose when payload.Length is 0 or 1 =>
new Frame.Close(sessionId, payload.Length == 1 ? payload[0] : null), 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 => Proto.TypeDiscover when payload.Length == 0 =>
new Frame.Discover(), new Frame.Discover(),
_ => null, _ => null,
}; };
} }
static ulong ParseNonce(ReadOnlySpan<byte> 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<byte> payload) static Frame.Manifest? ParseManifest(ReadOnlySpan<byte> payload)
{ {
if (payload.Length < 1)
return null;
var hnLen = payload[0];
if (1 + hnLen > payload.Length)
return null;
var hostname = hnLen == 0
? ""
: Encoding.UTF8.GetString(payload[1..(1 + hnLen)]);
var rest = payload[(1 + hnLen)..];
var entries = new List<UpstreamEntry>(); var entries = new List<UpstreamEntry>();
int i = 0; int i = 0;
while (i < payload.Length) while (i < rest.Length)
{ {
if (i + 5 > payload.Length) if (i + 5 > rest.Length)
return null; return null;
var id = payload[i]; var id = rest[i];
var proto = payload[i + 1]; var proto = rest[i + 1];
var port = (ushort)(payload[i + 2] << 8 | payload[i + 3]); var port = (ushort)(rest[i + 2] << 8 | rest[i + 3]);
var labelLen = payload[i + 4]; var labelLen = rest[i + 4];
i += 5; i += 5;
if (i + labelLen > payload.Length) if (i + labelLen > rest.Length)
return null; return null;
string? label = labelLen == 0 string? label = labelLen == 0
? null ? null
: Encoding.UTF8.GetString(payload[i..(i + labelLen)]); : Encoding.UTF8.GetString(rest[i..(i + labelLen)]);
i += labelLen; i += labelLen;
entries.Add(new UpstreamEntry(id, proto, port, label)); entries.Add(new UpstreamEntry(id, proto, port, label));
} }
return new Frame.Manifest(entries.ToArray()); return new Frame.Manifest(hostname, entries.ToArray());
} }
} }
+72 -14
View File
@@ -7,23 +7,26 @@ public partial class MainForm : Form
readonly SessionManager _sessions = new(); readonly SessionManager _sessions = new();
readonly ComboBox _deviceBox = new(); readonly ComboBox _deviceBox = new();
readonly Button _discoverBtn = new(); readonly Button _discoverBtn = new();
readonly Button _testBtn = new();
readonly Label _serverLabel = new();
readonly Label _pingStatsLabel = new();
readonly ListView _listView = new(); readonly ListView _listView = new();
readonly Label _statusLabel = new(); readonly Label _statusLabel = new();
TunnelLink? _link; TunnelLink? _link;
PingTest? _pingTest;
public MainForm() public MainForm()
{ {
Text = "gatuna"; Text = "gatuna";
Width = 520; Width = 520;
Height = 380; Height = 420;
FormBorderStyle = FormBorderStyle.FixedSingle;
MaximizeBox = false;
MinimizeBox = false;
StartPosition = FormStartPosition.CenterScreen; StartPosition = FormStartPosition.CenterScreen;
Icon = SystemIcons.GetStockIcon(StockIconId.NetworkConnect, 32);
InitializeComponents(); InitializeComponents();
_sessions.Log += msg => this.Invoke(() => _statusLabel.Text = msg); _sessions.Log += msg => this.Invoke(() => _statusLabel.Text = msg);
_sessions.ManifestReceived += entries => this.Invoke(() => PopulateList(entries)); _sessions.ManifestReceived += (hostname, mac, entries) =>
this.Invoke(() => PopulateList(hostname, mac, entries));
foreach (var d in TunnelLink.ListDevices()) foreach (var d in TunnelLink.ListDevices())
{ {
@@ -54,9 +57,27 @@ public partial class MainForm : Form
_discoverBtn.Click += OnDiscover; _discoverBtn.Click += OnDiscover;
Controls.Add(_discoverBtn); Controls.Add(_discoverBtn);
_listView.Left = pad; _listView.Top = _discoverBtn.Bottom + 8; _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);
_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.Width = ClientSize.Width - pad * 2;
_listView.Height = 220; _listView.Height = 180;
_listView.View = View.Details; _listView.View = View.Details;
_listView.FullRowSelect = true; _listView.FullRowSelect = true;
_listView.CheckBoxes = true; _listView.CheckBoxes = true;
@@ -96,11 +117,43 @@ public partial class MainForm : Form
_sessions.AttachLink(_link); _sessions.AttachLink(_link);
_link.Open(); _link.Open();
_sessions.Discover(); _sessions.Discover();
_serverLabel.Text = "Server: discovering...";
_statusLabel.Text = "discovering..."; _statusLabel.Text = "discovering...";
} }
void PopulateList(UpstreamEntry[] entries) 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")));
_serverLabel.Text = $"Server: {hostname} — {macStr}";
_listView.BeginUpdate(); _listView.BeginUpdate();
_listView.Items.Clear(); _listView.Items.Clear();
foreach (var up in entries) foreach (var up in entries)
@@ -134,14 +187,19 @@ public partial class MainForm : Form
} }
} }
protected override void OnResize(EventArgs e)
{
base.OnResize(e);
if (WindowState == FormWindowState.Minimized)
{
Hide();
WindowState = FormWindowState.Normal;
}
}
protected override void OnFormClosing(FormClosingEventArgs e) protected override void OnFormClosing(FormClosingEventArgs e)
{ {
if (e.CloseReason == CloseReason.UserClosing) Shutdown();
{
e.Cancel = true;
Hide();
return;
}
base.OnFormClosing(e); base.OnFormClosing(e);
} }
+101
View File
@@ -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<ulong, long> _outstanding = new();
readonly ConcurrentQueue<double> _rtts = new();
long _sent;
long _received;
double _lastRttMs;
double _jitterSum;
long _jitterCount;
public event Action<PingStats>? StatsUpdated;
public event Action<string>? 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);
+4 -3
View File
@@ -11,7 +11,7 @@ static class Program
using var tray = new NotifyIcon using var tray = new NotifyIcon
{ {
Icon = SystemIcons.Application, Icon = SystemIcons.GetStockIcon(StockIconId.NetworkConnect, 32),
Text = "gatuna", Text = "gatuna",
Visible = true, Visible = true,
}; };
@@ -20,18 +20,19 @@ static class Program
tray.ContextMenuStrip.Items.Add("Show", null, (_, _) => tray.ContextMenuStrip.Items.Add("Show", null, (_, _) =>
{ {
form.Show(); form.Show();
form.WindowState = FormWindowState.Normal;
form.Activate(); form.Activate();
}); });
tray.ContextMenuStrip.Items.Add("-"); tray.ContextMenuStrip.Items.Add("-");
tray.ContextMenuStrip.Items.Add("Exit", null, (_, _) => tray.ContextMenuStrip.Items.Add("Exit", null, (_, _) =>
{ {
form.Shutdown();
tray.Visible = false; tray.Visible = false;
Application.Exit(); form.Close();
}); });
tray.DoubleClick += (_, _) => tray.DoubleClick += (_, _) =>
{ {
form.Show(); form.Show();
form.WindowState = FormWindowState.Normal;
form.Activate(); form.Activate();
}; };
+66 -5
View File
@@ -12,7 +12,9 @@ sealed class SessionManager : IDisposable
readonly ConcurrentDictionary<int, ListenerState> _listeners = new(); readonly ConcurrentDictionary<int, ListenerState> _listeners = new();
byte[]? _serverMac; byte[]? _serverMac;
string _serverHostname = "";
UpstreamEntry[] _upstreams = []; UpstreamEntry[] _upstreams = [];
PingTest? _pingTest;
// Serialized OPEN: only one outstanding at a time. // Serialized OPEN: only one outstanding at a time.
readonly object _openLock = new(); readonly object _openLock = new();
@@ -20,10 +22,11 @@ sealed class SessionManager : IDisposable
readonly Queue<PendingOpen> _openQueue = new(); readonly Queue<PendingOpen> _openQueue = new();
public event Action<string>? Log; public event Action<string>? Log;
public event Action<UpstreamEntry[]>? ManifestReceived; public event Action<string, byte[], UpstreamEntry[]>? ManifestReceived;
public UpstreamEntry[] Upstreams => _upstreams; public UpstreamEntry[] Upstreams => _upstreams;
public byte[]? ServerMac => _serverMac; public byte[]? ServerMac => _serverMac;
public string ServerHostname => _serverHostname;
public TunnelLink? Link => _link; public TunnelLink? Link => _link;
public void AttachLink(TunnelLink link) public void AttachLink(TunnelLink link)
@@ -45,15 +48,32 @@ sealed class SessionManager : IDisposable
_link.SendBroadcast(new Frame.Discover()); _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) public void HandleFrame(Frame frame, byte[] srcMac)
{ {
switch (frame) switch (frame)
{ {
case Frame.Manifest manifest: case Frame.Manifest manifest:
_serverMac = srcMac; _serverMac = srcMac;
_serverHostname = manifest.Hostname;
_upstreams = manifest.Entries; _upstreams = manifest.Entries;
Log?.Invoke($"manifest: {manifest.Entries.Length} upstreams from {BitConverter.ToString(srcMac)}"); Log?.Invoke($"manifest: {manifest.Entries.Length} upstreams from {manifest.Hostname} ({BitConverter.ToString(srcMac)})");
ManifestReceived?.Invoke(manifest.Entries); ManifestReceived?.Invoke(manifest.Hostname, srcMac, manifest.Entries);
break; break;
case Frame.OpenAck ack: case Frame.OpenAck ack:
@@ -82,18 +102,58 @@ sealed class SessionManager : IDisposable
if (_sessions.TryRemove(close.SessionId, out var s)) if (_sessions.TryRemove(close.SessionId, out var s))
s.Dispose(); s.Dispose();
break; break;
case Frame.Pong pong:
_pingTest?.HandlePong(pong.Nonce);
break;
case Frame.Ping _:
break;
} }
} }
/// <summary>
/// Compute a deterministic mirror port from the server MAC and the
/// upstream port. XOR the upstream port with (mac[0]<<8 | mac[5]),
/// then ensure the result is outside the privileged range.
/// </summary>
static ushort ComputeMirrorPort(byte[] serverMac, ushort upstreamPort)
{
var k = (ushort)((serverMac[0] << 8) | serverMac[5]);
var port = (ushort)(upstreamPort ^ k);
if (port < 1024)
port += 1024;
return port;
}
/// <summary> /// <summary>
/// Start a local TCP listener for the given upstream. Returns the mirror /// Start a local TCP listener for the given upstream. Returns the mirror
/// port, or 0 on failure. /// port, or 0 on failure.
/// </summary> /// </summary>
public int StartListener(UpstreamEntry upstream) public int StartListener(UpstreamEntry upstream)
{ {
var listener = new TcpListener(IPAddress.Loopback, 0); if (_serverMac == null)
return 0;
var preferred = ComputeMirrorPort(_serverMac, upstream.Port);
// Try the deterministic port first; fall back to OS assignment.
TcpListener listener;
int port;
try
{
listener = new TcpListener(IPAddress.Loopback, preferred);
listener.Start(); listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port; port = ((IPEndPoint)listener.LocalEndpoint).Port;
}
catch
{
listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
port = ((IPEndPoint)listener.LocalEndpoint).Port;
Log?.Invoke($"port {preferred} in use, fell back to {port}");
}
var state = new ListenerState(listener, upstream); var state = new ListenerState(listener, upstream);
_listeners[port] = state; _listeners[port] = state;
_ = AcceptLoop(state); _ = AcceptLoop(state);
@@ -191,6 +251,7 @@ sealed class SessionManager : IDisposable
public void StopAll() public void StopAll()
{ {
StopPing();
foreach (var kv in _listeners) foreach (var kv in _listeners)
kv.Value.Listener.Stop(); kv.Value.Listener.Stop();
_listeners.Clear(); _listeners.Clear();
+5
View File
@@ -50,6 +50,11 @@ sealed class TunnelLink : IDisposable
var et = (ushort)(data[12] << 8 | data[13]); var et = (ushort)(data[12] << 8 | data[13]);
if (et != Proto.EtherType) if (et != Proto.EtherType)
return; return;
// Skip our own outgoing frames (Npcap loops them back in promiscuous mode).
if (data[6] == _ourMac[0] && data[7] == _ourMac[1]
&& data[8] == _ourMac[2] && data[9] == _ourMac[3]
&& data[10] == _ourMac[4] && data[11] == _ourMac[5])
return;
var srcMac = new byte[6]; var srcMac = new byte[6];
Buffer.BlockCopy(data, 6, srcMac, 0, 6); Buffer.BlockCopy(data, 6, srcMac, 0, 6);
var payload = data.AsSpan(Proto.EthHeaderLen); var payload = data.AsSpan(Proto.EthHeaderLen);