diff --git a/Cargo.toml b/Cargo.toml
index 5ec62672..12b0aaad 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "mhrv-rs"
-version = "1.9.24"
+version = "1.9.25"
edition = "2021"
description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting"
license = "MIT"
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 2cb00e5f..e7fc15ae 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -14,8 +14,8 @@ android {
applicationId = "com.therealaleph.mhrv"
minSdk = 24 // Android 7.0 — covers 99%+ of live devices.
targetSdk = 34
- versionCode = 158
- versionName = "1.8.1"
+ versionCode = 159
+ versionName = "1.9.25"
// Ship all four mainstream Android ABIs:
// - arm64-v8a — 95%+ of real-world Android phones since 2019
diff --git a/android/app/src/main/java/com/therealaleph/mhrv/MhrvVpnService.kt b/android/app/src/main/java/com/therealaleph/mhrv/MhrvVpnService.kt
index 355f85a1..053edc29 100644
--- a/android/app/src/main/java/com/therealaleph/mhrv/MhrvVpnService.kt
+++ b/android/app/src/main/java/com/therealaleph/mhrv/MhrvVpnService.kt
@@ -268,7 +268,7 @@ class MhrvVpnService : VpnService() {
append(" --dns virtual")
append(" --verbosity info")
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({
try {
@@ -499,5 +499,14 @@ class MhrvVpnService : VpnService() {
private const val NOTIF_ID = 0x1001
private const val MTU = 1500
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"
}
}
diff --git a/android/app/src/main/java/com/therealaleph/mhrv/Native.kt b/android/app/src/main/java/com/therealaleph/mhrv/Native.kt
index fbf44dc5..798d5236 100644
--- a/android/app/src/main/java/com/therealaleph/mhrv/Native.kt
+++ b/android/app/src/main/java/com/therealaleph/mhrv/Native.kt
@@ -114,7 +114,7 @@ object Native {
* Start tun2proxy via its CLI args C API (`tun2proxy_run_with_cli_args`).
* 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)
* @return 0 on normal shutdown, negative on error. BLOCKS.
*/
diff --git a/docs/changelog/v1.9.25.md b/docs/changelog/v1.9.25.md
new file mode 100644
index 00000000..b08b62a4
--- /dev/null
+++ b/docs/changelog/v1.9.25.md
@@ -0,0 +1,48 @@
+
+
+
+• **رفع باگ 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 رو.
+
+
+---
+• **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.
diff --git a/tunnel-node/src/udpgw.rs b/tunnel-node/src/udpgw.rs
index 3c6e1800..6e9a0b31 100644
--- a/tunnel-node/src/udpgw.rs
+++ b/tunnel-node/src/udpgw.rs
@@ -19,11 +19,28 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt, DuplexStream};
use tokio::net::UdpSocket;
/// Magic address that the client connects to via the tunnel protocol.
-/// `198.18.0.0/15` is reserved for benchmarking (RFC 2544) and will
-/// never be a real destination.
-pub const UDPGW_MAGIC_IP: [u8; 4] = [198, 18, 0, 1];
+/// `192.0.2.0/24` is reserved for documentation (RFC 5737 TEST-NET-1)
+/// and will never be a real destination.
+///
+/// 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;
+/// 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_DATA: u8 = 0x02;
const FLAG_ERR: u8 = 0x20;
@@ -195,8 +212,12 @@ fn serialise_frame(frame: &Frame) -> Vec {
// -------------------------------------------------------------------------
/// 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 {
- 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
@@ -505,8 +526,41 @@ mod tests {
#[test]
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));
+ // 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("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
+ );
+ }
}