From 8487940ac69e617ac0352df9d6769d186da930bd Mon Sep 17 00:00:00 2001 From: Emran Hejazi Date: Wed, 22 Apr 2026 11:20:01 +0330 Subject: [PATCH 1/8] Implement google candidate ips with a script that finds the fastest ip --- README.md | 44 +++++++++ README_FA.md | 44 +++++++++ main.py | 15 +++ src/constants.py | 33 +++++++ src/google_ip_scanner.py | 194 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 330 insertions(+) create mode 100644 src/google_ip_scanner.py diff --git a/README.md b/README.md index 8fbe892..f8e6780 100644 --- a/README.md +++ b/README.md @@ -249,10 +249,53 @@ python3 main.py --log-level DEBUG # Show detailed logs python3 main.py -c /path/to/config.json # Use a different config file python3 main.py --install-cert # Install MITM CA certificate and exit python3 main.py --no-cert-check # Skip automatic CA install check on startup +python3 main.py --scan # Scan Google IPs and find the fastest one ``` > **Auto-install:** On startup (MITM mode), the proxy automatically checks if the CA certificate is trusted and attempts to install it. Use `--no-cert-check` to skip this. If auto-install fails (e.g. needs elevation), run `python main.py --install-cert` manually or follow Step 6 above. +### Scanning for the Fastest Google IP + +If your current `google_ip` in `config.json` is blocked or slow, you can scan to find a faster one: + +```bash +python3 main.py --scan +``` + +This will: +1. Probe 27 candidate Google IPs in parallel +2. Measure latency from your network +3. Display results in a table +4. Recommend the fastest IP +5. Exit with exit code 0 if at least one IP is reachable, 1 otherwise + +**Example output:** +``` +Scanning 27 Google frontend IPs + SNI: www.google.com + Timeout: 4s per IP + Concurrency: 8 parallel probes + +IP LATENCY STATUS +-------------------- ------------ ------------------------- +216.239.32.120 42ms OK +216.239.34.120 45ms OK +216.239.36.120 52ms OK +142.250.80.142 timeout timeout +... + +Result: 15 / 27 reachable + +Top 3 fastest IPs: + 1. 216.239.32.120 (42ms) + 2. 216.239.34.120 (45ms) + 3. 216.239.36.120 (52ms) + +Recommended: Set "google_ip": "216.239.32.120" in config.json +``` + +After scanning, update your `config.json` with the recommended IP and restart the proxy. + --- ## Architecture @@ -287,6 +330,7 @@ MasterHttpRelayVPN/ ├── mitm.py # On-the-fly TLS interception ├── cert_installer.py # Cross-platform CA installer (Windows/macOS/Linux + Firefox) ├── codec.py # Content-Encoding decoder (gzip/deflate/br/zstd) + ├── google_ip_scanner.py # Scanner to find the fastest reachable Google IP ├── constants.py # Tunable defaults and shared data └── logging_utils.py # Colored, aligned log formatter ``` diff --git a/README_FA.md b/README_FA.md index 9024012..458982d 100644 --- a/README_FA.md +++ b/README_FA.md @@ -236,10 +236,53 @@ python3 main.py --log-level DEBUG python3 main.py -c /path/to/config.json python3 main.py --install-cert # نصب گواهی CA و خروج python3 main.py --no-cert-check # رد شدن از بررسی خودکار گواهی +python3 main.py --scan # اسکن IP های Google و یافتن سریع‌ترین ``` > **نصب خودکار:** هنگام اجرا در حالت `apps_script`، برنامه به‌طور خودکار بررسی می‌کند که آیا گواهی CA قابل اعتماد است یا نه و در صورت نیاز آن را نصب می‌کند. اگر نصب خودکار ناموفق بود (مثلاً نیاز به دسترسی مدیر دارد)، می‌توانید دستور `python main.py --install-cert` را اجرا کنید یا مراحل مرحله ۶ را دنبال کنید. +### اسکن کردن برای یافتن سریع‌ترین IP گوگل + +اگر `google_ip` فعلی در `config.json` بلاک شده یا آهسته است، می‌توانید اسکن کنید تا سریع‌ترین آن را پیدا کنید: + +```bash +python3 main.py --scan +``` + +این دستور: +1. ۲۷ IP برای fronting Google را به‌صورت موازی بررسی می‌کند +2. تأخیر (latency) از شبکه شما را اندازه می‌گیرد +3. نتایج را در جدول نمایش می‌دهد +4. سریع‌ترین IP را پیشنهاد می‌دهد +5. اگر حداقل یک IP در دسترس باشد کد خروج ۰، ورنه ۱ را برمی‌گرداند + +**نمونه خروجی:** +``` +Scanning 27 Google frontend IPs + SNI: www.google.com + Timeout: 4s per IP + Concurrency: 8 parallel probes + +IP LATENCY STATUS +-------------------- ------------ ------------------------- +216.239.32.120 42ms OK +216.239.34.120 45ms OK +216.239.36.120 52ms OK +142.250.80.142 timeout timeout +... + +Result: 15 / 27 reachable + +Top 3 fastest IPs: + 1. 216.239.32.120 (42ms) + 2. 216.239.34.120 (45ms) + 3. 216.239.36.120 (52ms) + +Recommended: Set "google_ip": "216.239.32.120" in config.json +``` + +پس از اسکن، مقدار `google_ip` در `config.json` را با IP پیشنهادی به‌روزرسانی کنید و پراکسی را دوباره راه‌اندازی کنید. + --- ## معماری @@ -272,6 +315,7 @@ MasterHttpRelayVPN/ ├── mitm.py # ساخت و مدیریت گواهی‌ها ├── cert_installer.py # نصب خودکار CA در ویندوز/مک/لینوکس + فایرفاکس ├── codec.py # رمزگشای Content-Encoding (gzip/deflate/br/zstd) + ├── google_ip_scanner.py # اسکنر IP های Google برای یافتن سریع‌ترین ├── constants.py # مقادیر پیش‌فرض قابل تنظیم └── logging_utils.py # فرمت‌دهنده‌ی لاگ رنگی و منظم ``` diff --git a/main.py b/main.py index 8d0c8fa..a78f713 100644 --- a/main.py +++ b/main.py @@ -22,6 +22,7 @@ if _SRC_DIR not in sys.path: from cert_installer import install_ca, is_ca_trusted from constants import __version__ +from google_ip_scanner import scan_sync from logging_utils import configure as configure_logging, print_banner from mitm import CA_CERT_FILE from proxy_server import ProxyServer @@ -91,6 +92,11 @@ def parse_args(): action="store_true", help="Skip the certificate installation check on startup.", ) + parser.add_argument( + "--scan", + action="store_true", + help="Scan Google IPs to find the fastest reachable one and exit.", + ) return parser.parse_args() @@ -168,6 +174,15 @@ def main(): ok = install_ca(CA_CERT_FILE) sys.exit(0 if ok else 1) + # ── Google IP Scanner ────────────────────────────────────────────────── + if args.scan: + setup_logging("INFO") + front_domain = config.get("front_domain", "www.google.com") + _log = logging.getLogger("Main") + _log.info(f"Scanning Google IPs (fronting domain: {front_domain})") + ok = scan_sync(front_domain) + sys.exit(0 if ok else 1) + setup_logging(config.get("log_level", "INFO")) log = logging.getLogger("Main") diff --git a/src/constants.py b/src/constants.py index 06b1987..ec27f1d 100644 --- a/src/constants.py +++ b/src/constants.py @@ -23,6 +23,39 @@ RELAY_TIMEOUT = 25 TLS_CONNECT_TIMEOUT = 15 TCP_CONNECT_TIMEOUT = 10 +# ── Google IP Scanner settings ────────────────────────────────────────────── +GOOGLE_SCANNER_TIMEOUT = 4 # Timeout per IP probe (seconds) +GOOGLE_SCANNER_CONCURRENCY = 8 # Parallel probes +# Candidate Google frontend IPs for scanning (multiple ASNs and regions) +CANDIDATE_IPS: tuple[str, ...] = ( + "216.239.32.120", + "216.239.34.120", + "216.239.36.120", + "216.239.38.120", + "142.250.80.142", + "142.250.80.138", + "142.250.179.110", + "142.250.185.110", + "142.250.184.206", + "142.250.190.238", + "142.250.191.78", + "172.217.1.206", + "172.217.14.206", + "172.217.16.142", + "172.217.22.174", + "172.217.164.110", + "172.217.168.206", + "172.217.169.206", + "34.107.221.82", + "142.251.32.110", + "142.251.33.110", + "142.251.46.206", + "142.251.46.238", + "142.250.80.170", + "142.250.72.206", + "142.250.64.206", + "142.250.72.110", +) # ── Response cache ──────────────────────────────────────────────────────── CACHE_MAX_MB = 50 diff --git a/src/google_ip_scanner.py b/src/google_ip_scanner.py new file mode 100644 index 0000000..79edc51 --- /dev/null +++ b/src/google_ip_scanner.py @@ -0,0 +1,194 @@ +""" +Google IP Scanner — finds the fastest reachable Google frontend IP. + +Scans a list of candidate Google IPs via HTTPS (with SNI fronting), measures +latency, and reports results in a formatted table. Useful for finding the best +IP to configure in config.json when your current IP is blocked. +""" + +from __future__ import annotations + +import asyncio +import logging +import ssl +import time +from dataclasses import dataclass +from typing import Optional + +from constants import CANDIDATE_IPS, GOOGLE_SCANNER_TIMEOUT, GOOGLE_SCANNER_CONCURRENCY + +log = logging.getLogger("Scanner") + + +@dataclass +class ProbeResult: + """Result of a single IP probe.""" + ip: str + latency_ms: Optional[int] = None + error: Optional[str] = None + + @property + def ok(self) -> bool: + return self.latency_ms is not None + + +async def _probe_ip( + ip: str, + sni: str, + semaphore: asyncio.Semaphore, + timeout: float, +) -> ProbeResult: + """ + Probe a single IP via HTTPS with SNI fronting. + + Args: + ip: The IP to probe (xxx.xxx.xxx.xxx). + sni: The SNI hostname to use in TLS handshake. + semaphore: Rate limiter to control concurrency. + timeout: Timeout in seconds for the entire probe. + + Returns: + ProbeResult with latency_ms (if successful) or error message. + """ + async with semaphore: + start_time = time.time() + try: + # Create SSL context that skips certificate verification + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + + # Connect to IP:443 with SNI set to the fronting domain + reader, writer = await asyncio.wait_for( + asyncio.open_connection( + ip, + 443, + ssl=ctx, + server_hostname=sni, + ), + timeout=timeout, + ) + + # Send minimal HTTP HEAD request + request = f"HEAD / HTTP/1.1\r\nHost: {sni}\r\nConnection: close\r\n\r\n" + writer.write(request.encode()) + await writer.drain() + + # Read response header (first 256 bytes is plenty for HTTP status) + response = await asyncio.wait_for(reader.read(256), timeout=timeout) + + writer.close() + try: + await writer.wait_closed() + except Exception: + pass + + # Check if we got an HTTP response + if not response: + return ProbeResult(ip=ip, error="empty response") + + response_str = response.decode("utf-8", errors="ignore") + if not response_str.startswith("HTTP/"): + return ProbeResult(ip=ip, error=f"invalid response: {response_str[:30]!r}") + + # Success — return latency in milliseconds + elapsed_ms = int((time.time() - start_time) * 1000) + return ProbeResult(ip=ip, latency_ms=elapsed_ms) + + except asyncio.TimeoutError: + return ProbeResult(ip=ip, error="timeout") + except ConnectionRefusedError: + return ProbeResult(ip=ip, error="connection refused") + except ConnectionResetError: + return ProbeResult(ip=ip, error="connection reset") + except OSError as e: + return ProbeResult(ip=ip, error=f"network error: {e.strerror or str(e)}") + except Exception as e: + return ProbeResult(ip=ip, error=f"probe failed: {type(e).__name__}") + + +async def run(front_domain: str) -> bool: + """ + Scan all candidate Google IPs and display results. + + Args: + front_domain: The SNI hostname to use (e.g. "www.google.com"). + + Returns: + True if at least one IP is reachable, False otherwise. + """ + timeout = GOOGLE_SCANNER_TIMEOUT + concurrency = GOOGLE_SCANNER_CONCURRENCY + + print() + print(f"Scanning {len(CANDIDATE_IPS)} Google frontend IPs") + print(f" SNI: {front_domain}") + print(f" Timeout: {timeout}s per IP") + print(f" Concurrency: {concurrency} parallel probes") + print() + + # Create semaphore to limit concurrency + semaphore = asyncio.Semaphore(concurrency) + + # Launch all probes concurrently + tasks = [ + _probe_ip(ip, front_domain, semaphore, timeout) + for ip in CANDIDATE_IPS + ] + results = await asyncio.gather(*tasks) + + # Sort by latency (successful first, then by speed) + results.sort(key=lambda r: (not r.ok, r.latency_ms or float("inf"))) + + # Display results table + print(f"{'IP':<20} {'LATENCY':<12} {'STATUS':<25}") + print(f"{'-' * 20} {'-' * 12} {'-' * 25}") + + ok_count = 0 + for result in results: + if result.ok: + print(f"{result.ip:<20} {result.latency_ms:>8}ms OK") + ok_count += 1 + else: + status = result.error or "unknown error" + print(f"{result.ip:<20} {'—':<12} {status:<25}") + + print() + print(f"Result: {ok_count} / {len(results)} reachable") + + if ok_count == 0: + print("No Google IPs reachable from this network.") + print() + return False + + # Show top 3 fastest + fastest = [r for r in results if r.ok][:3] + print() + print("Top 3 fastest IPs:") + for i, result in enumerate(fastest, 1): + print(f" {i}. {result.ip} ({result.latency_ms}ms)") + + print() + print(f"Recommended: Set \"google_ip\": \"{fastest[0].ip}\" in config.json") + print() + return True + + +def scan_sync(front_domain: str) -> bool: + """ + Wrapper to run async scanner from sync context (e.g. main.py). + + Args: + front_domain: The SNI hostname to use. + + Returns: + True if at least one IP is reachable, False otherwise. + """ + try: + return asyncio.run(run(front_domain)) + except KeyboardInterrupt: + print("\nScan interrupted by user.") + return False + except Exception as e: + log.error(f"Scan failed: {e}") + return False From 7b38663bf9b2e9016cd396a04697aaa085c79a50 Mon Sep 17 00:00:00 2001 From: lostact Date: Wed, 22 Apr 2026 20:58:45 +0330 Subject: [PATCH 2/8] feat: Add configurable streaming chunked downloads for large files Implemented parallel chunked downloading with real-time streaming to prevent timeouts and memory issues on large files. Features: - Configurable extension filtering (supports ".*" wildcard) - Configurable size threshold, chunk size, and parallelism - Streams chunks to client as they arrive in correct order - Retry mechanism with timeout handling (5 retries, 15s timeout) - Falls back to single request if Range not supported Config options (config.json): - chunked_download_extensions: File extensions array - chunked_download_min_size: Minimum size in bytes (default: 5MB) - chunked_download_chunk_size: Chunk size (default: 256KB) - chunked_download_max_parallel: Max concurrent downloads (default: 16) --- src/domain_fronter.py | 181 ++++++++++++++++++++++++++++++++++++++++++ src/proxy_server.py | 54 ++++++++++--- 2 files changed, 223 insertions(+), 12 deletions(-) diff --git a/src/domain_fronter.py b/src/domain_fronter.py index 3464602..ab7705e 100644 --- a/src/domain_fronter.py +++ b/src/domain_fronter.py @@ -851,6 +851,187 @@ class DomainFronter: result += "\r\n" return result.encode() + full_body + async def relay_parallel_streaming(self, method: str, url: str, + headers: dict, body: bytes, + writer, min_size: int = 0, + chunk_size: int = 256 * 1024, + max_parallel: int = 16) -> bytes: + """Stream large file download to client as chunks arrive. + + Downloads file in parallel chunks and streams to client immediately, + avoiding memory buildup and timeout issues for large files. + + Args: + min_size: Minimum file size to enable chunking (0 = no minimum) + chunk_size: Size of each chunk in bytes (default 256KB) + max_parallel: Maximum parallel chunk downloads (default 16) + """ + + if method != "GET" or body: + return await self.relay(method, url, headers, body) + + # Probe: first chunk with Range header + range_headers = dict(headers) if headers else {} + range_headers["Range"] = f"bytes=0-{chunk_size - 1}" + first_resp = await self.relay("GET", url, range_headers, b"") + + status, resp_hdrs, resp_body = self._split_raw_response(first_resp) + + # No range support → return single response + if status != 206: + return first_resp + + # Parse total size from Content-Range + content_range = resp_hdrs.get("content-range", "") + m = re.search(r"/(\d+)", content_range) + if not m: + return self._rewrite_206_to_200(first_resp) + + total_size = int(m.group(1)) + + # Check minimum size threshold (if configured) + if min_size > 0 and total_size < min_size: + log.debug( + "File size %d < min threshold %d, using single request", + total_size, min_size + ) + return self._rewrite_206_to_200(first_resp) + + # Small file (less than one chunk) → return immediately + if total_size <= chunk_size or len(resp_body) >= total_size: + return self._rewrite_206_to_200(first_resp) + + # Build response header + response_header = "HTTP/1.1 200 OK\r\n" + skip = {"transfer-encoding", "connection", "keep-alive", + "content-length", "content-encoding", "content-range"} + for k, v in resp_hdrs.items(): + if k.lower() not in skip: + response_header += f"{k}: {v}\r\n" + response_header += f"Content-Length: {total_size}\r\n\r\n" + + # Send header + first chunk immediately + writer.write(response_header.encode() + resp_body) + await writer.drain() + + log.info("Streaming download: %d bytes, %d chunks of %d KB", + total_size, (total_size + chunk_size - 1) // chunk_size, + chunk_size // 1024) + + # Calculate remaining ranges + ranges = [] + start = len(resp_body) + while start < total_size: + end = min(start + chunk_size - 1, total_size - 1) + ranges.append((start, end)) + start = end + 1 + + # Download and stream chunks in order + sem = asyncio.Semaphore(max_parallel) + chunk_buffer = {} # idx -> chunk_data + next_to_stream = 0 + stream_event = asyncio.Event() + all_downloaded = asyncio.Event() + + async def fetch_chunk(idx: int, s: int, e: int, max_retries: int = 5): + """Download chunk with retry and timeout.""" + async with sem: + rh = dict(headers) if headers else {} + rh["Range"] = f"bytes={s}-{e}" + expected_size = e - s + 1 + + for attempt in range(max_retries): + try: + # Low timeout for small chunks (256KB should download quickly) + raw = await asyncio.wait_for( + self.relay("GET", url, rh, b""), + timeout=15 + ) + _, _, chunk_body = self._split_raw_response(raw) + + # Verify chunk size + if len(chunk_body) != expected_size: + log.warning( + "Chunk %d size mismatch: got %d, expected %d (retry %d/%d)", + idx, len(chunk_body), expected_size, attempt + 1, max_retries + ) + if attempt < max_retries - 1: + await asyncio.sleep(0.5 * (attempt + 1)) + continue + + # Store chunk in buffer + chunk_buffer[idx] = chunk_body + stream_event.set() # Signal streamer + log.debug("Downloaded chunk %d/%d", idx + 1, len(ranges)) + return + + except asyncio.TimeoutError: + log.warning( + "Chunk %d timeout (retry %d/%d)", + idx, attempt + 1, max_retries + ) + if attempt < max_retries - 1: + await asyncio.sleep(0.5 * (attempt + 1)) + except Exception as ex: + log.warning( + "Chunk %d error: %s (retry %d/%d)", + idx, ex, attempt + 1, max_retries + ) + if attempt < max_retries - 1: + await asyncio.sleep(0.5 * (attempt + 1)) + + # All retries failed + raise Exception(f"Chunk {idx} failed after {max_retries} retries") + + async def stream_chunks(): + """Stream chunks to client in sequential order.""" + nonlocal next_to_stream + try: + while next_to_stream < len(ranges): + # Wait for next chunk to be available + while next_to_stream not in chunk_buffer: + if all_downloaded.is_set(): + # All downloads done but chunk missing + raise Exception(f"Chunk {next_to_stream} never arrived") + stream_event.clear() + await asyncio.wait_for(stream_event.wait(), timeout=30) + + # Stream the chunk + chunk_data = chunk_buffer.pop(next_to_stream) + writer.write(chunk_data) + await writer.drain() + log.debug("Streamed chunk %d/%d", next_to_stream + 1, len(ranges)) + next_to_stream += 1 + + except Exception as e: + log.error("Streaming error: %s", e) + raise + + # Start downloads and streaming concurrently + download_tasks = [ + asyncio.create_task(fetch_chunk(i, s, e)) + for i, (s, e) in enumerate(ranges) + ] + stream_task = asyncio.create_task(stream_chunks()) + + # Wait for downloads to complete + try: + await asyncio.gather(*download_tasks) + all_downloaded.set() + stream_event.set() # Wake up streamer for final check + await stream_task + except Exception as e: + # Cancel remaining tasks + for task in download_tasks: + if not task.done(): + task.cancel() + if not stream_task.done(): + stream_task.cancel() + raise + + # Return empty bytes since we already streamed everything + return b"" + @staticmethod def _rewrite_206_to_200(raw: bytes) -> bytes: """Rewrite a 206 Partial Content response to 200 OK. diff --git a/src/proxy_server.py b/src/proxy_server.py index 7e17b52..b47f7a1 100644 --- a/src/proxy_server.py +++ b/src/proxy_server.py @@ -156,6 +156,14 @@ class ProxyServer: self.socks_port = config.get("socks5_port", 1080) self.fronter = DomainFronter(config) self.mitm = None + + # Chunked download settings (configurable) + exts = config.get("chunked_download_extensions", list(LARGE_FILE_EXTS)) + self._chunked_extensions = frozenset(exts) + self._chunked_bypass_check = ".*" in exts + self._chunked_min_size = config.get("chunked_download_min_size", 5 * 1024 * 1024) # 5MB default + self._chunked_chunk_size = config.get("chunked_download_chunk_size", 256 * 1024) # 256KB default + self._chunked_max_parallel = config.get("chunked_download_max_parallel", 16) # 16 parallel default self._cache = ResponseCache(max_mb=CACHE_MAX_MB) self._direct_fail_until: dict[str, float] = {} self._servers: list[asyncio.base_events.Server] = [] @@ -1000,7 +1008,7 @@ class ProxyServer: if response is None: # Relay through Apps Script try: - response = await self._relay_smart(method, url, headers, body) + response = await self._relay_smart(method, url, headers, body, writer) except Exception as e: log.error("Relay error (%s): %s", url[:60], e) err_body = f"Relay error: {e}".encode() @@ -1025,8 +1033,10 @@ class ProxyServer: self._log_response_summary(url, response) - writer.write(response) - await writer.drain() + # Only write if response not empty (streaming already sent data) + if response: + writer.write(response) + await writer.drain() except asyncio.TimeoutError: break @@ -1096,10 +1106,10 @@ class ProxyServer: additions.append("Vary: Origin") return ("\r\n".join(lines + additions) + "\r\n\r\n").encode() + body - async def _relay_smart(self, method, url, headers, body): + async def _relay_smart(self, method, url, headers, body, writer=None): """Choose optimal relay strategy based on request type. - - GET requests for likely-large downloads use parallel-range. + - GET requests for likely-large downloads use parallel-range with streaming. - All other requests (API calls, HTML, JSON, XHR) go through the single-request relay. This avoids injecting a synthetic Range header on normal traffic, which some origins honor by returning @@ -1116,19 +1126,37 @@ class ProxyServer: ) # Only probe with Range when the URL looks like a big file. if self._is_likely_download(url, headers): + # Use streaming version if writer provided + if writer: + return await self.fronter.relay_parallel_streaming( + method, url, headers, body, writer, + min_size=self._chunked_min_size, + chunk_size=self._chunked_chunk_size, + max_parallel=self._chunked_max_parallel + ) return await self.fronter.relay_parallel( - method, url, headers, body + method, url, headers, body, + chunk_size=self._chunked_chunk_size, + max_parallel=self._chunked_max_parallel ) return await self.fronter.relay(method, url, headers, body) def _is_likely_download(self, url: str, headers: dict) -> bool: - """Heuristic: is this URL likely a large file download?""" + """Heuristic: is this URL likely a large file download? + + If ".*" is in chunked_download_extensions, bypasses extension check + and returns True for all URLs (enables chunking for any file). + """ + if self._chunked_bypass_check: + return True + path = url.split("?")[0].lower() - for ext in LARGE_FILE_EXTS: - if path.endswith(ext): + for ext in self._chunked_extensions: + if path.endswith(ext.lower()): return True return False + # ── Plain HTTP forwarding ───────────────────────────────────── async def _do_http(self, header_block: bytes, reader, writer): @@ -1181,7 +1209,7 @@ class ProxyServer: log.debug("Cache HIT (HTTP): %s", url[:60]) if response is None: - response = await self._relay_smart(method, url, headers, body) + response = await self._relay_smart(method, url, headers, body, writer) # Cache successful GET if self._cache_allowed(method, url, headers, body) and response: ttl = ResponseCache.parse_ttl(response, url) @@ -1193,5 +1221,7 @@ class ProxyServer: response = self._inject_cors_headers(response, origin) self._log_response_summary(url, response) - writer.write(response) - await writer.drain() + # Only write if response not empty (streaming already sent data) + if response: + writer.write(response) + await writer.drain() From e8b8d8ca3ec2f21cde2db1cda6fae68c705e532d Mon Sep 17 00:00:00 2001 From: hmb_m Date: Thu, 23 Apr 2026 15:22:01 +0330 Subject: [PATCH 3/8] fix: load certifi CA bundle for outbound TLS contexts --- src/domain_fronter.py | 13 +++++++++++++ src/h2_transport.py | 12 ++++++++++++ src/proxy_server.py | 10 ++++++++++ 3 files changed, 35 insertions(+) diff --git a/src/domain_fronter.py b/src/domain_fronter.py index 3464602..79ad30a 100644 --- a/src/domain_fronter.py +++ b/src/domain_fronter.py @@ -20,6 +20,11 @@ import time from dataclasses import dataclass from urllib.parse import urlparse +try: + import certifi +except Exception: # optional dependency fallback + certifi = None + import codec from constants import ( BATCH_MAX, @@ -175,6 +180,14 @@ class DomainFronter: def _ssl_ctx(self) -> ssl.SSLContext: ctx = ssl.create_default_context() + # Some Python builds on macOS ship without a usable system CA path. + # Prefer certifi's CA bundle when available to avoid spurious + # CERTIFICATE_VERIFY_FAILED errors on valid public certificates. + if certifi is not None: + try: + ctx.load_verify_locations(cafile=certifi.where()) + except Exception: + pass if not self.verify_ssl: ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE diff --git a/src/h2_transport.py b/src/h2_transport.py index 6eb3d85..eb699d6 100644 --- a/src/h2_transport.py +++ b/src/h2_transport.py @@ -20,6 +20,11 @@ import socket import ssl from urllib.parse import urlparse +try: + import certifi +except Exception: # optional dependency fallback + certifi = None + import codec log = logging.getLogger("H2") @@ -106,6 +111,13 @@ class H2Transport: async def _do_connect(self): """Establish the HTTP/2 connection with optimized socket settings.""" ctx = ssl.create_default_context() + # Some Python builds don't expose a usable default CA store. + # Load certifi bundle when present to keep TLS verification stable. + if certifi is not None: + try: + ctx.load_verify_locations(cafile=certifi.where()) + except Exception: + pass # Advertise both h2 and http/1.1 — some DPI blocks h2-only ALPN ctx.set_alpn_protocols(["h2", "http/1.1"]) if not self.verify_ssl: diff --git a/src/proxy_server.py b/src/proxy_server.py index 46ca267..3f51250 100644 --- a/src/proxy_server.py +++ b/src/proxy_server.py @@ -15,6 +15,11 @@ import time import ipaddress from urllib.parse import urlparse +try: + import certifi +except Exception: # optional dependency fallback + certifi = None + from constants import ( CACHE_MAX_MB, CACHE_TTL_MAX, @@ -854,6 +859,11 @@ class ProxyServer: # Step 2: open outgoing TLS to target IP with the safe SNI ssl_ctx_client = ssl.create_default_context() + if certifi is not None: + try: + ssl_ctx_client.load_verify_locations(cafile=certifi.where()) + except Exception: + pass if not self.fronter.verify_ssl: ssl_ctx_client.check_hostname = False ssl_ctx_client.verify_mode = ssl.CERT_NONE From fdc222c9226c512d06d1967aa5a812da077efaa1 Mon Sep 17 00:00:00 2001 From: free-the-internet <115314771+free-the-internet@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:58:42 +0200 Subject: [PATCH 4/8] re-enable batching in case the reason is beyond Apps Script quota exhaustion --- src/domain_fronter.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/domain_fronter.py b/src/domain_fronter.py index 3464602..d58a3e5 100644 --- a/src/domain_fronter.py +++ b/src/domain_fronter.py @@ -143,6 +143,8 @@ class DomainFronter: self._batch_window_macro = BATCH_WINDOW_MACRO self._batch_max = BATCH_MAX self._batch_enabled = True + self._batch_disabled_at = 0.0 # timestamp when batching was disabled; 0 = never disabled + self._batch_cooldown = 60 # seconds before re-enabling after failure # Request coalescing — dedup concurrent identical GETs self._coalesce: dict[str, list[asyncio.Future]] = {} @@ -946,9 +948,15 @@ class DomainFronter: async def _batch_submit(self, payload: dict) -> bytes: """Submit a request to the batch collector. Returns raw HTTP response.""" - # If batching is disabled (old Code.gs), go direct + # If batching is disabled, check if cooldown has expired if not self._batch_enabled: - return await self._relay_with_retry(payload) + if self._batch_disabled_at > 0 and (time.time() - self._batch_disabled_at) >= self._batch_cooldown: + # Cooldown expired — try re-enabling batching + self._batch_enabled = True + log.info("Batch mode re-enabled after %ds cooldown", self._batch_cooldown) + else: + # Still in cooldown or permanently disabled (old Code.gs) — go direct + return await self._relay_with_retry(payload) future = asyncio.get_event_loop().create_future() @@ -1016,9 +1024,10 @@ class DomainFronter: if not future.done(): future.set_result(result) except Exception as e: - log.warning("Batch relay failed, disabling batch mode. " - "Redeploy Code.gs for batch support. Error: %s", e) + log.warning("Batch relay failed, disabling batch mode for %ds cooldown. " + "Error: %s", self._batch_cooldown, e) self._batch_enabled = False + self._batch_disabled_at = time.time() # Fallback: send individually tasks = [] for payload, future in batch: @@ -1548,4 +1557,4 @@ class DomainFronter: f"Content-Length: {len(body)}\r\n" f"\r\n" f"{body}" - ).encode() + ).encode() \ No newline at end of file From 1df9cf4d682e40abea166fe8ed2c6f89c9ab6739 Mon Sep 17 00:00:00 2001 From: Abolfazl Date: Thu, 23 Apr 2026 22:31:27 +0330 Subject: [PATCH 5/8] feat: add certifi for TLS verification on macOS/Windows --- requirements.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/requirements.txt b/requirements.txt index ee2b572..5b0e2a3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,6 +7,9 @@ cryptography>=41.0.0 # Optional: HTTP/2 multiplexing (faster apps_script relay) h2>=4.1.0 +# Optional: CA bundle for TLS verification (recommended on macOS / Windows) +certifi>=2024.1.0 + # Optional: Brotli decompression (modern websites send `br` encoding) brotli>=1.1.0 From bca757a46a722eeb6ddc61ef84b775a79a92b5e9 Mon Sep 17 00:00:00 2001 From: Abolfazl Date: Thu, 23 Apr 2026 22:38:59 +0330 Subject: [PATCH 6/8] fix: replace deprecated get_event_loop() with get_running_loop() in async functions --- src/domain_fronter.py | 75 ++++++++++++++++++++++--------------------- src/h2_transport.py | 2 +- src/proxy_server.py | 14 ++++---- 3 files changed, 46 insertions(+), 45 deletions(-) diff --git a/src/domain_fronter.py b/src/domain_fronter.py index c8b8284..687b376 100644 --- a/src/domain_fronter.py +++ b/src/domain_fronter.py @@ -202,7 +202,7 @@ class DomainFronter: we rotate across `self._sni_hosts` so DPI can't fingerprint "always www.google.com" from the client side. """ - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) sock.setblocking(False) @@ -228,7 +228,7 @@ class DomainFronter: async def _acquire(self): """Get a healthy TLS connection from pool (TTL-checked) or open new.""" - now = asyncio.get_event_loop().time() + now = asyncio.get_running_loop().time() async with self._pool_lock: while self._pool: reader, writer, created = self._pool.pop() @@ -247,11 +247,11 @@ class DomainFronter: if not self._refilling: self._refilling = True self._spawn(self._refill_pool()) - return reader, writer, asyncio.get_event_loop().time() + return reader, writer, asyncio.get_running_loop().time() async def _release(self, reader, writer, created): """Return a connection to the pool if still young and healthy.""" - now = asyncio.get_event_loop().time() + now = asyncio.get_running_loop().time() if (now - created) >= self._conn_ttl or reader.at_eof(): try: writer.close() @@ -442,6 +442,7 @@ class DomainFronter: def _exec_path_for_sid(self, sid: str) -> str: """Build the /macros/s//(dev|exec) path for a specific script ID.""" return f"/macros/s/{sid}/{'dev' if self._dev_available else 'exec'}" + async def _flush_pool(self): """Close all pooled connections (they may be stale after errors).""" async with self._pool_lock: @@ -464,7 +465,7 @@ class DomainFronter: """Open one TLS connection and add it to the pool.""" try: r, w = await asyncio.wait_for(self._open(), timeout=5) - t = asyncio.get_event_loop().time() + t = asyncio.get_running_loop().time() async with self._pool_lock: if len(self._pool) < self._pool_max: self._pool.append((r, w, t)) @@ -481,7 +482,7 @@ class DomainFronter: while True: try: await asyncio.sleep(3) - now = asyncio.get_event_loop().time() + now = asyncio.get_running_loop().time() # Purge expired / dead connections async with self._pool_lock: @@ -713,7 +714,7 @@ class DomainFronter: race where the owning task's `finally` pops the entry between the check and append by a second task. """ - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() async with self._batch_lock: waiters = self._coalesce.get(url) if waiters is not None: @@ -833,12 +834,12 @@ class DomainFronter: f"chunk {s}-{e} failed after {max_tries} tries: {last_err}" ) - t0 = asyncio.get_event_loop().time() + t0 = asyncio.get_running_loop().time() results = await asyncio.gather( *[fetch_range(s, e) for s, e in ranges], return_exceptions=True, ) - elapsed = asyncio.get_event_loop().time() - t0 + elapsed = asyncio.get_running_loop().time() - t0 # Assemble full body parts = [resp_body] @@ -870,38 +871,38 @@ class DomainFronter: chunk_size: int = 256 * 1024, max_parallel: int = 16) -> bytes: """Stream large file download to client as chunks arrive. - + Downloads file in parallel chunks and streams to client immediately, avoiding memory buildup and timeout issues for large files. - + Args: min_size: Minimum file size to enable chunking (0 = no minimum) chunk_size: Size of each chunk in bytes (default 256KB) max_parallel: Maximum parallel chunk downloads (default 16) """ - + if method != "GET" or body: return await self.relay(method, url, headers, body) - + # Probe: first chunk with Range header range_headers = dict(headers) if headers else {} range_headers["Range"] = f"bytes=0-{chunk_size - 1}" first_resp = await self.relay("GET", url, range_headers, b"") - + status, resp_hdrs, resp_body = self._split_raw_response(first_resp) - + # No range support → return single response if status != 206: return first_resp - + # Parse total size from Content-Range content_range = resp_hdrs.get("content-range", "") m = re.search(r"/(\d+)", content_range) if not m: return self._rewrite_206_to_200(first_resp) - + total_size = int(m.group(1)) - + # Check minimum size threshold (if configured) if min_size > 0 and total_size < min_size: log.debug( @@ -909,11 +910,11 @@ class DomainFronter: total_size, min_size ) return self._rewrite_206_to_200(first_resp) - + # Small file (less than one chunk) → return immediately if total_size <= chunk_size or len(resp_body) >= total_size: return self._rewrite_206_to_200(first_resp) - + # Build response header response_header = "HTTP/1.1 200 OK\r\n" skip = {"transfer-encoding", "connection", "keep-alive", @@ -922,15 +923,15 @@ class DomainFronter: if k.lower() not in skip: response_header += f"{k}: {v}\r\n" response_header += f"Content-Length: {total_size}\r\n\r\n" - + # Send header + first chunk immediately writer.write(response_header.encode() + resp_body) await writer.drain() - + log.info("Streaming download: %d bytes, %d chunks of %d KB", total_size, (total_size + chunk_size - 1) // chunk_size, chunk_size // 1024) - + # Calculate remaining ranges ranges = [] start = len(resp_body) @@ -938,21 +939,21 @@ class DomainFronter: end = min(start + chunk_size - 1, total_size - 1) ranges.append((start, end)) start = end + 1 - + # Download and stream chunks in order sem = asyncio.Semaphore(max_parallel) chunk_buffer = {} # idx -> chunk_data next_to_stream = 0 stream_event = asyncio.Event() all_downloaded = asyncio.Event() - + async def fetch_chunk(idx: int, s: int, e: int, max_retries: int = 5): """Download chunk with retry and timeout.""" async with sem: rh = dict(headers) if headers else {} rh["Range"] = f"bytes={s}-{e}" expected_size = e - s + 1 - + for attempt in range(max_retries): try: # Low timeout for small chunks (256KB should download quickly) @@ -961,7 +962,7 @@ class DomainFronter: timeout=15 ) _, _, chunk_body = self._split_raw_response(raw) - + # Verify chunk size if len(chunk_body) != expected_size: log.warning( @@ -971,13 +972,13 @@ class DomainFronter: if attempt < max_retries - 1: await asyncio.sleep(0.5 * (attempt + 1)) continue - + # Store chunk in buffer chunk_buffer[idx] = chunk_body stream_event.set() # Signal streamer log.debug("Downloaded chunk %d/%d", idx + 1, len(ranges)) return - + except asyncio.TimeoutError: log.warning( "Chunk %d timeout (retry %d/%d)", @@ -992,10 +993,10 @@ class DomainFronter: ) if attempt < max_retries - 1: await asyncio.sleep(0.5 * (attempt + 1)) - + # All retries failed raise Exception(f"Chunk {idx} failed after {max_retries} retries") - + async def stream_chunks(): """Stream chunks to client in sequential order.""" nonlocal next_to_stream @@ -1008,25 +1009,25 @@ class DomainFronter: raise Exception(f"Chunk {next_to_stream} never arrived") stream_event.clear() await asyncio.wait_for(stream_event.wait(), timeout=30) - + # Stream the chunk chunk_data = chunk_buffer.pop(next_to_stream) writer.write(chunk_data) await writer.drain() log.debug("Streamed chunk %d/%d", next_to_stream + 1, len(ranges)) next_to_stream += 1 - + except Exception as e: log.error("Streaming error: %s", e) raise - + # Start downloads and streaming concurrently download_tasks = [ asyncio.create_task(fetch_chunk(i, s, e)) for i, (s, e) in enumerate(ranges) ] stream_task = asyncio.create_task(stream_chunks()) - + # Wait for downloads to complete try: await asyncio.gather(*download_tasks) @@ -1041,7 +1042,7 @@ class DomainFronter: if not stream_task.done(): stream_task.cancel() raise - + # Return empty bytes since we already streamed everything return b"" @@ -1144,7 +1145,7 @@ class DomainFronter: if not self._batch_enabled: return await self._relay_with_retry(payload) - future = asyncio.get_event_loop().create_future() + future = asyncio.get_running_loop().create_future() async with self._batch_lock: self._batch_pending.append((payload, future)) diff --git a/src/h2_transport.py b/src/h2_transport.py index eb699d6..d68bc14 100644 --- a/src/h2_transport.py +++ b/src/h2_transport.py @@ -139,7 +139,7 @@ class H2Transport: try: await asyncio.wait_for( - asyncio.get_event_loop().sock_connect( + asyncio.get_running_loop().sock_connect( raw, (self.connect_host, 443) ), timeout=15, diff --git a/src/proxy_server.py b/src/proxy_server.py index 954198a..571e3e6 100644 --- a/src/proxy_server.py +++ b/src/proxy_server.py @@ -161,7 +161,7 @@ class ProxyServer: self.socks_port = config.get("socks5_port", 1080) self.fronter = DomainFronter(config) self.mitm = None - + # Chunked download settings (configurable) exts = config.get("chunked_download_extensions", list(LARGE_FILE_EXTS)) self._chunked_extensions = frozenset(exts) @@ -853,7 +853,7 @@ class ProxyServer: # Step 1: MITM — accept TLS from the browser ssl_ctx_server = self.mitm.get_server_context(host) - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() transport = writer.transport protocol = transport.get_protocol() try: @@ -925,7 +925,7 @@ class ProxyServer: ssl_ctx = self.mitm.get_server_context(host) # Upgrade the existing connection to TLS (we are the server) - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() transport = writer.transport protocol = transport.get_protocol() @@ -1012,11 +1012,11 @@ class ProxyServer: if b":" in raw_line: k, v = raw_line.decode(errors="replace").split(":", 1) headers[k.strip()] = v.strip() - + # Shortening the length of X API URLs to prevent relay errors. if host == "x.com" and re.match(r"/i/api/graphql/[^/]+/[^?]+\?variables=", path): path = path.split("&")[0] - + # MITM traffic arrives as origin-form paths; SOCKS/plain HTTP can # also send absolute-form requests. Normalize both to full URLs. if path.startswith("http://") or path.startswith("https://"): @@ -1196,13 +1196,13 @@ class ProxyServer: def _is_likely_download(self, url: str, headers: dict) -> bool: """Heuristic: is this URL likely a large file download? - + If ".*" is in chunked_download_extensions, bypasses extension check and returns True for all URLs (enables chunking for any file). """ if self._chunked_bypass_check: return True - + path = url.split("?")[0].lower() for ext in self._chunked_extensions: if path.endswith(ext.lower()): From 4243135c66b39ac8832ab2f0f3d5755bfc0360fc Mon Sep 17 00:00:00 2001 From: Abolfazl Date: Thu, 23 Apr 2026 22:58:49 +0330 Subject: [PATCH 7/8] fix: improve error handling in proxy server and h2 transport classes --- src/domain_fronter.py | 2 +- src/h2_transport.py | 9 +++++++++ src/proxy_server.py | 15 ++++++++++++--- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/domain_fronter.py b/src/domain_fronter.py index 4a62849..912adbc 100644 --- a/src/domain_fronter.py +++ b/src/domain_fronter.py @@ -1752,4 +1752,4 @@ class DomainFronter: f"Content-Length: {len(body)}\r\n" f"\r\n" f"{body}" - ).encode() \ No newline at end of file + ).encode() diff --git a/src/h2_transport.py b/src/h2_transport.py index d68bc14..94a1454 100644 --- a/src/h2_transport.py +++ b/src/h2_transport.py @@ -363,6 +363,15 @@ class H2Transport: except asyncio.CancelledError: pass + except ssl.SSLError as e: + # APPLICATION_DATA_AFTER_CLOSE_NOTIFY is raised when the server + # sends data after its TLS close_notify — technically a protocol + # violation but very common with CDNs. It just means the + # connection is closed; reconnect on the next request. + if "APPLICATION_DATA_AFTER_CLOSE_NOTIFY" in str(e): + log.debug("H2 TLS session closed by remote (close_notify): %s", e) + else: + log.error("H2 reader error: %s", e) except Exception as e: log.error("H2 reader error: %s", e) finally: diff --git a/src/proxy_server.py b/src/proxy_server.py index 571e3e6..6511c0b 100644 --- a/src/proxy_server.py +++ b/src/proxy_server.py @@ -1014,7 +1014,7 @@ class ProxyServer: headers[k.strip()] = v.strip() # Shortening the length of X API URLs to prevent relay errors. - if host == "x.com" and re.match(r"/i/api/graphql/[^/]+/[^?]+\?variables=", path): + if host == "x.com" and re.match(r"/i/api/graphql/[^/]+/[^?]+\?variables=", path): path = path.split("&")[0] # MITM traffic arrives as origin-form paths; SOCKS/plain HTTP can @@ -1062,9 +1062,18 @@ class ProxyServer: # Relay through Apps Script try: response = await self._relay_smart(method, url, headers, body, writer) + except asyncio.TimeoutError: + log.warning("Relay timeout (%s)", url[:60]) + response = ( + b"HTTP/1.1 504 Gateway Timeout\r\n" + b"Content-Type: text/plain\r\n" + b"Content-Length: 13\r\n" + b"\r\nRelay timeout" + ) except Exception as e: - log.error("Relay error (%s): %s", url[:60], e) - err_body = f"Relay error: {e}".encode() + err_label = str(e) or type(e).__name__ + log.error("Relay error (%s): %s", url[:60], err_label) + err_body = f"Relay error: {err_label}".encode() response = ( b"HTTP/1.1 502 Bad Gateway\r\n" b"Content-Type: text/plain\r\n" From fc46e3d6e0172e3ee7f4358c0bbe0b5720238ee9 Mon Sep 17 00:00:00 2001 From: Abolfazl Date: Thu, 23 Apr 2026 23:02:04 +0330 Subject: [PATCH 8/8] Fixed the youtube safesearch problems. --- README.md | 1 + README_FA.md | 3 ++- config.example.json | 1 + src/proxy_server.py | 20 ++++++++++++++++++++ 4 files changed, 24 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ba1e6a2..364b2c2 100644 --- a/README.md +++ b/README.md @@ -289,6 +289,7 @@ This project focuses entirely on the **Apps Script** relay — a free Google acc | `bypass_hosts` | `["localhost", ".local", ".lan", ".home.arpa"]` | Hosts that go direct (no MITM, no relay). Useful for LAN resources or sites that break under MITM. | | `direct_google_exclude` | see [config.example.json](config.example.json) | Google apps that must use the MITM relay path instead of the fast direct tunnel. | | `hosts` | `{}` | Manual DNS override: map a hostname to a specific IP. | +| `youtube_via_relay` | `false` | Route YouTube (`youtube.com`, `youtu.be`, `youtube-nocookie.com`) through the Apps Script relay instead of the SNI-rewrite path. The SNI-rewrite path uses Google's frontend IP which enforces SafeSearch and can cause **"Video Unavailable"** errors. Setting this to `true` fixes playback at the cost of using more Apps Script executions and slightly higher latency. | ### Optional Dependencies diff --git a/README_FA.md b/README_FA.md index 246a1c4..accb3f4 100644 --- a/README_FA.md +++ b/README_FA.md @@ -24,7 +24,7 @@ `masterking32.ton` -- آدرس روی شبکه‌های EVM (ETH و سازگارها): +- آدرس روی شبکه‌های EVM (ETH و سازگارها): `0x517f07305D6ED781A089322B6cD93d1461bF8652` @@ -237,6 +237,7 @@ json | `block_hosts` | `[]` | هاست‌هایی که هرگز نباید tunnel شوند (پاسخ 403). نام دقیق (`ads.example.com`) یا پسوند با نقطه‌ی ابتدایی (`.doubleclick.net`). | | `bypass_hosts` | `["localhost", ".local", ".lan", ".home.arpa"]` | هاست‌هایی که مستقیم می‌روند (بدون MITM و بدون رله). برای منابع داخلی شبکه یا سایت‌هایی که با MITM مشکل دارند. | | `direct_google_exclude` | مراجعه به [config.example.json](config.example.json) | اپ‌های Google که باید از مسیر MITM برای رله استفاده کنند به‌جای tunnel مستقیم. | +| `youtube_via_relay` | `false` | مسیردهی YouTube (`youtube.com`، `youtu.be`، `youtube-nocookie.com`) از طریق رله Apps Script به‌جای مسیر SNI-rewrite. مسیر SNI-rewrite از IP فرانت‌اند Google عبور می‌کند که SafeSearch را اجباری می‌کند و می‌تواند باعث خطای **«ویدیو در دسترس نیست»** شود. با فعال کردن این گزینه، پخش ویدیو درست می‌شود اما تعداد اجراهای Apps Script بیشتر و تأخیر اندکی بالاتر می‌رود. | ### وابستگی‌های اختیاری diff --git a/config.example.json b/config.example.json index 7c709ba..371343e 100644 --- a/config.example.json +++ b/config.example.json @@ -42,5 +42,6 @@ "www.google.com", "safebrowsing.google.com" ], + "youtube_via_relay": false, "hosts": {} } diff --git a/src/proxy_server.py b/src/proxy_server.py index 6511c0b..0249298 100644 --- a/src/proxy_server.py +++ b/src/proxy_server.py @@ -201,6 +201,20 @@ class ProxyServer: self._block_hosts = self._load_host_rules(config.get("block_hosts", [])) self._bypass_hosts = self._load_host_rules(config.get("bypass_hosts", [])) + # youtube_via_relay: route YouTube through Apps Script relay instead of + # the SNI-rewrite path. The SNI-rewrite path shares Google's frontend + # IP which enforces SafeSearch and can cause "Video Unavailable". + # Enabling this fixes YouTube playback at the cost of using more + # Apps Script executions and slightly higher latency. + if config.get("youtube_via_relay", False): + self._SNI_REWRITE_SUFFIXES = tuple( + s for s in SNI_REWRITE_SUFFIXES + if s not in self._YOUTUBE_SNI_SUFFIXES + ) + log.info("youtube_via_relay enabled — YouTube routed through relay") + else: + self._SNI_REWRITE_SUFFIXES = SNI_REWRITE_SUFFIXES + try: from mitm import MITMCertManager self.mitm = MITMCertManager() @@ -624,6 +638,12 @@ class ProxyServer: # Built-in list of domains that must be reached via Google's frontend IP # with SNI rewritten to `front_domain` (default: www.google.com). # Source: constants.SNI_REWRITE_SUFFIXES. + # When youtube_via_relay is enabled the YouTube suffixes are removed so + # YouTube goes through the Apps Script relay instead (avoids SafeSearch + # forced by the Google frontend IP, at the cost of extra relay executions). + _YOUTUBE_SNI_SUFFIXES = frozenset({ + "youtube.com", "youtu.be", "youtube-nocookie.com", + }) _SNI_REWRITE_SUFFIXES = SNI_REWRITE_SUFFIXES def _sni_rewrite_ip(self, host: str) -> str | None: