Skip to content

feat: native udpgw without QUIC/DNS - QUIC/DNS with udp associate — stable VoIP, faster browsing - needs new tunnel deployment for udpgw#222

Open
yyoyoian-pixel wants to merge 5 commits intotherealaleph:mainfrom
yyoyoian-pixel:feat/udpgw-v3
Open

feat: native udpgw without QUIC/DNS - QUIC/DNS with udp associate — stable VoIP, faster browsing - needs new tunnel deployment for udpgw#222
yyoyoian-pixel wants to merge 5 commits intotherealaleph:mainfrom
yyoyoian-pixel:feat/udpgw-v3

Conversation

@yyoyoian-pixel
Copy link
Copy Markdown

@yyoyoian-pixel yyoyoian-pixel commented Apr 25, 2026

What this does

Adds native udpgw wire protocol to the tunnel-node and wires it through tun2proxy on Android. Blocks QUIC (UDP 443) and DNS (UDP 53) from the udpgw path so browsers use TCP/HTTP2 (faster through the batch pipeline) and DNS uses tun2proxy's virtual DNS (more reliable).

Result: VoIP calls (Telegram, Meet) work through udpgw. Browsing stays fast on TCP.

Needs new tunnel deployment for udpgw.

Why udpgw is needed even with UDP associate

UDP associate creates one tunnel session per UDP destination and polls each independently. On slow or shaky networks:

  • N simultaneous flows = N polling loops = N× batch overhead
  • Google Meet (dozens of concurrent STUN + RTP flows) stalls under per-destination polling
  • Each session creates a fresh UDP socket — source ports aren't stable, breaking VoIP protocols that expect replies on the same port

udpgw tunnels all UDP over one persistent TCP connection. TCP handles retransmission and congestion — the connection stays alive even when the underlying network drops packets. Persistent sockets per (conn_id, dest) with continuous reader tasks keep source ports stable.

Why block QUIC and DNS in udpgw

  • QUIC (UDP 443): through udpgw → Apps Script → tunnel-node is slower than TCP/HTTP2 through the batch pipeline. Blocking forces Chrome to fall back to TCP — YouTube loads significantly faster.
  • DNS (UDP 53): tun2proxy's virtual DNS / SOCKS5 path handles single request-response exchanges more reliably than the udpgw round-trip.

Changes

tunnel-node/src/udpgw.rs (new, ~500 lines)

  • tun2proxy-compatible wire format parser/serializer
  • Persistent UDP sockets per (conn_id, dest_addr) with continuous reader tasks
  • Virtual session via tokio::io::duplex() — no extra port needed
  • QUIC (443) and DNS (53) blocked — returns error frame, client falls back
  • 7 unit tests

tunnel-node/src/main.rs

  • SessionWriter enum (TCP or Duplex), generic reader_task
  • create_udpgw_session() for in-process virtual session
  • Magic address 198.18.0.1:7300 intercepted in connect handlers

vendor/tun2proxy (git submodule)

Android

  • Tun2proxy.kt: udpgwServer: String parameter
  • MhrvVpnService.kt: passes 198.18.0.1:7300 in full mode

Cloud Run note: Cloud Run drops UDP responses. Deploy tunnel-node on a VPS for udpgw to work.

Test plan

  • 30 tunnel-node tests pass (7 new udpgw + 23 existing)
  • Desktop + Android builds pass
  • Google Meet video calls work through udpgw on GCE VPS (us-central1)
  • Telegram calls work
  • YouTube loads fast (QUIC blocked, falls back to TCP)
  • DNS resolves via virtual DNS path

🤖 Generated with Claude Code

yyoyoian-pixel and others added 2 commits April 25, 2026 19:58
Why udpgw is needed even with UDP associate:

UDP associate (udp_open/udp_data) creates one tunnel session per UDP
destination and polls each independently. On high-latency or shaky
networks this compounds — N simultaneous UDP flows need N separate
polling loops, each paying its own batch round-trip overhead. Google
Meet calls, which fire dozens of concurrent STUN + RTP flows, stall
or fail entirely because the per-destination polling can't keep up.

udpgw multiplexes ALL UDP over one persistent TCP-like session using
conn_id framing. One batch op carries frames for many destinations.
Persistent sockets per (conn_id, dest) with continuous reader tasks
keep source ports stable — critical for protocols like Telegram VoIP
and STUN that expect replies on the same port.

Both paths coexist — they serve different traffic:
  - UDP associate (SOCKS5): apps that negotiate SOCKS5 UDP relay
  - udpgw (198.18.0.1:7300): TUN-captured UDP (DNS, QUIC, Meet, etc.)

tun2proxy vendored as git submodule at v0.7.20 with one transparent
commit adding udpgw_server to the Android JNI run() function.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
QUIC through udpgw is slower than TCP/HTTP2 through the batch pipeline
— blocking it forces browsers to fall back to TCP, improving YouTube
and general browsing speed.

DNS is better handled by tun2proxy's virtual DNS / SOCKS5 UDP associate
path which is more reliable for single request-response exchanges.

VoIP (Telegram, Meet) still flows through udpgw normally.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@yyoyoian-pixel yyoyoian-pixel changed the title feat: native udpgw + QUIC/DNS blocking — stable VoIP, faster browsing feat: native udpgw without QUIC/DNS - QUIC/DNS with udp associate — stable VoIP, faster browsing Apr 25, 2026
@yyoyoian-pixel yyoyoian-pixel changed the title feat: native udpgw without QUIC/DNS - QUIC/DNS with udp associate — stable VoIP, faster browsing feat: native udpgw without QUIC/DNS - QUIC/DNS with udp associate — stable VoIP, faster browsing - needs new tunnel deployment for udpgw Apr 25, 2026
@therealaleph
Copy link
Copy Markdown
Owner

Hi @yyoyoian-pixel — the udpgw work itself is reasonable (the UDP-associate-vs-udpgw analysis is exactly right; per-destination polling does fall over under Meet-style flow counts). The integration shape is what blocks merge. Concrete issues, in priority order:

1. (blocker) The submodule SHA can't be fetched from the URL declared in .gitmodules.

.gitmodules says url = https://github.com/tun2proxy/tun2proxy.git. The pinned SHA is 68c2cd6e0446545bd19038ee82757680a4ffb001. That SHA returns 422 on:

  • canonical tun2proxy/tun2proxy
  • your own yyoyoian-pixel/tun2proxy fork (currently)
  • GitHub-wide commit search (0 results)

So git submodule update --init fails for any fresh clone — the remote rejects the fetch because the object isn't there. cargoBuildDebug in android/app/build.gradle.kts then can't compile the workspace, and the Android APK can't be built. The PR works on your machine because your local .git/objects/ already has the blob; it doesn't work on CI, on mine, or on any other contributor's machine. That's a build-correctness fault before anything else.

Could you push the actual commit to yyoyoian-pixel/tun2proxy (or any public fork) and update .gitmodules to point at that fork's URL? Right now the URL and the SHA disagree on where the bytes live.

2. (blocker) [patch.crates-io] is the right tool for this, not a submodule.

The stated reason for vendoring is needing udpgw_server: String in the JNI signature ahead of upstream 0.8. The idiomatic Rust solution is:

# Cargo.toml
[patch.crates-io]
tun2proxy = { git = \"https://github.com/<your-fork>/tun2proxy\", branch = \"udpgw-jni-arg\" }

This pins via Cargo.lock to a real, fetchable, content-addressed SHA on a fork we can audit. With the submodule approach, after this lands, whatever bytes happen to live at vendor/tun2proxy/ determine what compiles into the APK — the diff drops both the crates.io registry source and the checksum from Cargo.lock (the change is visible in the diff: removal of source = \"registry+...\" and the checksum = \"...\" lines for tun2proxy 0.7.20). With [patch.crates-io], Cargo.lock keeps a real revision pin. Same end result for you (custom JNI signature now, swap to upstream 0.8 + feature flag later), much smaller surface area for build-reproducibility regressions.

If you'd like, I can take the udpgw branch you've been working off of, push it to a fork under therealaleph/tun2proxy (so we have ownership of the patch lifecycle independent of any one contributor), and you switch the PR to use that as the [patch.crates-io] git source.

3. (bug) Blocking UDP/53 in tunnel-node/src/udpgw.rs breaks DNS in desktop Full mode.

The rationale ("tun2proxy's virtual DNS handles it more reliably") is correct on Android, where tun2proxy runs in-process and provides a virtual DNS responder on the TUN interface. On desktop Full mode, UDP/53 reaches udpgw via SOCKS5 UDP ASSOCIATE — there's no tun2proxy in that path, no virtual DNS. So blocking UDP/53 in udpgw means desktop Full-mode users lose DNS entirely. The block needs to be Android-conditional, either by gating in the udpgw client (Android-side passes a flag) or by leaving DNS pass-through and relying on the Android tun2proxy DNS strategy to intercept it before it ever reaches udpgw on the wire.

4. (note, not blocker) The JNI signature change creates fork lock-in until upstream 0.8 lands.

Tun2proxy.kt adds udpgwServer: String to the extern fun run() declaration — the symbol now has to match the patched .so. Once this is on main, we're committed to the patched tun2proxy until canonical upstream's PR #247 lands and ships in a 0.8 release. That's fine if it's an explicit decision; just want to flag that the JNI side is the lock-in point. Worth a comment in Tun2proxy.kt noting this so the next person to touch it knows.

**5. (small) The 198.18.0.1:7300 magic address in MhrvVpnService.kt should be a const at the top of the file (or in Native.kt) rather than a string literal mid-call site, with a comment explaining the RFC2544 reservation. Easy cleanup.


Net: the udpgw rationale is sound, the wire-protocol code in udpgw.rs looks reasonable, and the test coverage is decent. The PR can land once (1)+(2) are restructured (real fetchable submodule URL or [patch.crates-io] — I'd prefer the latter) and (3) is gated to the Android path. Happy to keep reviewing once those are in.


[reply via Anthropic Claude | reviewed by @therealaleph]

Use the idiomatic Rust [patch.crates-io] mechanism instead of a git
submodule. Points to yyoyoian-pixel/tun2proxy fork with the udpgw
JNI parameter patch (upstream PR: tun2proxy/tun2proxy#247).

Will be removed once upstream ships the change in tun2proxy >= 0.8.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@yyoyoian-pixel
Copy link
Copy Markdown
Author

@therealaleph regarding dns bug on desktop:

  1. Desktop full mode doesn't use udpgw at all — no code path connects to 198.18.0.1:7300
  2. Android DNS uses tun2proxy's virtual DNS mode (dnsStrategy=virtual), which resolves through SOCKS5, not udpgw
  3. The port 53 block is a belt-and-suspenders guard for any DNS that somehow leaks to udpgw — it falls back cleanly
  • DNS goes through the system resolver or SOCKS5 domain resolution — both are TCP paths
  • The udpgw handler (and its port 53 block) only runs inside a 198.18.0.1:7300 virtual session
  • Nothing on desktop ever opens that session
  • So the block literally never executes on desktop

I changed the submodule to use the patch in cargo, once the PR is merged in tun2proxy i clean this up.

@yyoyoian-pixel
Copy link
Copy Markdown
Author

@therealaleph

If you'd like, I can take the udpgw branch you've been working off of, push it to a fork under therealaleph/tun2proxy (so we have ownership of the patch lifecycle independent of any one contributor), and you switch the PR to use that as the [patch.crates-io] git source.

also fine with this, we can clean that up in the next PR.

@therealaleph
Copy link
Copy Markdown
Owner

Thanks for the quick rework — that's the right shape.

Verified:

  • ✅ Submodule replaced with [patch.crates-io] tun2proxy = { git = "https://github.com/yyoyoian-pixel/tun2proxy", branch = "feat/udpgw-jni-param" } in Cargo.toml
  • udpgw feature flag enabled on the Android dep
  • ✅ Fork branch resolves cleanly: tip dfc24ed12cdee69987bdd321ea55c6b940f2d0f0, single commit, 16 LOC added to src/android.rs — matches the JNI signature change as claimed
  • ✅ Conceded my point 3 — re-reading handle_connect, udpgw::udpgw_server_task only spawns when is_udpgw_dest(host, port) is true (i.e. magic 198.18.0.1:7300), which only Android's tun2proxy ever connects to. Desktop Full mode never enters that branch, so the UDP/53 block is correctly never reached on desktop. My DNS regression concern was incorrect.

Two small asks before merge:

  1. Pin the patch in Cargo.lock. The PR doesn't update Cargo.lock for the patch resolution, so the first cargo build after merge resolves the branch tip at that moment, locks it, and commits implicitly. That's fine for correctness, but if you run cargo update -p tun2proxy locally on this branch and add the resulting Cargo.lock change to the PR, we get a recorded SHA that other contributors / CI verify against. Otherwise the lockfile pin happens silently in whoever's environment runs the first build.

  2. Patch ownership. The fork is under your account (yyoyoian-pixel/tun2proxy). If you push-force the branch tomorrow, the next cargo update here pulls whatever's there — there's no project-level guarantee against a branch rewrite. Two options:

    • (easier for you) I'll fork your feat/udpgw-jni-param branch to therealaleph/tun2proxy and you change the [patch.crates-io] URL to point at our fork. Patch lifecycle becomes ours; you keep the canonical PR #247 going at upstream.
    • (easier for me) Leave as-is, but pin Cargo.lock as in (1) so a branch rewrite is at least visible in the next lockfile diff.

Either is fine. I'd take (a) — happy to fork the branch over today.

The remaining notes from my earlier review (JNI fork lock-in comment in Tun2proxy.kt, hoisting the 198.18.0.1:7300 magic to a const) are nits, not blockers.

Once one of the two ownership options is settled and the lockfile is committed, this is ready to merge from my side.


[reply via Anthropic Claude | reviewed by @therealaleph]

Locks tun2proxy at dfc24ed1 so the patch resolution is recorded and
any branch rewrite is visible in the lockfile diff.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@vahidlazio
Copy link
Copy Markdown
Contributor

@therealaleph

Pin the patch in Cargo.lock

done.

@Feiabyte
Copy link
Copy Markdown

Feiabyte commented Apr 26, 2026

will discord work with this update?

@yyoyoian-pixel
Copy link
Copy Markdown
Author

@Feiabyte it should on android.

@Feiabyte
Copy link
Copy Markdown

@Feiabyte it should on android.

what about pc? since discord uses udp for voice it keeps saying no route

@yyoyoian-pixel
Copy link
Copy Markdown
Author

yyoyoian-pixel commented Apr 26, 2026

@Feiabyte
i think you can already use tun2proxy on desktop, then it will work.

  sudo tun2proxy --proxy socks5://127.0.0.1:8088 --udpgw-server 198.18.0.1:7300                                                                                                                                                                                                                                                                                                     

This creates a TUN on your Mac, captures all traffic (including Discord UDP), and routes it through mhrv-rs's SOCKS5 → the tunnel. Same as Android.

then your apps and everything will work as well.

P.s you need the new tunnel deployed.

@Feiabyte
Copy link
Copy Markdown

Feiabyte commented Apr 26, 2026

@Feiabyte i think you can already use tun2proxy on desktop, then it will work.

  sudo tun2proxy --proxy socks5://127.0.0.1:8088 --udpgw-server 198.18.0.1:7300                                                                                                                                                                                                                                                                                                     

This creates a TUN on your Mac, captures all traffic (including Discord UDP), and routes it through mhrv-rs's SOCKS5 → the tunnel. Same as Android.

then your apps and everything will work as well.

P.s you need the new tunnel deployed.

i see. do i run that command on my vps? Im using windows and the vps in ubuntu

@yyoyoian-pixel
Copy link
Copy Markdown
Author

it routes all your env to mhrv desktop, in client side. (windows). read about tun2proxy what it does it will help your case.

@Feiabyte
Copy link
Copy Markdown

it routes all your env to mhrv desktop, in client side. (windows). read about tun2proxy what it does it will help your case.

I will thanks for the help

@yyoyoian-pixel
Copy link
Copy Markdown
Author

@therealaleph talking to claude here :D can we merge?

@dazzling-no-more
Copy link
Copy Markdown
Contributor

Bug: ConnSocket._reader JoinHandle leaks on session close

Found one real bug in the udpgw implementation worth fixing before merge.

The bug

ConnSocket._reader: JoinHandle<()> leaks on session close.

When handle_close calls s.abort_all(), the outer udpgw_server_task is aborted at its next await point — which is almost always inside the read_half.read(&mut tmp).await in the main loop. The post-loop cleanup never runs:

for (_, cs) in sockets {
    cs._reader.abort();
}

abort cancels the future before it reaches this code. The sockets HashMap is dropped, which drops each ConnSocket, which drops each _reader: JoinHandle. JoinHandle::drop does not abort the task — it just detaches it. So every per-(conn_id, dest) UDP reader becomes a detached zombie task holding an Arc<UdpSocket>, looping on sock.recv() until the process exits.

Impact

For a single Telegram call with ~20 concurrent UDP destinations, each disconnect leaks 20 reader tasks plus 20 file descriptors. Over a long tunnel-node uptime, this exhausts FDs.

Fix (one-liner)

Replace JoinHandle with AbortHandle in ConnSocket. AbortHandle::drop aborts the task automatically, so the post-loop cleanup becomes redundant and the abort path is correct by construction:

struct ConnSocket {
    sock: Arc<UdpSocket>,
    _reader: tokio::task::AbortHandle,  // was JoinHandle<()>
}

// in get_or_create_socket:
let reader = tokio::spawn(async move { /* ... */ });
sockets.insert(key, ConnSocket {
    sock: sock.clone(),
    _reader: reader.abort_handle(),
});

// the explicit `for (_, cs) in sockets { cs._reader.abort(); }`
// at the end of udpgw_server_task can be removed — Drop handles it.

Alternative

Wrap the spawns in a tokio::task::JoinSet whose Drop aborts all members.

@yyoyoian-pixel
Copy link
Copy Markdown
Author

@dazzling-no-more thanks! on it

JoinHandle::drop detaches the task without aborting it. When
udpgw_server_task is cancelled (session close), the post-loop
cleanup never runs and per-(conn_id, dest) reader tasks become
zombies holding Arc<UdpSocket> file descriptors.

AbortHandle::drop aborts the task automatically, so cleanup is
correct by construction regardless of how the parent task exits.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@yyoyoian-pixel
Copy link
Copy Markdown
Author

@dazzling-no-more fixed & tested.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants