Compare commits

...

16 Commits

Author SHA1 Message Date
mute fcd172f341 fix: premature socket teardown on CLOSE + duplicate CLOSE echo
build / build (push) Has been cancelled
Two bugs when upstream closes:

1. Data loss: gatuna received CLOSE from gatunad and immediately
   RST'd the socket to curl while the drain pump still had queued
   payloads in _deliverChannel. Fix: OnRemoteClose() completes the
   channel writer and cancels the read pump, but lets the drain pump
   finish naturally. Drain pump no longer takes _cts.Token — it stops
   on channel completion and closes the socket gracefully (FIN, OS
   flushes in background).

2. Duplicate CLOSE: gatuna echoed CLOSE back to gatunad (server
   already knows — it initiated). SendClose() now guarded by
   _closeSent flag. OnRemoteClose() never sends CLOSE at all.

Server-side: remove session from store before sending CLOSE so the
retransmit timer stops before CLOSE is queued. Only log CLOSE if
the session actually existed (dedup).
2026-08-17 07:27:50 +00:00
mute b4222df349 ci: add Gitea Actions build workflow
Builds Rust + .NET on push/PR. On version tags, publishes
framework-dependent win-x64 zip + gatunad linux binary as
Gitea release assets via REST API.

Runs on hugmaster (HuggingFace Spaces runner).
2026-08-16 08:43:59 +00:00
mute 527b462291 format MAC addresses as hex with colons in logs
Add Display impl for MacAddr (XX:XX:XX:XX:XX:XX) and use {src}
instead of {src:?} in all log calls.
2026-08-16 08:03:28 +00:00
mute 781fe959eb fix: parse args before using args.verbose 2026-08-16 08:00:01 +00:00
mute ee6b121370 bump version to 2.1.0
--verbose flag and TCP RST are feature additions within wire
protocol v2 (no protocol changes, MAJOR stays at 2).
2026-08-14 14:01:30 +00:00
mute f476f6b145 add --verbose flag and TCP RST on connection failure
Server (gatunad):
- New --verbose/-v flag: logs lifecycle events at info level
  (DISCOVER, MANIFEST sent, OPEN, session established, CLOSE,
  OPEN_NAK). Without the flag, errors only as before.

Client (gatuna):
- OPEN_NAK and session CLOSE now send TCP RST to the local app
  instead of a graceful FIN. LingerOption(true, 0) causes Winsock
  to emit RST on close. The local app (e.g. ssh) sees a broken
  connection instead of a clean close, which is more honest about
  what happened (upstream was unreachable).
2026-08-14 13:59:15 +00:00
mute a700514849 silence warnings: unused proto field, dead upstream_id field 2026-08-13 09:26:32 +00:00
mute bf2915d8bd fix: add tokio time feature, make SendState/RecvState::new public
- tokio 'time' feature needed for tokio::time::interval in retransmit timer
- SendState::new and RecvState::new must be pub for use from main.rs
- prefix unused tx param with underscore
2026-08-13 09:25:27 +00:00
mute 6f7367e36d add name etymology and AI attribution to README 2026-08-13 09:20:16 +00:00
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
mute 7fa50dd4e4 carry transport proto end-to-end through OPEN/OPEN_ACK
OPEN and OPEN_ACK now carry proto:1 alongside upstream_id:1. The
session is tagged with its transport proto and reliability semantics
switch on the specific proto:

- TCP (proto=1): full L2 reliability (seq, ack, retransmit, in-order)
- UDP (proto=2, reserved): best-effort (no retransmit, no ordering,
  no pure ACKs — seq/ack_seq fields present but ignored)

This is architecturally correct: instead of a generic 'reliable:bool'
flag, each proto gets the semantics it needs. Today only TCP exists so
every session is reliable, but the extension point is clean for when
stateless protos are added.

Updated: PROTOCOL.md, Rust frame.rs/session.rs/main.rs,
C# Frame.cs/SessionManager.cs.
2026-08-13 09:15:15 +00:00
mute eb8994d1e1 add L2 reliability: seq + cumulative ACK + retransmit (protocol v2)
DATA frames now carry seq:4 and ack_seq:4 in a 16-byte extended
header. Both sides maintain per-session send/recv state:

Sender:
- Monotonic seq counter, retransmit buffer (seq -> frame bytes)
- Retransmit timer: 5ms timeout, 10 max retries -> CLOSE
- Window advances on cumulative ACK

Receiver:
- In-order delivery to TCP socket (expected_seq)
- Out-of-order buffering (SortedList by seq)
- Duplicate detection (seq < expected -> discard + re-ACK)
- Pure ACK frames (empty-payload DATA) for duplicate/OOO responses

This prevents lost Ethernet frames from permanently corrupting TCP
sessions, which was the key v1 limitation. The local kernel TCP stack
ACKs data before we chunk it into DATA frames; without L2 reliability
a dropped frame creates an unrecoverable gap.

Version bumped to 2. Both sides must speak v2; no negotiation.

Updated: PROTOCOL.md (full v2 spec), README.md, Rust frame.rs/
session.rs/main.rs, C# Frame.cs/SessionManager.cs/TunnelLink.cs.
2026-08-13 09:08:05 +00:00
mute 2f61f2bb1b 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
2026-08-13 08:43:11 +00:00
mute 5bbda4c4c4 finalise docs: accurate header size, PING/PONG, build instructions, repo layout
README:
- Fix header size (8 bytes, not 6) and payload_len field
- Mark client as built (not 'planned')
- Add PING/PONG to protocol summary
- Add client usage section (Discover, mirror ports, Test button, tray)
- Add building from source section (Rust + .NET)
- Add Npcap prerequisite explanation
- Add Windows privilege/UAC note
- Add full repository layout tree

PROTOCOL:
- Fix MANIFEST payload description in frame types table (hostname + entries)
- Add PING/PONG flow diagram and semantics section
2026-08-13 08:40:02 +00:00
mute 094d080a95 disable maximize button and form resizing 2026-08-13 08:34:59 +00:00
mute 49f6abd8d4 remove unused Frame import in upstream.rs 2026-08-13 08:30:20 +00:00
17 changed files with 1127 additions and 185 deletions
+55
View File
@@ -0,0 +1,55 @@
name: build
on: [push, pull_request]
jobs:
build:
runs-on: hugmaster
steps:
- name: Checkout
run: |
git clone "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" .
git checkout "$GITHUB_SHA"
- name: Build Rust
run: |
cd gatunad
cargo build --release
- name: Build .NET
run: |
cd gatuna-win
dotnet build
- name: Publish Windows (framework-dependent)
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
run: |
cd gatuna-win
dotnet publish -c Release -r win-x64 --no-self-contained -o ../publish/win
- name: Package release assets
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
run: |
mkdir -p release
cp gatunad/target/release/gatunad release/gatunad-linux-amd64
cd publish/win && zip -r ../../release/gatuna-win.zip . && cd ../..
- name: Create Gitea release
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
run: |
TAG="${GITHUB_REF#refs/tags/}"
RESP=$(curl -sS -X POST \
-H "Authorization: token ${GITHUB_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"body\":\"Automated build for $TAG\"}" \
"${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases")
RID=$(echo "$RESP" | jq .id)
curl -sS -X POST \
-H "Authorization: token ${GITHUB_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary @release/gatunad-linux-amd64 \
"${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases/${RID}/assets?name=gatunad-linux-amd64"
curl -sS -X POST \
-H "Authorization: token ${GITHUB_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary @release/gatuna-win.zip \
"${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases/${RID}/assets?name=gatuna-win.zip"
+184 -29
View File
@@ -3,7 +3,7 @@
All frames ride Ethernet with ethertype `0x6969`. The ethertype is the sole
discriminator; there is no magic number inside the payload.
## Frame layout
## Frame layout (non-DATA frames)
```
0 1 2 3
@@ -17,7 +17,7 @@ discriminator; there is no magic number inside the payload.
+---------------------------------------------------------------+
```
- **version** (u8): protocol version. Currently `1`.
- **version** (u8): protocol version. Currently `2`.
- **type** (u8): frame type, see table below.
- **session_id** (u32, big-endian): `0` for non-session frames; the
server-assigned ID for session-scoped frames.
@@ -27,29 +27,53 @@ discriminator; there is no magic number inside the payload.
meet the minimum Ethernet frame size).
- **payload** (`payload_len` bytes): type-dependent.
No CRC, no retransmit, no ordering at this layer. TCP reliability is handled by
the endpoints' TCP stacks; UDP (reserved) will rely on application-level
mechanisms.
## DATA frame layout
Maximum payload: 1500 (Ethernet MTU) 8 (our header) = **1492 bytes**. In
practice we cap at **1480** to stay conservative. Larger payloads are not
emitted in v1.
DATA frames carry two additional fields after the common header for L2-level
reliability:
```
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
+---------------+---------------+-------------------------------+
| version=2 | type=0x06 | session_id |
+---------------+---------------+-------------------------------+
| payload_len (big-endian) | seq (big-endian) |
+-------------------------------+-------------------------------+
| ack_seq (big-endian) | payload ... |
+-------------------------------+ +
| |
+---------------------------------------------------------------+
```
- **seq** (u32, big-endian): monotonically increasing per-session sequence
number. Wraps at 2^32 (same as TCP). Identifies this DATA frame's position
in the byte stream.
- **ack_seq** (u32, big-endian): cumulative acknowledgment — the highest
contiguous `seq` that the sender of this frame has delivered to its local
TCP socket. The receiver uses this to advance its retransmit window.
- **payload** (`payload_len` bytes): raw application bytes (01480). A
`payload_len = 0` DATA frame is a **pure ACK** — it carries no data, just
an acknowledgment. This mirrors TCP's empty-segment ACK.
Maximum payload: 1500 (Ethernet MTU) 8 (common header) 8 (seq + ack_seq)
= **1484 bytes**. In practice we cap at **1480** to stay conservative.
## Frame types
| Type | Name | Direction | session_id | Payload |
|------|-------------|---------------|------------|----------------------------------|
| 0x01 | DISCOVER | C → broadcast | 0 | empty |
| 0x02 | MANIFEST | S → C | 0 | `id:1, proto:1, port:2` × N |
| 0x03 | OPEN | C → S | 0 | `upstream_id:1` |
| 0x04 | OPEN_ACK | S → C | assigned | `upstream_id:1` |
| 0x02 | MANIFEST | S → C | 0 | `hostname_len:1, hostname:N, entries...` |
| 0x03 | OPEN | C → S | 0 | `upstream_id:1, proto:1` |
| 0x04 | OPEN_ACK | S → C | assigned | `upstream_id:1, proto:1` |
| 0x05 | OPEN_NAK | S → C | 0 | `upstream_id:1, reason:1` |
| 0x06 | DATA | both | session | raw bytes (≤1480) |
| 0x06 | DATA | both | session | `seq:4, ack_seq:4, raw bytes` |
| 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):
Reserved (unimplemented; parse returns Err, encode unimplemented):
| Type | Name |
|------|-------------|
@@ -57,6 +81,91 @@ Reserved (unimplemented in v1; parse returns Err, encode unimplemented):
| 0x09 | UDP_DATA |
| 0x0A | UDP_CLOSE |
## L2 reliability
The reliability semantics of a session depend on its transport proto,
which is carried end-to-end through OPEN/OPEN_ACK:
- **TCP (`proto = 1`):** DATA frames are delivered reliably and in-order via
a TCP-like sequence + cumulative ACK + retransmit mechanism. This ensures
that a dropped Ethernet frame does not permanently corrupt a TCP session
(which would otherwise happen because the local kernel TCP stack ACKs data
before it is chunked into DATA frames).
- **UDP (`proto = 2`, reserved):** DATA frames are delivered best-effort. The
`seq` and `ack_seq` fields are present in the frame layout for uniformity
but are ignored — no retransmit, no in-order buffering, no pure ACKs. Drops
are tolerated because stateless protocols either don't care or handle
recovery at the application layer.
The following sections describe the TCP reliability mechanism.
### Sender state (per session)
- `send_seq`: next seq to assign (starts at 0, increments per DATA frame).
- `retransmit_buffer`: map of `seq → (payload, timestamp)`, holding all sent
but unacked frames.
- `acked_seq`: highest seq acknowledged by the peer (initially `None`).
On sending DATA:
1. Assign `seq = send_seq; send_seq += 1`.
2. Set `ack_seq` to the highest contiguous seq we have received from the peer
(our receive side's `deliver_seq`).
3. Store `(payload, now)` in `retransmit_buffer[seq]`.
4. Transmit the frame.
On receiving an `ack_seq` in any DATA frame (including pure ACKs):
1. Advance `acked_seq` to `max(acked_seq, ack_seq)`.
2. Remove all entries from `retransmit_buffer` with `seq <= ack_seq`.
Retransmit timer (per session, checked periodically):
1. For each entry in `retransmit_buffer` older than `RETRANSMIT_TIMEOUT`
(default 5 ms), retransmit the frame and reset its timestamp.
2. If any entry has been retransmitted more than `MAX_RETRIES` times (default
10), send `CLOSE` and tear down the session.
### Receiver state (per session)
- `expected_seq`: next seq expected (starts at 0).
- `receive_buffer`: map of `seq → payload`, holding out-of-order frames.
- `deliver_seq`: highest seq delivered to the local TCP socket (starts at
`None`; reported as `ack_seq` in outgoing DATA frames).
On receiving DATA with `seq`:
1. If `seq < expected_seq`: duplicate (already delivered). Discard the payload,
but still process the `ack_seq` field to advance the send window. Send a
pure ACK so the sender can converge.
2. If `seq == expected_seq`: deliver payload to the TCP socket. Increment
`expected_seq`. Then check `receive_buffer` for the next contiguous seq and
deliver those too (drain the buffer in order). Update `deliver_seq`.
3. If `seq > expected_seq`: store in `receive_buffer[seq]`. Do not deliver yet.
Send a pure ACK (re-ACKing `deliver_seq`) to trigger retransmit of the gap.
### Pure ACK frames
When a side needs to ACK but has no data to send, it sends a DATA frame with
`payload_len = 0`. The `seq` field is set to `send_seq` (consuming a seq
number, same as TCP's empty segment) and `ack_seq` carries the cumulative
acknowledgment. The receiver processes the `ack_seq` and discards the empty
payload without delivering to the TCP socket.
### Acknowledgment timing
- When data is flowing in both directions, each DATA frame carries the latest
`ack_seq` — no separate ACK frames needed.
- When data is one-directional, the receiver sends a pure ACK after each DATA
frame (or after a small batch, implementation-defined).
- On receiving a duplicate or out-of-order frame, the receiver immediately
sends a pure ACK to help the sender converge.
### Why this is not the Two Generals Problem
We do not need mutual consensus. We need one-sided reliable delivery: the
sender retransmits until it gets an ACK. If the ACK is lost, the sender
retransmits the data; the receiver sees a duplicate, discards it, and re-ACKs.
This converges in O(1) round trips. The only unsolvable case — the last frame
before a permanent link death — is handled by `MAX_RETRIES``CLOSE`, which is
correct: a dead link should kill the session.
## Payload field encodings
### MANIFEST payload
@@ -98,23 +207,28 @@ the payload is exhausted. The number of entries is not carried explicitly.
### OPEN payload
```
+---------------+
| upstream_id |
+---------------+
+---------------+---------------+
| upstream_id | proto |
+---------------+---------------+
```
- **upstream_id** (u8): which MANIFEST entry to open.
- **proto** (u8): the transport protocol of the upstream (`1 = TCP`,
`2 = UDP`). Carried end-to-end so both sides know which reliability
semantics apply to the session. Must match the proto advertised in the
MANIFEST for that upstream.
### OPEN_ACK payload
```
+---------------+
| upstream_id |
+---------------+
+---------------+---------------+
| upstream_id | proto |
+---------------+---------------+
```
- **upstream_id** (u8): echoes the requested upstream. The session is
identified by the `session_id` field in the header, not the payload.
- **upstream_id** (u8): echoes the requested upstream.
- **proto** (u8): echoes the requested proto. The session is identified by
the `session_id` field in the header, not the payload.
### OPEN_NAK payload
@@ -129,8 +243,10 @@ the payload is exhausted. The number of entries is not carried explicitly.
### DATA payload
Raw application bytes. Up to 1480 bytes per frame. The `session_id` header
field identifies which session the bytes belong to.
`[ seq:4 ][ ack_seq:4 ][ raw bytes ]` — the `seq` and `ack_seq` fields are
part of the DATA frame's extended header (between `payload_len` and the
payload). The `payload_len` field counts only the raw bytes, not the seq/ack
fields. Up to 1480 bytes of application data per frame.
### CLOSE payload
@@ -163,6 +279,7 @@ field identifies which session the bytes belong to.
| 2 | connect_failed |
| 3 | oversize |
| 4 | unknown_session |
| 5 | max_retries |
## Discovery flow
@@ -195,20 +312,58 @@ client server
| OPEN_NAK { reason } |
|<--------------------------------| (on failure)
| |
| DATA { session_id, bytes } |
|<------------------------------->| DATA { session_id, bytes }
| DATA { seq, ack_seq, bytes } |
|<------------------------------->| DATA { seq, ack_seq, bytes }
| |
| CLOSE { session_id, reason? } |
|<------------------------------->| (on EOF, RST, or error)
|<------------------------------->| (on EOF, RST, or max_retries)
| |
```
- `session_id` is allocated by the server as a monotonically increasing u32
(starting at 1) from an atomic counter. Collision by wraparound is ignored.
- `seq` starts at 0 on both sides of each session and increments per DATA
frame (including pure ACKs).
- Either side may send `CLOSE`. The side receiving `CLOSE` tears down its half
and stops emitting frames for that session.
- The server's socket→tunnel pump reads `TcpStream` in 1480-byte chunks and
emits one `DATA` frame per chunk. On `read` returning 0 (FIN) or an error,
emits one DATA frame per chunk. On `read` returning 0 (FIN) or an error,
it emits `CLOSE` and exits.
- The server's tunnel→socket path writes `DATA` payloads to the `TcpStream`
with `write_all`. On error it emits `CLOSE` and drops the session.
- The server's tunnel→socket path delivers DATA payloads in seq order to the
`TcpStream` with `write_all`. On error it emits `CLOSE` and drops the session.
- A DATA frame is retransmitted if no ACK is received within 5 ms. After 10
failed retransmits, the session is closed with `reason = max_retries`.
## Network test (PING/PONG)
```
client server
| |
| PING { nonce } |
|-------------------------------->|
| |
| PONG { nonce } |
|<--------------------------------|
| |
| (repeated at random intervals) |
| |
```
- The client sends `PING` frames at random 10100 ms intervals, each with a
unique `nonce`.
- The server echoes the nonce verbatim in a `PONG` frame.
- The client correlates `PONG` nonces with outstanding `PING` timestamps to
compute RTT, average latency, jitter (mean absolute delta of consecutive
RTTs), and drop rate (unanswered PINGs).
- PING/PONG frames use `session_id = 0`; they are independent of TCP sessions.
## Version compatibility
- Version 1: no `seq`/`ack_seq` in DATA frames, no L2 reliability. Dropped
DATA frames kill the TCP session.
- Version 2: DATA frames carry `seq` + `ack_seq`, L2 retransmit. Dropped
frames are recovered.
Version 2 is the current version. A receiver that sees `version != 2` rejects
the frame. Both sides of a session must speak the same version; there is no
negotiation.
+149 -26
View File
@@ -1,9 +1,16 @@
# 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.
**gata** (Spanish: *cat*) + **atún** (Spanish: *tuna*) — a cat fishing a
tunnel. The "tun" root is also the commonplace shorthand for a network tunnel
(as in `/dev/net/tun`, `wintun`, `tuntap`), so the name works in both languages:
a Spanish portmanteau that sounds like "gatuna" (feline), and an English pun on
"cat-tun" — a cat that tunnels.
## Attribution
Entirely vibe-coded with [GLM 5.2](https://github.com/zai-org/GLM-5.2). No
human-typed source code; all implementation was directed through natural
language and reviewed by the human in the loop.
## Why this works
@@ -16,33 +23,39 @@ 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 and
- **`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` (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.
- **`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 6-byte header + payload.
- **BPF:** the server attaches a classic BPF filter `ether proto 0x6969` to its
`AF_PACKET` socket so it only wakes on our ethertype. No eBPF authoring.
- **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`](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 unicasts `MANIFEST` back.
- 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: `OPEN``OPEN_ACK` (or `OPEN_NAK`) → `DATA`* ↔ `DATA`* → `CLOSE`.
- v1 ships TCP only. UDP frame types are reserved but unimplemented.
- Network test: `PING` (with 8-byte nonce) → `PONG` (nonce echoed).
- TCP only. UDP frame types are reserved but unimplemented.
## `gatunad` usage
@@ -62,26 +75,136 @@ 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)
```sh
cd gatunad
cargo build --release
```
The binary is at `gatunad/target/release/gatunad`.
### Build the client (Windows)
```powershell
cd gatuna-win
dotnet build -c Release
```
Or run directly:
```powershell
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
`AF_PACKET` requires `CAP_NET_RAW`. Run as root, or grant the binary the
capability once:
**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](https://npcap.com/guide/npcap-dev-guide-1.html)
for details on the `AdminOnly` flag if you want to change this.
## Logging
Errors only, to stdout. Normal lifecycle (DISCOVER/OPEN/CLOSE) is silent.
**Server:** errors only, to stdout. Normal lifecycle (DISCOVER/OPEN/CLOSE) is
silent.
## v1 limitations
**Client:** status line in the UI. Errors are not logged to disk.
## Limitations
- TCP only. UDP wire types reserved, code paths stubbed.
- No retransmit at the L2 layer. A dropped `DATA` frame 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 `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:
```sh
gatunad --version # Rust server
```
The Windows client shows its version in the title bar and tray tooltip.
## License
@@ -1,4 +1,4 @@
namespace gatuna_client;
namespace gatuna;
using System.Text;
@@ -6,8 +6,9 @@ static class Proto
{
public const ushort EtherType = 0x6969;
public const int EthHeaderLen = 14;
public const byte Version = 1;
public const byte Version = 2;
public const int HeaderLen = 8;
public const int DataHeaderLen = 16; // 8 common + 4 seq + 4 ack_seq
public const int MaxPayload = 1480;
public const byte TypeDiscover = 0x01;
@@ -28,6 +29,7 @@ static class Proto
public const byte ReasonConnectFailed = 2;
public const byte ReasonOversize = 3;
public const byte ReasonUnknownSession = 4;
public const byte ReasonMaxRetries = 5;
}
readonly record struct UpstreamEntry(
@@ -43,10 +45,10 @@ 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 Open(byte UpstreamId, byte Proto) : Frame;
internal record OpenAck(uint SessionId, byte UpstreamId, byte Proto) : Frame;
internal record OpenNak(byte UpstreamId, byte Reason) : Frame;
internal record Data(uint SessionId, byte[] Payload) : Frame;
internal record Data(uint SessionId, uint Seq, uint AckSeq, byte[] Payload) : Frame;
internal record Close(uint SessionId, byte? Reason) : Frame;
internal record Ping(ulong Nonce) : Frame;
internal record Pong(ulong Nonce) : Frame;
@@ -54,8 +56,7 @@ abstract record Frame
static class FrameCodec
{
/// Build the 8-byte header + payload. payload_len records the exact
/// payload length so the receiver can ignore Ethernet padding.
/// Build a non-DATA frame: 8-byte common header + payload.
static byte[] Build(byte type, uint sessionId, byte[] payload)
{
var buf = new byte[Proto.HeaderLen + payload.Length];
@@ -71,6 +72,31 @@ static class FrameCodec
return buf;
}
/// Build a DATA frame: 8-byte common header + seq + ack_seq + payload.
/// payload_len counts only the raw bytes, not seq/ack_seq.
static byte[] BuildData(uint sessionId, uint seq, uint ackSeq, byte[] payload)
{
var buf = new byte[Proto.DataHeaderLen + payload.Length];
buf[0] = Proto.Version;
buf[1] = Proto.TypeData;
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);
buf[8] = (byte)(seq >> 24);
buf[9] = (byte)(seq >> 16);
buf[10] = (byte)(seq >> 8);
buf[11] = (byte)(seq & 0xFF);
buf[12] = (byte)(ackSeq >> 24);
buf[13] = (byte)(ackSeq >> 16);
buf[14] = (byte)(ackSeq >> 8);
buf[15] = (byte)(ackSeq & 0xFF);
Buffer.BlockCopy(payload, 0, buf, Proto.DataHeaderLen, payload.Length);
return buf;
}
public static byte[] Encode(Frame frame)
{
return frame switch
@@ -80,13 +106,13 @@ static class FrameCodec
Frame.Manifest manifest =>
Build(Proto.TypeManifest, 0, BuildManifestPayload(manifest.Hostname, manifest.Entries)),
Frame.Open open =>
Build(Proto.TypeOpen, 0, [open.UpstreamId]),
Build(Proto.TypeOpen, 0, [open.UpstreamId, open.Proto]),
Frame.OpenAck ack =>
Build(Proto.TypeOpenAck, ack.SessionId, [ack.UpstreamId]),
Build(Proto.TypeOpenAck, ack.SessionId, [ack.UpstreamId, ack.Proto]),
Frame.OpenNak nak =>
Build(Proto.TypeOpenNak, 0, [nak.UpstreamId, nak.Reason]),
Frame.Data data =>
Build(Proto.TypeData, data.SessionId, data.Payload),
BuildData(data.SessionId, data.Seq, data.AckSeq, data.Payload),
Frame.Close close =>
Build(Proto.TypeClose, close.SessionId,
close.Reason.HasValue ? [close.Reason.Value] : []),
@@ -130,27 +156,38 @@ static class FrameCodec
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]);
// DATA frames have seq + ack_seq after the common header.
if (type == Proto.TypeData)
{
if (buf.Length < Proto.DataHeaderLen + payloadLen)
return null;
var seq = (uint)(buf[8] << 24 | buf[9] << 16 | buf[10] << 8 | buf[11]);
var ackSeq = (uint)(buf[12] << 24 | buf[13] << 16 | buf[14] << 8 | buf[15]);
var payload = buf.Slice(Proto.DataHeaderLen, payloadLen);
if (payload.Length > Proto.MaxPayload)
return null;
return new Frame.Data(sessionId, seq, ackSeq, payload.ToArray());
}
if (buf.Length < Proto.HeaderLen + payloadLen)
return null;
// Slice exactly payloadLen bytes, ignoring any trailing Ethernet padding.
var payload = buf.Slice(Proto.HeaderLen, payloadLen);
var payload2 = 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 =>
Proto.TypeManifest => ParseManifest(payload2),
Proto.TypeOpenAck when payload2.Length == 2 =>
new Frame.OpenAck(sessionId, payload2[0], payload2[1]),
Proto.TypeOpenNak when payload2.Length == 2 =>
new Frame.OpenNak(payload2[0], payload2[1]),
Proto.TypeClose when payload2.Length is 0 or 1 =>
new Frame.Close(sessionId, payload2.Length == 1 ? payload2[0] : null),
Proto.TypePong when payload2.Length == 8 =>
new Frame.Pong(ParseNonce(payload2)),
Proto.TypePing when payload2.Length == 8 =>
new Frame.Ping(ParseNonce(payload2)),
Proto.TypeDiscover when payload2.Length == 0 =>
new Frame.Discover(),
_ => null,
};
@@ -1,6 +1,6 @@
using SharpPcap.LibPcap;
namespace gatuna_client;
namespace gatuna;
public partial class MainForm : Form
{
@@ -17,9 +17,12 @@ public partial class MainForm : Form
public MainForm()
{
Text = "gatuna";
var ver = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version!;
Text = $"gatuna {ver.Major}.{ver.Minor}";
Width = 520;
Height = 420;
FormBorderStyle = FormBorderStyle.FixedSingle;
MaximizeBox = false;
StartPosition = FormStartPosition.CenterScreen;
Icon = SystemIcons.GetStockIcon(StockIconId.NetworkConnect, 32);
InitializeComponents();
@@ -1,6 +1,6 @@
using System.Collections.Concurrent;
namespace gatuna_client;
namespace gatuna;
sealed class PingTest
{
@@ -1,4 +1,4 @@
namespace gatuna_client;
namespace gatuna;
static class Program
{
@@ -9,10 +9,12 @@ static class Program
var form = new MainForm();
var ver = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version!;
using var tray = new NotifyIcon
{
Icon = SystemIcons.GetStockIcon(StockIconId.NetworkConnect, 32),
Text = "gatuna",
Text = $"gatuna {ver.Major}.{ver.Minor}",
Visible = true,
};
@@ -3,7 +3,7 @@ using System.Net;
using System.Net.Sockets;
using System.Threading.Channels;
namespace gatuna_client;
namespace gatuna;
sealed class SessionManager : IDisposable
{
@@ -85,14 +85,14 @@ sealed class SessionManager : IDisposable
lock (_openLock)
{
if (_pending != null)
_pending.Client.Dispose();
NetUtil.RstClose(_pending.Client);
}
ProcessQueue();
break;
case Frame.Data data:
if (_sessions.TryGetValue(data.SessionId, out var session))
session.Deliver(data.Payload);
session.HandleData(data);
else if (_link != null && _serverMac != null)
_link.SendTo(_serverMac,
new Frame.Close(data.SessionId, Proto.ReasonUnknownSession));
@@ -100,7 +100,7 @@ sealed class SessionManager : IDisposable
case Frame.Close close:
if (_sessions.TryRemove(close.SessionId, out var s))
s.Dispose();
s.OnRemoteClose();
break;
case Frame.Pong pong:
@@ -180,22 +180,22 @@ sealed class SessionManager : IDisposable
client = await state.Listener.AcceptTcpClientAsync();
}
catch { break; }
EnqueueOpen(client, state.Upstream.Id);
EnqueueOpen(client, state.Upstream.Id, state.Upstream.Protocol);
}
}
void EnqueueOpen(TcpClient client, byte upstreamId)
void EnqueueOpen(TcpClient client, byte upstreamId, byte proto)
{
lock (_openLock)
{
if (_pending == null)
{
_pending = new PendingOpen(client, upstreamId);
_pending = new PendingOpen(client, upstreamId, proto);
SendOpen(_pending);
}
else
{
_openQueue.Enqueue(new PendingOpen(client, upstreamId));
_openQueue.Enqueue(new PendingOpen(client, upstreamId, proto));
}
}
}
@@ -208,7 +208,7 @@ sealed class SessionManager : IDisposable
po.Client.Dispose();
return;
}
_link.SendTo(_serverMac, new Frame.Open(po.UpstreamId));
_link.SendTo(_serverMac, new Frame.Open(po.UpstreamId, po.Proto));
}
void ProcessQueue()
@@ -240,7 +240,7 @@ sealed class SessionManager : IDisposable
}
var session = new Session(
ack.SessionId, po.Client, srcMac, _link!,
ack.SessionId, ack.Proto, po.Client, srcMac, _link!,
() => _sessions.TryRemove(ack.SessionId, out _),
msg => Log?.Invoke(msg));
_sessions[ack.SessionId] = session;
@@ -273,14 +273,115 @@ sealed class ListenerState(TcpListener listener, UpstreamEntry upstream)
public UpstreamEntry Upstream { get; } = upstream;
}
sealed class PendingOpen(TcpClient client, byte upstreamId)
sealed class PendingOpen(TcpClient client, byte upstreamId, byte proto)
{
public TcpClient Client { get; } = client;
public byte UpstreamId { get; } = upstreamId;
public byte Proto { get; } = proto;
}
/// L2 reliability: sender-side state.
class SendState
{
public uint SendSeq;
public uint AckedSeq;
// seq -> (frame_bytes, send_time_ticks, retry_count)
public readonly SortedList<uint, (byte[], long, uint)> RetransmitBuffer = new();
public uint NextSeq()
{
var s = SendSeq;
SendSeq++;
return s;
}
public void RecordSent(uint seq, byte[] frameBytes)
{
RetransmitBuffer[seq] = (frameBytes, Environment.TickCount64, 0);
}
public void ProcessAck(uint ackSeq)
{
var toRemove = RetransmitBuffer.Keys
.Where(k => k <= ackSeq)
.ToList();
foreach (var k in toRemove)
RetransmitBuffer.Remove(k);
if (ackSeq > AckedSeq)
AckedSeq = ackSeq;
}
/// Returns frames to retransmit and whether the session should close.
public (List<byte[]> resend, bool shouldClose) CheckRetransmit()
{
var resend = new List<byte[]>();
var shouldClose = false;
var now = Environment.TickCount64;
const int timeoutMs = 5;
const uint maxRetries = 10;
foreach (var kv in RetransmitBuffer.ToList())
{
if (now - kv.Value.Item2 > timeoutMs)
{
if (kv.Value.Item3 >= maxRetries)
{
shouldClose = true;
break;
}
RetransmitBuffer[kv.Key] = (kv.Value.Item1, now, kv.Value.Item3 + 1);
resend.Add(kv.Value.Item1);
}
}
return (resend, shouldClose);
}
}
/// L2 reliability: receiver-side state.
class RecvState
{
public uint ExpectedSeq;
public uint DeliverSeq;
// seq -> payload (out-of-order buffer)
public readonly SortedList<uint, byte[]> ReceiveBuffer = new();
/// Process an incoming DATA frame. Returns (payloads to deliver, needAck).
public (List<byte[]> deliver, bool needAck) ProcessData(uint seq, byte[] payload)
{
if (seq < ExpectedSeq)
{
// Duplicate.
return ([], true);
}
if (seq == ExpectedSeq)
{
// In-order: deliver and drain buffer.
var deliver = new List<byte[]> { payload };
ExpectedSeq++;
DeliverSeq = ExpectedSeq - 1;
while (ReceiveBuffer.Remove(ExpectedSeq, out var buffered))
{
deliver.Add(buffered);
ExpectedSeq++;
DeliverSeq = ExpectedSeq - 1;
}
return (deliver, false);
}
else
{
// Out-of-order: buffer.
ReceiveBuffer[seq] = payload;
return ([], true);
}
}
/// The ack_seq to report in outgoing DATA frames.
public uint CurrentAckSeq => ExpectedSeq == 0 ? uint.MaxValue : ExpectedSeq - 1;
}
sealed class Session(
uint sessionId,
byte proto,
TcpClient client,
byte[] serverMac,
TunnelLink link,
@@ -288,35 +389,107 @@ sealed class Session(
Action<string>? log) : IDisposable
{
readonly CancellationTokenSource _cts = new();
readonly Channel<byte[]> _incoming = Channel.CreateBounded<byte[]>(256);
readonly bool _isTcp = proto == Proto.ProtoTcp;
readonly SendState _send = new();
readonly RecvState _recv = new();
readonly object _sendLock = new();
readonly object _recvLock = new();
volatile bool _closeSent;
public void Start()
{
_ = PumpSocketToTunnel();
_ = PumpTunnelToSocket();
if (_isTcp)
_ = RetransmitTimer();
}
public void Deliver(byte[] payload)
/// <summary>
/// Called when a CLOSE frame arrives from the server. The server has
/// already torn down its side — we just need to flush queued data to
/// the local socket and close gracefully. Do NOT echo CLOSE back.
/// </summary>
public void OnRemoteClose()
{
if (!_incoming.Writer.TryWrite(payload))
log?.Invoke($"session {sessionId}: incoming channel full");
_cts.Cancel();
_deliverChannel.Writer.TryComplete();
}
/// Handle a DATA frame from the tunnel.
public void HandleData(Frame.Data data)
{
if (!_isTcp)
{
// Best-effort: deliver directly, ignore seq/ack.
if (data.Payload.Length > 0)
_deliverChannel.Writer.TryWrite(data.Payload);
return;
}
// TCP: process ack_seq to advance send window.
lock (_sendLock)
_send.ProcessAck(data.AckSeq);
// Process seq for in-order delivery.
List<byte[]> deliver;
bool needAck;
lock (_recvLock)
(deliver, needAck) = _recv.ProcessData(data.Seq, data.Payload);
// Deliver to the TCP socket via the channel.
foreach (var chunk in deliver)
{
if (!_deliverChannel.Writer.TryWrite(chunk))
log?.Invoke($"session {sessionId}: deliver channel full");
}
// Send pure ACK if duplicate or out-of-order.
if (needAck)
SendPureAck();
}
readonly Channel<byte[]> _deliverChannel = Channel.CreateBounded<byte[]>(256);
async Task PumpSocketToTunnel()
{
var buf = new byte[Proto.MaxPayload];
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]));
uint seq = 0, ackSeq = 0;
byte[]? frameBytes = null;
if (_isTcp)
{
lock (_sendLock)
{
seq = _send.NextSeq();
lock (_recvLock)
ackSeq = _recv.CurrentAckSeq;
}
}
var frame = new Frame.Data(sessionId, seq, ackSeq, buf[..n]);
if (_isTcp)
{
frameBytes = FrameCodec.Encode(frame);
lock (_sendLock)
_send.RecordSent(seq, frameBytes);
}
link.SendTo(serverMac, frame);
}
}
catch { }
_cts.Cancel();
_deliverChannel.Writer.TryComplete();
SendClose();
onClosed();
}
@@ -326,20 +499,88 @@ sealed class Session(
try
{
var stream = client.GetStream();
await foreach (var payload in _incoming.Reader.ReadAllAsync(_cts.Token))
await stream.WriteAsync(payload, _cts.Token);
await foreach (var payload in _deliverChannel.Reader.ReadAllAsync())
await stream.WriteAsync(payload);
}
catch { }
finally { client.Close(); }
}
async Task RetransmitTimer()
{
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(1));
try
{
while (!_cts.IsCancellationRequested)
{
await timer.WaitForNextTickAsync(_cts.Token);
List<byte[]>? resend = null;
bool shouldClose = false;
lock (_sendLock)
(resend, shouldClose) = _send.CheckRetransmit();
if (resend != null)
{
foreach (var frameBytes in resend)
{
// Send raw bytes directly (already encoded).
link.SendRaw(serverMac, frameBytes);
}
}
if (shouldClose)
{
link.SendTo(serverMac, new Frame.Close(sessionId, Proto.ReasonMaxRetries));
onClosed();
break;
}
}
}
catch { }
}
void SendClose() => link.SendTo(serverMac, new Frame.Close(sessionId, null));
void SendPureAck()
{
if (!_isTcp) return;
uint seq, ackSeq;
lock (_sendLock)
{
seq = _send.NextSeq();
lock (_recvLock)
ackSeq = _recv.CurrentAckSeq;
}
// Pure ACK: empty payload. Not stored in retransmit buffer.
link.SendTo(serverMac, new Frame.Data(sessionId, seq, ackSeq, []));
}
void SendClose()
{
if (_closeSent) return;
_closeSent = true;
link.SendTo(serverMac, new Frame.Close(sessionId, null));
}
public void Dispose()
{
_cts.Cancel();
_incoming.Writer.TryComplete();
_deliverChannel.Writer.TryComplete();
SendClose();
try { client.Dispose(); } catch { }
NetUtil.RstClose(client);
_cts.Dispose();
}
}
/// Close a TcpClient with a TCP RST instead of a FIN.
static partial class NetUtil
{
public static void RstClose(TcpClient c)
{
try
{
c.LingerState = new LingerOption(true, 0);
c.Close();
}
catch { }
}
}
@@ -1,7 +1,7 @@
using SharpPcap;
using SharpPcap.LibPcap;
namespace gatuna_client;
namespace gatuna;
sealed class TunnelLink : IDisposable
{
@@ -72,7 +72,9 @@ sealed class TunnelLink : IDisposable
SendRaw(dstMac, FrameCodec.Encode(frame));
}
void SendRaw(byte[] dstMac, byte[] payload)
/// Send a pre-encoded protocol payload (for retransmit, where we already
/// have the exact bytes and want to avoid re-encoding).
public void SendRaw(byte[] dstMac, byte[] payload)
{
var frame = new byte[Proto.EthHeaderLen + payload.Length];
Buffer.BlockCopy(dstMac, 0, frame, 0, 6);
@@ -1,6 +1,6 @@
<?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" />
<assemblyIdentity version="2.1.0.0" name="gatuna" />
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
@@ -3,7 +3,9 @@
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<RootNamespace>gatuna_client</RootNamespace>
<AssemblyName>gatuna</AssemblyName>
<RootNamespace>gatuna</RootNamespace>
<Version>2.1.0</Version>
<Nullable>enable</Nullable>
<UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings>
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "gatuna"
version = "0.1.0"
version = "2.1.0"
edition = "2021"
license = "CC0-1.0"
@@ -10,7 +10,7 @@ path = "src/main.rs"
[dependencies]
libc = "0.2"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "sync", "io-util"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "sync", "io-util", "time"] }
clap = { version = "4", features = ["derive"] }
pnet_datalink = "0.35"
tracing = "0.1"
+57 -25
View File
@@ -1,9 +1,10 @@
//! gatuna wire protocol frame encode/decode.
//! gatuna wire protocol frame encode/decode (v2).
pub const ETHERTYPE: u16 = 0x6969;
pub const ETH_HEADER_LEN: usize = 14;
pub const VERSION: u8 = 1;
pub const VERSION: u8 = 2;
pub const HEADER_LEN: usize = 8;
pub const DATA_HEADER_LEN: usize = 16; // 8 common + 4 seq + 4 ack_seq
pub const MAX_PAYLOAD: usize = 1480;
pub const TYPE_DISCOVER: u8 = 0x01;
@@ -29,6 +30,7 @@ pub const REASON_CONNECT_FAILED: u8 = 2;
#[allow(dead_code)]
pub const REASON_OVERSIZE: u8 = 3;
pub const REASON_UNKNOWN_SESSION: u8 = 4;
pub const REASON_MAX_RETRIES: u8 = 5;
#[derive(Clone, Debug)]
pub struct UpstreamEntry {
@@ -42,10 +44,10 @@ pub struct UpstreamEntry {
pub enum Frame {
Discover,
Manifest { hostname: String, entries: Vec<UpstreamEntry> },
Open { upstream_id: u8 },
OpenAck { session_id: u32, upstream_id: u8 },
Open { upstream_id: u8, proto: u8 },
OpenAck { session_id: u32, upstream_id: u8, proto: u8 },
OpenNak { upstream_id: u8, reason: u8 },
Data { session_id: u32, payload: Vec<u8> },
Data { session_id: u32, seq: u32, ack_seq: u32, payload: Vec<u8> },
Close { session_id: u32, reason: Option<u8> },
Ping { nonce: u64 },
Pong { nonce: u64 },
@@ -81,8 +83,7 @@ fn encode_entry(buf: &mut Vec<u8>, e: &UpstreamEntry) {
buf.extend_from_slice(&label_bytes[..label_len as usize]);
}
/// Build the 8-byte header + payload. The payload_len field records the
/// exact payload length so the receiver can ignore Ethernet padding.
/// Build a non-DATA frame: 8-byte common header + payload.
fn build(type_byte: u8, session_id: u32, payload: Vec<u8>) -> Vec<u8> {
let len = payload.len() as u16;
let mut buf = Vec::with_capacity(HEADER_LEN + payload.len());
@@ -94,6 +95,21 @@ fn build(type_byte: u8, session_id: u32, payload: Vec<u8>) -> Vec<u8> {
buf
}
/// Build a DATA frame: 8-byte common header + seq + ack_seq + payload.
/// payload_len counts only the raw bytes, not seq/ack_seq.
fn build_data(session_id: u32, seq: u32, ack_seq: u32, payload: Vec<u8>) -> Vec<u8> {
let len = payload.len() as u16;
let mut buf = Vec::with_capacity(DATA_HEADER_LEN + payload.len());
buf.push(VERSION);
buf.push(TYPE_DATA);
buf.extend_from_slice(&session_id.to_be_bytes());
buf.extend_from_slice(&len.to_be_bytes());
buf.extend_from_slice(&seq.to_be_bytes());
buf.extend_from_slice(&ack_seq.to_be_bytes());
buf.extend_from_slice(&payload);
buf
}
impl Frame {
pub fn encode(&self) -> Vec<u8> {
match self {
@@ -109,14 +125,16 @@ impl Frame {
}
build(TYPE_MANIFEST, 0, payload)
}
Frame::Open { upstream_id } => build(TYPE_OPEN, 0, vec![*upstream_id]),
Frame::OpenAck { session_id, upstream_id } => {
build(TYPE_OPEN_ACK, *session_id, vec![*upstream_id])
Frame::Open { upstream_id, proto } => build(TYPE_OPEN, 0, vec![*upstream_id, *proto]),
Frame::OpenAck { session_id, upstream_id, proto } => {
build(TYPE_OPEN_ACK, *session_id, vec![*upstream_id, *proto])
}
Frame::OpenNak { upstream_id, reason } => {
build(TYPE_OPEN_NAK, 0, vec![*upstream_id, *reason])
}
Frame::Data { session_id, payload } => build(TYPE_DATA, *session_id, payload.clone()),
Frame::Data { session_id, seq, ack_seq, payload } => {
build_data(*session_id, *seq, *ack_seq, payload.clone())
}
Frame::Close { session_id, reason } => {
let p = match reason {
Some(r) => vec![*r],
@@ -140,11 +158,31 @@ impl Frame {
let typ = buf[1];
let session_id = u32::from_be_bytes([buf[2], buf[3], buf[4], buf[5]]);
let payload_len = u16::from_be_bytes([buf[6], buf[7]]) as usize;
// DATA frames have seq + ack_seq after the common header.
if typ == TYPE_DATA {
if buf.len() < DATA_HEADER_LEN + payload_len {
return Err(DecodeError::BadPayload("DATA: payload_len exceeds available data"));
}
let seq = u32::from_be_bytes([buf[8], buf[9], buf[10], buf[11]]);
let ack_seq = u32::from_be_bytes([buf[12], buf[13], buf[14], buf[15]]);
let payload = &buf[DATA_HEADER_LEN..DATA_HEADER_LEN + payload_len];
if payload.len() > MAX_PAYLOAD {
return Err(DecodeError::BadPayload("DATA payload exceeds max"));
}
return Ok(Frame::Data {
session_id,
seq,
ack_seq,
payload: payload.to_vec(),
});
}
if buf.len() < HEADER_LEN + payload_len {
return Err(DecodeError::BadPayload("payload_len exceeds available data"));
}
// Slice exactly payload_len bytes, ignoring any trailing Ethernet padding.
let payload = &buf[HEADER_LEN..HEADER_LEN + payload_len];
match typ {
TYPE_DISCOVER => {
if !payload.is_empty() {
@@ -187,16 +225,16 @@ impl Frame {
Ok(Frame::Manifest { hostname, entries })
}
TYPE_OPEN => {
if payload.len() != 1 {
return Err(DecodeError::BadPayload("OPEN payload must be 1 byte"));
if payload.len() != 2 {
return Err(DecodeError::BadPayload("OPEN payload must be 2 bytes"));
}
Ok(Frame::Open { upstream_id: payload[0] })
Ok(Frame::Open { upstream_id: payload[0], proto: payload[1] })
}
TYPE_OPEN_ACK => {
if payload.len() != 1 {
return Err(DecodeError::BadPayload("OPEN_ACK payload must be 1 byte"));
if payload.len() != 2 {
return Err(DecodeError::BadPayload("OPEN_ACK payload must be 2 bytes"));
}
Ok(Frame::OpenAck { session_id, upstream_id: payload[0] })
Ok(Frame::OpenAck { session_id, upstream_id: payload[0], proto: payload[1] })
}
TYPE_OPEN_NAK => {
if payload.len() != 2 {
@@ -204,12 +242,6 @@ impl Frame {
}
Ok(Frame::OpenNak { upstream_id: payload[0], reason: payload[1] })
}
TYPE_DATA => {
if payload.len() > MAX_PAYLOAD {
return Err(DecodeError::BadPayload("DATA payload exceeds max"));
}
Ok(Frame::Data { session_id, payload: payload.to_vec() })
}
TYPE_CLOSE => {
let reason = match payload.len() {
0 => None,
@@ -233,7 +265,7 @@ impl Frame {
Ok(Frame::Pong { nonce })
}
TYPE_UDP_OPEN | TYPE_UDP_DATA | TYPE_UDP_CLOSE => {
Err(DecodeError::BadPayload("UDP frame types not implemented in v1"))
Err(DecodeError::BadPayload("UDP frame types not implemented"))
}
other => Err(DecodeError::UnknownType(other)),
}
+10
View File
@@ -12,6 +12,16 @@ use crate::frame::{ETHERTYPE, ETH_HEADER_LEN};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct MacAddr(pub [u8; 6]);
impl std::fmt::Display for MacAddr {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}",
self.0[0], self.0[1], self.0[2], self.0[3], self.0[4], self.0[5]
)
}
}
impl MacAddr {
#[allow(dead_code)]
pub fn broadcast() -> Self {
+37 -16
View File
@@ -16,13 +16,13 @@ use std::sync::{Arc, Mutex};
use tokio::net::TcpStream;
use tokio::sync::mpsc;
use tokio::sync::Mutex as AsyncMutex;
use tracing::{error, Level};
use tracing::{error, info, Level};
use crate::frame::{
Frame, REASON_CONNECT_FAILED, REASON_UNKNOWN_SESSION, REASON_UNKNOWN_UPSTREAM,
};
use crate::link::Link;
use crate::session::{spawn_pump, write_to_session, SessionHandle, SessionStore};
use crate::session::{spawn_pump, handle_data, send_pure_ack, SessionHandle, SessionStore};
use crate::upstream::build_table;
#[derive(Parser)]
@@ -33,17 +33,21 @@ struct Args {
/// One or more TCP upstreams as PORT[:label], relayed to 127.0.0.1:PORT.
#[arg(num_args = 1..)]
ports: Vec<String>,
/// Verbose logging (lifecycle events to stdout).
#[arg(short, long)]
verbose: bool,
}
#[tokio::main]
async fn main() -> ExitCode {
let args = Args::parse();
let level = if args.verbose { Level::INFO } else { Level::ERROR };
tracing_subscriber::fmt()
.with_max_level(Level::ERROR)
.with_max_level(level)
.with_writer(|| std::io::stdout())
.init();
let args = Args::parse();
let table = match build_table(&args.ports) {
Ok(t) => Arc::new(t),
Err(e) => {
@@ -110,7 +114,7 @@ async fn main() -> ExitCode {
let frame = match Frame::parse(payload) {
Ok(f) => f,
Err(e) => {
error!("decode from {src:?}: {e}");
error!("decode from {src}: {e}");
continue;
}
};
@@ -141,16 +145,21 @@ async fn handle_frame(
) {
match frame {
Frame::Discover => {
info!("DISCOVER from {src}");
let manifest = Frame::Manifest {
hostname: (**hostname).clone(),
entries: table.entries(),
};
let _ = tx.send((src, manifest.encode())).await;
info!("MANIFEST sent to {src} ({} upstreams)", table.0.len());
}
Frame::Open { upstream_id } => {
let port = table.get(upstream_id).map(|u| u.port);
match port {
Some(port) => {
Frame::Open { upstream_id, proto: _ } => {
info!("OPEN upstream {upstream_id} from {src}");
let upstream = table.get(upstream_id);
match upstream {
Some(upstream) => {
let port = upstream.port;
let proto = upstream.proto.as_u8();
// Spawn so connect() doesn't block the rx loop.
let tx = tx.clone();
let store = Arc::clone(store);
@@ -165,15 +174,21 @@ async fn handle_frame(
sid,
SessionHandle {
upstream_id,
proto,
write: w,
send_state: Arc::new(Mutex::new(session::SendState::new())),
recv_state: Arc::new(Mutex::new(session::RecvState::new())),
peer_mac: src,
},
);
let ack = Frame::OpenAck { session_id: sid, upstream_id };
let ack = Frame::OpenAck { session_id: sid, upstream_id, proto };
let _ = tx.send((src, ack.encode())).await;
spawn_pump(r, sid, src, tx, store);
info!("session {sid} established (upstream {upstream_id})");
spawn_pump(r, sid, proto, src, tx, store);
}
Err(e) => {
error!("connect 127.0.0.1:{port} failed: {e}");
info!("OPEN_NAK upstream {upstream_id} (connect_failed) to {src}");
let nak =
Frame::OpenNak { upstream_id, reason: REASON_CONNECT_FAILED };
let _ = tx.send((src, nak.encode())).await;
@@ -187,9 +202,13 @@ async fn handle_frame(
}
}
}
Frame::Data { session_id, payload } => {
match write_to_session(store, session_id, &payload).await {
Ok(()) => {}
Frame::Data { session_id, seq, ack_seq, payload } => {
match handle_data(store, session_id, seq, ack_seq, &payload, tx).await {
Ok(need_ack) => {
if need_ack {
send_pure_ack(store, session_id, tx);
}
}
Err(session::WriteError::UnknownSession) => {
let close = Frame::Close {
session_id,
@@ -205,7 +224,9 @@ async fn handle_frame(
}
}
Frame::Close { session_id, reason: _ } => {
store.lock().expect("store poisoned").remove(&session_id);
if store.lock().expect("store poisoned").remove(&session_id).is_some() {
info!("CLOSE session {session_id} from {src}");
}
}
Frame::Ping { nonce } => {
let pong = Frame::Pong { nonce };
+291 -32
View File
@@ -1,20 +1,142 @@
//! Per-session state and the localhost→tunnel pump.
//! Per-session state: L2 reliability layer + localhost→tunnel pump.
use crate::frame::{Frame, MAX_PAYLOAD};
use crate::frame::{Frame, MAX_PAYLOAD, REASON_MAX_RETRIES};
use crate::link::MacAddr;
use std::collections::HashMap;
use std::collections::{HashMap, BTreeMap};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::tcp::OwnedReadHalf;
use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf};
use tokio::sync::mpsc::Sender;
use tokio::sync::Mutex as AsyncMutex;
pub type TxChan = Sender<(MacAddr, Vec<u8>)>;
const RETRANSMIT_TIMEOUT: Duration = Duration::from_millis(5);
const MAX_RETRIES: u32 = 10;
const RETRANSMIT_TICK: Duration = Duration::from_millis(1);
/// Sender-side reliability state (per session).
pub struct SendState {
send_seq: u32,
acked_seq: u32,
/// seq -> (frame_bytes, send_time, retry_count)
retransmit_buffer: BTreeMap<u32, (Vec<u8>, Instant, u32)>,
}
/// Receiver-side reliability state (per session).
pub struct RecvState {
expected_seq: u32,
deliver_seq: u32,
/// seq -> payload (out-of-order buffer)
receive_buffer: BTreeMap<u32, Vec<u8>>,
}
impl SendState {
pub fn new() -> Self {
SendState {
send_seq: 0,
acked_seq: 0,
retransmit_buffer: BTreeMap::new(),
}
}
/// Record a sent DATA frame in the retransmit buffer.
fn record_sent(&mut self, seq: u32, frame_bytes: Vec<u8>) {
self.retransmit_buffer
.insert(seq, (frame_bytes, Instant::now(), 0));
}
/// Process an incoming ack_seq: advance window, remove acked frames.
fn process_ack(&mut self, ack_seq: u32) {
self.retransmit_buffer.retain(|&seq, _| seq > ack_seq);
if ack_seq > self.acked_seq || self.retransmit_buffer.is_empty() {
self.acked_seq = ack_seq.max(self.acked_seq);
}
}
/// Check for frames needing retransmit. Returns frames to resend and
/// whether the session should be closed (max retries exceeded).
fn check_retransmit(&mut self) -> (Vec<Vec<u8>>, bool) {
let mut resend = Vec::new();
let mut should_close = false;
let now = Instant::now();
for (_seq, (frame_bytes, send_time, retries)) in self.retransmit_buffer.iter_mut() {
if now.duration_since(*send_time) > RETRANSMIT_TIMEOUT {
if *retries >= MAX_RETRIES {
should_close = true;
break;
}
*retries += 1;
*send_time = now;
resend.push(frame_bytes.clone());
}
}
(resend, should_close)
}
fn next_seq(&mut self) -> u32 {
let s = self.send_seq;
self.send_seq = self.send_seq.wrapping_add(1);
s
}
}
impl RecvState {
pub fn new() -> Self {
RecvState {
expected_seq: 0,
deliver_seq: 0,
receive_buffer: BTreeMap::new(),
}
}
/// Process an incoming DATA frame's seq. Returns:
/// - `Ok((payloads, need_ack))` — payloads to deliver (may be empty if
/// out-of-order), need_ack is true if the frame was duplicate or
/// out-of-order and the caller should send a pure ACK.
/// - `Err(())` — should not happen with current callers.
fn process_data(&mut self, seq: u32, payload: Vec<u8>) -> (Vec<Vec<u8>>, bool) {
if seq < self.expected_seq {
// Duplicate — already delivered.
return (vec![], true);
}
if seq == self.expected_seq {
// In-order: deliver immediately, then drain the receive buffer.
let mut deliver = vec![payload];
self.expected_seq = self.expected_seq.wrapping_add(1);
self.deliver_seq = self.expected_seq.wrapping_sub(1);
// Drain contiguous buffered frames.
while let Some(payload) = self.receive_buffer.remove(&self.expected_seq) {
self.expected_seq = self.expected_seq.wrapping_add(1);
self.deliver_seq = self.expected_seq.wrapping_sub(1);
deliver.push(payload);
}
(deliver, false)
} else {
// Out-of-order: buffer it.
self.receive_buffer.insert(seq, payload);
(vec![], true)
}
}
/// The ack_seq to report in outgoing DATA frames (highest contiguous
/// delivered seq). If nothing delivered yet, report expected_seq - 1
/// (wrapping), which is the last seq we can cumulatively ACK.
fn current_ack_seq(&self) -> u32 {
self.expected_seq.wrapping_sub(1)
}
}
#[derive(Clone)]
#[allow(dead_code)]
pub struct SessionHandle {
pub upstream_id: u8,
pub write: Arc<AsyncMutex<tokio::net::tcp::OwnedWriteHalf>>,
pub proto: u8,
pub write: Arc<AsyncMutex<OwnedWriteHalf>>,
pub send_state: Arc<Mutex<SendState>>,
pub recv_state: Arc<Mutex<RecvState>>,
pub peer_mac: MacAddr,
}
pub type SessionStore = Arc<Mutex<HashMap<u32, SessionHandle>>>;
@@ -25,54 +147,191 @@ pub enum WriteError {
Io,
}
pub async fn write_to_session(
/// Handle a DATA frame received from the tunnel. For TCP sessions, delivers
/// in-order via the reliability layer. For stateless protos, delivers
/// best-effort. Returns whether a pure ACK should be sent back.
pub async fn handle_data(
store: &SessionStore,
id: u32,
session_id: u32,
seq: u32,
ack_seq: u32,
payload: &[u8],
) -> Result<(), WriteError> {
let write = {
_tx: &TxChan,
) -> Result<bool, WriteError> {
let handle = {
let store = store.lock().expect("store lock poisoned");
store.get(&id).map(|h| h.write.clone())
store.get(&session_id).cloned()
};
match write {
Some(w) => {
let mut w = w.lock().await;
w.write_all(payload).await.map_err(|_| WriteError::Io)?;
Ok(())
let Some(handle) = handle else {
return Err(WriteError::UnknownSession);
};
// Non-TCP protos: best-effort delivery, no reliability machinery.
if handle.proto != crate::frame::PROTO_TCP {
if !payload.is_empty() {
let mut w = handle.write.lock().await;
if w.write_all(payload).await.is_err() {
return Err(WriteError::Io);
}
None => Err(WriteError::UnknownSession),
}
return Ok(false);
}
// TCP: full reliability — process ack_seq to advance send window.
{
let mut ss = handle.send_state.lock().expect("send_state poisoned");
ss.process_ack(ack_seq);
}
// Process seq for in-order delivery.
let need_ack;
{
let mut rs = handle.recv_state.lock().expect("recv_state poisoned");
let (deliver, ack) = rs.process_data(seq, payload.to_vec());
if !deliver.is_empty() {
let mut w = handle.write.lock().await;
for chunk in deliver {
if w.write_all(&chunk).await.is_err() {
return Err(WriteError::Io);
}
}
}
need_ack = ack;
}
Ok(need_ack)
}
/// Send a pure ACK (DATA frame with empty payload) for the given session.
pub fn send_pure_ack(
store: &SessionStore,
session_id: u32,
tx: &TxChan,
) {
let handle = {
let store = store.lock().expect("store lock poisoned");
store.get(&session_id).cloned()
};
let Some(handle) = handle else { return };
// Pure ACKs are only meaningful for reliable (TCP) sessions.
if handle.proto != crate::frame::PROTO_TCP {
return;
}
let (seq, ack_seq) = {
let mut ss = handle.send_state.lock().expect("send_state poisoned");
let rs = handle.recv_state.lock().expect("recv_state poisoned");
let seq = ss.next_seq();
(seq, rs.current_ack_seq())
};
// Pure ACKs are not stored in the retransmit buffer (no payload to lose).
let frame = Frame::Data {
session_id,
seq,
ack_seq,
payload: Vec::new(),
};
let _ = tx.try_send((handle.peer_mac, frame.encode()));
}
/// Spawn the socket→tunnel pump: reads from the localhost TCP stream in
/// 1480-byte chunks and emits DATA frames. On EOF/error sends CLOSE and
/// removes the session from the store.
/// 1480-byte chunks, tags each with seq, and emits DATA frames. For TCP
/// sessions, also stores in retransmit buffer and spawns the retransmit
/// timer. For stateless protos, best-effort (no retransmit).
pub fn spawn_pump(
mut read: OwnedReadHalf,
session_id: u32,
proto: u8,
peer_mac: MacAddr,
tx: TxChan,
store: SessionStore,
) {
let is_tcp = proto == crate::frame::PROTO_TCP;
// Retransmit timer task (TCP only).
if is_tcp {
let store = Arc::clone(&store);
let tx = tx.clone();
tokio::spawn(async move {
let mut buf = vec![0u8; MAX_PAYLOAD];
let mut interval = tokio::time::interval(RETRANSMIT_TICK);
loop {
match read.read(&mut buf).await {
Ok(0) => break,
Ok(n) => {
let frame = Frame::Data {
session_id,
payload: buf[..n].to_vec(),
interval.tick().await;
let handle = {
let store = store.lock().expect("store poisoned");
store.get(&session_id).cloned()
};
if tx.send((peer_mac, frame.encode())).await.is_err() {
let Some(handle) = handle else { break };
let (resend, should_close) = {
let mut ss = handle.send_state.lock().expect("send_state poisoned");
ss.check_retransmit()
};
for frame_bytes in resend {
let _ = tx.try_send((peer_mac, frame_bytes));
}
if should_close {
store.lock().expect("store poisoned").remove(&session_id);
let close = Frame::Close {
session_id,
reason: Some(REASON_MAX_RETRIES),
};
let _ = tx.try_send((peer_mac, close.encode()));
break;
}
}
Err(_) => break,
}
}
let close = Frame::Close { session_id, reason: None };
let _ = tx.send((peer_mac, close.encode())).await;
store.lock().expect("store poisoned").remove(&session_id);
});
}
// Socket read pump.
tokio::spawn(async move {
let mut buf = vec![0u8; MAX_PAYLOAD];
loop {
let n = match read.read(&mut buf).await {
Ok(0) => break,
Ok(n) => n,
Err(_) => break,
};
let handle = {
let store = store.lock().expect("store poisoned");
store.get(&session_id).cloned()
};
let Some(handle) = handle else { break };
let (seq, ack_seq) = if is_tcp {
let mut ss = handle.send_state.lock().expect("send_state poisoned");
let rs = handle.recv_state.lock().expect("recv_state poisoned");
let seq = ss.next_seq();
(seq, rs.current_ack_seq())
} else {
(0, 0)
};
let frame = Frame::Data {
session_id,
seq,
ack_seq,
payload: buf[..n].to_vec(),
};
let frame_bytes = frame.encode();
// Store in retransmit buffer before sending (TCP only).
if is_tcp {
let mut ss = handle.send_state.lock().expect("send_state poisoned");
ss.record_sent(seq, frame_bytes.clone());
}
if tx.send((peer_mac, frame_bytes)).await.is_err() {
break;
}
}
store.lock().expect("store poisoned").remove(&session_id);
let close = Frame::Close { session_id, reason: None };
let _ = tx.send((peer_mac, close.encode())).await;
});
}
+1 -1
View File
@@ -1,6 +1,6 @@
//! Upstream table: cmdline parsing and MANIFEST entry construction.
use crate::frame::{Frame, UpstreamEntry, PROTO_TCP, PROTO_UDP};
use crate::frame::{UpstreamEntry, PROTO_TCP, PROTO_UDP};
/// Read the system hostname via `gethostname(2)`.
pub fn get_hostname() -> String {