move win/gatuna-client to gatuna-win, rename project to gatuna
- Directory: win/gatuna-client/ -> gatuna-win/ - Project file: gatuna-client.csproj -> gatuna.csproj - Assembly name: gatuna-client -> gatuna - Root namespace: gatuna_client -> gatuna - All .cs files: namespace gatuna_client -> gatuna - app.manifest: assemblyIdentity name -> gatuna - README: updated all paths and references
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
namespace gatuna;
|
||||
|
||||
using System.Text;
|
||||
|
||||
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;
|
||||
public const byte TypeManifest = 0x02;
|
||||
public const byte TypeOpen = 0x03;
|
||||
public const byte TypeOpenAck = 0x04;
|
||||
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;
|
||||
|
||||
public const byte ReasonUnspecified = 0;
|
||||
public const byte ReasonUnknownUpstream = 1;
|
||||
public const byte ReasonConnectFailed = 2;
|
||||
public const byte ReasonOversize = 3;
|
||||
public const byte ReasonUnknownSession = 4;
|
||||
}
|
||||
|
||||
readonly record struct UpstreamEntry(
|
||||
byte Id,
|
||||
byte Protocol,
|
||||
ushort Port,
|
||||
string? Label)
|
||||
{
|
||||
public string ProtoName => Protocol == Proto.ProtoTcp ? "tcp" : "udp";
|
||||
}
|
||||
|
||||
abstract record Frame
|
||||
{
|
||||
internal record Discover : 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
|
||||
{
|
||||
/// 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 =>
|
||||
Build(Proto.TypeDiscover, 0, []),
|
||||
Frame.Manifest manifest =>
|
||||
Build(Proto.TypeManifest, 0, BuildManifestPayload(manifest.Hostname, manifest.Entries)),
|
||||
Frame.Open open =>
|
||||
Build(Proto.TypeOpen, 0, [open.UpstreamId]),
|
||||
Frame.OpenAck ack =>
|
||||
Build(Proto.TypeOpenAck, ack.SessionId, [ack.UpstreamId]),
|
||||
Frame.OpenNak nak =>
|
||||
Build(Proto.TypeOpenNak, 0, [nak.UpstreamId, nak.Reason]),
|
||||
Frame.Data data =>
|
||||
Build(Proto.TypeData, data.SessionId, data.Payload),
|
||||
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(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 ?? "");
|
||||
var labelLen = (byte)Math.Min(labelBytes.Length, 255);
|
||||
ms.WriteByte(e.Id);
|
||||
ms.WriteByte(e.Protocol);
|
||||
ms.WriteByte((byte)(e.Port >> 8));
|
||||
ms.WriteByte((byte)(e.Port & 0xFF));
|
||||
ms.WriteByte(labelLen);
|
||||
if (labelLen > 0)
|
||||
ms.Write(labelBytes, 0, labelLen);
|
||||
}
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
public static Frame? Parse(ReadOnlySpan<byte> buf)
|
||||
{
|
||||
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 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
|
||||
{
|
||||
Proto.TypeManifest => ParseManifest(payload),
|
||||
Proto.TypeOpenAck when payload.Length == 1 =>
|
||||
new Frame.OpenAck(sessionId, payload[0]),
|
||||
Proto.TypeOpenNak when payload.Length == 2 =>
|
||||
new Frame.OpenNak(payload[0], payload[1]),
|
||||
Proto.TypeData when payload.Length <= Proto.MaxPayload =>
|
||||
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 < rest.Length)
|
||||
{
|
||||
if (i + 5 > rest.Length)
|
||||
return null;
|
||||
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 > rest.Length)
|
||||
return null;
|
||||
string? label = labelLen == 0
|
||||
? null
|
||||
: Encoding.UTF8.GetString(rest[i..(i + labelLen)]);
|
||||
i += labelLen;
|
||||
entries.Add(new UpstreamEntry(id, proto, port, label));
|
||||
}
|
||||
return new Frame.Manifest(hostname, entries.ToArray());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
using SharpPcap.LibPcap;
|
||||
|
||||
namespace gatuna;
|
||||
|
||||
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 = 420;
|
||||
FormBorderStyle = FormBorderStyle.FixedSingle;
|
||||
MaximizeBox = false;
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
Icon = SystemIcons.GetStockIcon(StockIconId.NetworkConnect, 32);
|
||||
InitializeComponents();
|
||||
|
||||
_sessions.Log += msg => this.Invoke(() => _statusLabel.Text = msg);
|
||||
_sessions.ManifestReceived += (hostname, mac, entries) =>
|
||||
this.Invoke(() => PopulateList(hostname, mac, entries));
|
||||
|
||||
foreach (var d in TunnelLink.ListDevices())
|
||||
{
|
||||
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()
|
||||
{
|
||||
const int pad = 12;
|
||||
|
||||
_deviceBox.Left = pad; _deviceBox.Top = 12;
|
||||
_deviceBox.Width = ClientSize.Width - pad * 2;
|
||||
_deviceBox.DropDownStyle = ComboBoxStyle.DropDownList;
|
||||
Controls.Add(_deviceBox);
|
||||
|
||||
_discoverBtn.Text = "Discover";
|
||||
_discoverBtn.Left = pad; _discoverBtn.Top = _deviceBox.Bottom + 8;
|
||||
_discoverBtn.Width = 80;
|
||||
_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);
|
||||
|
||||
_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 = 180;
|
||||
_listView.View = View.Details;
|
||||
_listView.FullRowSelect = true;
|
||||
_listView.CheckBoxes = true;
|
||||
_listView.Columns.Add("ID", 36);
|
||||
_listView.Columns.Add("Proto", 50);
|
||||
_listView.Columns.Add("Port", 56);
|
||||
_listView.Columns.Add("Label", 160);
|
||||
_listView.Columns.Add("Mirror", 70);
|
||||
_listView.ItemChecked += OnItemChecked;
|
||||
Controls.Add(_listView);
|
||||
|
||||
_statusLabel.Left = pad; _statusLabel.Top = _listView.Bottom + 8;
|
||||
_statusLabel.Width = ClientSize.Width - pad * 2;
|
||||
_statusLabel.AutoEllipsis = true;
|
||||
Controls.Add(_statusLabel);
|
||||
}
|
||||
|
||||
void OnDiscover(object? s, EventArgs e)
|
||||
{
|
||||
if (_deviceBox.SelectedIndex < 0)
|
||||
{
|
||||
MessageBox.Show("Select a network adapter first.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_link != null)
|
||||
{
|
||||
_sessions.StopAll();
|
||||
_sessions.DetachLink();
|
||||
_link.Dispose();
|
||||
}
|
||||
|
||||
var devices = TunnelLink.ListDevices();
|
||||
var device = devices[_deviceBox.SelectedIndex];
|
||||
_link = new TunnelLink(device);
|
||||
_link.Log += msg => this.Invoke(() => _statusLabel.Text = msg);
|
||||
_sessions.AttachLink(_link);
|
||||
_link.Open();
|
||||
_sessions.Discover();
|
||||
_serverLabel.Text = "Server: discovering...";
|
||||
_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")));
|
||||
_serverLabel.Text = $"Server: {hostname} — {macStr}";
|
||||
|
||||
_listView.BeginUpdate();
|
||||
_listView.Items.Clear();
|
||||
foreach (var up in entries)
|
||||
{
|
||||
var item = new ListViewItem(up.Id.ToString());
|
||||
item.SubItems.Add(up.ProtoName);
|
||||
item.SubItems.Add(up.Port.ToString());
|
||||
item.SubItems.Add(up.Label ?? "");
|
||||
item.SubItems.Add("");
|
||||
item.Tag = up;
|
||||
_listView.Items.Add(item);
|
||||
}
|
||||
_listView.EndUpdate();
|
||||
}
|
||||
|
||||
void OnItemChecked(object? s, ItemCheckedEventArgs e)
|
||||
{
|
||||
if (e.Item.Tag is not UpstreamEntry up) return;
|
||||
if (e.Item.Checked)
|
||||
{
|
||||
var port = _sessions.StartListener(up);
|
||||
e.Item.SubItems[4].Text = port.ToString();
|
||||
}
|
||||
else
|
||||
{
|
||||
if (int.TryParse(e.Item.SubItems[4].Text, out var port) && port > 0)
|
||||
{
|
||||
_sessions.StopListener(port);
|
||||
e.Item.SubItems[4].Text = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnResize(EventArgs e)
|
||||
{
|
||||
base.OnResize(e);
|
||||
if (WindowState == FormWindowState.Minimized)
|
||||
{
|
||||
Hide();
|
||||
WindowState = FormWindowState.Normal;
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnFormClosing(FormClosingEventArgs e)
|
||||
{
|
||||
Shutdown();
|
||||
base.OnFormClosing(e);
|
||||
}
|
||||
|
||||
public void Shutdown()
|
||||
{
|
||||
_sessions.Dispose();
|
||||
_link?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace gatuna;
|
||||
|
||||
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);
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace gatuna;
|
||||
|
||||
static class Program
|
||||
{
|
||||
[STAThread]
|
||||
static void Main()
|
||||
{
|
||||
ApplicationConfiguration.Initialize();
|
||||
|
||||
var form = new MainForm();
|
||||
|
||||
using var tray = new NotifyIcon
|
||||
{
|
||||
Icon = SystemIcons.GetStockIcon(StockIconId.NetworkConnect, 32),
|
||||
Text = "gatuna",
|
||||
Visible = true,
|
||||
};
|
||||
|
||||
tray.ContextMenuStrip = new ContextMenuStrip();
|
||||
tray.ContextMenuStrip.Items.Add("Show", null, (_, _) =>
|
||||
{
|
||||
form.Show();
|
||||
form.WindowState = FormWindowState.Normal;
|
||||
form.Activate();
|
||||
});
|
||||
tray.ContextMenuStrip.Items.Add("-");
|
||||
tray.ContextMenuStrip.Items.Add("Exit", null, (_, _) =>
|
||||
{
|
||||
tray.Visible = false;
|
||||
form.Close();
|
||||
});
|
||||
tray.DoubleClick += (_, _) =>
|
||||
{
|
||||
form.Show();
|
||||
form.WindowState = FormWindowState.Normal;
|
||||
form.Activate();
|
||||
};
|
||||
|
||||
Application.Run(form);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace gatuna;
|
||||
|
||||
sealed class SessionManager : IDisposable
|
||||
{
|
||||
TunnelLink? _link;
|
||||
readonly ConcurrentDictionary<uint, Session> _sessions = new();
|
||||
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();
|
||||
PendingOpen? _pending;
|
||||
readonly Queue<PendingOpen> _openQueue = new();
|
||||
|
||||
public event Action<string>? Log;
|
||||
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)
|
||||
{
|
||||
_link = link;
|
||||
link.FrameReceived += HandleFrame;
|
||||
}
|
||||
|
||||
public void DetachLink()
|
||||
{
|
||||
if (_link != null)
|
||||
_link.FrameReceived -= HandleFrame;
|
||||
_link = null;
|
||||
}
|
||||
|
||||
public void Discover()
|
||||
{
|
||||
if (_link == null) return;
|
||||
_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 {manifest.Hostname} ({BitConverter.ToString(srcMac)})");
|
||||
ManifestReceived?.Invoke(manifest.Hostname, srcMac, manifest.Entries);
|
||||
break;
|
||||
|
||||
case Frame.OpenAck ack:
|
||||
HandleOpenAck(ack, srcMac);
|
||||
break;
|
||||
|
||||
case Frame.OpenNak nak:
|
||||
Log?.Invoke($"OPEN_NAK upstream {nak.UpstreamId} reason {nak.Reason}");
|
||||
lock (_openLock)
|
||||
{
|
||||
if (_pending != null)
|
||||
_pending.Client.Dispose();
|
||||
}
|
||||
ProcessQueue();
|
||||
break;
|
||||
|
||||
case Frame.Data data:
|
||||
if (_sessions.TryGetValue(data.SessionId, out var session))
|
||||
session.Deliver(data.Payload);
|
||||
else if (_link != null && _serverMac != null)
|
||||
_link.SendTo(_serverMac,
|
||||
new Frame.Close(data.SessionId, Proto.ReasonUnknownSession));
|
||||
break;
|
||||
|
||||
case Frame.Close close:
|
||||
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)
|
||||
{
|
||||
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();
|
||||
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);
|
||||
Log?.Invoke($"listening 127.0.0.1:{port} -> upstream {upstream.Id} ({upstream.ProtoName}:{upstream.Port})");
|
||||
return port;
|
||||
}
|
||||
|
||||
public void StopListener(int port)
|
||||
{
|
||||
if (_listeners.TryRemove(port, out var state))
|
||||
{
|
||||
state.Listener.Stop();
|
||||
Log?.Invoke($"stopped listener port {port}");
|
||||
}
|
||||
}
|
||||
|
||||
async Task AcceptLoop(ListenerState state)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
TcpClient client;
|
||||
try
|
||||
{
|
||||
client = await state.Listener.AcceptTcpClientAsync();
|
||||
}
|
||||
catch { break; }
|
||||
EnqueueOpen(client, state.Upstream.Id);
|
||||
}
|
||||
}
|
||||
|
||||
void EnqueueOpen(TcpClient client, byte upstreamId)
|
||||
{
|
||||
lock (_openLock)
|
||||
{
|
||||
if (_pending == null)
|
||||
{
|
||||
_pending = new PendingOpen(client, upstreamId);
|
||||
SendOpen(_pending);
|
||||
}
|
||||
else
|
||||
{
|
||||
_openQueue.Enqueue(new PendingOpen(client, upstreamId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void SendOpen(PendingOpen po)
|
||||
{
|
||||
if (_link == null || _serverMac == null)
|
||||
{
|
||||
Log?.Invoke("no server; cannot OPEN");
|
||||
po.Client.Dispose();
|
||||
return;
|
||||
}
|
||||
_link.SendTo(_serverMac, new Frame.Open(po.UpstreamId));
|
||||
}
|
||||
|
||||
void ProcessQueue()
|
||||
{
|
||||
lock (_openLock)
|
||||
{
|
||||
if (_openQueue.Count > 0)
|
||||
{
|
||||
_pending = _openQueue.Dequeue();
|
||||
SendOpen(_pending);
|
||||
}
|
||||
else
|
||||
{
|
||||
_pending = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void HandleOpenAck(Frame.OpenAck ack, byte[] srcMac)
|
||||
{
|
||||
PendingOpen? po;
|
||||
lock (_openLock)
|
||||
po = _pending;
|
||||
|
||||
if (po == null || po.UpstreamId != ack.UpstreamId)
|
||||
{
|
||||
Log?.Invoke($"OPEN_ACK upstream {ack.UpstreamId} session {ack.SessionId} — no matching pending");
|
||||
return;
|
||||
}
|
||||
|
||||
var session = new Session(
|
||||
ack.SessionId, po.Client, srcMac, _link!,
|
||||
() => _sessions.TryRemove(ack.SessionId, out _),
|
||||
msg => Log?.Invoke(msg));
|
||||
_sessions[ack.SessionId] = session;
|
||||
session.Start();
|
||||
Log?.Invoke($"session {ack.SessionId} upstream {ack.UpstreamId} established");
|
||||
ProcessQueue();
|
||||
}
|
||||
|
||||
public void StopAll()
|
||||
{
|
||||
StopPing();
|
||||
foreach (var kv in _listeners)
|
||||
kv.Value.Listener.Stop();
|
||||
_listeners.Clear();
|
||||
foreach (var s in _sessions.Values)
|
||||
s.Dispose();
|
||||
_sessions.Clear();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
DetachLink();
|
||||
StopAll();
|
||||
}
|
||||
}
|
||||
|
||||
sealed class ListenerState(TcpListener listener, UpstreamEntry upstream)
|
||||
{
|
||||
public TcpListener Listener { get; } = listener;
|
||||
public UpstreamEntry Upstream { get; } = upstream;
|
||||
}
|
||||
|
||||
sealed class PendingOpen(TcpClient client, byte upstreamId)
|
||||
{
|
||||
public TcpClient Client { get; } = client;
|
||||
public byte UpstreamId { get; } = upstreamId;
|
||||
}
|
||||
|
||||
sealed class Session(
|
||||
uint sessionId,
|
||||
TcpClient client,
|
||||
byte[] serverMac,
|
||||
TunnelLink link,
|
||||
Action onClosed,
|
||||
Action<string>? log) : IDisposable
|
||||
{
|
||||
readonly CancellationTokenSource _cts = new();
|
||||
readonly Channel<byte[]> _incoming = Channel.CreateBounded<byte[]>(256);
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_ = PumpSocketToTunnel();
|
||||
_ = PumpTunnelToSocket();
|
||||
}
|
||||
|
||||
public void Deliver(byte[] payload)
|
||||
{
|
||||
if (!_incoming.Writer.TryWrite(payload))
|
||||
log?.Invoke($"session {sessionId}: incoming channel full");
|
||||
}
|
||||
|
||||
async Task PumpSocketToTunnel()
|
||||
{
|
||||
try
|
||||
{
|
||||
var stream = client.GetStream();
|
||||
var buf = new byte[Proto.MaxPayload];
|
||||
using var reg = _cts.Token.Register(() => client.Dispose());
|
||||
while (!_cts.IsCancellationRequested)
|
||||
{
|
||||
var n = await stream.ReadAsync(buf, _cts.Token);
|
||||
if (n == 0) break;
|
||||
link.SendTo(serverMac, new Frame.Data(sessionId, buf[..n]));
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
SendClose();
|
||||
onClosed();
|
||||
}
|
||||
|
||||
async Task PumpTunnelToSocket()
|
||||
{
|
||||
try
|
||||
{
|
||||
var stream = client.GetStream();
|
||||
await foreach (var payload in _incoming.Reader.ReadAllAsync(_cts.Token))
|
||||
await stream.WriteAsync(payload, _cts.Token);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
void SendClose() => link.SendTo(serverMac, new Frame.Close(sessionId, null));
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts.Cancel();
|
||||
_incoming.Writer.TryComplete();
|
||||
SendClose();
|
||||
try { client.Dispose(); } catch { }
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using SharpPcap;
|
||||
using SharpPcap.LibPcap;
|
||||
|
||||
namespace gatuna;
|
||||
|
||||
sealed class TunnelLink : IDisposable
|
||||
{
|
||||
LibPcapLiveDevice _device;
|
||||
readonly byte[] _ourMac = new byte[6];
|
||||
|
||||
public event Action<Frame, byte[]>? FrameReceived;
|
||||
public event Action<string>? Log;
|
||||
|
||||
public TunnelLink(LibPcapLiveDevice device)
|
||||
{
|
||||
_device = device;
|
||||
}
|
||||
|
||||
public static LibPcapLiveDevice[] ListDevices()
|
||||
{
|
||||
return [.. LibPcapLiveDeviceList.Instance
|
||||
.Where(d => !d.Loopback && d.MacAddress != null)];
|
||||
}
|
||||
|
||||
public void Open()
|
||||
{
|
||||
_device.Open(new DeviceConfiguration
|
||||
{
|
||||
Mode = DeviceModes.Promiscuous | DeviceModes.MaxResponsiveness,
|
||||
ReadTimeout = 1000,
|
||||
});
|
||||
_device.Filter = $"ether proto 0x{Proto.EtherType:X4}";
|
||||
|
||||
if (_device.MacAddress?.GetAddressBytes() is { Length: 6 } mac)
|
||||
{
|
||||
Buffer.BlockCopy(mac, 0, _ourMac, 0, 6);
|
||||
}
|
||||
|
||||
_device.OnPacketArrival += OnPacketArrival;
|
||||
_device.StartCapture();
|
||||
Log?.Invoke($"capture started on {_device.Name}");
|
||||
}
|
||||
|
||||
void OnPacketArrival(object? sender, PacketCapture capture)
|
||||
{
|
||||
var raw = capture.GetPacket();
|
||||
var data = raw.Data;
|
||||
if (data.Length < Proto.EthHeaderLen + 6)
|
||||
return;
|
||||
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);
|
||||
if (FrameCodec.Parse(payload) is { } frame)
|
||||
FrameReceived?.Invoke(frame, srcMac);
|
||||
}
|
||||
|
||||
public void SendBroadcast(Frame frame)
|
||||
{
|
||||
SendRaw([0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF], FrameCodec.Encode(frame));
|
||||
}
|
||||
|
||||
public void SendTo(byte[] dstMac, Frame frame)
|
||||
{
|
||||
SendRaw(dstMac, FrameCodec.Encode(frame));
|
||||
}
|
||||
|
||||
void SendRaw(byte[] dstMac, byte[] payload)
|
||||
{
|
||||
var frame = new byte[Proto.EthHeaderLen + payload.Length];
|
||||
Buffer.BlockCopy(dstMac, 0, frame, 0, 6);
|
||||
Buffer.BlockCopy(_ourMac, 0, frame, 6, 6);
|
||||
frame[12] = (byte)(Proto.EtherType >> 8);
|
||||
frame[13] = (byte)(Proto.EtherType & 0xFF);
|
||||
Buffer.BlockCopy(payload, 0, frame, 14, payload.Length);
|
||||
try
|
||||
{
|
||||
_device.SendPacket(frame);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log?.Invoke($"send failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_device.Started)
|
||||
_device.StopCapture();
|
||||
_device.OnPacketArrival -= OnPacketArrival;
|
||||
_device.Close();
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="0.1.0.0" name="gatuna" />
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
|
||||
<security>
|
||||
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
</assembly>
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<AssemblyName>gatuna</AssemblyName>
|
||||
<RootNamespace>gatuna</RootNamespace>
|
||||
<Nullable>enable</Nullable>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="SharpPcap" Version="6.3.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user