Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
4 changes: 2 additions & 2 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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"
}
}
2 changes: 1 addition & 1 deletion android/app/src/main/java/com/therealaleph/mhrv/Native.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
48 changes: 48 additions & 0 deletions docs/changelog/v1.9.25.md
Original file line number Diff line number Diff line change
@@ -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.
62 changes: 58 additions & 4 deletions tunnel-node/src/udpgw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -195,8 +212,12 @@ fn serialise_frame(frame: &Frame) -> Vec<u8> {
// -------------------------------------------------------------------------

/// 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
Expand Down Expand Up @@ -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
);
}
}
Loading