add gatunad server and winforms client v1 (TCP-only)

Raw-Ethernet tunnel over ethertype 0x6969 to bypass WFP killswitches.
Server (Rust, AF_PACKET + classic BPF) relays TCP to 127.0.0.1 services.
Client (.NET 8 WinForms, SharpPcap/Npcap) discovers upstreams and exposes
local loopback listeners. Wire protocol: 6-byte header, 7 frame types,
MANIFEST with labeled upstreams. UDP types reserved, unimplemented.
This commit is contained in:
2026-08-12 18:20:51 +00:00
parent 606d50432c
commit 25f00b0deb
16 changed files with 1852 additions and 1 deletions
+156
View File
@@ -0,0 +1,156 @@
namespace gatuna_client;
using System.Text;
static class Proto
{
public const ushort EtherType = 0x6969;
public const int EthHeaderLen = 14;
public const byte Version = 1;
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 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(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;
}
static class FrameCodec
{
public static byte[] Encode(Frame frame)
{
return frame switch
{
Frame.Discover =>
Header(Proto.TypeDiscover, 0),
Frame.Manifest manifest =>
BuildManifest(manifest.Entries),
Frame.Open open =>
[.. Header(Proto.TypeOpen, 0), open.UpstreamId],
Frame.OpenAck ack =>
[.. Header(Proto.TypeOpenAck, ack.SessionId), ack.UpstreamId],
Frame.OpenNak nak =>
[.. Header(Proto.TypeOpenNak, 0), nak.UpstreamId, nak.Reason],
Frame.Data data =>
[.. Header(Proto.TypeData, data.SessionId), .. data.Payload],
Frame.Close close =>
close.Reason.HasValue
? [.. Header(Proto.TypeClose, close.SessionId), close.Reason.Value]
: Header(Proto.TypeClose, close.SessionId),
_ => throw new InvalidOperationException($"unknown frame type: {frame.GetType()}"),
};
}
static byte[] BuildManifest(UpstreamEntry[] entries)
{
using var ms = new MemoryStream();
ms.Write(Header(Proto.TypeManifest, 0));
foreach (var e in entries)
{
var labelBytes = Encoding.UTF8.GetBytes(e.Label ?? "");
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();
}
static byte[] Header(byte type, uint sessionId)
{
return [
Proto.Version, type,
(byte)(sessionId >> 24),
(byte)(sessionId >> 16),
(byte)(sessionId >> 8),
(byte)(sessionId & 0xFF),
];
}
public static Frame? Parse(ReadOnlySpan<byte> buf)
{
if (buf.Length < 6)
return null;
if (buf[0] != Proto.Version)
return null;
var type = buf[1];
var sessionId = (uint)(buf[2] << 24 | buf[3] << 16 | buf[4] << 8 | buf[5]);
var payload = buf[6..];
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.TypeDiscover when payload.Length == 0 =>
new Frame.Discover(),
_ => null,
};
}
static Frame.Manifest? ParseManifest(ReadOnlySpan<byte> payload)
{
var entries = new List<UpstreamEntry>();
int i = 0;
while (i < payload.Length)
{
if (i + 5 > payload.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];
i += 5;
if (i + labelLen > payload.Length)
return null;
string? label = labelLen == 0
? null
: Encoding.UTF8.GetString(payload[i..(i + labelLen)]);
i += labelLen;
entries.Add(new UpstreamEntry(id, proto, port, label));
}
return new Frame.Manifest(entries.ToArray());
}
}
+141
View File
@@ -0,0 +1,141 @@
using SharpPcap.LibPcap;
namespace gatuna_client;
public partial class MainForm : Form
{
readonly SessionManager _sessions = new();
readonly ComboBox _deviceBox = new();
readonly Button _discoverBtn = new();
readonly ListView _listView = new();
readonly Label _statusLabel = new();
TunnelLink? _link;
public MainForm()
{
Text = "gatuna";
Width = 520;
Height = 380;
FormBorderStyle = FormBorderStyle.FixedSingle;
MaximizeBox = false;
MinimizeBox = false;
StartPosition = FormStartPosition.CenterScreen;
InitializeComponents();
_sessions.Log += msg => this.Invoke(() => _statusLabel.Text = msg);
_sessions.ManifestReceived += entries => this.Invoke(() => PopulateList(entries));
foreach (var d in TunnelLink.ListDevices())
_deviceBox.Items.Add($"{d.Name} — {d.Interface?.FriendlyName ?? d.Interface?.Description}");
if (_deviceBox.Items.Count > 0)
_deviceBox.SelectedIndex = 0;
}
void InitializeComponents()
{
Controls.Add(new Label { Text = "Adapter:", Left = 12, Top = 12, AutoSize = true });
_deviceBox.Left = 70; _deviceBox.Top = 9;
_deviceBox.Width = 330; _deviceBox.DropDownStyle = ComboBoxStyle.DropDownList;
Controls.Add(_deviceBox);
_discoverBtn.Text = "Discover";
_discoverBtn.Left = 410; _discoverBtn.Top = 8; _discoverBtn.Width = 80;
_discoverBtn.Click += OnDiscover;
Controls.Add(_discoverBtn);
_listView.Left = 12; _listView.Top = 40;
_listView.Width = 478; _listView.Height = 250;
_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 = 12; _statusLabel.Top = 304;
_statusLabel.Width = 478; _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();
_statusLabel.Text = "discovering...";
}
void PopulateList(UpstreamEntry[] entries)
{
_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 OnFormClosing(FormClosingEventArgs e)
{
if (e.CloseReason == CloseReason.UserClosing)
{
e.Cancel = true;
Hide();
return;
}
base.OnFormClosing(e);
}
public void Shutdown()
{
_sessions.Dispose();
_link?.Dispose();
}
}
+40
View File
@@ -0,0 +1,40 @@
namespace gatuna_client;
static class Program
{
[STAThread]
static void Main()
{
ApplicationConfiguration.Initialize();
var form = new MainForm();
using var tray = new NotifyIcon
{
Icon = SystemIcons.Application,
Text = "gatuna",
Visible = true,
};
tray.ContextMenuStrip = new ContextMenuStrip();
tray.ContextMenuStrip.Items.Add("Show", null, (_, _) =>
{
form.Show();
form.Activate();
});
tray.ContextMenuStrip.Items.Add("-");
tray.ContextMenuStrip.Items.Add("Exit", null, (_, _) =>
{
form.Shutdown();
tray.Visible = false;
Application.Exit();
});
tray.DoubleClick += (_, _) =>
{
form.Show();
form.Activate();
};
Application.Run(form);
}
}
+284
View File
@@ -0,0 +1,284 @@
using System.Collections.Concurrent;
using System.Net;
using System.Net.Sockets;
using System.Threading.Channels;
namespace gatuna_client;
sealed class SessionManager : IDisposable
{
TunnelLink? _link;
readonly ConcurrentDictionary<uint, Session> _sessions = new();
readonly ConcurrentDictionary<int, ListenerState> _listeners = new();
byte[]? _serverMac;
UpstreamEntry[] _upstreams = [];
// 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<UpstreamEntry[]>? ManifestReceived;
public UpstreamEntry[] Upstreams => _upstreams;
public byte[]? ServerMac => _serverMac;
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 void HandleFrame(Frame frame, byte[] srcMac)
{
switch (frame)
{
case Frame.Manifest manifest:
_serverMac = srcMac;
_upstreams = manifest.Entries;
Log?.Invoke($"manifest: {manifest.Entries.Length} upstreams from {BitConverter.ToString(srcMac)}");
ManifestReceived?.Invoke(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;
}
}
/// <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);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).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()
{
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();
}
}
+99
View File
@@ -0,0 +1,99 @@
using SharpPcap;
using SharpPcap.LibPcap;
namespace gatuna_client;
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;
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 { }
}
}
+11
View File
@@ -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-client" />
<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>
+17
View File
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<RootNamespace>gatuna_client</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>