Skip to content

Commit e70947f

Browse files
dazzling-no-moreweb-flowclaude
authored
fix(udpgw): move magic IP out of tun2proxy virtual-DNS range (#251, #1143)
Closes #251. In Android Full mode, Telegram worked but Google search and most other websites failed silently. `apps_script` mode on the same setup was unaffected. **Root cause**: the udpgw magic destination (`198.18.0.1:7300`) was inside `198.18.0.0/15` — the exact range tun2proxy's `--dns virtual` allocator uses to synthesise fake IPs for hostname lookups. Whenever virtual DNS assigned `198.18.0.1` to a real hostname, that hostname's traffic was intercepted by tun2proxy *itself* as a udpgw connection and dropped. Telegram was immune because it uses hardcoded numeric IPs; `apps_script` mode was immune because it never sets `--udpgw-server`. **Fix**: move `UDPGW_MAGIC_IP` to `192.0.2.1` (RFC 5737 TEST-NET-1) — outside any virtual-DNS allocation pool. Coordinated change across the tunnel-node constant and the Android `--udpgw-server` flag. ## Back-compat v1.9.25 tunnel-nodes still recognise the legacy `198.18.0.1:7300` for one deprecation cycle (removal in v1.10.0). | Android | Tunnel-node | Full-mode UDP | |---|---|---| | v1.9.25 | v1.9.25 | ✅ fully fixed | | ≤v1.9.24 | v1.9.25 | ⚠️ handshake works (legacy IP still recognised), but the old client still asks tun2proxy for `198.18.0.1`, so the #251 virtual-DNS collision is still live on-device | | v1.9.25 | ≤v1.9.24 | ❌ breaks silently (old node rejects `192.0.2.1`) | The fix lives on the client side (which magic IP it asks tun2proxy to reserve). The back-compat is on the tunnel-node side (accepting both during the deprecation window). ## Verified locally - `cargo test --lib --release`: 231/231 ✅ - `cargo build --release --features ui --bin mhrv-rs-ui`: clean ✅ - `(cd tunnel-node && cargo test --release)`: 38/38 ✅ (+2 new tests for the IP change) ## Version bump Cargo.toml already bumped to 1.9.25 in this PR; `docs/changelog/v1.9.25.md` pre-baked. Will combine with any other PRs landing into v1.9.25 before tagging. Reviewed via Anthropic Claude. Co-Authored-By: dazzling-no-more <noreply@github.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 283073f commit e70947f

6 files changed

Lines changed: 120 additions & 9 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "mhrv-rs"
3-
version = "1.9.24"
3+
version = "1.9.25"
44
edition = "2021"
55
description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting"
66
license = "MIT"

android/app/build.gradle.kts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ android {
1414
applicationId = "com.therealaleph.mhrv"
1515
minSdk = 24 // Android 7.0 — covers 99%+ of live devices.
1616
targetSdk = 34
17-
versionCode = 158
18-
versionName = "1.8.1"
17+
versionCode = 159
18+
versionName = "1.9.25"
1919

2020
// Ship all four mainstream Android ABIs:
2121
// - arm64-v8a — 95%+ of real-world Android phones since 2019

android/app/src/main/java/com/therealaleph/mhrv/MhrvVpnService.kt

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,7 @@ class MhrvVpnService : VpnService() {
268268
append(" --dns virtual")
269269
append(" --verbosity info")
270270
append(" --close-fd-on-drop true")
271-
if (cfg.mode == Mode.FULL) append(" --udpgw-server 198.18.0.1:7300")
271+
if (cfg.mode == Mode.FULL) append(" --udpgw-server $UDPGW_MAGIC_DEST")
272272
}
273273
val worker = Thread({
274274
try {
@@ -499,5 +499,14 @@ class MhrvVpnService : VpnService() {
499499
private const val NOTIF_ID = 0x1001
500500
private const val MTU = 1500
501501
const val ACTION_STOP = "com.therealaleph.mhrv.STOP"
502+
503+
// Magic udpgw destination passed to tun2proxy in Full mode. MUST stay
504+
// outside tun2proxy's --dns virtual range (198.18.0.0/15) — otherwise
505+
// virtual DNS can synthesise the magic IP for a real hostname and
506+
// silently mis-route its traffic into the udpgw path. See issue #251
507+
// and `UDPGW_MAGIC_IP` / `UDPGW_MAGIC_PORT` in tunnel-node/src/udpgw.rs.
508+
// Wire-protocol convention: both sides must agree. v1.9.25+ tunnel-nodes
509+
// also accept the legacy 198.18.0.1:7300 for one deprecation cycle.
510+
private const val UDPGW_MAGIC_DEST = "192.0.2.1:7300"
502511
}
503512
}

android/app/src/main/java/com/therealaleph/mhrv/Native.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ object Native {
114114
* Start tun2proxy via its CLI args C API (`tun2proxy_run_with_cli_args`).
115115
* Resolved at runtime via dlsym from libtun2proxy.so — no fork needed.
116116
*
117-
* @param cliArgs full CLI string, e.g. "tun2proxy --proxy socks5://... --tun-fd 42 --udpgw-server 198.18.0.1:7300"
117+
* @param cliArgs full CLI string, e.g. "tun2proxy --proxy socks5://... --tun-fd 42 --udpgw-server 192.0.2.1:7300"
118118
* @param tunMtu TUN MTU (typically 1500)
119119
* @return 0 on normal shutdown, negative on error. BLOCKS.
120120
*/

docs/changelog/v1.9.25.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<!-- see docs/changelog/v1.1.0.md for the file format: Persian, then `---`, then English. -->
2+
<div dir="rtl">
3+
4+
**رفع باگ Full mode «Google و اکثر سایت‌ها خراب، تلگرام سالم» — `udpgw magic IP از داخل virtual-DNS range tun2proxy منتقل شد`** ([#251](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/251) by @dazzling-no-more).
5+
6+
در Full mode روی Android، تلگرام کار می‌کرد ولی Google search و اکثر سایت‌ها silently fail می‌شدن — `apps_script` mode روی همون device سالم بود و VPS هم idle.
7+
8+
**علت**: آدرس magic مربوط به udpgw (یعنی `198.18.0.1:7300`) داخل `198.18.0.0/15` بود، یعنی دقیقاً همون range‌ای که `tun2proxy --dns virtual` ازش IPهای ساختگی رو برای hostname lookupها اختصاص می‌ده. هر دفعه که virtual DNS اتفاقاً `198.18.0.1` رو به یک hostname مثل `www.google.com` allocate می‌کرد، traffic اون host به‌عنوان udpgw connection مصادره می‌شد و drop می‌شد. تلگرام immune بود چون native clientش از IPهای عددی hardcoded استفاده می‌کنه؛ همچنین `apps_script` mode هم immune بود چون اصلاً `--udpgw-server` ست نمی‌کنه.
9+
10+
**راه‌حل**: ثابت `UDPGW_MAGIC_IP` به `192.0.2.1` (RFC 5737 TEST-NET-1) منتقل شد. دو فایل تغییر کرده: یکی `tunnel-node/src/udpgw.rs` (constant + tests) و دیگری `android/.../MhrvVpnService.kt` (که حالا از یک companion const به اسم `UDPGW_MAGIC_DEST` استفاده می‌کنه).
11+
12+
**سازگاری با نسخه‌های قدیمی**: نسخهٔ جدید tunnel-node همچنان `198.18.0.1:7300` قدیمی رو هم accept می‌کنه برای یک deprecation cycle (حذف در v1.10.0) — یعنی اگه VPS رو زودتر آپدیت کنی، Android قدیمی هنوز کار می‌کنه. **ولی اگه Android رو زودتر آپدیت کنی، tunnel-node قدیمی UDP relay رو در Full mode break می‌کنه**. توصیه: اول tunnel-node رو آپدیت کن، بعد APK رو.
13+
14+
</div>
15+
---
16+
**Fix Full mode "Google + most websites broken while Telegram works" — `udpgw magic IP moved out of tun2proxy virtual-DNS range`** ([#251](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/251) by @dazzling-no-more). Users on Android Full mode reported that Telegram worked fine but Google search and most other websites failed to load — while apps_script mode on the same device + same `google_ip` worked perfectly and the VPS was sitting idle.
17+
18+
**Root cause**: the udpgw magic destination address (`198.18.0.1:7300`) lived inside `198.18.0.0/15` — the exact same range that tun2proxy's `--dns virtual` allocator uses to synthesise fake IPs for hostname lookups. Whenever virtual DNS happened to assign `198.18.0.1` to a real hostname (e.g. `www.google.com`), that hostname's connections were intercepted by tun2proxy *itself* as a udpgw request before they ever reached the SOCKS5 proxy. Result: a random subset of DNS-resolved hosts silently broke per session, depending on which hostname won the `198.18.0.1` allocation. Telegram was unaffected because its native client uses hardcoded numeric IPs (no DNS allocation needed). apps_script mode was unaffected because it doesn't pass `--udpgw-server` to tun2proxy at all.
19+
20+
**Fix**: relocate `UDPGW_MAGIC_IP` from `198.18.0.1` to `192.0.2.1` (RFC 5737 TEST-NET-1). TEST-NET-1 is reserved for documentation, never routed on the public internet, and — critically — outside any virtual-DNS allocation pool. Structurally equivalent to the old address as a "guaranteed-not-real-destination", just no longer colliding with tun2proxy's reserved range.
21+
22+
Coordinated two-side change:
23+
24+
1. **`tunnel-node/src/udpgw.rs`**: `UDPGW_MAGIC_IP = [192, 0, 2, 1]`, doc comment now cites RFC 5737 + explicitly explains why it must stay out of `198.18.0.0/15`. Test additions: `is_udpgw_dest_works` covers both the new IP and the legacy IP (back-compat assertion); new `magic_ip_outside_virtual_dns_range` enforces the invariant at the `198.18.0.0/15` *range* level, so any future move to `198.19.x.y` would also fail the test rather than re-introducing the same class of bug.
25+
2. **`android/.../MhrvVpnService.kt`**: `--udpgw-server $UDPGW_MAGIC_DEST` where `UDPGW_MAGIC_DEST = "192.0.2.1:7300"` is a new companion-object constant, with a docstring pointing back at the Rust constant — gives the next editor a single, labelled place to update if the convention ever changes again.
26+
27+
**Back-compatibility — partial, one-way**:
28+
29+
The udpgw magic IP is a wire-protocol convention between the Android client and the `mhrv-tunnel` Docker container. v1.9.25 tunnel-nodes accept both the new `192.0.2.1:7300` and the legacy `198.18.0.1:7300` for one deprecation cycle (slated for removal in v1.10.0). That softens — but does *not* fully resolve — the asymmetric-upgrade matrix:
30+
31+
| Android | Tunnel-node | Full-mode UDP relay |
32+
|---|---|---|
33+
| v1.9.25 | v1.9.25 | ✅ fully fixed |
34+
| ≤v1.9.24 | v1.9.25 | ⚠️ udpgw handshake works (legacy IP still recognised by the node), but the **old client still asks tun2proxy for `--udpgw-server 198.18.0.1:7300`** — meaning the underlying #251 virtual-DNS-pool collision is still live on the device. Telegram works; the random Google-search-style breakage persists until the APK is updated. |
35+
| v1.9.25 | ≤v1.9.24 |**breaks silently** — new client sends `192.0.2.1`, old node treats it as a real TCP destination and the connect fails |
36+
| ≤v1.9.24 | ≤v1.9.24 | unchanged from before (still has the original #251 bug) |
37+
38+
**Recommended upgrade order**: update **both halves** to v1.9.25. The fix is on the *client* side (which magic IP it asks tun2proxy to reserve) — the tunnel-node back-compat shim only prevents a hard handshake break during the window where the node is upgraded first; it does not fix the original bug. If you can only update one half right now: do the **APK first** (or both together), since updating just the tunnel-node leaves clients still hitting the virtual-DNS collision. `apps_script`-only users are unaffected (the udpgw path isn't used in apps_script mode).
39+
40+
**Diagnostic note for stuck users**: if Telegram works on Full mode but Google search / random websites silently fail on v1.9.24 or earlier, this is your bug. As a workaround pending upgrade, add Google domains to `passthrough_hosts` to route them through tunnel-node like Telegram does:
41+
42+
```json
43+
{
44+
"passthrough_hosts": [".google.com", ".gstatic.com", ".googleusercontent.com", ".googleapis.com", ".youtube.com", ".ytimg.com"]
45+
}
46+
```
47+
48+
Slower per-request (Apps Script overhead) but bypasses the virtual-DNS clash entirely. Remove once both halves are on v1.9.25.

tunnel-node/src/udpgw.rs

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,28 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt, DuplexStream};
1919
use tokio::net::UdpSocket;
2020

2121
/// Magic address that the client connects to via the tunnel protocol.
22-
/// `198.18.0.0/15` is reserved for benchmarking (RFC 2544) and will
23-
/// never be a real destination.
24-
pub const UDPGW_MAGIC_IP: [u8; 4] = [198, 18, 0, 1];
22+
/// `192.0.2.0/24` is reserved for documentation (RFC 5737 TEST-NET-1)
23+
/// and will never be a real destination.
24+
///
25+
/// Must NOT live in `198.18.0.0/15`: tun2proxy's `--dns virtual` allocator
26+
/// (used by the Android client in Full mode) synthesises fake IPs in that
27+
/// range for hostname lookups. If the magic IP collided with one of those
28+
/// synthetic IPs, every request to whichever hostname got that allocation
29+
/// would be silently mis-routed into the udpgw path. See issue #251.
30+
pub const UDPGW_MAGIC_IP: [u8; 4] = [192, 0, 2, 1];
31+
/// Pre-formatted dotted-quad form of `UDPGW_MAGIC_IP`. Compared against
32+
/// incoming hostnames in [`is_udpgw_dest`]; kept in sync with the octets
33+
/// above by the `magic_host_matches_octets` test.
34+
pub const UDPGW_MAGIC_HOST: &str = "192.0.2.1";
2535
pub const UDPGW_MAGIC_PORT: u16 = 7300;
2636

37+
/// Pre-#251 magic IP — still recognised by `is_udpgw_dest` for one
38+
/// deprecation cycle so users who upgrade the `mhrv-tunnel` Docker
39+
/// container ahead of the Android APK don't lose Full-mode UDP relay
40+
/// during the version-skew window. Slated for removal in v1.10.0.
41+
const LEGACY_UDPGW_MAGIC_IP: [u8; 4] = [198, 18, 0, 1];
42+
const LEGACY_UDPGW_MAGIC_HOST: &str = "198.18.0.1";
43+
2744
const FLAG_KEEPALIVE: u8 = 0x01;
2845
const FLAG_DATA: u8 = 0x02;
2946
const FLAG_ERR: u8 = 0x20;
@@ -195,8 +212,12 @@ fn serialise_frame(frame: &Frame) -> Vec<u8> {
195212
// -------------------------------------------------------------------------
196213

197214
/// Returns `true` if the connect destination is the magic udpgw address.
215+
///
216+
/// Accepts both the current `UDPGW_MAGIC_HOST` (`192.0.2.1`) and the legacy
217+
/// `LEGACY_UDPGW_MAGIC_HOST` (`198.18.0.1`) so a v1.9.25+ tunnel-node still
218+
/// works with pre-#251 Android clients during the upgrade window.
198219
pub fn is_udpgw_dest(host: &str, port: u16) -> bool {
199-
port == UDPGW_MAGIC_PORT && host == format!("{}.{}.{}.{}", UDPGW_MAGIC_IP[0], UDPGW_MAGIC_IP[1], UDPGW_MAGIC_IP[2], UDPGW_MAGIC_IP[3])
220+
port == UDPGW_MAGIC_PORT && (host == UDPGW_MAGIC_HOST || host == LEGACY_UDPGW_MAGIC_HOST)
200221
}
201222

202223
/// Per-conn_id persistent UDP socket with a background reader that
@@ -505,8 +526,41 @@ mod tests {
505526

506527
#[test]
507528
fn is_udpgw_dest_works() {
529+
// Current magic IP — must be recognised.
530+
assert!(is_udpgw_dest("192.0.2.1", 7300));
531+
// Legacy pre-#251 magic IP — still recognised for one deprecation
532+
// cycle so old Android clients keep working against a new tunnel-node.
533+
// Remove this assertion (and `LEGACY_UDPGW_MAGIC_IP`) in v1.10.0.
508534
assert!(is_udpgw_dest("198.18.0.1", 7300));
535+
// Wrong port on either IP, or unrelated host on the magic port, must not match.
536+
assert!(!is_udpgw_dest("192.0.2.1", 80));
509537
assert!(!is_udpgw_dest("198.18.0.1", 80));
510538
assert!(!is_udpgw_dest("8.8.8.8", 7300));
511539
}
540+
541+
#[test]
542+
fn magic_host_matches_octets() {
543+
// The dotted-quad `_HOST` constants are what `is_udpgw_dest` actually
544+
// compares against — but the `_IP` octet arrays are what tests and
545+
// future humans reason about. If they drift, `is_udpgw_dest` silently
546+
// stops matching what the Android client is sending. Pin them here.
547+
let dotted = |ip: [u8; 4]| format!("{}.{}.{}.{}", ip[0], ip[1], ip[2], ip[3]);
548+
assert_eq!(dotted(UDPGW_MAGIC_IP), UDPGW_MAGIC_HOST);
549+
assert_eq!(dotted(LEGACY_UDPGW_MAGIC_IP), LEGACY_UDPGW_MAGIC_HOST);
550+
}
551+
552+
#[test]
553+
fn magic_ip_outside_virtual_dns_range() {
554+
// tun2proxy's `--dns virtual` allocator synthesises fake IPs inside
555+
// 198.18.0.0/15 (covers 198.18.0.0 – 198.19.255.255). The *current*
556+
// magic IP MUST stay outside that range — see #251. The legacy IP
557+
// is intentionally still in the bad range (that was the bug); it
558+
// is exempt and will be removed in v1.10.0.
559+
let [a, b, _, _] = UDPGW_MAGIC_IP;
560+
assert!(
561+
!(a == 198 && (b == 18 || b == 19)),
562+
"UDPGW_MAGIC_IP {:?} is inside 198.18.0.0/15 — will collide with tun2proxy --dns virtual (see #251)",
563+
UDPGW_MAGIC_IP
564+
);
565+
}
512566
}

0 commit comments

Comments
 (0)