mute ef3735764a version both programs at 2.0.0 (wire protocol v2)
Versioning scheme: MAJOR.MINOR.PATCH where MAJOR matches the wire
protocol version (no cross-major compat), MINOR is the main change
indicator, PATCH generally unused.

- gatunad: Cargo.toml version 2.0.0 (clap --version picks it up)
- gatuna: csproj Version 2.0.0, shown in form title + tray tooltip
- app.manifest: assemblyIdentity version 2.0.0.0
- Frame version rejection already in place (both sides reject != 2)
- README: document versioning scheme
2026-08-13 09:18:34 +00:00
2026-08-12 18:23:46 +00:00
2026-08-12 14:32:34 +00:00

gatuna

A raw-Ethernet tunnel for reaching services on a peer machine when a WFP killswitch on the local machine blocks normal IP traffic. Two programs carry bytes between their respective loopbacks over a private L2 protocol, bypassing the IP stack (and therefore the killswitch) entirely.

Why this works

WireGuard-for-Windows installs its killswitch as WFP filters at the ALE layers only (ALE_AUTH_CONNECT_V4/V6, ALE_AUTH_RECV_ACCEPT_V4/V6). It never installs MAC-layer callouts — the L2 code in tunnel/firewall/ is commented out. Raw Ethernet frames sent via a packet driver (Npcap on Windows, AF_PACKET on Linux) never enter the IP stack, so they never reach the ALE classify path and pass unimpeded. See wireguard-windows/tunnel/firewall/blocker.go:156.

Components

  • gatunad — Rust server (gatunad/). Runs on the peer (Linux) that owns the real services. Announces upstreams and relays TCP between the tunnel and 127.0.0.1:<port>.
  • gatuna — .NET 8 WinForms client (gatuna-win/). Runs on the killswitched Windows box. Discovers the server, presents its upstreams as local loopback listeners, and hauls bytes over the same L2 protocol. Includes a network test (PING/PONG) for measuring latency, jitter, and loss.

Transport

  • Medium: raw Ethernet frames on a shared L2 segment.
  • Ethertype: 0x6969 (hardcoded).
  • No IP stack involvement. Frames carry only our 8-byte header + payload.
  • BPF: both sides filter on ethertype — the server via classic BPF on AF_PACKET (SO_ATTACH_FILTER), the client via Npcap's compiled filter. No eBPF authoring.

Protocol

See PROTOCOL.md for the full wire format. Summary:

  • 8-byte header, big-endian: [ version:1 ][ type:1 ][ session_id:4 ][ payload_len:2 ][ payload:N ].
  • version = 2. payload_len lets the receiver ignore Ethernet padding (frames under 60 bytes are zero-padded by the NIC).
  • DATA frames carry an extended 16-byte header with seq and ack_seq fields for L2-level reliability (retransmit + in-order delivery), preventing lost Ethernet frames from corrupting TCP sessions.
  • Discovery: client broadcasts DISCOVER; server unicasts MANIFEST (with hostname and upstream list) back.
  • Sessions: OPENOPEN_ACK (or OPEN_NAK) → DATA* ↔ DATA* → CLOSE.
  • Network test: PING (with 8-byte nonce) → PONG (nonce echoed).
  • TCP only. UDP frame types are reserved but unimplemented.

gatunad usage

gatunad IFACE PORT [PORT...]
  • IFACE — L2 interface name (e.g. eth0).
  • PORTint[:label]; a TCP upstream relayed to 127.0.0.1:int. The label is optional, max 255 bytes, carried in the MANIFEST for the client to display. At least one required. Upstream ID = 1-based positional index in cmdline order.

Example:

sudo ./gatunad eth0 22 8800:site-a 8080:site-b

Exposes upstreams 1→127.0.0.1:22, 2→127.0.0.1:8800 (label site-a), 3→127.0.0.1:8080 (label site-b).

gatuna usage

  1. Launch gatuna.exe (UAC prompt expected — Npcap requires admin).
  2. Select the network adapter connected to the same L2 segment as the server.
  3. Click Discover. The server's hostname and MAC appear; the upstream list populates.
  4. Check the upstreams you want to use. The Mirror column shows the local loopback port to connect to.
  5. Connect your app to 127.0.0.1:<mirror_port>.
  6. Click Test to run a PING/PONG network test (latency, jitter, loss).
  7. Minimize to tray; close to exit.

Mirror ports

Mirror ports are deterministic: port ^ (mac[0]<<8 | mac[5]), clamped to ≥1024. The same server + upstream always yields the same local port, so you know where to connect without checking the UI. If the computed port is already in use, the client falls back to an OS-assigned port.

Building from source

Prerequisites

  • Server: Rust toolchain (edition 2021, MSRV 1.70+), Linux with AF_PACKET support.
  • Client: .NET 8 SDK with Windows Desktop workload, Npcap installed (bundled with Wireshark or standalone from https://npcap.com).

Build the server (Linux)

cd gatunad
cargo build --release

The binary is at gatunad/target/release/gatunad.

Build the client (Windows)

cd gatuna-win
dotnet build -c Release

Or run directly:

dotnet run --project gatuna-win -c Release

Npcap

The client requires Npcap's wpcap.dll and Packet.dll in the system path. These are installed by the Npcap installer (also bundled with Wireshark). No additional NuGet packages or driver installs are needed — SharpPcap talks to the existing Npcap installation.

Privileges

Server (Linux): AF_PACKET requires CAP_NET_RAW. Run as root, or grant the binary the capability once:

sudo setcap cap_net_raw+ep ./target/release/gatunad

Client (Windows): Npcap requires admin by default (AdminOnly=1 in HKLM\SYSTEM\CurrentControlSet\Services\npcap\Parameters). The client's app.manifest requests requireAdministrator, so a UAC prompt appears on launch. See the Npcap docs for details on the AdminOnly flag if you want to change this.

Logging

Server: errors only, to stdout. Normal lifecycle (DISCOVER/OPEN/CLOSE) is silent.

Client: status line in the UI. Errors are not logged to disk.

Limitations

  • TCP only. UDP wire types reserved, code paths stubbed.
  • No auth/crypto. Anyone on the same L2 segment can DISCOVER and OPEN.
  • Single server instance per interface.
  • One outstanding OPEN at a time on the client (serialized via queue).
  • L2 retransmit caps at 10 retries × 5 ms = 50 ms. A permanently dead link closes the session with reason = max_retries.

Repository layout

gatuna/
├── README.md
├── PROTOCOL.md
├── LICENSE                  # CC0 1.0 Universal
├── .gitignore
├── gatunad/                 # Rust server
│   ├── Cargo.toml
│   └── src/
│       ├── main.rs          # cmdline, rx dispatch, tx task
│       ├── link.rs          # AF_PACKET, classic BPF, raw Ethernet I/O
│       ├── frame.rs         # wire protocol encode/decode
│       ├── upstream.rs      # cmdline parsing, hostname, upstream table
│       └── session.rs       # session store, socket→tunnel pump
└── gatuna-win/              # .NET 8 WinForms client
    ├── gatuna.csproj
    ├── app.manifest         # requireAdministrator
    ├── Program.cs           # tray icon, entry point
    ├── MainForm.cs          # UI: adapter picker, discover, test, upstream list
    ├── TunnelLink.cs        # SharpPcap wrapper, raw Ethernet send/recv
    ├── Frame.cs             # wire protocol encode/decode (mirrors Rust frame.rs)
    ├── SessionManager.cs    # session lifecycle, listeners, OPEN serialization
    └── PingTest.cs          # PING/PONG network test with stats

Versioning

Both programs use MAJOR.MINOR.PATCH where:

  • MAJOR matches the wire protocol version. A v2.x program rejects any frame with version != 2. No cross-major compatibility.
  • MINOR is the main change indicator for features and fixes within a major version.
  • PATCH is reserved for bug fixes; generally unused.

Current version: 2.0.0 (wire protocol v2).

Check versions:

gatunad --version          # Rust server

The Windows client shows its version in the title bar and tray tooltip.

License

CC0 1.0 Universal. See LICENSE.

S
Description
raw ethernet tunnel to circumvent WFP killswitches
Readme CC0-1.0 253 KiB
Languages
Rust 51.7%
C# 48.3%