Merge PR #9: dynamic Google IP discovery + frontend validation

Thanks @v4g4b0nd-0x76 for the feature. Two small fixes folded in on
the merge so master still builds + doesn't hit sharp edges:

- src/scan_ips.rs: rand::thread_rng() held across an .await tripped
  the Send bound on the async fn (ThreadRng isn't Send). Scoped the
  rng in a block so it drops before subsequent awaits.
- src/scan_ips.rs: guard /0 and /32 CIDRs in cidr_to_ips and
  ip_in_cidr against the 1u32 << 32 shift panic (debug mode). goog.json
  is unlikely to contain either but defensive.

Behavior unchanged otherwise:
- fetch_ips_from_api=false (default): identical to previous static
  scan-ips behavior.
- fetch_ips_from_api=true: fetches goog.json from www.gstatic.com,
  resolves famous Google domain IPs, prioritises matching CIDRs,
  samples up to max_ips_to_scan candidates, validates with gws/
  x-google-/alt-svc headers. If the fetch fails, falls back to the
  static list cleanly — verified locally.

Closes #10.
This commit is contained in:
therealaleph
2026-04-22 13:52:36 +03:00
4 changed files with 545 additions and 26 deletions
+52
View File
@@ -174,6 +174,32 @@ Then:
`script_id` can also be a JSON array: `["id1", "id2", "id3"]`.
#### scan-ips configuration (optional)
By default, the scan-ips subcommand uses a static array of IPs.
You can enable dynamic IP discovery by setting fetch_ips_from_api to true in config.json:
```json
{
"fetch_ips_from_api": true,
"max_ips_to_scan": 100,
"scan_batch_size":100,
"google_ip_validation": true // check whether ips belongs to frontend sites of google or not
}
```
When enabled:
- Fetches goog.json from Googles public IP ranges API
- Extracts all CIDRs and expands them to individual IPs
- Prioritizes IPs from famous Google domains (google.com, youtube.com, etc.)
- Randomly selects up to max_ips_to_scan candidates (prioritized IPs first)
- Tests only the selected candidates for connectivity and frontend validation.
By using this options you may find ips witch are faster than static array that is provided as default but there is no guarantee that this ips would work.
### Step 5 — Point your client at the proxy
The tool listens on **two** ports. Use whichever your client supports:
@@ -423,6 +449,32 @@ Original project: <https://github.com/masterking32/MasterHttpRelayVPN> by [@mast
**فایرفاکس (ساده‌ترین):**
#### پیکربندی scan-ips (اختیاری)
به‌طور پیش‌فرض، دستور scan-ips از آرایه‌ای ثابت از IPها استفاده می‌کند.
می‌توانید کشف پویای IP را با تنظیم fetch_ips_from_api روی true در config.json فعال کنید:
```json
{
"fetch_ips_from_api": true,
"max_ips_to_scan": 100,
"scan_batch_size":100,
"google_ip_validation": true // برسی هدر های بازگشته از ایپی برای برسی هدر ها و تشخیص کاربردی بودن ایپی
}
```
زمانی که فعال باشد:
- فایل goog.json را از API محدوده‌های عمومی IP گوگل دریافت می‌کند
تمام CIDRها را استخراج کرده و به IPهای جداگانه تبدیل می‌کند
- به IPهای دامنه‌های معروف گوگل (google.com، youtube.com و غیره) اولویت می‌دهد
به‌صورت تصادفی تا max_ips_to_scan کاندید انتخاب می‌کند (ابتدا IPهای اولویت‌دار)
فقط کاندیدهای انتخاب‌شده را برای اتصال و اعتبارسنجی frontend تست می‌کند.
با استفاده از این گزینه‌ها ممکن است IPهایی پیدا کنید که سریع‌تر از آرایه ثابت پیش‌فرض هستند اما تضمینی وجود ندارد که این IPها کار کنند.
#### ۵. تنظیم proxy در کلاینت
۱. منوی `Settings` را باز کنید، در خانهٔ جست‌وجو عبارت `proxy` را تایپ کنید
۲. روی **`Network Settings`** کلیک کنید
۳. گزینهٔ **`Manual proxy configuration`** را انتخاب کنید
+16
View File
@@ -137,6 +137,10 @@ struct FormState {
sni_custom_input: String,
/// Whether the floating SNI editor window is open.
sni_editor_open: bool,
fetch_ips_from_api: bool,
max_ips_to_scan: usize,
scan_batch_size:usize,
google_ip_validation: bool
}
#[derive(Clone, Debug)]
@@ -182,6 +186,10 @@ fn load_form() -> FormState {
sni_pool,
sni_custom_input: String::new(),
sni_editor_open: false,
fetch_ips_from_api:c.fetch_ips_from_api,
max_ips_to_scan:c.max_ips_to_scan,
google_ip_validation: c.google_ip_validation,
scan_batch_size:c.scan_batch_size
}
} else {
FormState {
@@ -200,6 +208,10 @@ fn load_form() -> FormState {
sni_pool: sni_pool_for_form(None, "www.google.com"),
sni_custom_input: String::new(),
sni_editor_open: false,
fetch_ips_from_api:false,
max_ips_to_scan:100,
google_ip_validation:true,
scan_batch_size:500
}
}
}
@@ -321,6 +333,10 @@ impl FormState {
Some(active)
}
},
fetch_ips_from_api:self.fetch_ips_from_api,
max_ips_to_scan: self.max_ips_to_scan,
google_ip_validation:self.google_ip_validation,
scan_batch_size:self.scan_batch_size
})
}
}
+16
View File
@@ -80,8 +80,24 @@ pub struct Config {
/// various times). Can be tested per-name via the UI or `mhrv-rs test-sni`.
#[serde(default)]
pub sni_hosts: Option<Vec<String>>,
#[serde(default = "default_fetch_ips_from_api")]
pub fetch_ips_from_api: bool,
#[serde(default = "default_max_ips_to_scan")]
pub max_ips_to_scan: usize,
#[serde(default = "default_scan_batch_size")]
pub scan_batch_size:usize,
#[serde(default = "default_google_ip_validation")]
pub google_ip_validation: bool
}
fn default_fetch_ips_from_api() -> bool { false }
fn default_max_ips_to_scan() -> usize { 100 }
fn default_scan_batch_size() -> usize {500}
fn default_google_ip_validation() -> bool {true}
fn default_google_ip() -> String {
"216.239.38.120".into()
}
+461 -26
View File
@@ -2,6 +2,7 @@ use std::net::SocketAddr;
use std::sync::Arc;
use std::time::{Duration, Instant};
use rand::seq::SliceRandom;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio_rustls::rustls::client::danger::{
@@ -44,6 +45,17 @@ const CANDIDATE_IPS: &[&str] = &[
"142.250.72.110",
];
const FAMOUS_GOOGLE_DOMAINS: &[&str] = &[
"google.com",
"www.google.com",
"youtube.com",
"www.youtube.com",
"gmail.com",
"drive.google.com",
"docs.google.com",
"maps.google.com",
];
const PROBE_TIMEOUT: Duration = Duration::from_secs(4);
const CONCURRENCY: usize = 8;
@@ -54,8 +66,15 @@ struct Result_ {
}
pub async fn run(config: &Config) -> bool {
let ips = fetch_google_ips(config).await;
let google_ip_validation = config.google_ip_validation;
let sni = config.front_domain.clone();
println!("Scanning {} Google frontend IPs (SNI={}, timeout={}s)...", CANDIDATE_IPS.len(), sni, PROBE_TIMEOUT.as_secs());
println!(
"Scanning {} Google frontend IPs (SNI={}, timeout={}s)...",
ips.len(),
sni,
PROBE_TIMEOUT.as_secs()
);
println!();
let tls_cfg = ClientConfig::builder()
@@ -65,15 +84,15 @@ pub async fn run(config: &Config) -> bool {
let connector = TlsConnector::from(Arc::new(tls_cfg));
let sem = Arc::new(tokio::sync::Semaphore::new(CONCURRENCY));
let mut tasks = Vec::with_capacity(CANDIDATE_IPS.len());
for ip in CANDIDATE_IPS {
let mut tasks = Vec::with_capacity(ips.len());
for ip in &ips {
let sni = sni.clone();
let connector = connector.clone();
let sem = sem.clone();
let ip = ip.to_string();
tasks.push(tokio::spawn(async move {
let _permit = sem.acquire().await.ok();
probe(&ip, &sni, connector).await
let _permit: Option<tokio::sync::SemaphorePermit<'_>> = sem.acquire().await.ok();
probe(&ip, &sni, connector,google_ip_validation).await
}));
}
@@ -115,7 +134,405 @@ pub async fn run(config: &Config) -> bool {
}
}
async fn probe(ip: &str, sni: &str, connector: TlsConnector) -> Result_ {
async fn fetch_google_ips(config: &Config) -> Vec<String> {
if !config.fetch_ips_from_api {
tracing::info!("fetch_ips_from_api disabled, using static fallback");
return CANDIDATE_IPS.iter().map(|s| s.to_string()).collect();
}
match fetch_and_validate_google_ips(
&config.front_domain,
config.max_ips_to_scan,
config.scan_batch_size,
config.google_ip_validation
)
.await
{
Ok(ips) if !ips.is_empty() => {
tracing::info!("✓ Validated {} working IPs from goog.json", ips.len());
ips
}
Ok(_) => {
tracing::warn!("No working IPs found in goog.json, using static fallback");
CANDIDATE_IPS.iter().map(|s| s.to_string()).collect()
}
Err(e) => {
tracing::warn!(
"Failed to fetch/validate Google IPs: {}, using static fallback",
e
);
CANDIDATE_IPS.iter().map(|s| s.to_string()).collect()
}
}
}
async fn fetch_and_validate_google_ips(
sni: &str,
max_ips: usize,
batch_size: usize,
google_ip_validation: bool
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let famous_ips = resolve_famous_domains().await;
tracing::info!(
"Resolved {} IPs from famous Google domains",
famous_ips.len()
);
let cidrs = fetch_google_cidrs().await?;
tracing::info!("Fetched {} CIDR blocks from goog.json", cidrs.len());
let priority_cidrs = find_matching_cidrs(&famous_ips, &cidrs);
tracing::info!("Found {} CIDRs containing famous IPs", priority_cidrs.len());
let mut priority_ips = Vec::new();
for cidr in &priority_cidrs {
priority_ips.extend(cidr_to_ips(cidr));
}
let mut other_ips = Vec::new();
for cidr in &cidrs {
if !priority_cidrs.contains(cidr) {
other_ips.extend(cidr_to_ips(cidr));
}
}
tracing::info!(
"Extracted {} priority IPs and {} other IPs",
priority_ips.len(),
other_ips.len()
);
let priority_ips_len = priority_ips.len();
let other_ips_len = other_ips.len();
// Scope the rng so it's dropped before any subsequent `.await` — rand's
// ThreadRng isn't Send, so holding it across an await would error out
// the whole async fn's Send bound.
{
let mut rng = rand::thread_rng();
priority_ips.shuffle(&mut rng);
other_ips.shuffle(&mut rng);
}
let mut candidate_ips = Vec::new();
candidate_ips.extend(priority_ips.into_iter().take(max_ips));
if candidate_ips.len() < max_ips {
let remaining = max_ips - candidate_ips.len();
candidate_ips.extend(other_ips.into_iter().take(remaining));
}
if candidate_ips.is_empty() {
return Err("No valid IPs extracted from CIDRs".into());
}
tracing::info!(
"Selected {} IPs to test (from {} total), testing in batches...",
candidate_ips.len(),
priority_ips_len + other_ips_len
);
let mut working_ips = Vec::new();
for (i, chunk) in candidate_ips.chunks(batch_size).enumerate() {
tracing::debug!("Testing batch {} ({} IPs)...", i + 1, chunk.len());
let batch_working = validate_ips(chunk, sni,google_ip_validation).await;
working_ips.extend(batch_working);
}
tracing::info!(
"Found {} working IPs from {} tested",
working_ips.len(),
candidate_ips.len()
);
Ok(working_ips)
}
async fn resolve_famous_domains() -> Vec<String> {
let mut ips = Vec::new();
for domain in FAMOUS_GOOGLE_DOMAINS {
match tokio::net::lookup_host(format!("{}:443", domain)).await {
Ok(addrs) => {
for addr in addrs {
if let SocketAddr::V4(v4) = addr {
ips.push(v4.ip().to_string());
}
}
}
Err(e) => {
tracing::debug!("Failed to resolve {}: {}", domain, e);
}
}
}
ips.sort();
ips.dedup();
ips
}
fn find_matching_cidrs(ips: &[String], cidrs: &[String]) -> Vec<String> {
let mut matches = Vec::new();
for cidr in cidrs {
for ip in ips {
if ip_in_cidr(ip, cidr) {
matches.push(cidr.clone());
break;
}
}
}
matches
}
fn ip_in_cidr(ip: &str, cidr: &str) -> bool {
let parts: Vec<&str> = cidr.split('/').collect();
if parts.len() != 2 {
return false;
}
let base_ip = parts[0];
let prefix_len: u8 = match parts[1].parse() {
Ok(p) => p,
Err(_) => return false,
};
let ip_num = match ip_to_u32(ip) {
Some(n) => n,
None => return false,
};
let base_num = match ip_to_u32(base_ip) {
Some(n) => n,
None => return false,
};
// Mask defends against /0 (shift-32 of u32 would panic in debug).
let mask: u32 = if prefix_len == 0 {
0
} else if prefix_len >= 32 {
u32::MAX
} else {
!((1u32 << (32 - prefix_len)) - 1)
};
(ip_num & mask) == (base_num & mask)
}
fn ip_to_u32(ip: &str) -> Option<u32> {
let octets: Vec<&str> = ip.split('.').collect();
if octets.len() != 4 {
return None;
}
let o: Vec<u8> = octets.iter().filter_map(|s| s.parse().ok()).collect();
if o.len() != 4 {
return None;
}
Some(((o[0] as u32) << 24) | ((o[1] as u32) << 16) | ((o[2] as u32) << 8) | (o[3] as u32))
}
async fn fetch_google_cidrs() -> Result<Vec<String>, Box<dyn std::error::Error>> {
let stream = tokio::time::timeout(
Duration::from_secs(10),
TcpStream::connect("www.gstatic.com:443"),
)
.await??;
let tls_cfg = ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(NoVerify))
.with_no_client_auth();
let connector = TlsConnector::from(Arc::new(tls_cfg));
let server_name = ServerName::try_from("www.gstatic.com".to_string())?;
let mut tls_stream = tokio::time::timeout(
Duration::from_secs(10),
connector.connect(server_name, stream),
)
.await??;
let request = "GET /ipranges/goog.json HTTP/1.1\r\n\
Host: www.gstatic.com\r\n\
Connection: close\r\n\
\r\n";
tls_stream.write_all(request.as_bytes()).await?;
tls_stream.flush().await?;
let mut response = Vec::new();
tokio::time::timeout(Duration::from_secs(15), async {
let mut buf = [0u8; 4096];
loop {
match tls_stream.read(&mut buf).await {
Ok(0) => break,
Ok(n) => response.extend_from_slice(&buf[..n]),
Err(_) => break,
}
}
})
.await?;
let response_str = String::from_utf8_lossy(&response);
let body = response_str
.split("\r\n\r\n")
.nth(1)
.ok_or("No HTTP body found")?;
let json: serde_json::Value = serde_json::from_str(body)?;
let prefixes = json["prefixes"].as_array().ok_or("No prefixes array")?;
let mut cidrs = Vec::new();
for prefix in prefixes {
if let Some(ipv4) = prefix["ipv4Prefix"].as_str() {
cidrs.push(ipv4.to_string());
}
}
Ok(cidrs)
}
fn cidr_to_ips(cidr: &str) -> Vec<String> {
let parts: Vec<&str> = cidr.split('/').collect();
if parts.len() != 2 {
return Vec::new();
}
let base_ip = parts[0];
let prefix_len: u8 = match parts[1].parse() {
Ok(p) => p,
Err(_) => return Vec::new(),
};
let octets: Vec<&str> = base_ip.split('.').collect();
if octets.len() != 4 {
return Vec::new();
}
let o: Vec<u8> = match octets.iter().map(|s| s.parse()).collect() {
Ok(v) => v,
Err(_) => return Vec::new(),
};
let base = ((o[0] as u32) << 24) | ((o[1] as u32) << 16) | ((o[2] as u32) << 8) | (o[3] as u32);
if prefix_len > 32 {
return Vec::new();
}
let host_bits = 32 - prefix_len;
let num_hosts: u32 = if host_bits >= 32 { u32::MAX } else { 1u32 << host_bits };
let limit = num_hosts.min(256);
if limit < 2 {
return Vec::new();
}
(1..limit - 1)
.map(|i| {
let ip = base + i;
format!(
"{}.{}.{}.{}",
(ip >> 24) & 0xFF,
(ip >> 16) & 0xFF,
(ip >> 8) & 0xFF,
ip & 0xFF
)
})
.collect()
}
async fn validate_ips(ips: &[String], sni: &str, google_ip_validation: bool) -> Vec<String> {
let tls_cfg = ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(NoVerify))
.with_no_client_auth();
let connector = TlsConnector::from(Arc::new(tls_cfg));
let sem = Arc::new(tokio::sync::Semaphore::new(CONCURRENCY));
let mut tasks = Vec::new();
for ip in ips {
let ip = ip.clone();
let sni = sni.to_string();
let connector = connector.clone();
let sem = sem.clone();
tasks.push(tokio::spawn(async move {
let _permit = sem.acquire().await.ok();
let result = quick_probe(&ip, &sni, connector, google_ip_validation).await;
(ip, result)
}));
}
let mut working = Vec::new();
for task in tasks {
if let Ok((ip, true)) = task.await {
working.push(ip);
}
}
working
}
async fn quick_probe(
ip: &str,
sni: &str,
connector: TlsConnector,
google_ip_validation: bool,
) -> bool {
let addr: SocketAddr = match format!("{}:443", ip).parse() {
Ok(a) => a,
Err(_) => return false,
};
let tcp = match tokio::time::timeout(Duration::from_secs(2), TcpStream::connect(addr)).await {
Ok(Ok(t)) => t,
_ => return false,
};
let server_name = match ServerName::try_from(sni.to_string()) {
Ok(n) => n,
Err(_) => return false,
};
let mut tls =
match tokio::time::timeout(Duration::from_secs(2), connector.connect(server_name, tcp))
.await
{
Ok(Ok(t)) => t,
_ => return false,
};
let req = format!(
"HEAD / HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n",
sni
);
if tls.write_all(req.as_bytes()).await.is_err() {
return false;
}
let _ = tls.flush().await;
let mut buf = [0u8; 1024];
match tokio::time::timeout(Duration::from_secs(2), tls.read(&mut buf)).await {
Ok(Ok(n)) if n > 0 => {
let response = String::from_utf8_lossy(&buf[..n]);
if !response.starts_with("HTTP/") {
return false;
}
if google_ip_validation {
let lower = response.to_lowercase();
return lower.contains("server: gws")
|| lower.contains("x-google-")
|| lower.contains("alt-svc: h3=");
}
true
}
_ => false,
}
}
async fn probe(
ip: &str,
sni: &str,
connector: TlsConnector,
google_ip_validation: bool,
) -> Result_ {
let start = Instant::now();
let addr: SocketAddr = match format!("{}:443", ip).parse() {
Ok(a) => a,
@@ -158,23 +575,24 @@ async fn probe(ip: &str, sni: &str, connector: TlsConnector) -> Result_ {
}
};
let mut tls = match tokio::time::timeout(PROBE_TIMEOUT, connector.connect(server_name, tcp)).await {
Ok(Ok(t)) => t,
Ok(Err(e)) => {
return Result_ {
ip: ip.into(),
latency_ms: None,
error: Some(format!("tls: {}", e)),
let mut tls =
match tokio::time::timeout(PROBE_TIMEOUT, connector.connect(server_name, tcp)).await {
Ok(Ok(t)) => t,
Ok(Err(e)) => {
return Result_ {
ip: ip.into(),
latency_ms: None,
error: Some(format!("tls: {}", e)),
}
}
}
Err(_) => {
return Result_ {
ip: ip.into(),
latency_ms: None,
error: Some("tls timeout".into()),
Err(_) => {
return Result_ {
ip: ip.into(),
latency_ms: None,
error: Some("tls timeout".into()),
}
}
}
};
};
let req = format!(
"HEAD / HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n",
@@ -189,12 +607,29 @@ async fn probe(ip: &str, sni: &str, connector: TlsConnector) -> Result_ {
}
let _ = tls.flush().await;
let mut buf = [0u8; 256];
let mut buf = [0u8; 1024];
match tokio::time::timeout(PROBE_TIMEOUT, tls.read(&mut buf)).await {
Ok(Ok(n)) if n > 0 => {
let elapsed = start.elapsed().as_millis();
let head = String::from_utf8_lossy(&buf[..n.min(32)]);
if head.starts_with("HTTP/") {
let response = String::from_utf8_lossy(&buf[..n]);
if !response.starts_with("HTTP/") {
return Result_ {
ip: ip.into(),
latency_ms: None,
error: Some("bad reply".into()),
};
}
let lower = response.to_lowercase();
let mut is_google = true;
if google_ip_validation {
is_google = lower.contains("server: gws")
|| lower.contains("x-google-")
|| lower.contains("alt-svc: h3=");
}
if is_google {
let elapsed = start.elapsed().as_millis();
Result_ {
ip: ip.into(),
latency_ms: Some(elapsed),
@@ -204,7 +639,7 @@ async fn probe(ip: &str, sni: &str, connector: TlsConnector) -> Result_ {
Result_ {
ip: ip.into(),
latency_ms: None,
error: Some(format!("bad reply: {:?}", head)),
error: Some("not google frontend".into()),
}
}
}