mirror of
https://github.com/masterking32/MasterHttpRelayVPN.git
synced 2026-05-17 21:24:37 +03:00
Merge branch 'python_testing' into feature/uninstall-cert
This commit is contained in:
@@ -16,6 +16,28 @@ For the latest news, releases, and project updates, follow our Telegram channel:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### If you like this project, please support it by starring it on GitHub (⭐). It helps the project get discovered.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Optional Financial Support 💸
|
||||||
|
|
||||||
|
- TON network:
|
||||||
|
|
||||||
|
`masterking32.ton`
|
||||||
|
|
||||||
|
- EVM-compatible networks (ETH and compatible chains):
|
||||||
|
|
||||||
|
`0x517f07305D6ED781A089322B6cD93d1461bF8652`
|
||||||
|
|
||||||
|
- TRC20 network (TRON):
|
||||||
|
|
||||||
|
`TLApdY8APWkFHHoxebxGY8JhMeChiETqFH`
|
||||||
|
|
||||||
|
Every contribution and every piece of feedback is appreciated. Support directly helps ongoing development and improvement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Disclaimer
|
## Disclaimer
|
||||||
|
|
||||||
MasterHttpRelayVPN is provided for educational, testing, and research purposes only.
|
MasterHttpRelayVPN is provided for educational, testing, and research purposes only.
|
||||||
@@ -263,12 +285,22 @@ This project focuses entirely on the **Apps Script** relay — a free Google acc
|
|||||||
|---------|---------|-------------|
|
|---------|---------|-------------|
|
||||||
| `google_ip` | `216.239.38.120` | Google IP address to connect through |
|
| `google_ip` | `216.239.38.120` | Google IP address to connect through |
|
||||||
| `front_domain` | `www.google.com` | Domain shown to the firewall/filter |
|
| `front_domain` | `www.google.com` | Domain shown to the firewall/filter |
|
||||||
| `verify_ssl` | `true` | Verify TLS certificates |
|
| `verify_ssl` | `true` | Verify the TLS certificate on the local fronted connection to Google/CDN |
|
||||||
|
| `relay_timeout` | `25` | Total timeout for one relayed request before it fails |
|
||||||
|
| `tls_connect_timeout` | `15` | Timeout for the proxy's TLS connection to the fronted Google/CDN endpoint |
|
||||||
|
| `tcp_connect_timeout` | `10` | Timeout for direct TCP tunnels and outbound SNI-rewrite connects |
|
||||||
|
| `max_response_body_bytes` | `209715200` | Hard cap for a single relay response body after buffering/decoding |
|
||||||
| `script_ids` | — | Multiple Script IDs for load balancing (array) |
|
| `script_ids` | — | Multiple Script IDs for load balancing (array) |
|
||||||
|
| `chunked_download_extensions` | see [config.example.json](config.example.json) | File extensions that should use parallel range downloading. Supports `".*"` to probe all GET downloads. |
|
||||||
|
| `chunked_download_min_size` | `5242880` | Minimum total file size (5 MB) before range-parallel download stays enabled |
|
||||||
|
| `chunked_download_chunk_size` | `524288` | Per-range chunk size used by parallel downloads |
|
||||||
|
| `chunked_download_max_parallel` | `8` | Maximum simultaneous range requests for one download |
|
||||||
|
| `chunked_download_max_chunks` | `256` | Soft upper bound for total chunk requests; chunk size is raised automatically for very large files |
|
||||||
| `block_hosts` | `[]` | Hosts that must never be tunneled (return HTTP 403). Supports exact names (`ads.example.com`) or leading-dot suffixes (`.doubleclick.net`). |
|
| `block_hosts` | `[]` | Hosts that must never be tunneled (return HTTP 403). Supports exact names (`ads.example.com`) or leading-dot suffixes (`.doubleclick.net`). |
|
||||||
| `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. |
|
| `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. |
|
| `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. |
|
| `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
|
### Optional Dependencies
|
||||||
|
|
||||||
@@ -280,7 +312,7 @@ Install everything from [`requirements.txt`](requirements.txt). All listed packa
|
|||||||
| `h2` | HTTP/2 multiplexing to the Apps Script relay (significantly faster) |
|
| `h2` | HTTP/2 multiplexing to the Apps Script relay (significantly faster) |
|
||||||
| `brotli` | Decompression of `Content-Encoding: br` responses |
|
| `brotli` | Decompression of `Content-Encoding: br` responses |
|
||||||
| `zstandard` | Decompression of `Content-Encoding: zstd` responses |
|
| `zstandard` | Decompression of `Content-Encoding: zstd` responses |
|
||||||
| `netifaces` | Better network interface detection for LAN sharing (fallback available without it) |
|
|
||||||
|
|
||||||
### Load Balancing
|
### Load Balancing
|
||||||
|
|
||||||
@@ -316,10 +348,53 @@ 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 --install-cert # Install MITM CA certificate and exit
|
||||||
python3 main.py --uninstall-cert # Remove MITM CA certificate and exit
|
python3 main.py --uninstall-cert # Remove MITM CA certificate and exit
|
||||||
python3 main.py --no-cert-check # Skip automatic CA install check on startup
|
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.
|
> **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
|
## Architecture
|
||||||
@@ -356,6 +431,7 @@ MasterHttpRelayVPN/
|
|||||||
├── mitm.py # On-the-fly TLS interception
|
├── mitm.py # On-the-fly TLS interception
|
||||||
├── cert_installer.py # Cross-platform CA installer (Windows/macOS/Linux + Firefox)
|
├── cert_installer.py # Cross-platform CA installer (Windows/macOS/Linux + Firefox)
|
||||||
├── codec.py # Content-Encoding decoder (gzip/deflate/br/zstd)
|
├── 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
|
├── constants.py # Tunable defaults and shared data
|
||||||
└── logging_utils.py # Colored, aligned log formatter
|
└── logging_utils.py # Colored, aligned log formatter
|
||||||
```
|
```
|
||||||
|
|||||||
+77
-2
@@ -14,6 +14,28 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### اگر از پروژه راضیاید، با دادن ستاره (⭐) در گیتهاب از ما حمایت کنید — این کار به دیدهشدن پروژه کمک میکند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### حمایت مالی (اختیاری) 💸
|
||||||
|
|
||||||
|
- شبکه TON:
|
||||||
|
|
||||||
|
`masterking32.ton`
|
||||||
|
|
||||||
|
- آدرس روی شبکههای EVM (ETH و سازگارها):
|
||||||
|
|
||||||
|
`0x517f07305D6ED781A089322B6cD93d1461bF8652`
|
||||||
|
|
||||||
|
- شبکه TRC20 (TRON):
|
||||||
|
|
||||||
|
`TLApdY8APWkFHHoxebxGY8JhMeChiETqFH`
|
||||||
|
|
||||||
|
از هر نوع حمایت و بازخورد شما سپاسگزاریم — کمکها برای توسعه و بهبود پروژه بسیار ارزشمند است.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## سلب مسئولیت
|
## سلب مسئولیت
|
||||||
|
|
||||||
پروژه MasterHttpRelayVPN فقط برای اهداف آموزشی، تست و پژوهش ارائه شده است.
|
پروژه MasterHttpRelayVPN فقط برای اهداف آموزشی، تست و پژوهش ارائه شده است.
|
||||||
@@ -210,11 +232,21 @@ json
|
|||||||
|------|---------------|-------|
|
|------|---------------|-------|
|
||||||
| `google_ip` | `216.239.38.120` | IP مورد استفاده برای مسیر Google |
|
| `google_ip` | `216.239.38.120` | IP مورد استفاده برای مسیر Google |
|
||||||
| `front_domain` | `www.google.com` | دامنهای که فیلتر میبیند |
|
| `front_domain` | `www.google.com` | دامنهای که فیلتر میبیند |
|
||||||
| `verify_ssl` | `true` | بررسی اعتبار TLS |
|
| `verify_ssl` | `true` | بررسی اعتبار TLS فقط برای اتصال fronted محلی به Google/CDN |
|
||||||
|
| `relay_timeout` | `25` | مهلت کل برای هر درخواست relay قبل از fail شدن |
|
||||||
|
| `tls_connect_timeout` | `15` | مهلت اتصال TLS پروکسی به endpoint fronted روی Google/CDN |
|
||||||
|
| `tcp_connect_timeout` | `10` | مهلت اتصال برای tunnel مستقیم و SNI-rewrite |
|
||||||
|
| `max_response_body_bytes` | `209715200` | سقف نهایی برای اندازه body هر پاسخ relay بعد از buffer/decode |
|
||||||
| `script_ids` | - | چند Deployment ID برای load balancing |
|
| `script_ids` | - | چند Deployment ID برای load balancing |
|
||||||
|
| `chunked_download_extensions` | مطابق [config.example.json](config.example.json) | پسوند فایلهایی که باید از دانلود range-parallel استفاده کنند. از `".*"` هم برای probe همه دانلودهای GET پشتیبانی میشود. |
|
||||||
|
| `chunked_download_min_size` | `5242880` | حداقل اندازه کل فایل (۵ مگابایت) برای فعال ماندن دانلود موازی |
|
||||||
|
| `chunked_download_chunk_size` | `524288` | اندازه هر chunk در دانلود موازی |
|
||||||
|
| `chunked_download_max_parallel` | `8` | حداکثر تعداد range request همزمان برای یک دانلود |
|
||||||
|
| `chunked_download_max_chunks` | `256` | سقف نرم برای تعداد کل chunk request ها؛ برای فایلهای خیلی بزرگ اندازه chunk بهصورت خودکار بیشتر میشود |
|
||||||
| `block_hosts` | `[]` | هاستهایی که هرگز نباید tunnel شوند (پاسخ 403). نام دقیق (`ads.example.com`) یا پسوند با نقطهی ابتدایی (`.doubleclick.net`). |
|
| `block_hosts` | `[]` | هاستهایی که هرگز نباید tunnel شوند (پاسخ 403). نام دقیق (`ads.example.com`) یا پسوند با نقطهی ابتدایی (`.doubleclick.net`). |
|
||||||
| `bypass_hosts` | `["localhost", ".local", ".lan", ".home.arpa"]` | هاستهایی که مستقیم میروند (بدون MITM و بدون رله). برای منابع داخلی شبکه یا سایتهایی که با MITM مشکل دارند. |
|
| `bypass_hosts` | `["localhost", ".local", ".lan", ".home.arpa"]` | هاستهایی که مستقیم میروند (بدون MITM و بدون رله). برای منابع داخلی شبکه یا سایتهایی که با MITM مشکل دارند. |
|
||||||
| `direct_google_exclude` | مراجعه به [config.example.json](config.example.json) | اپهای Google که باید از مسیر MITM برای رله استفاده کنند بهجای tunnel مستقیم. |
|
| `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 بیشتر و تأخیر اندکی بالاتر میرود. |
|
||||||
|
|
||||||
### وابستگیهای اختیاری
|
### وابستگیهای اختیاری
|
||||||
|
|
||||||
@@ -226,7 +258,6 @@ json
|
|||||||
| `h2` | ارتباط HTTP/2 با رله Apps Script (بهطور محسوسی سریعتر) |
|
| `h2` | ارتباط HTTP/2 با رله Apps Script (بهطور محسوسی سریعتر) |
|
||||||
| `brotli` | پشتیبانی از فشردهسازی `Content-Encoding: br` |
|
| `brotli` | پشتیبانی از فشردهسازی `Content-Encoding: br` |
|
||||||
| `zstandard` | پشتیبانی از فشردهسازی `Content-Encoding: zstd` |
|
| `zstandard` | پشتیبانی از فشردهسازی `Content-Encoding: zstd` |
|
||||||
| `netifaces` | تشخیص بهتر اینترفیسهای شبکه برای اشتراکگذاری LAN (در صورت نبود آن، حالت جایگزین در دسترس است) |
|
|
||||||
|
|
||||||
### استفاده از چند Script ID
|
### استفاده از چند Script ID
|
||||||
|
|
||||||
@@ -262,10 +293,53 @@ python3 main.py -c /path/to/config.json
|
|||||||
python3 main.py --install-cert # نصب گواهی CA و خروج
|
python3 main.py --install-cert # نصب گواهی CA و خروج
|
||||||
python3 main.py --uninstall-cert # حذف گراهی CA و خروج
|
python3 main.py --uninstall-cert # حذف گراهی CA و خروج
|
||||||
python3 main.py --no-cert-check # رد شدن از بررسی خودکار گواهی
|
python3 main.py --no-cert-check # رد شدن از بررسی خودکار گواهی
|
||||||
|
python3 main.py --scan # اسکن IP های Google و یافتن سریعترین
|
||||||
```
|
```
|
||||||
|
|
||||||
> **نصب خودکار:** هنگام اجرا در حالت `apps_script`، برنامه بهطور خودکار بررسی میکند که آیا گواهی CA قابل اعتماد است یا نه و در صورت نیاز آن را نصب میکند. اگر نصب خودکار ناموفق بود (مثلاً نیاز به دسترسی مدیر دارد)، میتوانید دستور `python main.py --install-cert` را اجرا کنید یا مراحل مرحله ۶ را دنبال کنید.
|
> **نصب خودکار:** هنگام اجرا در حالت `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 پیشنهادی بهروزرسانی کنید و پراکسی را دوباره راهاندازی کنید.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## معماری
|
## معماری
|
||||||
@@ -298,6 +372,7 @@ MasterHttpRelayVPN/
|
|||||||
├── mitm.py # ساخت و مدیریت گواهیها
|
├── mitm.py # ساخت و مدیریت گواهیها
|
||||||
├── cert_installer.py # نصب خودکار CA در ویندوز/مک/لینوکس + فایرفاکس
|
├── cert_installer.py # نصب خودکار CA در ویندوز/مک/لینوکس + فایرفاکس
|
||||||
├── codec.py # رمزگشای Content-Encoding (gzip/deflate/br/zstd)
|
├── codec.py # رمزگشای Content-Encoding (gzip/deflate/br/zstd)
|
||||||
|
├── google_ip_scanner.py # اسکنر IP های Google برای یافتن سریعترین
|
||||||
├── constants.py # مقادیر پیشفرض قابل تنظیم
|
├── constants.py # مقادیر پیشفرض قابل تنظیم
|
||||||
└── logging_utils.py # فرمتدهندهی لاگ رنگی و منظم
|
└── logging_utils.py # فرمتدهندهی لاگ رنگی و منظم
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -11,7 +11,48 @@
|
|||||||
"log_level": "INFO",
|
"log_level": "INFO",
|
||||||
"verify_ssl": true,
|
"verify_ssl": true,
|
||||||
"lan_sharing": true,
|
"lan_sharing": true,
|
||||||
|
"relay_timeout": 25,
|
||||||
|
"tls_connect_timeout": 15,
|
||||||
|
"tcp_connect_timeout": 10,
|
||||||
|
"max_response_body_bytes": 209715200,
|
||||||
"parallel_relay": 1,
|
"parallel_relay": 1,
|
||||||
|
"chunked_download_extensions": [
|
||||||
|
".bin",
|
||||||
|
".zip",
|
||||||
|
".tar",
|
||||||
|
".gz",
|
||||||
|
".bz2",
|
||||||
|
".xz",
|
||||||
|
".7z",
|
||||||
|
".rar",
|
||||||
|
".exe",
|
||||||
|
".msi",
|
||||||
|
".dmg",
|
||||||
|
".deb",
|
||||||
|
".rpm",
|
||||||
|
".apk",
|
||||||
|
".iso",
|
||||||
|
".img",
|
||||||
|
".mp4",
|
||||||
|
".mkv",
|
||||||
|
".avi",
|
||||||
|
".mov",
|
||||||
|
".webm",
|
||||||
|
".mp3",
|
||||||
|
".flac",
|
||||||
|
".wav",
|
||||||
|
".aac",
|
||||||
|
".pdf",
|
||||||
|
".doc",
|
||||||
|
".docx",
|
||||||
|
".ppt",
|
||||||
|
".pptx",
|
||||||
|
".wasm"
|
||||||
|
],
|
||||||
|
"chunked_download_min_size": 5242880,
|
||||||
|
"chunked_download_chunk_size": 524288,
|
||||||
|
"chunked_download_max_parallel": 8,
|
||||||
|
"chunked_download_max_chunks": 256,
|
||||||
"block_hosts": [],
|
"block_hosts": [],
|
||||||
"bypass_hosts": [
|
"bypass_hosts": [
|
||||||
"localhost",
|
"localhost",
|
||||||
@@ -42,5 +83,6 @@
|
|||||||
"www.google.com",
|
"www.google.com",
|
||||||
"safebrowsing.google.com"
|
"safebrowsing.google.com"
|
||||||
],
|
],
|
||||||
|
"youtube_via_relay": false,
|
||||||
"hosts": {}
|
"hosts": {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ if _SRC_DIR not in sys.path:
|
|||||||
from cert_installer import install_ca, uninstall_ca, is_ca_trusted
|
from cert_installer import install_ca, uninstall_ca, is_ca_trusted
|
||||||
from constants import __version__
|
from constants import __version__
|
||||||
from lan_utils import log_lan_access
|
from lan_utils import log_lan_access
|
||||||
|
from google_ip_scanner import scan_sync
|
||||||
from logging_utils import configure as configure_logging, print_banner
|
from logging_utils import configure as configure_logging, print_banner
|
||||||
from mitm import CA_CERT_FILE
|
from mitm import CA_CERT_FILE
|
||||||
from proxy_server import ProxyServer
|
from proxy_server import ProxyServer
|
||||||
@@ -97,6 +98,11 @@ def parse_args():
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="Skip the certificate installation check on startup.",
|
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()
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
@@ -207,6 +213,13 @@ def main():
|
|||||||
_log.info("CA certificate removed successfully.")
|
_log.info("CA certificate removed successfully.")
|
||||||
else:
|
else:
|
||||||
_log.warning("CA certificate removal may have failed. Check logs above.")
|
_log.warning("CA certificate removal may have failed. Check logs above.")
|
||||||
|
# ── 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)
|
sys.exit(0 if ok else 1)
|
||||||
|
|
||||||
setup_logging(config.get("log_level", "INFO"))
|
setup_logging(config.get("log_level", "INFO"))
|
||||||
@@ -247,7 +260,7 @@ def main():
|
|||||||
log.info("MITM CA is already trusted.")
|
log.info("MITM CA is already trusted.")
|
||||||
|
|
||||||
# ── LAN sharing configuration ────────────────────────────────────────
|
# ── LAN sharing configuration ────────────────────────────────────────
|
||||||
lan_sharing = config.get("lan_sharing", True)
|
lan_sharing = config.get("lan_sharing", False)
|
||||||
if lan_sharing:
|
if lan_sharing:
|
||||||
# If LAN sharing is enabled and host is still localhost, change to all interfaces
|
# If LAN sharing is enabled and host is still localhost, change to all interfaces
|
||||||
if config.get("listen_host", "127.0.0.1") == "127.0.0.1":
|
if config.get("listen_host", "127.0.0.1") == "127.0.0.1":
|
||||||
|
|||||||
+5
-2
@@ -7,11 +7,14 @@ cryptography>=41.0.0
|
|||||||
# Optional: HTTP/2 multiplexing (faster apps_script relay)
|
# Optional: HTTP/2 multiplexing (faster apps_script relay)
|
||||||
h2>=4.1.0
|
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)
|
# Optional: Brotli decompression (modern websites send `br` encoding)
|
||||||
brotli>=1.1.0
|
brotli>=1.1.0
|
||||||
|
|
||||||
# Optional: Zstandard decompression (some CDNs now serve `zstd`)
|
# Optional: Zstandard decompression (some CDNs now serve `zstd`)
|
||||||
zstandard>=0.22.0
|
zstandard>=0.22.0
|
||||||
|
|
||||||
# Optional: Better network interface detection for LAN sharing
|
# LAN interface detection now uses only the Python standard library
|
||||||
netifaces>=0.11.0
|
# (works on Windows, Linux, macOS, Android/Termux without a C compiler).
|
||||||
|
|||||||
@@ -86,6 +86,15 @@ def load_base_config() -> dict:
|
|||||||
"socks5_port": 1080,
|
"socks5_port": 1080,
|
||||||
"log_level": "INFO",
|
"log_level": "INFO",
|
||||||
"verify_ssl": True,
|
"verify_ssl": True,
|
||||||
|
"lan_sharing": False,
|
||||||
|
"relay_timeout": 25,
|
||||||
|
"tls_connect_timeout": 15,
|
||||||
|
"tcp_connect_timeout": 10,
|
||||||
|
"max_response_body_bytes": 200 * 1024 * 1024,
|
||||||
|
"chunked_download_min_size": 5 * 1024 * 1024,
|
||||||
|
"chunked_download_chunk_size": 512 * 1024,
|
||||||
|
"chunked_download_max_parallel": 8,
|
||||||
|
"chunked_download_max_chunks": 256,
|
||||||
"hosts": {},
|
"hosts": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +127,15 @@ def configure_apps_script(cfg: dict) -> dict:
|
|||||||
def configure_network(cfg: dict) -> dict:
|
def configure_network(cfg: dict) -> dict:
|
||||||
print()
|
print()
|
||||||
print(bold("Network settings") + dim(" (press enter to accept defaults)"))
|
print(bold("Network settings") + dim(" (press enter to accept defaults)"))
|
||||||
cfg["listen_host"] = prompt("Listen host", default=str(cfg.get("listen_host", "127.0.0.1")))
|
cfg["lan_sharing"] = prompt_yes_no(
|
||||||
|
"Enable LAN sharing?",
|
||||||
|
default=bool(cfg.get("lan_sharing", False)),
|
||||||
|
)
|
||||||
|
|
||||||
|
default_host = str(cfg.get("listen_host", "127.0.0.1"))
|
||||||
|
if cfg["lan_sharing"] and default_host == "127.0.0.1":
|
||||||
|
default_host = "0.0.0.0"
|
||||||
|
cfg["listen_host"] = prompt("Listen host", default=default_host)
|
||||||
|
|
||||||
port = prompt("HTTP proxy port", default=str(cfg.get("listen_port", 8085)))
|
port = prompt("HTTP proxy port", default=str(cfg.get("listen_port", 8085)))
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -23,6 +23,39 @@ RELAY_TIMEOUT = 25
|
|||||||
TLS_CONNECT_TIMEOUT = 15
|
TLS_CONNECT_TIMEOUT = 15
|
||||||
TCP_CONNECT_TIMEOUT = 10
|
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 ────────────────────────────────────────────────────────
|
# ── Response cache ────────────────────────────────────────────────────────
|
||||||
CACHE_MAX_MB = 50
|
CACHE_MAX_MB = 50
|
||||||
@@ -66,6 +99,8 @@ FRONT_SNI_POOL_GOOGLE: tuple[str, ...] = (
|
|||||||
"translate.google.com",
|
"translate.google.com",
|
||||||
"play.google.com",
|
"play.google.com",
|
||||||
"lens.google.com",
|
"lens.google.com",
|
||||||
|
"scholar.google.com",
|
||||||
|
"chromewebstore.google.com",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -165,6 +200,7 @@ STATIC_EXTS: tuple[str, ...] = (
|
|||||||
".mp3", ".mp4", ".webm", ".wasm", ".avif",
|
".mp3", ".mp4", ".webm", ".wasm", ".avif",
|
||||||
)
|
)
|
||||||
LARGE_FILE_EXTS = frozenset({
|
LARGE_FILE_EXTS = frozenset({
|
||||||
|
".bin",
|
||||||
".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar",
|
".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar",
|
||||||
".exe", ".msi", ".dmg", ".deb", ".rpm", ".apk",
|
".exe", ".msi", ".dmg", ".deb", ".rpm", ".apk",
|
||||||
".iso", ".img",
|
".iso", ".img",
|
||||||
|
|||||||
+709
-76
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
+53
-17
@@ -20,6 +20,11 @@ import socket
|
|||||||
import ssl
|
import ssl
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
try:
|
||||||
|
import certifi
|
||||||
|
except Exception: # optional dependency fallback
|
||||||
|
certifi = None
|
||||||
|
|
||||||
import codec
|
import codec
|
||||||
|
|
||||||
log = logging.getLogger("H2")
|
log = logging.getLogger("H2")
|
||||||
@@ -80,6 +85,7 @@ class H2Transport:
|
|||||||
self._write_lock = asyncio.Lock()
|
self._write_lock = asyncio.Lock()
|
||||||
self._connect_lock = asyncio.Lock()
|
self._connect_lock = asyncio.Lock()
|
||||||
self._read_task: asyncio.Task | None = None
|
self._read_task: asyncio.Task | None = None
|
||||||
|
self._conn_generation = 0
|
||||||
|
|
||||||
# Per-stream tracking
|
# Per-stream tracking
|
||||||
self._streams: dict[int, _StreamState] = {}
|
self._streams: dict[int, _StreamState] = {}
|
||||||
@@ -106,6 +112,13 @@ class H2Transport:
|
|||||||
async def _do_connect(self):
|
async def _do_connect(self):
|
||||||
"""Establish the HTTP/2 connection with optimized socket settings."""
|
"""Establish the HTTP/2 connection with optimized socket settings."""
|
||||||
ctx = ssl.create_default_context()
|
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
|
# Advertise both h2 and http/1.1 — some DPI blocks h2-only ALPN
|
||||||
ctx.set_alpn_protocols(["h2", "http/1.1"])
|
ctx.set_alpn_protocols(["h2", "http/1.1"])
|
||||||
if not self.verify_ssl:
|
if not self.verify_ssl:
|
||||||
@@ -127,7 +140,7 @@ class H2Transport:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(
|
await asyncio.wait_for(
|
||||||
asyncio.get_event_loop().sock_connect(
|
asyncio.get_running_loop().sock_connect(
|
||||||
raw, (self.connect_host, 443)
|
raw, (self.connect_host, 443)
|
||||||
),
|
),
|
||||||
timeout=15,
|
timeout=15,
|
||||||
@@ -174,26 +187,34 @@ class H2Transport:
|
|||||||
await self._flush()
|
await self._flush()
|
||||||
|
|
||||||
self._connected = True
|
self._connected = True
|
||||||
self._read_task = asyncio.create_task(self._reader_loop())
|
self._conn_generation += 1
|
||||||
|
generation = self._conn_generation
|
||||||
|
self._read_task = asyncio.create_task(self._reader_loop(generation))
|
||||||
log.info("H2 connected → %s (SNI=%s, TCP_NODELAY=on)",
|
log.info("H2 connected → %s (SNI=%s, TCP_NODELAY=on)",
|
||||||
self.connect_host, sni)
|
self.connect_host, sni)
|
||||||
|
|
||||||
async def reconnect(self):
|
async def reconnect(self):
|
||||||
"""Close current connection and re-establish."""
|
"""Close current connection and re-establish."""
|
||||||
await self._close_internal()
|
async with self._connect_lock:
|
||||||
await self._do_connect()
|
await self._close_internal()
|
||||||
|
await self._do_connect()
|
||||||
|
|
||||||
async def _close_internal(self):
|
async def _close_internal(self):
|
||||||
self._connected = False
|
self._connected = False
|
||||||
if self._read_task:
|
read_task = self._read_task
|
||||||
self._read_task.cancel()
|
self._read_task = None
|
||||||
self._read_task = None
|
if read_task:
|
||||||
|
read_task.cancel()
|
||||||
|
await asyncio.gather(read_task, return_exceptions=True)
|
||||||
if self._writer:
|
if self._writer:
|
||||||
try:
|
try:
|
||||||
self._writer.close()
|
writer = self._writer
|
||||||
|
self._writer = None
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
self._writer = None
|
self._reader = None
|
||||||
# Wake all pending streams so they can raise
|
# Wake all pending streams so they can raise
|
||||||
for state in self._streams.values():
|
for state in self._streams.values():
|
||||||
state.error = "Connection closed"
|
state.error = "Connection closed"
|
||||||
@@ -327,7 +348,7 @@ class H2Transport:
|
|||||||
|
|
||||||
# ── Background reader ─────────────────────────────────────────
|
# ── Background reader ─────────────────────────────────────────
|
||||||
|
|
||||||
async def _reader_loop(self):
|
async def _reader_loop(self, generation: int):
|
||||||
"""Background: read H2 frames, dispatch events to waiting streams."""
|
"""Background: read H2 frames, dispatch events to waiting streams."""
|
||||||
try:
|
try:
|
||||||
while self._connected:
|
while self._connected:
|
||||||
@@ -351,15 +372,30 @@ class H2Transport:
|
|||||||
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
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:
|
except Exception as e:
|
||||||
log.error("H2 reader error: %s", e)
|
if "application data after close notify" in str(e).lower():
|
||||||
|
log.debug("H2 reader closed after close_notify: %s", e)
|
||||||
|
else:
|
||||||
|
log.error("H2 reader error: %s", e)
|
||||||
finally:
|
finally:
|
||||||
self._connected = False
|
if generation != self._conn_generation:
|
||||||
for state in self._streams.values():
|
log.debug("H2 reader loop ended for stale generation %d", generation)
|
||||||
if not state.done.is_set():
|
else:
|
||||||
state.error = "Connection lost"
|
self._connected = False
|
||||||
state.done.set()
|
for state in self._streams.values():
|
||||||
log.info("H2 reader loop ended")
|
if not state.done.is_set():
|
||||||
|
state.error = "Connection lost"
|
||||||
|
state.done.set()
|
||||||
|
log.info("H2 reader loop ended")
|
||||||
|
|
||||||
def _dispatch(self, event):
|
def _dispatch(self, event):
|
||||||
"""Route a single h2 event to its stream."""
|
"""Route a single h2 event to its stream."""
|
||||||
|
|||||||
+82
-79
@@ -1,101 +1,103 @@
|
|||||||
"""
|
"""
|
||||||
LAN utilities for detecting network interfaces and IP addresses.
|
LAN utilities for detecting network interfaces and IPv4 addresses.
|
||||||
|
|
||||||
Provides functionality to enumerate local network interfaces and their
|
Provides functionality to enumerate local IPv4 addresses for LAN proxy
|
||||||
associated IP addresses for LAN proxy sharing.
|
sharing. IPv6 is intentionally not reported — this project only exposes
|
||||||
|
the proxy over IPv4 LANs, which is what every consumer router and
|
||||||
|
phone/desktop client actually uses.
|
||||||
|
|
||||||
|
Implementation notes
|
||||||
|
--------------------
|
||||||
|
This module relies only on the Python standard library so it works
|
||||||
|
out-of-the-box on every supported OS (Windows, Linux, macOS,
|
||||||
|
Android/Termux, *BSD) without requiring a C compiler or native build
|
||||||
|
tools (previous versions depended on ``netifaces``, which needs
|
||||||
|
"Microsoft Visual C++ 14.0 or greater" on Windows and was a frequent
|
||||||
|
install blocker for users on slow connections).
|
||||||
|
|
||||||
|
Strategy (in order):
|
||||||
|
1. "UDP connect trick" to reliably discover the primary outbound
|
||||||
|
IPv4 address on any OS.
|
||||||
|
2. ``socket.getaddrinfo(hostname, AF_INET)`` to enumerate any additional
|
||||||
|
IPv4 addresses bound to the host (covers multi-homed machines).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import logging
|
import logging
|
||||||
import socket
|
import socket
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional, Set
|
||||||
|
|
||||||
log = logging.getLogger("LAN")
|
log = logging.getLogger("LAN")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Primary-IP discovery (UDP connect trick)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def _primary_ipv4() -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Return the primary local IPv4 the OS would use for outbound traffic.
|
||||||
|
|
||||||
|
Uses a connected UDP socket which does *not* actually send packets —
|
||||||
|
the kernel just picks the source address from its routing table.
|
||||||
|
Works identically on Windows, Linux, macOS, and Android.
|
||||||
|
"""
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
try:
|
||||||
|
s.settimeout(0.5)
|
||||||
|
# TEST-NET-1 address, port is arbitrary; no packet is sent for UDP connect().
|
||||||
|
s.connect(('192.0.2.1', 80))
|
||||||
|
return s.getsockname()[0]
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
def get_network_interfaces() -> Dict[str, List[str]]:
|
def get_network_interfaces() -> Dict[str, List[str]]:
|
||||||
"""
|
"""
|
||||||
Get all network interfaces and their associated IP addresses.
|
Get network interfaces and their associated non-loopback IPv4 addresses.
|
||||||
|
|
||||||
Returns a dictionary mapping interface names to lists of IP addresses
|
|
||||||
(both IPv4 and IPv6). Only includes interfaces with valid IP addresses
|
|
||||||
that are not loopback.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict[str, List[str]]: Interface name -> list of IP addresses
|
Dict[str, List[str]]: Interface label -> list of IPv4 addresses.
|
||||||
|
Labels are best-effort synthetic names such as ``"primary"``
|
||||||
|
and ``"host"``.
|
||||||
"""
|
"""
|
||||||
interfaces = {}
|
interfaces: Dict[str, List[str]] = {}
|
||||||
|
seen_ips: Set[str] = set()
|
||||||
|
|
||||||
|
def _add(label: str, ip: Optional[str]) -> None:
|
||||||
|
if not ip or ip in seen_ips:
|
||||||
|
return
|
||||||
|
if ip.startswith('127.'):
|
||||||
|
return
|
||||||
|
seen_ips.add(ip)
|
||||||
|
interfaces.setdefault(label, []).append(ip)
|
||||||
|
|
||||||
|
# 1) Primary outbound IPv4 (most reliable, cross-platform).
|
||||||
|
_add('primary', _primary_ipv4())
|
||||||
|
|
||||||
|
# 2) Enumerate via hostname resolution (picks up multi-homed hosts).
|
||||||
try:
|
try:
|
||||||
import netifaces
|
|
||||||
for iface in netifaces.interfaces():
|
|
||||||
addrs = netifaces.ifaddresses(iface)
|
|
||||||
ips = []
|
|
||||||
# IPv4 addresses
|
|
||||||
if netifaces.AF_INET in addrs:
|
|
||||||
for addr in addrs[netifaces.AF_INET]:
|
|
||||||
ip = addr.get('addr')
|
|
||||||
if ip and not ip.startswith('127.'):
|
|
||||||
ips.append(ip)
|
|
||||||
# IPv6 addresses (without scope)
|
|
||||||
if netifaces.AF_INET6 in addrs:
|
|
||||||
for addr in addrs[netifaces.AF_INET6]:
|
|
||||||
ip = addr.get('addr')
|
|
||||||
if ip and not ip.startswith('::1') and not '%' in ip:
|
|
||||||
# Remove scope if present
|
|
||||||
ips.append(ip.split('%')[0])
|
|
||||||
if ips:
|
|
||||||
interfaces[iface] = ips
|
|
||||||
except ImportError:
|
|
||||||
# Fallback to socket method for basic detection
|
|
||||||
log.debug("netifaces not available, using socket fallback")
|
|
||||||
interfaces = _get_interfaces_socket_fallback()
|
|
||||||
|
|
||||||
return interfaces
|
|
||||||
|
|
||||||
|
|
||||||
def _get_interfaces_socket_fallback() -> Dict[str, List[str]]:
|
|
||||||
"""
|
|
||||||
Fallback method to get network interfaces using socket.
|
|
||||||
|
|
||||||
This is less comprehensive than netifaces but works without extra dependencies.
|
|
||||||
"""
|
|
||||||
interfaces = {}
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Get hostname and try to resolve to IPs
|
|
||||||
hostname = socket.gethostname()
|
hostname = socket.gethostname()
|
||||||
try:
|
except OSError:
|
||||||
# Get IPv4 addresses
|
hostname = ''
|
||||||
ipv4_info = socket.getaddrinfo(hostname, None, socket.AF_INET)
|
|
||||||
ipv4_addrs = [info[4][0] for info in ipv4_info if not info[4][0].startswith('127.')]
|
|
||||||
if ipv4_addrs:
|
|
||||||
interfaces['primary'] = list(set(ipv4_addrs)) # Remove duplicates
|
|
||||||
except socket.gaierror:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
if hostname:
|
||||||
try:
|
try:
|
||||||
# Get IPv6 addresses
|
for info in socket.getaddrinfo(hostname, None, socket.AF_INET):
|
||||||
ipv6_info = socket.getaddrinfo(hostname, None, socket.AF_INET6)
|
_add('host', info[4][0])
|
||||||
ipv6_addrs = []
|
except (socket.gaierror, OSError):
|
||||||
for info in ipv6_info:
|
|
||||||
ip = info[4][0]
|
|
||||||
if not ip.startswith('::1') and not '%' in ip:
|
|
||||||
ipv6_addrs.append(ip.split('%')[0])
|
|
||||||
if ipv6_addrs:
|
|
||||||
interfaces['primary_ipv6'] = list(set(ipv6_addrs))
|
|
||||||
except socket.gaierror:
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
log.debug("Socket fallback failed: %s", e)
|
|
||||||
|
|
||||||
return interfaces
|
return interfaces
|
||||||
|
|
||||||
|
|
||||||
def get_lan_ips(port: int = 8085) -> List[str]:
|
def get_lan_ips(port: int = 8085) -> List[str]:
|
||||||
"""
|
"""
|
||||||
Get list of LAN-accessible proxy addresses.
|
Get list of LAN-accessible proxy addresses (IPv4 only).
|
||||||
|
|
||||||
Returns a list of IP:port combinations that can be used to access
|
Returns a list of IP:port combinations that can be used to access
|
||||||
the proxy from other devices on the local network.
|
the proxy from other devices on the local network.
|
||||||
@@ -107,21 +109,22 @@ def get_lan_ips(port: int = 8085) -> List[str]:
|
|||||||
List[str]: List of "IP:port" strings for LAN access
|
List[str]: List of "IP:port" strings for LAN access
|
||||||
"""
|
"""
|
||||||
interfaces = get_network_interfaces()
|
interfaces = get_network_interfaces()
|
||||||
lan_addresses = []
|
lan_addresses: List[str] = []
|
||||||
|
|
||||||
for iface_ips in interfaces.values():
|
for iface_ips in interfaces.values():
|
||||||
for ip in iface_ips:
|
for ip in iface_ips:
|
||||||
try:
|
try:
|
||||||
# Validate IP and check if it's a private address
|
addr = ipaddress.IPv4Address(ip)
|
||||||
addr = ipaddress.ip_address(ip)
|
except (ValueError, ipaddress.AddressValueError):
|
||||||
if addr.is_private or addr.is_link_local:
|
|
||||||
lan_addresses.append(f"{ip}:{port}")
|
|
||||||
except ValueError:
|
|
||||||
continue
|
continue
|
||||||
|
if addr.is_loopback or addr.is_unspecified:
|
||||||
|
continue
|
||||||
|
if addr.is_private or addr.is_link_local:
|
||||||
|
lan_addresses.append(f"{ip}:{port}")
|
||||||
|
|
||||||
# Remove duplicates while preserving order
|
# Remove duplicates while preserving order.
|
||||||
seen = set()
|
seen: Set[str] = set()
|
||||||
unique_addresses = []
|
unique_addresses: List[str] = []
|
||||||
for addr in lan_addresses:
|
for addr in lan_addresses:
|
||||||
if addr not in seen:
|
if addr not in seen:
|
||||||
seen.add(addr)
|
seen.add(addr)
|
||||||
|
|||||||
+341
-86
@@ -15,6 +15,11 @@ import time
|
|||||||
import ipaddress
|
import ipaddress
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
try:
|
||||||
|
import certifi
|
||||||
|
except Exception: # optional dependency fallback
|
||||||
|
certifi = None
|
||||||
|
|
||||||
from constants import (
|
from constants import (
|
||||||
CACHE_MAX_MB,
|
CACHE_MAX_MB,
|
||||||
CACHE_TTL_MAX,
|
CACHE_TTL_MAX,
|
||||||
@@ -65,6 +70,23 @@ def _parse_content_length(header_block: bytes) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _has_unsupported_transfer_encoding(header_block: bytes) -> bool:
|
||||||
|
"""True when the request uses Transfer-Encoding, which we don't stream."""
|
||||||
|
for raw_line in header_block.split(b"\r\n"):
|
||||||
|
name, sep, value = raw_line.partition(b":")
|
||||||
|
if not sep:
|
||||||
|
continue
|
||||||
|
if name.strip().lower() != b"transfer-encoding":
|
||||||
|
continue
|
||||||
|
encodings = [
|
||||||
|
token.strip().lower()
|
||||||
|
for token in value.decode(errors="replace").split(",")
|
||||||
|
if token.strip()
|
||||||
|
]
|
||||||
|
return any(token != "identity" for token in encodings)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class ResponseCache:
|
class ResponseCache:
|
||||||
"""Simple LRU response cache — avoids repeated relay calls."""
|
"""Simple LRU response cache — avoids repeated relay calls."""
|
||||||
|
|
||||||
@@ -147,6 +169,14 @@ class ProxyServer:
|
|||||||
_GOOGLE_DIRECT_ALLOW_EXACT = GOOGLE_DIRECT_ALLOW_EXACT
|
_GOOGLE_DIRECT_ALLOW_EXACT = GOOGLE_DIRECT_ALLOW_EXACT
|
||||||
_GOOGLE_DIRECT_ALLOW_SUFFIXES = GOOGLE_DIRECT_ALLOW_SUFFIXES
|
_GOOGLE_DIRECT_ALLOW_SUFFIXES = GOOGLE_DIRECT_ALLOW_SUFFIXES
|
||||||
_TRACE_HOST_SUFFIXES = TRACE_HOST_SUFFIXES
|
_TRACE_HOST_SUFFIXES = TRACE_HOST_SUFFIXES
|
||||||
|
_DOWNLOAD_DEFAULT_EXTS = tuple(sorted(LARGE_FILE_EXTS))
|
||||||
|
_DOWNLOAD_ACCEPT_MARKERS = (
|
||||||
|
"application/octet-stream",
|
||||||
|
"application/zip",
|
||||||
|
"application/x-bittorrent",
|
||||||
|
"video/",
|
||||||
|
"audio/",
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(self, config: dict):
|
def __init__(self, config: dict):
|
||||||
self.host = config.get("listen_host", "127.0.0.1")
|
self.host = config.get("listen_host", "127.0.0.1")
|
||||||
@@ -154,11 +184,42 @@ class ProxyServer:
|
|||||||
self.socks_enabled = config.get("socks5_enabled", True)
|
self.socks_enabled = config.get("socks5_enabled", True)
|
||||||
self.socks_host = config.get("socks5_host", self.host)
|
self.socks_host = config.get("socks5_host", self.host)
|
||||||
self.socks_port = config.get("socks5_port", 1080)
|
self.socks_port = config.get("socks5_port", 1080)
|
||||||
|
if self.socks_enabled and self.socks_host == self.host \
|
||||||
|
and int(self.socks_port) == int(self.port):
|
||||||
|
raise ValueError(
|
||||||
|
f"listen_port and socks5_port must differ on the same host "
|
||||||
|
f"(both set to {self.port} on {self.host}). "
|
||||||
|
f"Change one of them in config.json."
|
||||||
|
)
|
||||||
self.fronter = DomainFronter(config)
|
self.fronter = DomainFronter(config)
|
||||||
self.mitm = None
|
self.mitm = None
|
||||||
self._cache = ResponseCache(max_mb=CACHE_MAX_MB)
|
self._cache = ResponseCache(max_mb=CACHE_MAX_MB)
|
||||||
self._direct_fail_until: dict[str, float] = {}
|
self._direct_fail_until: dict[str, float] = {}
|
||||||
self._servers: list[asyncio.base_events.Server] = []
|
self._servers: list[asyncio.base_events.Server] = []
|
||||||
|
self._client_tasks: set[asyncio.Task] = set()
|
||||||
|
self._tcp_connect_timeout = self._cfg_float(
|
||||||
|
config, "tcp_connect_timeout", TCP_CONNECT_TIMEOUT, minimum=1.0,
|
||||||
|
)
|
||||||
|
self._download_min_size = self._cfg_int(
|
||||||
|
config, "chunked_download_min_size", 5 * 1024 * 1024, minimum=0,
|
||||||
|
)
|
||||||
|
self._download_chunk_size = self._cfg_int(
|
||||||
|
config, "chunked_download_chunk_size", 512 * 1024, minimum=64 * 1024,
|
||||||
|
)
|
||||||
|
self._download_max_parallel = self._cfg_int(
|
||||||
|
config, "chunked_download_max_parallel", 8, minimum=1,
|
||||||
|
)
|
||||||
|
self._download_max_chunks = self._cfg_int(
|
||||||
|
config, "chunked_download_max_chunks", 256, minimum=1,
|
||||||
|
)
|
||||||
|
self._download_extensions, self._download_any_extension = (
|
||||||
|
self._normalize_download_extensions(
|
||||||
|
config.get(
|
||||||
|
"chunked_download_extensions",
|
||||||
|
list(self._DOWNLOAD_DEFAULT_EXTS),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# hosts override — DNS fake-map: domain/suffix → IP
|
# hosts override — DNS fake-map: domain/suffix → IP
|
||||||
# Checked before any real DNS lookup; supports exact and suffix matching.
|
# Checked before any real DNS lookup; supports exact and suffix matching.
|
||||||
@@ -188,6 +249,17 @@ class ProxyServer:
|
|||||||
self._block_hosts = self._load_host_rules(config.get("block_hosts", []))
|
self._block_hosts = self._load_host_rules(config.get("block_hosts", []))
|
||||||
self._bypass_hosts = self._load_host_rules(config.get("bypass_hosts", []))
|
self._bypass_hosts = self._load_host_rules(config.get("bypass_hosts", []))
|
||||||
|
|
||||||
|
# Route YouTube through the relay when requested; the Google frontend
|
||||||
|
# IP can enforce SafeSearch on the SNI-rewrite path.
|
||||||
|
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:
|
try:
|
||||||
from mitm import MITMCertManager
|
from mitm import MITMCertManager
|
||||||
self.mitm = MITMCertManager()
|
self.mitm = MITMCertManager()
|
||||||
@@ -198,6 +270,55 @@ class ProxyServer:
|
|||||||
|
|
||||||
# ── Host-policy helpers ───────────────────────────────────────
|
# ── Host-policy helpers ───────────────────────────────────────
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _cfg_int(config: dict, key: str, default: int, *, minimum: int = 1) -> int:
|
||||||
|
try:
|
||||||
|
value = int(config.get(key, default))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
value = default
|
||||||
|
return max(minimum, value)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _cfg_float(config: dict, key: str, default: float,
|
||||||
|
*, minimum: float = 0.1) -> float:
|
||||||
|
try:
|
||||||
|
value = float(config.get(key, default))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
value = default
|
||||||
|
return max(minimum, value)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _normalize_download_extensions(cls, raw) -> tuple[tuple[str, ...], bool]:
|
||||||
|
values = raw if isinstance(raw, (list, tuple)) else cls._DOWNLOAD_DEFAULT_EXTS
|
||||||
|
normalized: list[str] = []
|
||||||
|
any_extension = False
|
||||||
|
seen: set[str] = set()
|
||||||
|
for item in values:
|
||||||
|
ext = str(item).strip().lower()
|
||||||
|
if not ext:
|
||||||
|
continue
|
||||||
|
if ext in {"*", ".*"}:
|
||||||
|
any_extension = True
|
||||||
|
continue
|
||||||
|
if not ext.startswith("."):
|
||||||
|
ext = "." + ext
|
||||||
|
if ext not in seen:
|
||||||
|
seen.add(ext)
|
||||||
|
normalized.append(ext)
|
||||||
|
if not normalized and not any_extension:
|
||||||
|
normalized = list(cls._DOWNLOAD_DEFAULT_EXTS)
|
||||||
|
return tuple(normalized), any_extension
|
||||||
|
|
||||||
|
def _track_current_task(self) -> asyncio.Task | None:
|
||||||
|
task = asyncio.current_task()
|
||||||
|
if task is not None:
|
||||||
|
self._client_tasks.add(task)
|
||||||
|
return task
|
||||||
|
|
||||||
|
def _untrack_task(self, task: asyncio.Task | None) -> None:
|
||||||
|
if task is not None:
|
||||||
|
self._client_tasks.discard(task)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _load_host_rules(raw) -> tuple[set[str], tuple[str, ...]]:
|
def _load_host_rules(raw) -> tuple[set[str], tuple[str, ...]]:
|
||||||
"""Accept a list of host strings; return (exact_set, suffix_tuple).
|
"""Accept a list of host strings; return (exact_set, suffix_tuple).
|
||||||
@@ -352,15 +473,18 @@ class ProxyServer:
|
|||||||
self.socks_host, self.socks_port,
|
self.socks_host, self.socks_port,
|
||||||
)
|
)
|
||||||
|
|
||||||
async with http_srv:
|
try:
|
||||||
if socks_srv:
|
async with http_srv:
|
||||||
async with socks_srv:
|
if socks_srv:
|
||||||
await asyncio.gather(
|
async with socks_srv:
|
||||||
http_srv.serve_forever(),
|
await asyncio.gather(
|
||||||
socks_srv.serve_forever(),
|
http_srv.serve_forever(),
|
||||||
)
|
socks_srv.serve_forever(),
|
||||||
else:
|
)
|
||||||
await http_srv.serve_forever()
|
else:
|
||||||
|
await http_srv.serve_forever()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
|
||||||
async def stop(self):
|
async def stop(self):
|
||||||
"""Shut down all listeners and release relay resources."""
|
"""Shut down all listeners and release relay resources."""
|
||||||
@@ -375,6 +499,15 @@ class ProxyServer:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
self._servers = []
|
self._servers = []
|
||||||
|
|
||||||
|
current = asyncio.current_task()
|
||||||
|
client_tasks = [task for task in self._client_tasks if task is not current]
|
||||||
|
for task in client_tasks:
|
||||||
|
task.cancel()
|
||||||
|
if client_tasks:
|
||||||
|
await asyncio.gather(*client_tasks, return_exceptions=True)
|
||||||
|
self._client_tasks.clear()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self.fronter.close()
|
await self.fronter.close()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -384,6 +517,7 @@ class ProxyServer:
|
|||||||
|
|
||||||
async def _on_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
|
async def _on_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
|
||||||
addr = writer.get_extra_info("peername")
|
addr = writer.get_extra_info("peername")
|
||||||
|
task = self._track_current_task()
|
||||||
try:
|
try:
|
||||||
first_line = await asyncio.wait_for(reader.readline(), timeout=30)
|
first_line = await asyncio.wait_for(reader.readline(), timeout=30)
|
||||||
if not first_line:
|
if not first_line:
|
||||||
@@ -400,6 +534,16 @@ class ProxyServer:
|
|||||||
if line in (b"\r\n", b"\n", b""):
|
if line in (b"\r\n", b"\n", b""):
|
||||||
break
|
break
|
||||||
|
|
||||||
|
if _has_unsupported_transfer_encoding(header_block):
|
||||||
|
log.warning("Unsupported Transfer-Encoding on client request")
|
||||||
|
writer.write(
|
||||||
|
b"HTTP/1.1 501 Not Implemented\r\n"
|
||||||
|
b"Connection: close\r\n"
|
||||||
|
b"Content-Length: 0\r\n\r\n"
|
||||||
|
)
|
||||||
|
await writer.drain()
|
||||||
|
return
|
||||||
|
|
||||||
request_line = first_line.decode(errors="replace").strip()
|
request_line = first_line.decode(errors="replace").strip()
|
||||||
parts = request_line.split(" ", 2)
|
parts = request_line.split(" ", 2)
|
||||||
if len(parts) < 2:
|
if len(parts) < 2:
|
||||||
@@ -412,11 +556,14 @@ class ProxyServer:
|
|||||||
else:
|
else:
|
||||||
await self._do_http(header_block, reader, writer)
|
await self._do_http(header_block, reader, writer)
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
log.debug("Timeout: %s", addr)
|
log.debug("Timeout: %s", addr)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error("Error (%s): %s", addr, e)
|
log.error("Error (%s): %s", addr, e)
|
||||||
finally:
|
finally:
|
||||||
|
self._untrack_task(task)
|
||||||
try:
|
try:
|
||||||
writer.close()
|
writer.close()
|
||||||
await writer.wait_closed()
|
await writer.wait_closed()
|
||||||
@@ -426,6 +573,7 @@ class ProxyServer:
|
|||||||
async def _on_socks_client(self, reader: asyncio.StreamReader,
|
async def _on_socks_client(self, reader: asyncio.StreamReader,
|
||||||
writer: asyncio.StreamWriter):
|
writer: asyncio.StreamWriter):
|
||||||
addr = writer.get_extra_info("peername")
|
addr = writer.get_extra_info("peername")
|
||||||
|
task = self._track_current_task()
|
||||||
try:
|
try:
|
||||||
header = await asyncio.wait_for(reader.readexactly(2), timeout=15)
|
header = await asyncio.wait_for(reader.readexactly(2), timeout=15)
|
||||||
ver, nmethods = header[0], header[1]
|
ver, nmethods = header[0], header[1]
|
||||||
@@ -475,11 +623,14 @@ class ProxyServer:
|
|||||||
|
|
||||||
except asyncio.IncompleteReadError:
|
except asyncio.IncompleteReadError:
|
||||||
pass
|
pass
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
log.debug("SOCKS5 timeout: %s", addr)
|
log.debug("SOCKS5 timeout: %s", addr)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error("SOCKS5 error (%s): %s", addr, e)
|
log.error("SOCKS5 error (%s): %s", addr, e)
|
||||||
finally:
|
finally:
|
||||||
|
self._untrack_task(task)
|
||||||
try:
|
try:
|
||||||
writer.close()
|
writer.close()
|
||||||
await writer.wait_closed()
|
await writer.wait_closed()
|
||||||
@@ -533,12 +684,17 @@ class ProxyServer:
|
|||||||
# • port 443 → MITM + relay through Apps Script
|
# • port 443 → MITM + relay through Apps Script
|
||||||
# • port 80 → plain-HTTP relay through Apps Script
|
# • port 80 → plain-HTTP relay through Apps Script
|
||||||
# • other → give up (non-HTTP; can't be relayed)
|
# • other → give up (non-HTTP; can't be relayed)
|
||||||
|
# We use a shorter connect timeout for IP literals (4 s) because
|
||||||
|
# when the route is DPI-dropped, waiting longer doesn't help and
|
||||||
|
# clients like Telegram speed up DC-rotation when we fail fast.
|
||||||
# We remember per-IP failures for a short while so subsequent
|
# We remember per-IP failures for a short while so subsequent
|
||||||
# connects skip the doomed direct attempt.
|
# connects skip the doomed direct attempt.
|
||||||
if _is_ip_literal(host):
|
if _is_ip_literal(host):
|
||||||
if not self._direct_temporarily_disabled(host):
|
if not self._direct_temporarily_disabled(host):
|
||||||
log.info("Direct tunnel → %s:%d (IP literal)", host, port)
|
log.info("Direct tunnel → %s:%d (IP literal)", host, port)
|
||||||
ok = await self._do_direct_tunnel(host, port, reader, writer)
|
ok = await self._do_direct_tunnel(
|
||||||
|
host, port, reader, writer, timeout=4.0,
|
||||||
|
)
|
||||||
if ok:
|
if ok:
|
||||||
return
|
return
|
||||||
self._remember_direct_failure(host, ttl=300)
|
self._remember_direct_failure(host, ttl=300)
|
||||||
@@ -606,6 +762,11 @@ class ProxyServer:
|
|||||||
# Built-in list of domains that must be reached via Google's frontend IP
|
# Built-in list of domains that must be reached via Google's frontend IP
|
||||||
# with SNI rewritten to `front_domain` (default: www.google.com).
|
# with SNI rewritten to `front_domain` (default: www.google.com).
|
||||||
# Source: constants.SNI_REWRITE_SUFFIXES.
|
# 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.
|
||||||
|
_YOUTUBE_SNI_SUFFIXES = frozenset({
|
||||||
|
"youtube.com", "youtu.be", "youtube-nocookie.com",
|
||||||
|
})
|
||||||
_SNI_REWRITE_SUFFIXES = SNI_REWRITE_SUFFIXES
|
_SNI_REWRITE_SUFFIXES = SNI_REWRITE_SUFFIXES
|
||||||
|
|
||||||
def _sni_rewrite_ip(self, host: str) -> str | None:
|
def _sni_rewrite_ip(self, host: str) -> str | None:
|
||||||
@@ -724,14 +885,21 @@ class ProxyServer:
|
|||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
# Strip IPv6 brackets (CONNECT may deliver "[::1]" as the hostname).
|
||||||
|
# ipaddress.ip_address() rejects the bracketed form, which would
|
||||||
|
# otherwise force a DNS lookup for an IP literal and fail.
|
||||||
|
lookup_target = target.strip()
|
||||||
|
if lookup_target.startswith("[") and lookup_target.endswith("]"):
|
||||||
|
lookup_target = lookup_target[1:-1]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ipaddress.ip_address(target)
|
ipaddress.ip_address(lookup_target)
|
||||||
candidates = [(0, target)]
|
candidates = [(0, lookup_target)]
|
||||||
except ValueError:
|
except ValueError:
|
||||||
try:
|
try:
|
||||||
infos = await asyncio.wait_for(
|
infos = await asyncio.wait_for(
|
||||||
loop.getaddrinfo(
|
loop.getaddrinfo(
|
||||||
target,
|
lookup_target,
|
||||||
port,
|
port,
|
||||||
family=socket.AF_UNSPEC,
|
family=socket.AF_UNSPEC,
|
||||||
type=socket.SOCK_STREAM,
|
type=socket.SOCK_STREAM,
|
||||||
@@ -739,7 +907,7 @@ class ProxyServer:
|
|||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise OSError(f"dns lookup failed for {target}: {exc!r}") from exc
|
raise OSError(f"dns lookup failed for {lookup_target}: {exc!r}") from exc
|
||||||
|
|
||||||
candidates = []
|
candidates = []
|
||||||
seen = set()
|
seen = set()
|
||||||
@@ -772,7 +940,8 @@ class ProxyServer:
|
|||||||
async def _do_direct_tunnel(self, host: str, port: int,
|
async def _do_direct_tunnel(self, host: str, port: int,
|
||||||
reader: asyncio.StreamReader,
|
reader: asyncio.StreamReader,
|
||||||
writer: asyncio.StreamWriter,
|
writer: asyncio.StreamWriter,
|
||||||
connect_ip: str | None = None):
|
connect_ip: str | None = None,
|
||||||
|
timeout: float | None = None):
|
||||||
"""Pipe raw TLS bytes directly to the target server.
|
"""Pipe raw TLS bytes directly to the target server.
|
||||||
|
|
||||||
connect_ip overrides DNS: the TCP connection goes to that IP
|
connect_ip overrides DNS: the TCP connection goes to that IP
|
||||||
@@ -782,8 +951,13 @@ class ProxyServer:
|
|||||||
normal edge instead of being forced onto the fronting IP.
|
normal edge instead of being forced onto the fronting IP.
|
||||||
"""
|
"""
|
||||||
target_ip = connect_ip or host
|
target_ip = connect_ip or host
|
||||||
|
effective_timeout = (
|
||||||
|
self._tcp_connect_timeout if timeout is None else float(timeout)
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
r_remote, w_remote = await self._open_tcp_connection(target_ip, port, timeout=10)
|
r_remote, w_remote = await self._open_tcp_connection(
|
||||||
|
target_ip, port, timeout=effective_timeout,
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error("Direct tunnel connect failed (%s via %s): %s",
|
log.error("Direct tunnel connect failed (%s via %s): %s",
|
||||||
host, target_ip, e)
|
host, target_ip, e)
|
||||||
@@ -834,7 +1008,7 @@ class ProxyServer:
|
|||||||
|
|
||||||
# Step 1: MITM — accept TLS from the browser
|
# Step 1: MITM — accept TLS from the browser
|
||||||
ssl_ctx_server = self.mitm.get_server_context(host)
|
ssl_ctx_server = self.mitm.get_server_context(host)
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_running_loop()
|
||||||
transport = writer.transport
|
transport = writer.transport
|
||||||
protocol = transport.get_protocol()
|
protocol = transport.get_protocol()
|
||||||
try:
|
try:
|
||||||
@@ -848,6 +1022,11 @@ class ProxyServer:
|
|||||||
|
|
||||||
# Step 2: open outgoing TLS to target IP with the safe SNI
|
# Step 2: open outgoing TLS to target IP with the safe SNI
|
||||||
ssl_ctx_client = ssl.create_default_context()
|
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:
|
if not self.fronter.verify_ssl:
|
||||||
ssl_ctx_client.check_hostname = False
|
ssl_ctx_client.check_hostname = False
|
||||||
ssl_ctx_client.verify_mode = ssl.CERT_NONE
|
ssl_ctx_client.verify_mode = ssl.CERT_NONE
|
||||||
@@ -858,7 +1037,7 @@ class ProxyServer:
|
|||||||
ssl=ssl_ctx_client,
|
ssl=ssl_ctx_client,
|
||||||
server_hostname=sni_out,
|
server_hostname=sni_out,
|
||||||
),
|
),
|
||||||
timeout=10,
|
timeout=self._tcp_connect_timeout,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error("SNI-rewrite outbound connect failed (%s via %s): %s",
|
log.error("SNI-rewrite outbound connect failed (%s via %s): %s",
|
||||||
@@ -901,7 +1080,7 @@ class ProxyServer:
|
|||||||
ssl_ctx = self.mitm.get_server_context(host)
|
ssl_ctx = self.mitm.get_server_context(host)
|
||||||
|
|
||||||
# Upgrade the existing connection to TLS (we are the server)
|
# Upgrade the existing connection to TLS (we are the server)
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_running_loop()
|
||||||
transport = writer.transport
|
transport = writer.transport
|
||||||
protocol = transport.get_protocol()
|
protocol = transport.get_protocol()
|
||||||
|
|
||||||
@@ -914,17 +1093,15 @@ class ProxyServer:
|
|||||||
# • Telegram Desktop / MTProto over port 443 sends obfuscated
|
# • Telegram Desktop / MTProto over port 443 sends obfuscated
|
||||||
# non-TLS bytes — we literally cannot decrypt these, and
|
# non-TLS bytes — we literally cannot decrypt these, and
|
||||||
# since the target IP is blocked we can't direct-tunnel
|
# since the target IP is blocked we can't direct-tunnel
|
||||||
# either. The only workaround is to configure Telegram as
|
# either. Telegram will rotate to another DC on its own;
|
||||||
# an HTTP proxy (not SOCKS5), so it sends hostnames our
|
# failing fast here lets that happen sooner.
|
||||||
# SNI-rewrite path can handle.
|
|
||||||
# • Client CONNECTs but never speaks TLS (some probes).
|
# • Client CONNECTs but never speaks TLS (some probes).
|
||||||
if _is_ip_literal(host) and port == 443:
|
if _is_ip_literal(host) and port == 443:
|
||||||
log.warning(
|
log.info(
|
||||||
"MITM TLS handshake failed for %s:%d (%s). "
|
"Non-TLS traffic on %s:%d (likely Telegram MTProto / "
|
||||||
"Likely non-TLS traffic (e.g. Telegram MTProto over "
|
"obfuscated protocol). This DC appears blocked; the "
|
||||||
"SOCKS5). Cannot relay raw TCP to a blocked IP — "
|
"client should rotate to another endpoint shortly.",
|
||||||
"use the HTTP proxy instead so hostnames are preserved.",
|
host, port,
|
||||||
host, port, e,
|
|
||||||
)
|
)
|
||||||
elif port != 443:
|
elif port != 443:
|
||||||
log.debug(
|
log.debug(
|
||||||
@@ -959,16 +1136,47 @@ class ProxyServer:
|
|||||||
break
|
break
|
||||||
|
|
||||||
header_block = first_line
|
header_block = first_line
|
||||||
|
oversized_headers = False
|
||||||
while True:
|
while True:
|
||||||
line = await asyncio.wait_for(reader.readline(), timeout=10)
|
line = await asyncio.wait_for(reader.readline(), timeout=10)
|
||||||
header_block += line
|
header_block += line
|
||||||
if len(header_block) > MAX_HEADER_BYTES:
|
if len(header_block) > MAX_HEADER_BYTES:
|
||||||
|
oversized_headers = True
|
||||||
break
|
break
|
||||||
if line in (b"\r\n", b"\n", b""):
|
if line in (b"\r\n", b"\n", b""):
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# Reject truncated / oversized header blocks cleanly rather
|
||||||
|
# than forwarding a half-parsed request to the relay — doing
|
||||||
|
# so would send malformed JSON payloads to Apps Script and
|
||||||
|
# leave the client hanging until its own timeout fires.
|
||||||
|
if oversized_headers:
|
||||||
|
log.warning(
|
||||||
|
"MITM header block exceeds %d bytes — closing (%s)",
|
||||||
|
MAX_HEADER_BYTES, host,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
writer.write(
|
||||||
|
b"HTTP/1.1 431 Request Header Fields Too Large\r\n"
|
||||||
|
b"Connection: close\r\n"
|
||||||
|
b"Content-Length: 0\r\n\r\n"
|
||||||
|
)
|
||||||
|
await writer.drain()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
break
|
||||||
|
|
||||||
# Read body
|
# Read body
|
||||||
body = b""
|
body = b""
|
||||||
|
if _has_unsupported_transfer_encoding(header_block):
|
||||||
|
log.warning("Unsupported Transfer-Encoding → %s:%d", host, port)
|
||||||
|
writer.write(
|
||||||
|
b"HTTP/1.1 501 Not Implemented\r\n"
|
||||||
|
b"Connection: close\r\n"
|
||||||
|
b"Content-Length: 0\r\n\r\n"
|
||||||
|
)
|
||||||
|
await writer.drain()
|
||||||
|
break
|
||||||
length = _parse_content_length(header_block)
|
length = _parse_content_length(header_block)
|
||||||
if length > MAX_REQUEST_BODY_BYTES:
|
if length > MAX_REQUEST_BODY_BYTES:
|
||||||
raise ValueError(f"Request body too large: {length} bytes")
|
raise ValueError(f"Request body too large: {length} bytes")
|
||||||
@@ -1008,27 +1216,33 @@ class ProxyServer:
|
|||||||
|
|
||||||
log.info("MITM → %s %s", method, url)
|
log.info("MITM → %s %s", method, url)
|
||||||
|
|
||||||
# ── CORS: extract relevant request headers ────────────────────
|
# ── CORS: extract relevant request headers ─────────────
|
||||||
origin = next(
|
origin = self._header_value(headers, "origin")
|
||||||
(v for k, v in headers.items() if k.lower() == "origin"), ""
|
acr_method = self._header_value(
|
||||||
|
headers, "access-control-request-method",
|
||||||
)
|
)
|
||||||
acr_method = next(
|
acr_headers = self._header_value(
|
||||||
(v for k, v in headers.items()
|
headers, "access-control-request-headers",
|
||||||
if k.lower() == "access-control-request-method"), ""
|
|
||||||
)
|
|
||||||
acr_headers = next(
|
|
||||||
(v for k, v in headers.items()
|
|
||||||
if k.lower() == "access-control-request-headers"), ""
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# CORS preflight — respond directly; UrlFetchApp doesn't
|
# CORS preflight — respond directly. Apps Script's
|
||||||
# support OPTIONS so forwarding it would always fail.
|
# UrlFetchApp does not support the OPTIONS method, so
|
||||||
|
# forwarding preflights would always fail and break every
|
||||||
|
# cross-origin fetch/XHR the browser runs through us.
|
||||||
if method.upper() == "OPTIONS" and acr_method:
|
if method.upper() == "OPTIONS" and acr_method:
|
||||||
log.debug("CORS preflight → %s (responding locally)", url[:60])
|
log.debug(
|
||||||
writer.write(self._cors_preflight_response(origin, acr_method, acr_headers))
|
"CORS preflight → %s (responding locally)",
|
||||||
|
url[:60],
|
||||||
|
)
|
||||||
|
writer.write(self._cors_preflight_response(
|
||||||
|
origin, acr_method, acr_headers,
|
||||||
|
))
|
||||||
await writer.drain()
|
await writer.drain()
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if await self._maybe_stream_download(method, url, headers, body, writer):
|
||||||
|
continue
|
||||||
|
|
||||||
# Check local cache first (GET only)
|
# Check local cache first (GET only)
|
||||||
response = None
|
response = None
|
||||||
if self._cache_allowed(method, url, headers, body):
|
if self._cache_allowed(method, url, headers, body):
|
||||||
@@ -1057,8 +1271,10 @@ class ProxyServer:
|
|||||||
self._cache.put(url, response, ttl)
|
self._cache.put(url, response, ttl)
|
||||||
log.debug("Cached (%ds): %s", ttl, url[:60])
|
log.debug("Cached (%ds): %s", ttl, url[:60])
|
||||||
|
|
||||||
# Inject permissive CORS headers whenever the browser
|
# Inject permissive CORS headers whenever the browser sent
|
||||||
# sent an Origin (cross-origin XHR / fetch).
|
# an Origin (cross-origin XHR / fetch). Without this, the
|
||||||
|
# browser blocks the response even though the relay fetched
|
||||||
|
# it successfully.
|
||||||
if origin and response:
|
if origin and response:
|
||||||
response = self._inject_cors_headers(response, origin)
|
response = self._inject_cors_headers(response, origin)
|
||||||
|
|
||||||
@@ -1077,11 +1293,16 @@ class ProxyServer:
|
|||||||
log.error("MITM handler error (%s): %s", host, e)
|
log.error("MITM handler error (%s): %s", host, e)
|
||||||
break
|
break
|
||||||
|
|
||||||
# ── CORS helpers ──────────────────────────────────────────────────────────
|
# ── CORS helpers ──────────────────────────────────────────────
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _cors_preflight_response(origin: str, acr_method: str, acr_headers: str) -> bytes:
|
def _cors_preflight_response(origin: str, acr_method: str,
|
||||||
"""Return a 204 No Content response that satisfies a CORS preflight."""
|
acr_headers: str) -> bytes:
|
||||||
|
"""Build a 204 response that satisfies a CORS preflight locally.
|
||||||
|
|
||||||
|
Apps Script's UrlFetchApp does not support OPTIONS, so we have to
|
||||||
|
answer preflights here instead of forwarding them.
|
||||||
|
"""
|
||||||
allow_origin = origin or "*"
|
allow_origin = origin or "*"
|
||||||
allow_methods = (
|
allow_methods = (
|
||||||
f"{acr_method}, GET, POST, PUT, DELETE, PATCH, OPTIONS"
|
f"{acr_method}, GET, POST, PUT, DELETE, PATCH, OPTIONS"
|
||||||
@@ -1103,37 +1324,29 @@ class ProxyServer:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _inject_cors_headers(response: bytes, origin: str) -> bytes:
|
def _inject_cors_headers(response: bytes, origin: str) -> bytes:
|
||||||
"""Inject CORS headers only if the upstream response lacks them.
|
"""Strip existing Access-Control-* headers and add permissive ones.
|
||||||
|
|
||||||
We must NOT overwrite the origin server's CORS headers: sites like
|
Keeps the body untouched; only rewrites the header block. Using
|
||||||
x.com return carefully-scoped Access-Control-Allow-Headers that list
|
the exact browser-supplied Origin (rather than "*") is required
|
||||||
specific custom headers (e.g. x-csrf-token). Replacing them with
|
when the request is credentialed (cookies, Authorization).
|
||||||
wildcards together with Allow-Credentials: true makes browsers
|
|
||||||
reject the response (per the Fetch spec, "*" is literal when
|
|
||||||
credentials are included), which the site then blames on privacy
|
|
||||||
extensions. So we only fill in what the server omitted.
|
|
||||||
"""
|
"""
|
||||||
sep = b"\r\n\r\n"
|
sep = b"\r\n\r\n"
|
||||||
if sep not in response:
|
if sep not in response:
|
||||||
return response
|
return response
|
||||||
header_section, body = response.split(sep, 1)
|
header_section, body = response.split(sep, 1)
|
||||||
lines = header_section.decode(errors="replace").split("\r\n")
|
lines = header_section.decode(errors="replace").split("\r\n")
|
||||||
|
lines = [ln for ln in lines
|
||||||
existing = {ln.split(":", 1)[0].strip().lower()
|
if not ln.lower().startswith("access-control-")]
|
||||||
for ln in lines if ":" in ln}
|
|
||||||
|
|
||||||
# If the upstream already handled CORS, leave it completely alone.
|
|
||||||
if "access-control-allow-origin" in existing:
|
|
||||||
return response
|
|
||||||
|
|
||||||
# Otherwise inject a minimal, credential-safe set (no wildcards,
|
|
||||||
# since wildcards combined with credentials are invalid).
|
|
||||||
allow_origin = origin or "*"
|
allow_origin = origin or "*"
|
||||||
additions = [f"Access-Control-Allow-Origin: {allow_origin}"]
|
lines += [
|
||||||
if allow_origin != "*":
|
f"Access-Control-Allow-Origin: {allow_origin}",
|
||||||
additions.append("Access-Control-Allow-Credentials: true")
|
"Access-Control-Allow-Credentials: true",
|
||||||
additions.append("Vary: Origin")
|
"Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH, OPTIONS",
|
||||||
return ("\r\n".join(lines + additions) + "\r\n\r\n").encode() + body
|
"Access-Control-Allow-Headers: *",
|
||||||
|
"Access-Control-Expose-Headers: *",
|
||||||
|
"Vary: Origin",
|
||||||
|
]
|
||||||
|
return ("\r\n".join(lines) + "\r\n\r\n").encode() + body
|
||||||
|
|
||||||
async def _relay_smart(self, method, url, headers, body):
|
async def _relay_smart(self, method, url, headers, body):
|
||||||
"""Choose optimal relay strategy based on request type.
|
"""Choose optimal relay strategy based on request type.
|
||||||
@@ -1156,22 +1369,67 @@ class ProxyServer:
|
|||||||
# Only probe with Range when the URL looks like a big file.
|
# Only probe with Range when the URL looks like a big file.
|
||||||
if self._is_likely_download(url, headers):
|
if self._is_likely_download(url, headers):
|
||||||
return await self.fronter.relay_parallel(
|
return await self.fronter.relay_parallel(
|
||||||
method, url, headers, body
|
method,
|
||||||
|
url,
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
chunk_size=self._download_chunk_size,
|
||||||
|
max_parallel=self._download_max_parallel,
|
||||||
|
max_chunks=self._download_max_chunks,
|
||||||
|
min_size=self._download_min_size,
|
||||||
)
|
)
|
||||||
return await self.fronter.relay(method, url, headers, body)
|
return await self.fronter.relay(method, url, headers, body)
|
||||||
|
|
||||||
def _is_likely_download(self, url: str, headers: dict) -> bool:
|
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?"""
|
||||||
path = url.split("?")[0].lower()
|
path = url.split("?")[0].lower()
|
||||||
for ext in LARGE_FILE_EXTS:
|
if self._download_any_extension:
|
||||||
|
return True
|
||||||
|
for ext in self._download_extensions:
|
||||||
if path.endswith(ext):
|
if path.endswith(ext):
|
||||||
return True
|
return True
|
||||||
|
accept = self._header_value(headers, "accept").lower()
|
||||||
|
if any(marker in accept for marker in self._DOWNLOAD_ACCEPT_MARKERS):
|
||||||
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
async def _maybe_stream_download(self, method: str, url: str,
|
||||||
|
headers: dict | None, body: bytes,
|
||||||
|
writer) -> bool:
|
||||||
|
if method.upper() != "GET" or body:
|
||||||
|
return False
|
||||||
|
if headers:
|
||||||
|
for key in headers:
|
||||||
|
if key.lower() == "range":
|
||||||
|
return False
|
||||||
|
effective_headers = headers or {}
|
||||||
|
if not self._is_likely_download(url, effective_headers):
|
||||||
|
return False
|
||||||
|
if not self.fronter.stream_download_allowed(url):
|
||||||
|
return False
|
||||||
|
return await self.fronter.stream_parallel_download(
|
||||||
|
url,
|
||||||
|
effective_headers,
|
||||||
|
writer,
|
||||||
|
chunk_size=self._download_chunk_size,
|
||||||
|
max_parallel=self._download_max_parallel,
|
||||||
|
max_chunks=self._download_max_chunks,
|
||||||
|
min_size=self._download_min_size,
|
||||||
|
)
|
||||||
|
|
||||||
# ── Plain HTTP forwarding ─────────────────────────────────────
|
# ── Plain HTTP forwarding ─────────────────────────────────────
|
||||||
|
|
||||||
async def _do_http(self, header_block: bytes, reader, writer):
|
async def _do_http(self, header_block: bytes, reader, writer):
|
||||||
body = b""
|
body = b""
|
||||||
|
if _has_unsupported_transfer_encoding(header_block):
|
||||||
|
log.warning("Unsupported Transfer-Encoding on plain HTTP request")
|
||||||
|
writer.write(
|
||||||
|
b"HTTP/1.1 501 Not Implemented\r\n"
|
||||||
|
b"Connection: close\r\n"
|
||||||
|
b"Content-Length: 0\r\n\r\n"
|
||||||
|
)
|
||||||
|
await writer.drain()
|
||||||
|
return
|
||||||
length = _parse_content_length(header_block)
|
length = _parse_content_length(header_block)
|
||||||
if length > MAX_REQUEST_BODY_BYTES:
|
if length > MAX_REQUEST_BODY_BYTES:
|
||||||
writer.write(b"HTTP/1.1 413 Content Too Large\r\n\r\n")
|
writer.write(b"HTTP/1.1 413 Content Too Large\r\n\r\n")
|
||||||
@@ -1194,24 +1452,21 @@ class ProxyServer:
|
|||||||
k, v = raw_line.decode(errors="replace").split(":", 1)
|
k, v = raw_line.decode(errors="replace").split(":", 1)
|
||||||
headers[k.strip()] = v.strip()
|
headers[k.strip()] = v.strip()
|
||||||
|
|
||||||
# ── CORS preflight over plain HTTP ────────────────────────────
|
# ── CORS preflight over plain HTTP ─────────────────────────────
|
||||||
origin = next(
|
origin = self._header_value(headers, "origin")
|
||||||
(v for k, v in headers.items() if k.lower() == "origin"), ""
|
acr_method = self._header_value(headers, "access-control-request-method")
|
||||||
)
|
acr_headers = self._header_value(headers, "access-control-request-headers")
|
||||||
acr_method = next(
|
|
||||||
(v for k, v in headers.items()
|
|
||||||
if k.lower() == "access-control-request-method"), ""
|
|
||||||
)
|
|
||||||
acr_headers_val = next(
|
|
||||||
(v for k, v in headers.items()
|
|
||||||
if k.lower() == "access-control-request-headers"), ""
|
|
||||||
)
|
|
||||||
if method.upper() == "OPTIONS" and acr_method:
|
if method.upper() == "OPTIONS" and acr_method:
|
||||||
log.debug("CORS preflight (HTTP) → %s (responding locally)", url[:60])
|
log.debug("CORS preflight (HTTP) → %s (responding locally)", url[:60])
|
||||||
writer.write(self._cors_preflight_response(origin, acr_method, acr_headers_val))
|
writer.write(self._cors_preflight_response(
|
||||||
|
origin, acr_method, acr_headers,
|
||||||
|
))
|
||||||
await writer.drain()
|
await writer.drain()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if await self._maybe_stream_download(method, url, headers, body, writer):
|
||||||
|
return
|
||||||
|
|
||||||
# Cache check for GET
|
# Cache check for GET
|
||||||
response = None
|
response = None
|
||||||
if self._cache_allowed(method, url, headers, body):
|
if self._cache_allowed(method, url, headers, body):
|
||||||
@@ -1227,9 +1482,9 @@ class ProxyServer:
|
|||||||
if ttl > 0:
|
if ttl > 0:
|
||||||
self._cache.put(url, response, ttl)
|
self._cache.put(url, response, ttl)
|
||||||
|
|
||||||
# Inject CORS headers for cross-origin requests
|
|
||||||
if origin and response:
|
if origin and response:
|
||||||
response = self._inject_cors_headers(response, origin)
|
response = self._inject_cors_headers(response, origin)
|
||||||
|
|
||||||
self._log_response_summary(url, response)
|
self._log_response_summary(url, response)
|
||||||
|
|
||||||
writer.write(response)
|
writer.write(response)
|
||||||
|
|||||||
Reference in New Issue
Block a user