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` |
| 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):
@@ -59,18 +61,27 @@ Reserved (unimplemented in v1; parse returns Err, encode unimplemented):
### 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 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) |
+---------------+---------------+-------------------------------+
| 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`
cmdline).
- **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,
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.
### 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.
### 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 |
+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_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;
@@ -39,12 +41,14 @@ pub struct UpstreamEntry {
#[derive(Clone, Debug)]
pub enum Frame {
Discover,
Manifest(Vec<UpstreamEntry>),
Manifest { hostname: String, entries: Vec<UpstreamEntry> },
Open { upstream_id: u8 },
OpenAck { session_id: u32, upstream_id: u8 },
OpenNak { upstream_id: u8, reason: u8 },
Data { session_id: u32, payload: Vec<u8> },
Close { session_id: u32, reason: Option<u8> },
Ping { nonce: u64 },
Pong { nonce: u64 },
}
#[derive(Debug)]
@@ -94,8 +98,12 @@ impl Frame {
pub fn encode(&self) -> Vec<u8> {
match self {
Frame::Discover => build(TYPE_DISCOVER, 0, Vec::new()),
Frame::Manifest(entries) => {
Frame::Manifest { hostname, entries } => {
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 {
encode_entry(&mut payload, e);
}
@@ -116,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()),
}
}
@@ -143,29 +153,38 @@ impl Frame {
Ok(Frame::Discover)
}
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 i = 0;
while i < payload.len() {
if i + 5 > payload.len() {
while i < rest.len() {
if i + 5 > rest.len() {
return Err(DecodeError::BadPayload("MANIFEST entry truncated"));
}
let id = payload[i];
let proto = payload[i + 1];
let port = u16::from_be_bytes([payload[i + 2], payload[i + 3]]);
let label_len = payload[i + 4] as usize;
let id = rest[i];
let proto = rest[i + 1];
let port = u16::from_be_bytes([rest[i + 2], rest[i + 3]]);
let label_len = rest[i + 4] as usize;
i += 5;
if i + label_len > payload.len() {
if i + label_len > rest.len() {
return Err(DecodeError::BadPayload("MANIFEST label truncated"));
}
let label = if label_len == 0 {
None
} 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;
entries.push(UpstreamEntry { id, proto, port, label });
}
Ok(Frame::Manifest(entries))
Ok(Frame::Manifest { hostname, entries })
}
TYPE_OPEN => {
if payload.len() != 1 {
@@ -199,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"))
}
+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) {
Ok(l) => Arc::new(l),
Err(e) => {
@@ -112,7 +114,7 @@ async fn main() -> ExitCode {
continue;
}
};
handle_frame(frame, src, &tx, &store, &next_id, &table).await;
handle_frame(frame, src, &tx, &store, &next_id, &table, &hostname).await;
}
Ok(None) => {
// Ignorable frame (outgoing/short/mismatch) or transient; do not
@@ -135,10 +137,14 @@ async fn handle_frame(
store: &SessionStore,
next_id: &Arc<AtomicU32>,
table: &Arc<crate::upstream::UpstreamTable>,
hostname: &Arc<String>,
) {
match frame {
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;
}
Frame::Open { upstream_id } => {
@@ -201,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 { .. } => {}
}
}
+15 -1
View File
@@ -1,6 +1,20 @@
//! 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)]
#[allow(dead_code)]
+57 -12
View File
@@ -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;
@@ -40,12 +42,14 @@ readonly record struct UpstreamEntry(
abstract record 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 OpenAck(uint SessionId, byte UpstreamId) : 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
@@ -74,7 +78,7 @@ static class FrameCodec
Frame.Discover =>
Build(Proto.TypeDiscover, 0, []),
Frame.Manifest manifest =>
Build(Proto.TypeManifest, 0, BuildManifestPayload(manifest.Entries)),
Build(Proto.TypeManifest, 0, BuildManifestPayload(manifest.Hostname, manifest.Entries)),
Frame.Open open =>
Build(Proto.TypeOpen, 0, [open.UpstreamId]),
Frame.OpenAck ack =>
@@ -86,13 +90,22 @@ 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()}"),
};
}
static byte[] BuildManifestPayload(UpstreamEntry[] entries)
static byte[] BuildManifestPayload(string hostname, UpstreamEntry[] entries)
{
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)
{
var labelBytes = Encoding.UTF8.GetBytes(e.Label ?? "");
@@ -133,33 +146,65 @@ 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<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)
{
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>();
int i = 0;
while (i < payload.Length)
while (i < rest.Length)
{
if (i + 5 > payload.Length)
if (i + 5 > rest.Length)
return null;
var id = payload[i];
var proto = payload[i + 1];
var port = (ushort)(payload[i + 2] << 8 | payload[i + 3]);
var labelLen = payload[i + 4];
var id = rest[i];
var proto = rest[i + 1];
var port = (ushort)(rest[i + 2] << 8 | rest[i + 3]);
var labelLen = rest[i + 4];
i += 5;
if (i + labelLen > payload.Length)
if (i + labelLen > rest.Length)
return null;
string? label = labelLen == 0
? null
: Encoding.UTF8.GetString(payload[i..(i + labelLen)]);
: Encoding.UTF8.GetString(rest[i..(i + labelLen)]);
i += labelLen;
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 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;
FormBorderStyle = FormBorderStyle.FixedSingle;
MaximizeBox = false;
MinimizeBox = false;
Height = 420;
StartPosition = FormStartPosition.CenterScreen;
Icon = SystemIcons.GetStockIcon(StockIconId.NetworkConnect, 32);
InitializeComponents();
_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())
{
@@ -54,9 +57,27 @@ public partial class MainForm : Form
_discoverBtn.Click += OnDiscover;
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.Height = 220;
_listView.Height = 180;
_listView.View = View.Details;
_listView.FullRowSelect = true;
_listView.CheckBoxes = true;
@@ -96,11 +117,43 @@ public partial class MainForm : Form
_sessions.AttachLink(_link);
_link.Open();
_sessions.Discover();
_serverLabel.Text = "Server: 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.Items.Clear();
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)
{
if (e.CloseReason == CloseReason.UserClosing)
{
e.Cancel = true;
Hide();
return;
}
Shutdown();
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
{
Icon = SystemIcons.Application,
Icon = SystemIcons.GetStockIcon(StockIconId.NetworkConnect, 32),
Text = "gatuna",
Visible = true,
};
@@ -20,18 +20,19 @@ static class Program
tray.ContextMenuStrip.Items.Add("Show", null, (_, _) =>
{
form.Show();
form.WindowState = FormWindowState.Normal;
form.Activate();
});
tray.ContextMenuStrip.Items.Add("-");
tray.ContextMenuStrip.Items.Add("Exit", null, (_, _) =>
{
form.Shutdown();
tray.Visible = false;
Application.Exit();
form.Close();
});
tray.DoubleClick += (_, _) =>
{
form.Show();
form.WindowState = FormWindowState.Normal;
form.Activate();
};
+66 -5
View File
@@ -12,7 +12,9 @@ sealed class SessionManager : IDisposable
readonly ConcurrentDictionary<int, ListenerState> _listeners = new();
byte[]? _serverMac;
string _serverHostname = "";
UpstreamEntry[] _upstreams = [];
PingTest? _pingTest;
// Serialized OPEN: only one outstanding at a time.
readonly object _openLock = new();
@@ -20,10 +22,11 @@ sealed class SessionManager : IDisposable
readonly Queue<PendingOpen> _openQueue = new();
public event Action<string>? Log;
public event Action<UpstreamEntry[]>? ManifestReceived;
public event Action<string, byte[], UpstreamEntry[]>? ManifestReceived;
public UpstreamEntry[] Upstreams => _upstreams;
public byte[]? ServerMac => _serverMac;
public string ServerHostname => _serverHostname;
public TunnelLink? Link => _link;
public void AttachLink(TunnelLink link)
@@ -45,15 +48,32 @@ 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)
{
case Frame.Manifest manifest:
_serverMac = srcMac;
_serverHostname = manifest.Hostname;
_upstreams = manifest.Entries;
Log?.Invoke($"manifest: {manifest.Entries.Length} upstreams from {BitConverter.ToString(srcMac)}");
ManifestReceived?.Invoke(manifest.Entries);
Log?.Invoke($"manifest: {manifest.Entries.Length} upstreams from {manifest.Hostname} ({BitConverter.ToString(srcMac)})");
ManifestReceived?.Invoke(manifest.Hostname, srcMac, manifest.Entries);
break;
case Frame.OpenAck ack:
@@ -82,18 +102,58 @@ 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;
}
}
/// <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>
/// Start a local TCP listener for the given upstream. Returns the mirror
/// port, or 0 on failure.
/// </summary>
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();
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);
_listeners[port] = state;
_ = AcceptLoop(state);
@@ -191,6 +251,7 @@ sealed class SessionManager : IDisposable
public void StopAll()
{
StopPing();
foreach (var kv in _listeners)
kv.Value.Listener.Stop();
_listeners.Clear();
+5
View File
@@ -50,6 +50,11 @@ sealed class TunnelLink : IDisposable
var et = (ushort)(data[12] << 8 | data[13]);
if (et != Proto.EtherType)
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];
Buffer.BlockCopy(data, 6, srcMac, 0, 6);
var payload = data.AsSpan(Proto.EthHeaderLen);