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.
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. Runs on the peer (Linux) that owns the real services. Announces upstreams and relays TCP between the tunnel and127.0.0.1:<port>.gatuna(planned) — .NET WinForms client. Runs on the killswitched Windows box. Discovers the server, presents its upstreams as local loopback listeners, and hauls bytes over the same L2 protocol.
This repository builds gatunad first.
Transport
- Medium: raw Ethernet frames on a shared L2 segment.
- Ethertype:
0x6969(hardcoded). - No IP stack involvement. Frames carry only our 6-byte header + payload.
- BPF: the server attaches a classic BPF filter
ether proto 0x6969to itsAF_PACKETsocket so it only wakes on our ethertype. No eBPF authoring.
Protocol
See PROTOCOL.md for the full wire format. Summary:
- 6-byte header, big-endian:
[ version:1 ][ type:1 ][ session_id:4 ][ payload:N ]. version=1. No length field (frame length comes from the capture). No CRC. Ethertype discriminates our frames from everything else.- Discovery: client broadcasts
DISCOVER; server unicastsMANIFESTback. - Sessions:
OPEN→OPEN_ACK(orOPEN_NAK) →DATA* ↔DATA* →CLOSE. - v1 ships TCP only. UDP frame types are reserved but unimplemented.
gatunad usage
gatunad IFACE PORT [PORT...]
IFACE— L2 interface name (e.g.eth0).PORT—int[:label]; a TCP upstream relayed to127.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).
Privileges
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
Logging
Errors only, to stdout. Normal lifecycle (DISCOVER/OPEN/CLOSE) is silent.
v1 limitations
- TCP only. UDP wire types reserved, code paths stubbed.
- No retransmit at the L2 layer. A dropped
DATAframe breaks the TCP session irrecoverably because the localhost socket already ACKed the bytes. Acceptable on a healthy switched link. - No auth/crypto. Anyone on the same L2 segment can
DISCOVERandOPEN. - Single server instance per interface.
License
CC0 1.0 Universal. See LICENSE.