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>
This commit is contained in:
dazzling-no-more
2026-05-13 23:45:23 +04:00
committed by GitHub
parent 283073fe77
commit e70947ff0d
6 changed files with 120 additions and 9 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "mhrv-rs" name = "mhrv-rs"
version = "1.9.24" version = "1.9.25"
edition = "2021" edition = "2021"
description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting" description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting"
license = "MIT" license = "MIT"
+2 -2
View File
@@ -14,8 +14,8 @@ android {
applicationId = "com.therealaleph.mhrv" applicationId = "com.therealaleph.mhrv"
minSdk = 24 // Android 7.0 — covers 99%+ of live devices. minSdk = 24 // Android 7.0 — covers 99%+ of live devices.
targetSdk = 34 targetSdk = 34
versionCode = 158 versionCode = 159
versionName = "1.8.1" versionName = "1.9.25"
// Ship all four mainstream Android ABIs: // Ship all four mainstream Android ABIs:
// - arm64-v8a — 95%+ of real-world Android phones since 2019 // - arm64-v8a — 95%+ of real-world Android phones since 2019
@@ -268,7 +268,7 @@ class MhrvVpnService : VpnService() {
append(" --dns virtual") append(" --dns virtual")
append(" --verbosity info") append(" --verbosity info")
append(" --close-fd-on-drop true") append(" --close-fd-on-drop true")
if (cfg.mode == Mode.FULL) append(" --udpgw-server 198.18.0.1:7300") if (cfg.mode == Mode.FULL) append(" --udpgw-server $UDPGW_MAGIC_DEST")
} }
val worker = Thread({ val worker = Thread({
try { try {
@@ -499,5 +499,14 @@ class MhrvVpnService : VpnService() {
private const val NOTIF_ID = 0x1001 private const val NOTIF_ID = 0x1001
private const val MTU = 1500 private const val MTU = 1500
const val ACTION_STOP = "com.therealaleph.mhrv.STOP" const val ACTION_STOP = "com.therealaleph.mhrv.STOP"
// Magic udpgw destination passed to tun2proxy in Full mode. MUST stay
// outside tun2proxy's --dns virtual range (198.18.0.0/15) — otherwise
// virtual DNS can synthesise the magic IP for a real hostname and
// silently mis-route its traffic into the udpgw path. See issue #251
// and `UDPGW_MAGIC_IP` / `UDPGW_MAGIC_PORT` in tunnel-node/src/udpgw.rs.
// Wire-protocol convention: both sides must agree. v1.9.25+ tunnel-nodes
// also accept the legacy 198.18.0.1:7300 for one deprecation cycle.
private const val UDPGW_MAGIC_DEST = "192.0.2.1:7300"
} }
} }
@@ -114,7 +114,7 @@ object Native {
* Start tun2proxy via its CLI args C API (`tun2proxy_run_with_cli_args`). * Start tun2proxy via its CLI args C API (`tun2proxy_run_with_cli_args`).
* Resolved at runtime via dlsym from libtun2proxy.so — no fork needed. * Resolved at runtime via dlsym from libtun2proxy.so — no fork needed.
* *
* @param cliArgs full CLI string, e.g. "tun2proxy --proxy socks5://... --tun-fd 42 --udpgw-server 198.18.0.1:7300" * @param cliArgs full CLI string, e.g. "tun2proxy --proxy socks5://... --tun-fd 42 --udpgw-server 192.0.2.1:7300"
* @param tunMtu TUN MTU (typically 1500) * @param tunMtu TUN MTU (typically 1500)
* @return 0 on normal shutdown, negative on error. BLOCKS. * @return 0 on normal shutdown, negative on error. BLOCKS.
*/ */
+48
View File
@@ -0,0 +1,48 @@
<!-- see docs/changelog/v1.1.0.md for the file format: Persian, then `---`, then English. -->
<div dir="rtl">
**رفع باگ Full mode «Google و اکثر سایت‌ها خراب، تلگرام سالم» — `udpgw magic IP از داخل virtual-DNS range tun2proxy منتقل شد`** ([#251](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/251) by @dazzling-no-more).
در Full mode روی Android، تلگرام کار می‌کرد ولی Google search و اکثر سایت‌ها silently fail می‌شدن — `apps_script` mode روی همون device سالم بود و VPS هم idle.
**علت**: آدرس 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` ست نمی‌کنه.
**راه‌حل**: ثابت `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` استفاده می‌کنه).
**سازگاری با نسخه‌های قدیمی**: نسخهٔ جدید 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 رو.
</div>
---
**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.
**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.
**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.
Coordinated two-side change:
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.
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.
**Back-compatibility — partial, one-way**:
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:
| Android | Tunnel-node | Full-mode UDP relay |
|---|---|---|
| v1.9.25 | v1.9.25 | ✅ fully fixed |
| ≤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. |
| 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 |
| ≤v1.9.24 | ≤v1.9.24 | unchanged from before (still has the original #251 bug) |
**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).
**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:
```json
{
"passthrough_hosts": [".google.com", ".gstatic.com", ".googleusercontent.com", ".googleapis.com", ".youtube.com", ".ytimg.com"]
}
```
Slower per-request (Apps Script overhead) but bypasses the virtual-DNS clash entirely. Remove once both halves are on v1.9.25.
+58 -4
View File
@@ -19,11 +19,28 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt, DuplexStream};
use tokio::net::UdpSocket; use tokio::net::UdpSocket;
/// Magic address that the client connects to via the tunnel protocol. /// Magic address that the client connects to via the tunnel protocol.
/// `198.18.0.0/15` is reserved for benchmarking (RFC 2544) and will /// `192.0.2.0/24` is reserved for documentation (RFC 5737 TEST-NET-1)
/// never be a real destination. /// and will never be a real destination.
pub const UDPGW_MAGIC_IP: [u8; 4] = [198, 18, 0, 1]; ///
/// Must NOT live in `198.18.0.0/15`: tun2proxy's `--dns virtual` allocator
/// (used by the Android client in Full mode) synthesises fake IPs in that
/// range for hostname lookups. If the magic IP collided with one of those
/// synthetic IPs, every request to whichever hostname got that allocation
/// would be silently mis-routed into the udpgw path. See issue #251.
pub const UDPGW_MAGIC_IP: [u8; 4] = [192, 0, 2, 1];
/// Pre-formatted dotted-quad form of `UDPGW_MAGIC_IP`. Compared against
/// incoming hostnames in [`is_udpgw_dest`]; kept in sync with the octets
/// above by the `magic_host_matches_octets` test.
pub const UDPGW_MAGIC_HOST: &str = "192.0.2.1";
pub const UDPGW_MAGIC_PORT: u16 = 7300; pub const UDPGW_MAGIC_PORT: u16 = 7300;
/// Pre-#251 magic IP — still recognised by `is_udpgw_dest` for one
/// deprecation cycle so users who upgrade the `mhrv-tunnel` Docker
/// container ahead of the Android APK don't lose Full-mode UDP relay
/// during the version-skew window. Slated for removal in v1.10.0.
const LEGACY_UDPGW_MAGIC_IP: [u8; 4] = [198, 18, 0, 1];
const LEGACY_UDPGW_MAGIC_HOST: &str = "198.18.0.1";
const FLAG_KEEPALIVE: u8 = 0x01; const FLAG_KEEPALIVE: u8 = 0x01;
const FLAG_DATA: u8 = 0x02; const FLAG_DATA: u8 = 0x02;
const FLAG_ERR: u8 = 0x20; const FLAG_ERR: u8 = 0x20;
@@ -195,8 +212,12 @@ fn serialise_frame(frame: &Frame) -> Vec<u8> {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
/// Returns `true` if the connect destination is the magic udpgw address. /// Returns `true` if the connect destination is the magic udpgw address.
///
/// Accepts both the current `UDPGW_MAGIC_HOST` (`192.0.2.1`) and the legacy
/// `LEGACY_UDPGW_MAGIC_HOST` (`198.18.0.1`) so a v1.9.25+ tunnel-node still
/// works with pre-#251 Android clients during the upgrade window.
pub fn is_udpgw_dest(host: &str, port: u16) -> bool { pub fn is_udpgw_dest(host: &str, port: u16) -> bool {
port == UDPGW_MAGIC_PORT && host == format!("{}.{}.{}.{}", UDPGW_MAGIC_IP[0], UDPGW_MAGIC_IP[1], UDPGW_MAGIC_IP[2], UDPGW_MAGIC_IP[3]) port == UDPGW_MAGIC_PORT && (host == UDPGW_MAGIC_HOST || host == LEGACY_UDPGW_MAGIC_HOST)
} }
/// Per-conn_id persistent UDP socket with a background reader that /// Per-conn_id persistent UDP socket with a background reader that
@@ -505,8 +526,41 @@ mod tests {
#[test] #[test]
fn is_udpgw_dest_works() { fn is_udpgw_dest_works() {
// Current magic IP — must be recognised.
assert!(is_udpgw_dest("192.0.2.1", 7300));
// Legacy pre-#251 magic IP — still recognised for one deprecation
// cycle so old Android clients keep working against a new tunnel-node.
// Remove this assertion (and `LEGACY_UDPGW_MAGIC_IP`) in v1.10.0.
assert!(is_udpgw_dest("198.18.0.1", 7300)); assert!(is_udpgw_dest("198.18.0.1", 7300));
// Wrong port on either IP, or unrelated host on the magic port, must not match.
assert!(!is_udpgw_dest("192.0.2.1", 80));
assert!(!is_udpgw_dest("198.18.0.1", 80)); assert!(!is_udpgw_dest("198.18.0.1", 80));
assert!(!is_udpgw_dest("8.8.8.8", 7300)); assert!(!is_udpgw_dest("8.8.8.8", 7300));
} }
#[test]
fn magic_host_matches_octets() {
// The dotted-quad `_HOST` constants are what `is_udpgw_dest` actually
// compares against — but the `_IP` octet arrays are what tests and
// future humans reason about. If they drift, `is_udpgw_dest` silently
// stops matching what the Android client is sending. Pin them here.
let dotted = |ip: [u8; 4]| format!("{}.{}.{}.{}", ip[0], ip[1], ip[2], ip[3]);
assert_eq!(dotted(UDPGW_MAGIC_IP), UDPGW_MAGIC_HOST);
assert_eq!(dotted(LEGACY_UDPGW_MAGIC_IP), LEGACY_UDPGW_MAGIC_HOST);
}
#[test]
fn magic_ip_outside_virtual_dns_range() {
// tun2proxy's `--dns virtual` allocator synthesises fake IPs inside
// 198.18.0.0/15 (covers 198.18.0.0 198.19.255.255). The *current*
// magic IP MUST stay outside that range — see #251. The legacy IP
// is intentionally still in the bad range (that was the bug); it
// is exempt and will be removed in v1.10.0.
let [a, b, _, _] = UDPGW_MAGIC_IP;
assert!(
!(a == 198 && (b == 18 || b == 19)),
"UDPGW_MAGIC_IP {:?} is inside 198.18.0.0/15 — will collide with tun2proxy --dns virtual (see #251)",
UDPGW_MAGIC_IP
);
}
} }