mirror of
https://github.com/masterking32/MasterHttpRelayVPN.git
synced 2026-05-18 08:14:40 +03:00
Merge pull request #3 from PK3NZO/codex/apps-script-compat-socks5
Improve apps_script stability, add SOCKS5, and fix over-broad Google direct routing
This commit is contained in:
@@ -23,6 +23,7 @@ env/
|
|||||||
# IDE
|
# IDE
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
*.code-workspace
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
|
|||||||
@@ -18,9 +18,12 @@
|
|||||||
|
|
||||||
const AUTH_KEY = "CHANGE_ME_TO_A_STRONG_SECRET";
|
const AUTH_KEY = "CHANGE_ME_TO_A_STRONG_SECRET";
|
||||||
|
|
||||||
|
// Keep browser capability headers (sec-ch-ua*, sec-fetch-*) intact.
|
||||||
|
// Some modern apps, notably Google Meet, use them for browser gating.
|
||||||
const SKIP_HEADERS = {
|
const SKIP_HEADERS = {
|
||||||
host: 1, connection: 1, "content-length": 1,
|
host: 1, connection: 1, "content-length": 1,
|
||||||
"transfer-encoding": 1, "proxy-connection": 1, "proxy-authorization": 1,
|
"transfer-encoding": 1, "proxy-connection": 1, "proxy-authorization": 1,
|
||||||
|
"priority": 1, te: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
function doPost(e) {
|
function doPost(e) {
|
||||||
@@ -46,7 +49,7 @@ function _doSingle(req) {
|
|||||||
var resp = UrlFetchApp.fetch(req.u, opts);
|
var resp = UrlFetchApp.fetch(req.u, opts);
|
||||||
return _json({
|
return _json({
|
||||||
s: resp.getResponseCode(),
|
s: resp.getResponseCode(),
|
||||||
h: resp.getHeaders(),
|
h: _respHeaders(resp),
|
||||||
b: Utilities.base64Encode(resp.getContent()),
|
b: Utilities.base64Encode(resp.getContent()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -81,7 +84,7 @@ function _doBatch(items) {
|
|||||||
var resp = responses[rIdx++];
|
var resp = responses[rIdx++];
|
||||||
results.push({
|
results.push({
|
||||||
s: resp.getResponseCode(),
|
s: resp.getResponseCode(),
|
||||||
h: resp.getHeaders(),
|
h: _respHeaders(resp),
|
||||||
b: Utilities.base64Encode(resp.getContent()),
|
b: Utilities.base64Encode(resp.getContent()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -95,6 +98,7 @@ function _buildOpts(req) {
|
|||||||
muteHttpExceptions: true,
|
muteHttpExceptions: true,
|
||||||
followRedirects: req.r !== false,
|
followRedirects: req.r !== false,
|
||||||
validateHttpsCertificates: true,
|
validateHttpsCertificates: true,
|
||||||
|
escaping: false,
|
||||||
};
|
};
|
||||||
if (req.h && typeof req.h === "object") {
|
if (req.h && typeof req.h === "object") {
|
||||||
var headers = {};
|
var headers = {};
|
||||||
@@ -112,6 +116,15 @@ function _buildOpts(req) {
|
|||||||
return opts;
|
return opts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _respHeaders(resp) {
|
||||||
|
try {
|
||||||
|
if (typeof resp.getAllHeaders === "function") {
|
||||||
|
return resp.getAllHeaders();
|
||||||
|
}
|
||||||
|
} catch (err) {}
|
||||||
|
return resp.getHeaders();
|
||||||
|
}
|
||||||
|
|
||||||
function doGet(e) {
|
function doGet(e) {
|
||||||
return HtmlService.createHtmlOutput(
|
return HtmlService.createHtmlOutput(
|
||||||
"<!DOCTYPE html><html><head><title>My App</title></head>" +
|
"<!DOCTYPE html><html><head><title>My App</title></head>" +
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# PR Draft
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
This PR improves `apps_script` mode compatibility and local usability.
|
||||||
|
|
||||||
|
Changes included:
|
||||||
|
|
||||||
|
- add built-in SOCKS5 listener alongside the HTTP proxy
|
||||||
|
- use sticky per-host Apps Script routing instead of naive per-request round-robin
|
||||||
|
- preserve redirect semantics for `307/308` while still normalizing `301/302/303`
|
||||||
|
- avoid batching/coalescing/cache shortcuts for stateful requests
|
||||||
|
- make caching safer by skipping requests/responses involving cookies, auth, private cache directives, and `Set-Cookie`
|
||||||
|
- improve Linux CA trust detection
|
||||||
|
- make Google direct-tunnel routing more conservative
|
||||||
|
- add adaptive fallback from failed direct Google tunnels back to the MITM relay path
|
||||||
|
- preserve browser capability headers in `Code.gs` (`sec-ch-ua*`, `sec-fetch-*`)
|
||||||
|
- preserve multi-value response headers from Apps Script via `getAllHeaders()`
|
||||||
|
- keep signed URLs safer via `escaping: false` in Apps Script fetch options
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
The previous behavior worked for some simple/static sites, but modern sites and Google web apps were sensitive to:
|
||||||
|
|
||||||
|
- request-to-request route churn
|
||||||
|
- incorrect redirect method handling
|
||||||
|
- over-aggressive batching/cache reuse
|
||||||
|
- over-broad Google direct-tunnel shortcuts
|
||||||
|
- lost response headers such as `Set-Cookie`
|
||||||
|
- stripped browser capability headers in the Apps Script relay
|
||||||
|
|
||||||
|
This PR does not claim full compatibility for all websites. It focuses on making the existing architecture more stable and more predictable.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Local verification:
|
||||||
|
|
||||||
|
- `python3 -m py_compile main.py proxy_server.py domain_fronter.py mitm.py h2_transport.py ws.py cert_installer.py`
|
||||||
|
|
||||||
|
Observed behavior during manual testing:
|
||||||
|
|
||||||
|
- improved: YouTube, Facebook, Gmail, Drive
|
||||||
|
- improved / partial: Gemini loads further than before
|
||||||
|
- still limited by architecture: ChatGPT / Cloudflare PAT flows, Google Meet browser-gating / unsupported-browser flow in `apps_script` mode
|
||||||
|
|
||||||
|
## Important limitation
|
||||||
|
|
||||||
|
`apps_script` mode still uses Google Apps Script `UrlFetch`, which is not a real browser transport.
|
||||||
|
|
||||||
|
That means some sites may still reject or degrade requests because of:
|
||||||
|
|
||||||
|
- TLS / transport fingerprint differences
|
||||||
|
- anti-bot / PAT / Turnstile / attestation checks
|
||||||
|
- browser capability detection
|
||||||
|
- WebRTC / media / browser-runtime assumptions
|
||||||
|
|
||||||
|
## Deployment note
|
||||||
|
|
||||||
|
If `Code.gs` changes are included, users must create a new Google Apps Script deployment and update `script_id` in `config.json`.
|
||||||
|
|
||||||
|
## Security / hygiene checklist
|
||||||
|
|
||||||
|
- no real `config.json` included
|
||||||
|
- no real deployment IDs included
|
||||||
|
- `AUTH_KEY` reset to placeholder
|
||||||
|
- no local logs included
|
||||||
|
- no local workspace files intended for commit
|
||||||
@@ -89,6 +89,8 @@ This is the "relay" that sits on Google's servers and fetches websites for you.
|
|||||||
"auth_key": "your-secret-password-here",
|
"auth_key": "your-secret-password-here",
|
||||||
"listen_host": "127.0.0.1",
|
"listen_host": "127.0.0.1",
|
||||||
"listen_port": 8085,
|
"listen_port": 8085,
|
||||||
|
"socks5_enabled": true,
|
||||||
|
"socks5_port": 1080,
|
||||||
"log_level": "INFO",
|
"log_level": "INFO",
|
||||||
"verify_ssl": true
|
"verify_ssl": true
|
||||||
}
|
}
|
||||||
@@ -99,10 +101,10 @@ This is the "relay" that sits on Google's servers and fetches websites for you.
|
|||||||
### Step 4: Run
|
### Step 4: Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python main.py
|
python3 main.py
|
||||||
```
|
```
|
||||||
|
|
||||||
You should see a message saying the proxy is running on `127.0.0.1:8085`.
|
You should see a message saying the HTTP proxy is running on `127.0.0.1:8085` and SOCKS5 on `127.0.0.1:1080`.
|
||||||
|
|
||||||
### Step 5: Set Up Your Browser
|
### Step 5: Set Up Your Browser
|
||||||
|
|
||||||
@@ -111,6 +113,7 @@ Set your browser to use the proxy:
|
|||||||
- **Proxy Address:** `127.0.0.1`
|
- **Proxy Address:** `127.0.0.1`
|
||||||
- **Proxy Port:** `8085`
|
- **Proxy Port:** `8085`
|
||||||
- **Type:** HTTP
|
- **Type:** HTTP
|
||||||
|
- **Optional SOCKS5 Port:** `1080`
|
||||||
|
|
||||||
**How to set proxy in common browsers:**
|
**How to set proxy in common browsers:**
|
||||||
- **Firefox:** Settings → General → Network Settings → Manual proxy → enter `127.0.0.1` port `8085` for HTTP Proxy → check "Also use this proxy for HTTPS"
|
- **Firefox:** Settings → General → Network Settings → Manual proxy → enter `127.0.0.1` port `8085` for HTTP Proxy → check "Also use this proxy for HTTPS"
|
||||||
@@ -220,12 +223,14 @@ If you change `Code.gs`, you must **create a new deployment** in Google Apps Scr
|
|||||||
## Command Line Options
|
## Command Line Options
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python main.py # Normal start
|
python3 main.py # Normal start
|
||||||
python main.py -p 9090 # Use port 9090 instead
|
python3 main.py -p 9090 # Use HTTP port 9090 instead
|
||||||
python main.py --log-level DEBUG # Show detailed logs
|
python3 main.py --socks5-port 1081 # Use SOCKS5 port 1081
|
||||||
python main.py -c /path/to/config.json # Use a different config file
|
python3 main.py --disable-socks5 # Disable SOCKS5 listener
|
||||||
python main.py --install-cert # Install MITM CA certificate and exit
|
python3 main.py --log-level DEBUG # Show detailed logs
|
||||||
python main.py --no-cert-check # Skip automatic CA install check on startup
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
> **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.
|
||||||
|
|||||||
+13
-8
@@ -86,6 +86,8 @@ cp config.example.json config.json
|
|||||||
"auth_key": "your-secret-password-here",
|
"auth_key": "your-secret-password-here",
|
||||||
"listen_host": "127.0.0.1",
|
"listen_host": "127.0.0.1",
|
||||||
"listen_port": 8085,
|
"listen_port": 8085,
|
||||||
|
"socks5_enabled": true,
|
||||||
|
"socks5_port": 1080,
|
||||||
"log_level": "INFO",
|
"log_level": "INFO",
|
||||||
"verify_ssl": true
|
"verify_ssl": true
|
||||||
}
|
}
|
||||||
@@ -97,10 +99,10 @@ cp config.example.json config.json
|
|||||||
### مرحله 4: اجرا
|
### مرحله 4: اجرا
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python main.py
|
python3 main.py
|
||||||
```
|
```
|
||||||
|
|
||||||
اگر همهچیز درست باشد، پراکسی روی `127.0.0.1:8085` بالا میآید.
|
اگر همهچیز درست باشد، پراکسی HTTP روی `127.0.0.1:8085` و SOCKS5 روی `127.0.0.1:1080` بالا میآید.
|
||||||
|
|
||||||
### مرحله 5: تنظیم مرورگر
|
### مرحله 5: تنظیم مرورگر
|
||||||
|
|
||||||
@@ -109,6 +111,7 @@ python main.py
|
|||||||
- **Proxy Address:** `127.0.0.1`
|
- **Proxy Address:** `127.0.0.1`
|
||||||
- **Proxy Port:** `8085`
|
- **Proxy Port:** `8085`
|
||||||
- **Type:** HTTP
|
- **Type:** HTTP
|
||||||
|
- **SOCKS5 Port (اختیاری):** `1080`
|
||||||
|
|
||||||
نمونه تنظیم مرورگرها:
|
نمونه تنظیم مرورگرها:
|
||||||
|
|
||||||
@@ -208,12 +211,14 @@ Firefox معمولا certificate store جداگانه دارد:
|
|||||||
## دستورهای اجرا
|
## دستورهای اجرا
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python main.py
|
python3 main.py
|
||||||
python main.py -p 9090
|
python3 main.py -p 9090
|
||||||
python main.py --log-level DEBUG
|
python3 main.py --socks5-port 1081
|
||||||
python main.py -c /path/to/config.json
|
python3 main.py --disable-socks5
|
||||||
python main.py --install-cert # نصب گواهی CA و خروج
|
python3 main.py --log-level DEBUG
|
||||||
python main.py --no-cert-check # رد شدن از بررسی خودکار گواهی
|
python3 main.py -c /path/to/config.json
|
||||||
|
python3 main.py --install-cert # نصب گواهی CA و خروج
|
||||||
|
python3 main.py --no-cert-check # رد شدن از بررسی خودکار گواهی
|
||||||
```
|
```
|
||||||
|
|
||||||
> **نصب خودکار:** هنگام اجرا در حالت `apps_script`، برنامه بهطور خودکار بررسی میکند که آیا گواهی CA قابل اعتماد است یا نه و در صورت نیاز آن را نصب میکند. اگر نصب خودکار ناموفق بود (مثلاً نیاز به دسترسی مدیر دارد)، میتوانید دستور `python main.py --install-cert` را اجرا کنید یا مراحل مرحله ۶ را دنبال کنید.
|
> **نصب خودکار:** هنگام اجرا در حالت `apps_script`، برنامه بهطور خودکار بررسی میکند که آیا گواهی CA قابل اعتماد است یا نه و در صورت نیاز آن را نصب میکند. اگر نصب خودکار ناموفق بود (مثلاً نیاز به دسترسی مدیر دارد)، میتوانید دستور `python main.py --install-cert` را اجرا کنید یا مراحل مرحله ۶ را دنبال کنید.
|
||||||
|
|||||||
+55
-15
@@ -250,28 +250,68 @@ def _install_linux(cert_path: str, cert_name: str) -> bool:
|
|||||||
return installed
|
return installed
|
||||||
|
|
||||||
|
|
||||||
def _is_trusted_linux(cert_path: str) -> bool:
|
def _is_trusted_linux(cert_path: str, cert_name: str = CERT_NAME) -> bool:
|
||||||
"""Check if our cert thumbprint is in the system's OpenSSL trust bundle."""
|
"""Check whether the cert appears in common Linux trust stores."""
|
||||||
thumbprint = _cert_thumbprint(cert_path)
|
try:
|
||||||
if not thumbprint:
|
from cryptography import x509 as _x509
|
||||||
|
from cryptography.hazmat.primitives import hashes as _hashes
|
||||||
|
except Exception:
|
||||||
return False
|
return False
|
||||||
bundle_paths = [
|
|
||||||
"/etc/ssl/certs/ca-certificates.crt", # Debian/Ubuntu
|
try:
|
||||||
"/etc/pki/tls/certs/ca-bundle.crt", # RHEL/Fedora
|
with open(cert_path, "rb") as f:
|
||||||
"/etc/ssl/ca-bundle.pem", # OpenSUSE
|
target_cert = _x509.load_pem_x509_certificate(f.read())
|
||||||
"/etc/ca-certificates/ca-certificates.crt",
|
target_fp = target_cert.fingerprint(_hashes.SHA1())
|
||||||
]
|
except Exception:
|
||||||
# A fast heuristic: check if our CA cert file was copied to known dirs
|
return False
|
||||||
|
|
||||||
|
# First check the common anchor locations used by the installer.
|
||||||
|
expected_name = f"{cert_name.replace(' ', '_')}.crt"
|
||||||
anchor_dirs = [
|
anchor_dirs = [
|
||||||
"/usr/local/share/ca-certificates",
|
"/usr/local/share/ca-certificates",
|
||||||
"/etc/pki/ca-trust/source/anchors",
|
"/etc/pki/ca-trust/source/anchors",
|
||||||
"/etc/ca-certificates/trust-source/anchors",
|
"/etc/ca-certificates/trust-source/anchors",
|
||||||
]
|
]
|
||||||
for d in anchor_dirs:
|
for d in anchor_dirs:
|
||||||
if os.path.isdir(d):
|
try:
|
||||||
for f in os.listdir(d):
|
if not os.path.isdir(d):
|
||||||
if "DomainFront" in f or "domainfront" in f.lower():
|
continue
|
||||||
|
if expected_name in os.listdir(d):
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fall back to scanning the system bundle files directly.
|
||||||
|
bundle_paths = [
|
||||||
|
"/etc/ssl/certs/ca-certificates.crt", # Debian/Ubuntu
|
||||||
|
"/etc/pki/tls/certs/ca-bundle.crt", # RHEL/Fedora
|
||||||
|
"/etc/ssl/ca-bundle.pem", # OpenSUSE
|
||||||
|
"/etc/ca-certificates/ca-certificates.crt",
|
||||||
|
]
|
||||||
|
|
||||||
|
begin = b"-----BEGIN CERTIFICATE-----"
|
||||||
|
end = b"-----END CERTIFICATE-----"
|
||||||
|
for bundle in bundle_paths:
|
||||||
|
try:
|
||||||
|
with open(bundle, "rb") as f:
|
||||||
|
data = f.read()
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for chunk in data.split(begin):
|
||||||
|
if end not in chunk:
|
||||||
|
continue
|
||||||
|
pem = begin + chunk.split(end, 1)[0] + end + b"\n"
|
||||||
|
try:
|
||||||
|
cert = _x509.load_pem_x509_certificate(pem)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if cert.fingerprint(_hashes.SHA1()) == target_fp:
|
||||||
return True
|
return True
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -330,7 +370,7 @@ def is_ca_trusted(cert_path: str) -> bool:
|
|||||||
return _is_trusted_windows(cert_path)
|
return _is_trusted_windows(cert_path)
|
||||||
if system == "Darwin":
|
if system == "Darwin":
|
||||||
return _is_trusted_macos(CERT_NAME)
|
return _is_trusted_macos(CERT_NAME)
|
||||||
return _is_trusted_linux(cert_path)
|
return _is_trusted_linux(cert_path, CERT_NAME)
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,15 @@
|
|||||||
"auth_key": "CHANGE_ME_TO_A_STRONG_SECRET",
|
"auth_key": "CHANGE_ME_TO_A_STRONG_SECRET",
|
||||||
"listen_host": "127.0.0.1",
|
"listen_host": "127.0.0.1",
|
||||||
"listen_port": 8085,
|
"listen_port": 8085,
|
||||||
|
"socks5_enabled": true,
|
||||||
|
"socks5_host": "127.0.0.1",
|
||||||
|
"socks5_port": 1080,
|
||||||
"log_level": "INFO",
|
"log_level": "INFO",
|
||||||
"verify_ssl": true,
|
"verify_ssl": true,
|
||||||
|
"_direct_google_exclude_comment": "Google web apps that should NEVER use the raw direct-tunnel shortcut. Supports exact hosts and optional suffix patterns like \".googleapis.com\". They will go through the MITM relay path instead for better compatibility.",
|
||||||
|
"direct_google_exclude": ["gemini.google.com", "aistudio.google.com", "notebooklm.google.com", "labs.google.com", "meet.google.com", "accounts.google.com", "ogs.google.com", "mail.google.com", "calendar.google.com", "drive.google.com", "docs.google.com", "chat.google.com"],
|
||||||
|
"_direct_google_allow_comment": "Conservative allowlist for raw direct Google tunneling. Leave empty unless you have confirmed a host works better direct than via relay.",
|
||||||
|
"direct_google_allow": ["www.google.com", "safebrowsing.google.com"],
|
||||||
"_hosts_comment": "Optional SNI-rewrite overrides. YouTube, googlevideo, gstatic, fonts.googleapis.com, ytimg, ggpht, doubleclick, etc. are ALREADY handled automatically (routed via google_ip with SNI=front_domain, same trick as the Xray MITM-DomainFronting config). Add entries here only for custom domains, e.g. \"example.com\": \"216.239.38.120\".",
|
"_hosts_comment": "Optional SNI-rewrite overrides. YouTube, googlevideo, gstatic, fonts.googleapis.com, ytimg, ggpht, doubleclick, etc. are ALREADY handled automatically (routed via google_ip with SNI=front_domain, same trick as the Xray MITM-DomainFronting config). Add entries here only for custom domains, e.g. \"example.com\": \"216.239.38.120\".",
|
||||||
"hosts": {}
|
"hosts": {}
|
||||||
}
|
}
|
||||||
|
|||||||
+118
-24
@@ -19,6 +19,7 @@ Mode 4 (apps_script):
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
|
import hashlib
|
||||||
import gzip
|
import gzip
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
@@ -34,6 +35,12 @@ log = logging.getLogger("Fronter")
|
|||||||
|
|
||||||
|
|
||||||
class DomainFronter:
|
class DomainFronter:
|
||||||
|
_STATIC_EXTS = (
|
||||||
|
".css", ".js", ".mjs", ".woff", ".woff2", ".ttf", ".eot",
|
||||||
|
".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".ico",
|
||||||
|
".mp3", ".mp4", ".webm", ".wasm", ".avif",
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(self, config: dict):
|
def __init__(self, config: dict):
|
||||||
mode = config.get("mode", "domain_fronting")
|
mode = config.get("mode", "domain_fronting")
|
||||||
|
|
||||||
@@ -170,9 +177,34 @@ class DomainFronter:
|
|||||||
self._script_idx += 1
|
self._script_idx += 1
|
||||||
return sid
|
return sid
|
||||||
|
|
||||||
def _exec_path(self) -> str:
|
@staticmethod
|
||||||
"""Get the next Apps Script endpoint path (/dev or /exec)."""
|
def _host_key(url_or_host: str | None) -> str:
|
||||||
sid = self._next_script_id()
|
"""Return a stable routing key for a URL or host string."""
|
||||||
|
if not url_or_host:
|
||||||
|
return ""
|
||||||
|
parsed = urlparse(url_or_host if "://" in url_or_host else f"https://{url_or_host}")
|
||||||
|
host = parsed.hostname or url_or_host
|
||||||
|
return host.lower().rstrip(".")
|
||||||
|
|
||||||
|
def _script_id_for_key(self, key: str | None = None) -> str:
|
||||||
|
"""Pick a stable Apps Script ID for a host or fallback to round-robin.
|
||||||
|
|
||||||
|
When multiple deployments are configured, using a stable mapping per
|
||||||
|
host reduces IP/session churn for sites that are sensitive to endpoint
|
||||||
|
changes. If no key is available, we keep the older round-robin fallback
|
||||||
|
so warmup/keepalive traffic still distributes normally.
|
||||||
|
"""
|
||||||
|
if len(self._script_ids) == 1:
|
||||||
|
return self._script_ids[0]
|
||||||
|
if not key:
|
||||||
|
return self._next_script_id()
|
||||||
|
digest = hashlib.sha1(key.encode("utf-8")).digest()
|
||||||
|
idx = int.from_bytes(digest[:4], "big") % len(self._script_ids)
|
||||||
|
return self._script_ids[idx]
|
||||||
|
|
||||||
|
def _exec_path(self, url_or_host: str | None = None) -> str:
|
||||||
|
"""Get the Apps Script endpoint path (/dev or /exec)."""
|
||||||
|
sid = self._script_id_for_key(self._host_key(url_or_host))
|
||||||
return f"/macros/s/{sid}/{'dev' if self._dev_available else 'exec'}"
|
return f"/macros/s/{sid}/{'dev' if self._dev_available else 'exec'}"
|
||||||
|
|
||||||
async def _flush_pool(self):
|
async def _flush_pool(self):
|
||||||
@@ -332,7 +364,7 @@ class DomainFronter:
|
|||||||
|
|
||||||
# Apps Script keepalive — warm the container
|
# Apps Script keepalive — warm the container
|
||||||
payload = {"m": "HEAD", "u": "http://example.com/", "k": self.auth_key}
|
payload = {"m": "HEAD", "u": "http://example.com/", "k": self.auth_key}
|
||||||
path = self._exec_path()
|
path = self._exec_path("example.com")
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
await asyncio.wait_for(
|
await asyncio.wait_for(
|
||||||
self._h2.request(
|
self._h2.request(
|
||||||
@@ -514,6 +546,11 @@ class DomainFronter:
|
|||||||
|
|
||||||
payload = self._build_payload(method, url, headers, body)
|
payload = self._build_payload(method, url, headers, body)
|
||||||
|
|
||||||
|
# Stateful/browser-navigation requests should preserve exact ordering
|
||||||
|
# and header context; batching/coalescing is reserved for static fetches.
|
||||||
|
if self._is_stateful_request(method, url, headers, body):
|
||||||
|
return await self._relay_with_retry(payload)
|
||||||
|
|
||||||
# Coalesce concurrent GETs for the same URL.
|
# Coalesce concurrent GETs for the same URL.
|
||||||
# CRITICAL: do NOT coalesce when a Range header is present —
|
# CRITICAL: do NOT coalesce when a Range header is present —
|
||||||
# parallel range downloads MUST each hit the server independently.
|
# parallel range downloads MUST each hit the server independently.
|
||||||
@@ -711,7 +748,8 @@ class DomainFronter:
|
|||||||
payload = {
|
payload = {
|
||||||
"m": method,
|
"m": method,
|
||||||
"u": url,
|
"u": url,
|
||||||
"r": True,
|
# Let the browser/app see origin redirects and cookies directly.
|
||||||
|
"r": False,
|
||||||
}
|
}
|
||||||
if headers:
|
if headers:
|
||||||
# Strip Accept-Encoding: Apps Script auto-decompresses gzip
|
# Strip Accept-Encoding: Apps Script auto-decompresses gzip
|
||||||
@@ -726,6 +764,46 @@ class DomainFronter:
|
|||||||
payload["ct"] = ct
|
payload["ct"] = ct
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _is_static_asset_url(cls, url: str) -> bool:
|
||||||
|
path = urlparse(url).path.lower()
|
||||||
|
return any(path.endswith(ext) for ext in cls._STATIC_EXTS)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _header_value(headers: dict | None, name: str) -> str:
|
||||||
|
if not headers:
|
||||||
|
return ""
|
||||||
|
for key, value in headers.items():
|
||||||
|
if key.lower() == name:
|
||||||
|
return str(value)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _is_stateful_request(cls, method: str, url: str,
|
||||||
|
headers: dict | None, body: bytes) -> bool:
|
||||||
|
method = method.upper()
|
||||||
|
if method not in {"GET", "HEAD"} or body:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if headers:
|
||||||
|
for name in (
|
||||||
|
"cookie", "authorization", "proxy-authorization",
|
||||||
|
"origin", "referer", "if-none-match", "if-modified-since",
|
||||||
|
"cache-control", "pragma",
|
||||||
|
):
|
||||||
|
if cls._header_value(headers, name):
|
||||||
|
return True
|
||||||
|
|
||||||
|
accept = cls._header_value(headers, "accept").lower()
|
||||||
|
if "text/html" in accept or "application/json" in accept:
|
||||||
|
return True
|
||||||
|
|
||||||
|
fetch_mode = cls._header_value(headers, "sec-fetch-mode").lower()
|
||||||
|
if fetch_mode in {"navigate", "cors"}:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return not cls._is_static_asset_url(url)
|
||||||
|
|
||||||
# ── Batch collector ───────────────────────────────────────────
|
# ── Batch collector ───────────────────────────────────────────
|
||||||
|
|
||||||
async def _batch_submit(self, payload: dict) -> bytes:
|
async def _batch_submit(self, payload: dict) -> bytes:
|
||||||
@@ -866,7 +944,7 @@ class DomainFronter:
|
|||||||
full_payload["k"] = self.auth_key
|
full_payload["k"] = self.auth_key
|
||||||
json_body = json.dumps(full_payload).encode()
|
json_body = json.dumps(full_payload).encode()
|
||||||
|
|
||||||
path = self._exec_path()
|
path = self._exec_path(payload.get("u"))
|
||||||
|
|
||||||
status, headers, body = await self._h2.request(
|
status, headers, body = await self._h2.request(
|
||||||
method="POST", path=path, host=self.http_host,
|
method="POST", path=path, host=self.http_host,
|
||||||
@@ -883,7 +961,7 @@ class DomainFronter:
|
|||||||
full_payload["k"] = self.auth_key
|
full_payload["k"] = self.auth_key
|
||||||
json_body = json.dumps(full_payload).encode()
|
json_body = json.dumps(full_payload).encode()
|
||||||
|
|
||||||
path = self._exec_path()
|
path = self._exec_path(payload.get("u"))
|
||||||
reader, writer, created = await self._acquire()
|
reader, writer, created = await self._acquire()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -911,14 +989,22 @@ class DomainFronter:
|
|||||||
|
|
||||||
parsed = urlparse(location)
|
parsed = urlparse(location)
|
||||||
rpath = parsed.path + ("?" + parsed.query if parsed.query else "")
|
rpath = parsed.path + ("?" + parsed.query if parsed.query else "")
|
||||||
request = (
|
if status in (307, 308):
|
||||||
f"GET {rpath} HTTP/1.1\r\n"
|
redirect_method = "POST"
|
||||||
f"Host: {parsed.netloc}\r\n"
|
redirect_body = json_body
|
||||||
f"Accept-Encoding: gzip\r\n"
|
else:
|
||||||
f"Connection: keep-alive\r\n"
|
redirect_method = "GET"
|
||||||
f"\r\n"
|
redirect_body = b""
|
||||||
)
|
request_lines = [
|
||||||
writer.write(request.encode())
|
f"{redirect_method} {rpath} HTTP/1.1",
|
||||||
|
f"Host: {parsed.netloc}",
|
||||||
|
"Accept-Encoding: gzip",
|
||||||
|
"Connection: keep-alive",
|
||||||
|
]
|
||||||
|
if redirect_body:
|
||||||
|
request_lines.append(f"Content-Length: {len(redirect_body)}")
|
||||||
|
request = "\r\n".join(request_lines) + "\r\n\r\n"
|
||||||
|
writer.write(request.encode() + redirect_body)
|
||||||
await writer.drain()
|
await writer.drain()
|
||||||
status, resp_headers, resp_body = await self._read_http_response(reader)
|
status, resp_headers, resp_body = await self._read_http_response(reader)
|
||||||
|
|
||||||
@@ -939,7 +1025,7 @@ class DomainFronter:
|
|||||||
"q": payloads,
|
"q": payloads,
|
||||||
}
|
}
|
||||||
json_body = json.dumps(batch_payload).encode()
|
json_body = json.dumps(batch_payload).encode()
|
||||||
path = self._exec_path()
|
path = self._exec_path(payloads[0].get("u") if payloads else None)
|
||||||
|
|
||||||
# Try HTTP/2 first
|
# Try HTTP/2 first
|
||||||
if self._h2 and self._h2.is_connected:
|
if self._h2 and self._h2.is_connected:
|
||||||
@@ -983,14 +1069,22 @@ class DomainFronter:
|
|||||||
break
|
break
|
||||||
parsed = urlparse(location)
|
parsed = urlparse(location)
|
||||||
rpath = parsed.path + ("?" + parsed.query if parsed.query else "")
|
rpath = parsed.path + ("?" + parsed.query if parsed.query else "")
|
||||||
request = (
|
if status in (307, 308):
|
||||||
f"GET {rpath} HTTP/1.1\r\n"
|
redirect_method = "POST"
|
||||||
f"Host: {parsed.netloc}\r\n"
|
redirect_body = json_body
|
||||||
f"Accept-Encoding: gzip\r\n"
|
else:
|
||||||
f"Connection: keep-alive\r\n"
|
redirect_method = "GET"
|
||||||
f"\r\n"
|
redirect_body = b""
|
||||||
)
|
request_lines = [
|
||||||
writer.write(request.encode())
|
f"{redirect_method} {rpath} HTTP/1.1",
|
||||||
|
f"Host: {parsed.netloc}",
|
||||||
|
"Accept-Encoding: gzip",
|
||||||
|
"Connection: keep-alive",
|
||||||
|
]
|
||||||
|
if redirect_body:
|
||||||
|
request_lines.append(f"Content-Length: {len(redirect_body)}")
|
||||||
|
request = "\r\n".join(request_lines) + "\r\n\r\n"
|
||||||
|
writer.write(request.encode() + redirect_body)
|
||||||
await writer.drain()
|
await writer.drain()
|
||||||
status, resp_headers, resp_body = await self._read_http_response(reader)
|
status, resp_headers, resp_body = await self._read_http_response(reader)
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,17 @@ def parse_args():
|
|||||||
default=None,
|
default=None,
|
||||||
help="Override listen host (env: DFT_HOST)",
|
help="Override listen host (env: DFT_HOST)",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--socks5-port",
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
help="Override SOCKS5 listen port (env: DFT_SOCKS5_PORT)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--disable-socks5",
|
||||||
|
action="store_true",
|
||||||
|
help="Disable the built-in SOCKS5 listener.",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--log-level",
|
"--log-level",
|
||||||
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
|
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
|
||||||
@@ -107,6 +118,14 @@ def main():
|
|||||||
elif os.environ.get("DFT_HOST"):
|
elif os.environ.get("DFT_HOST"):
|
||||||
config["listen_host"] = os.environ["DFT_HOST"]
|
config["listen_host"] = os.environ["DFT_HOST"]
|
||||||
|
|
||||||
|
if args.socks5_port is not None:
|
||||||
|
config["socks5_port"] = args.socks5_port
|
||||||
|
elif os.environ.get("DFT_SOCKS5_PORT"):
|
||||||
|
config["socks5_port"] = int(os.environ["DFT_SOCKS5_PORT"])
|
||||||
|
|
||||||
|
if args.disable_socks5:
|
||||||
|
config["socks5_enabled"] = False
|
||||||
|
|
||||||
if args.log_level is not None:
|
if args.log_level is not None:
|
||||||
config["log_level"] = args.log_level
|
config["log_level"] = args.log_level
|
||||||
elif os.environ.get("DFT_LOG_LEVEL"):
|
elif os.environ.get("DFT_LOG_LEVEL"):
|
||||||
@@ -162,7 +181,7 @@ def main():
|
|||||||
config.get("front_domain", "www.google.com"))
|
config.get("front_domain", "www.google.com"))
|
||||||
script_ids = config.get("script_ids") or config.get("script_id")
|
script_ids = config.get("script_ids") or config.get("script_id")
|
||||||
if isinstance(script_ids, list):
|
if isinstance(script_ids, list):
|
||||||
log.info("Script IDs : %d scripts (round-robin)", len(script_ids))
|
log.info("Script IDs : %d scripts (sticky per-host)", len(script_ids))
|
||||||
for i, sid in enumerate(script_ids):
|
for i, sid in enumerate(script_ids):
|
||||||
log.info(" [%d] %s", i + 1, sid)
|
log.info(" [%d] %s", i + 1, sid)
|
||||||
else:
|
else:
|
||||||
@@ -192,7 +211,13 @@ def main():
|
|||||||
log.info("Front domain (SNI) : %s", config.get("front_domain", "?"))
|
log.info("Front domain (SNI) : %s", config.get("front_domain", "?"))
|
||||||
log.info("Worker host (Host) : %s", config.get("worker_host", "?"))
|
log.info("Worker host (Host) : %s", config.get("worker_host", "?"))
|
||||||
|
|
||||||
log.info("Proxy address : %s:%d", config.get("listen_host", "127.0.0.1"), config.get("listen_port", 8080))
|
log.info("HTTP proxy : %s:%d",
|
||||||
|
config.get("listen_host", "127.0.0.1"),
|
||||||
|
config.get("listen_port", 8080))
|
||||||
|
if config.get("socks5_enabled", True):
|
||||||
|
log.info("SOCKS5 proxy : %s:%d",
|
||||||
|
config.get("socks5_host", config.get("listen_host", "127.0.0.1")),
|
||||||
|
config.get("socks5_port", 1080))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
asyncio.run(ProxyServer(config).start())
|
asyncio.run(ProxyServer(config).start())
|
||||||
|
|||||||
+383
-26
@@ -12,8 +12,11 @@ Supports:
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import socket
|
||||||
import ssl
|
import ssl
|
||||||
import time
|
import time
|
||||||
|
import ipaddress
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from domain_fronter import DomainFronter
|
from domain_fronter import DomainFronter
|
||||||
|
|
||||||
@@ -69,7 +72,7 @@ class ResponseCache:
|
|||||||
# Don't cache errors or non-200
|
# Don't cache errors or non-200
|
||||||
if b"HTTP/1.1 200" not in raw_response[:20]:
|
if b"HTTP/1.1 200" not in raw_response[:20]:
|
||||||
return 0
|
return 0
|
||||||
if "no-store" in hdr:
|
if "no-store" in hdr or "private" in hdr or "set-cookie:" in hdr:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
# Explicit max-age
|
# Explicit max-age
|
||||||
@@ -101,13 +104,57 @@ class ResponseCache:
|
|||||||
|
|
||||||
|
|
||||||
class ProxyServer:
|
class ProxyServer:
|
||||||
|
_GOOGLE_DIRECT_EXACT_EXCLUDE = {
|
||||||
|
"gemini.google.com",
|
||||||
|
"aistudio.google.com",
|
||||||
|
"notebooklm.google.com",
|
||||||
|
"labs.google.com",
|
||||||
|
"meet.google.com",
|
||||||
|
"accounts.google.com",
|
||||||
|
"ogs.google.com",
|
||||||
|
"mail.google.com",
|
||||||
|
"calendar.google.com",
|
||||||
|
"drive.google.com",
|
||||||
|
"docs.google.com",
|
||||||
|
"chat.google.com",
|
||||||
|
"photos.google.com",
|
||||||
|
"maps.google.com",
|
||||||
|
"myaccount.google.com",
|
||||||
|
"contacts.google.com",
|
||||||
|
"classroom.google.com",
|
||||||
|
"keep.google.com",
|
||||||
|
"play.google.com",
|
||||||
|
}
|
||||||
|
_GOOGLE_DIRECT_SUFFIX_EXCLUDE = (
|
||||||
|
".meet.google.com",
|
||||||
|
)
|
||||||
|
_GOOGLE_DIRECT_ALLOW_EXACT = {
|
||||||
|
"www.google.com",
|
||||||
|
"google.com",
|
||||||
|
"safebrowsing.google.com",
|
||||||
|
}
|
||||||
|
_GOOGLE_DIRECT_ALLOW_SUFFIXES = ()
|
||||||
|
_TRACE_HOST_SUFFIXES = (
|
||||||
|
"chatgpt.com",
|
||||||
|
"openai.com",
|
||||||
|
"gemini.google.com",
|
||||||
|
"google.com",
|
||||||
|
"cloudflare.com",
|
||||||
|
"challenges.cloudflare.com",
|
||||||
|
"turnstile",
|
||||||
|
)
|
||||||
|
|
||||||
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")
|
||||||
self.port = config.get("listen_port", 8080)
|
self.port = config.get("listen_port", 8080)
|
||||||
|
self.socks_enabled = config.get("socks5_enabled", True)
|
||||||
|
self.socks_host = config.get("socks5_host", self.host)
|
||||||
|
self.socks_port = config.get("socks5_port", 1080)
|
||||||
self.mode = config.get("mode", "domain_fronting")
|
self.mode = config.get("mode", "domain_fronting")
|
||||||
self.fronter = DomainFronter(config)
|
self.fronter = DomainFronter(config)
|
||||||
self.mitm = None
|
self.mitm = None
|
||||||
self._cache = ResponseCache(max_mb=50)
|
self._cache = ResponseCache(max_mb=50)
|
||||||
|
self._direct_fail_until: dict[str, float] = {}
|
||||||
|
|
||||||
# Persistent HTTP tunnel cache for google_fronting mode
|
# Persistent HTTP tunnel cache for google_fronting mode
|
||||||
# Key: "host:port" → (tunnel_reader, tunnel_writer, lock)
|
# Key: "host:port" → (tunnel_reader, tunnel_writer, lock)
|
||||||
@@ -117,6 +164,22 @@ class ProxyServer:
|
|||||||
# 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.
|
||||||
self._hosts: dict[str, str] = config.get("hosts", {})
|
self._hosts: dict[str, str] = config.get("hosts", {})
|
||||||
|
configured_direct_exclude = config.get("direct_google_exclude", [])
|
||||||
|
self._direct_google_exclude = {
|
||||||
|
h.lower().rstrip(".")
|
||||||
|
for h in (
|
||||||
|
list(self._GOOGLE_DIRECT_EXACT_EXCLUDE) +
|
||||||
|
list(configured_direct_exclude)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
configured_direct_allow = config.get("direct_google_allow", [])
|
||||||
|
self._direct_google_allow = {
|
||||||
|
h.lower().rstrip(".")
|
||||||
|
for h in (
|
||||||
|
list(self._GOOGLE_DIRECT_ALLOW_EXACT) +
|
||||||
|
list(configured_direct_allow)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if self.mode == "apps_script":
|
if self.mode == "apps_script":
|
||||||
try:
|
try:
|
||||||
@@ -127,14 +190,94 @@ class ProxyServer:
|
|||||||
log.error("Run: pip install cryptography")
|
log.error("Run: pip install cryptography")
|
||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _header_value(headers: dict | None, name: str) -> str:
|
||||||
|
if not headers:
|
||||||
|
return ""
|
||||||
|
for key, value in headers.items():
|
||||||
|
if key.lower() == name:
|
||||||
|
return str(value)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _cache_allowed(self, method: str, url: str,
|
||||||
|
headers: dict | None, body: bytes) -> bool:
|
||||||
|
if method.upper() != "GET" or body:
|
||||||
|
return False
|
||||||
|
for name in (
|
||||||
|
"cookie", "authorization", "proxy-authorization", "range",
|
||||||
|
"if-none-match", "if-modified-since", "cache-control", "pragma",
|
||||||
|
):
|
||||||
|
if self._header_value(headers, name):
|
||||||
|
return False
|
||||||
|
return self.fronter._is_static_asset_url(url)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _should_trace_host(cls, host: str) -> bool:
|
||||||
|
h = host.lower().rstrip(".")
|
||||||
|
return any(
|
||||||
|
token == h or token in h or h.endswith("." + token)
|
||||||
|
for token in cls._TRACE_HOST_SUFFIXES
|
||||||
|
)
|
||||||
|
|
||||||
|
def _log_response_summary(self, url: str, response: bytes):
|
||||||
|
status, headers, body = self.fronter._split_raw_response(response)
|
||||||
|
host = (urlparse(url).hostname or "").lower()
|
||||||
|
if status >= 300 or self._should_trace_host(host):
|
||||||
|
location = headers.get("location", "")
|
||||||
|
server = headers.get("server", "")
|
||||||
|
cf_ray = headers.get("cf-ray", "")
|
||||||
|
content_type = headers.get("content-type", "")
|
||||||
|
body_len = len(body)
|
||||||
|
body_hint = "-"
|
||||||
|
if "text/html" in content_type.lower() and body:
|
||||||
|
sample = body[:800].decode(errors="replace").lower()
|
||||||
|
if "<title>" in sample and "</title>" in sample:
|
||||||
|
title = sample.split("<title>", 1)[1].split("</title>", 1)[0]
|
||||||
|
body_hint = title[:120]
|
||||||
|
elif "captcha" in sample:
|
||||||
|
body_hint = "captcha"
|
||||||
|
elif "turnstile" in sample:
|
||||||
|
body_hint = "turnstile"
|
||||||
|
elif "loading" in sample:
|
||||||
|
body_hint = "loading"
|
||||||
|
log.info(
|
||||||
|
"RESP ← %s status=%s type=%s len=%s server=%s location=%s cf-ray=%s hint=%s",
|
||||||
|
host or url[:60], status, content_type or "-", body_len,
|
||||||
|
server or "-", location or "-", cf_ray or "-", body_hint,
|
||||||
|
)
|
||||||
|
|
||||||
async def start(self):
|
async def start(self):
|
||||||
srv = await asyncio.start_server(self._on_client, self.host, self.port)
|
http_srv = await asyncio.start_server(self._on_client, self.host, self.port)
|
||||||
|
socks_srv = None
|
||||||
|
|
||||||
|
if self.socks_enabled:
|
||||||
|
try:
|
||||||
|
socks_srv = await asyncio.start_server(
|
||||||
|
self._on_socks_client, self.socks_host, self.socks_port
|
||||||
|
)
|
||||||
|
except OSError as e:
|
||||||
|
log.error("SOCKS5 listener failed on %s:%d: %s",
|
||||||
|
self.socks_host, self.socks_port, e)
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
"Listening on %s:%d — configure your browser HTTP proxy to this address",
|
"HTTP proxy listening on %s:%d",
|
||||||
self.host, self.port,
|
self.host, self.port,
|
||||||
)
|
)
|
||||||
async with srv:
|
if socks_srv:
|
||||||
await srv.serve_forever()
|
log.info(
|
||||||
|
"SOCKS5 proxy listening on %s:%d",
|
||||||
|
self.socks_host, self.socks_port,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with http_srv:
|
||||||
|
if socks_srv:
|
||||||
|
async with socks_srv:
|
||||||
|
await asyncio.gather(
|
||||||
|
http_srv.serve_forever(),
|
||||||
|
socks_srv.serve_forever(),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await http_srv.serve_forever()
|
||||||
|
|
||||||
# ── client handler ────────────────────────────────────────────
|
# ── client handler ────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -176,6 +319,69 @@ class ProxyServer:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
async def _on_socks_client(self, reader: asyncio.StreamReader,
|
||||||
|
writer: asyncio.StreamWriter):
|
||||||
|
addr = writer.get_extra_info("peername")
|
||||||
|
try:
|
||||||
|
header = await asyncio.wait_for(reader.readexactly(2), timeout=15)
|
||||||
|
ver, nmethods = header[0], header[1]
|
||||||
|
if ver != 5:
|
||||||
|
return
|
||||||
|
|
||||||
|
methods = await asyncio.wait_for(reader.readexactly(nmethods), timeout=10)
|
||||||
|
if 0x00 not in methods:
|
||||||
|
writer.write(b"\x05\xff")
|
||||||
|
await writer.drain()
|
||||||
|
return
|
||||||
|
|
||||||
|
writer.write(b"\x05\x00")
|
||||||
|
await writer.drain()
|
||||||
|
|
||||||
|
req = await asyncio.wait_for(reader.readexactly(4), timeout=15)
|
||||||
|
ver, cmd, _rsv, atyp = req
|
||||||
|
if ver != 5 or cmd != 0x01:
|
||||||
|
writer.write(b"\x05\x07\x00\x01\x00\x00\x00\x00\x00\x00")
|
||||||
|
await writer.drain()
|
||||||
|
return
|
||||||
|
|
||||||
|
if atyp == 0x01:
|
||||||
|
raw = await asyncio.wait_for(reader.readexactly(4), timeout=10)
|
||||||
|
host = socket.inet_ntoa(raw)
|
||||||
|
elif atyp == 0x03:
|
||||||
|
ln = (await asyncio.wait_for(reader.readexactly(1), timeout=10))[0]
|
||||||
|
host = (await asyncio.wait_for(reader.readexactly(ln), timeout=10)).decode(
|
||||||
|
errors="replace"
|
||||||
|
)
|
||||||
|
elif atyp == 0x04:
|
||||||
|
raw = await asyncio.wait_for(reader.readexactly(16), timeout=10)
|
||||||
|
host = socket.inet_ntop(socket.AF_INET6, raw)
|
||||||
|
else:
|
||||||
|
writer.write(b"\x05\x08\x00\x01\x00\x00\x00\x00\x00\x00")
|
||||||
|
await writer.drain()
|
||||||
|
return
|
||||||
|
|
||||||
|
port_raw = await asyncio.wait_for(reader.readexactly(2), timeout=10)
|
||||||
|
port = int.from_bytes(port_raw, "big")
|
||||||
|
|
||||||
|
log.info("SOCKS5 CONNECT → %s:%d", host, port)
|
||||||
|
|
||||||
|
writer.write(b"\x05\x00\x00\x01\x00\x00\x00\x00\x00\x00")
|
||||||
|
await writer.drain()
|
||||||
|
await self._handle_target_tunnel(host, port, reader, writer)
|
||||||
|
|
||||||
|
except asyncio.IncompleteReadError:
|
||||||
|
pass
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
log.debug("SOCKS5 timeout: %s", addr)
|
||||||
|
except Exception as e:
|
||||||
|
log.error("SOCKS5 error (%s): %s", addr, e)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# ── CONNECT (HTTPS tunnelling) ────────────────────────────────
|
# ── CONNECT (HTTPS tunnelling) ────────────────────────────────
|
||||||
|
|
||||||
async def _do_connect(self, target: str, reader, writer):
|
async def _do_connect(self, target: str, reader, writer):
|
||||||
@@ -189,6 +395,13 @@ class ProxyServer:
|
|||||||
writer.write(b"HTTP/1.1 200 Connection Established\r\n\r\n")
|
writer.write(b"HTTP/1.1 200 Connection Established\r\n\r\n")
|
||||||
await writer.drain()
|
await writer.drain()
|
||||||
|
|
||||||
|
await self._handle_target_tunnel(host, port, reader, writer)
|
||||||
|
|
||||||
|
async def _handle_target_tunnel(self, host: str, port: int,
|
||||||
|
reader: asyncio.StreamReader,
|
||||||
|
writer: asyncio.StreamWriter):
|
||||||
|
"""Route a target connection through the active relay mode."""
|
||||||
|
|
||||||
if self.mode == "apps_script":
|
if self.mode == "apps_script":
|
||||||
override_ip = self._sni_rewrite_ip(host)
|
override_ip = self._sni_rewrite_ip(host)
|
||||||
if override_ip:
|
if override_ip:
|
||||||
@@ -200,10 +413,29 @@ class ProxyServer:
|
|||||||
await self._do_sni_rewrite_tunnel(host, port, reader, writer,
|
await self._do_sni_rewrite_tunnel(host, port, reader, writer,
|
||||||
connect_ip=override_ip)
|
connect_ip=override_ip)
|
||||||
elif self._is_google_domain(host):
|
elif self._is_google_domain(host):
|
||||||
|
if self._direct_temporarily_disabled(host):
|
||||||
|
log.info("Relay fallback → %s (direct tunnel temporarily disabled)", host)
|
||||||
|
if port == 443:
|
||||||
|
await self._do_mitm_connect(host, port, reader, writer)
|
||||||
|
else:
|
||||||
|
await self._do_plain_http_tunnel(host, port, reader, writer)
|
||||||
|
return
|
||||||
|
|
||||||
log.info("Direct tunnel → %s (Google domain, skipping relay)", host)
|
log.info("Direct tunnel → %s (Google domain, skipping relay)", host)
|
||||||
await self._do_direct_tunnel(host, port, reader, writer)
|
ok = await self._do_direct_tunnel(host, port, reader, writer)
|
||||||
else:
|
if ok:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._remember_direct_failure(host)
|
||||||
|
log.warning("Direct tunnel fallback → %s (switching to relay)", host)
|
||||||
|
if port == 443:
|
||||||
|
await self._do_mitm_connect(host, port, reader, writer)
|
||||||
|
else:
|
||||||
|
await self._do_plain_http_tunnel(host, port, reader, writer)
|
||||||
|
elif port == 443:
|
||||||
await self._do_mitm_connect(host, port, reader, writer)
|
await self._do_mitm_connect(host, port, reader, writer)
|
||||||
|
else:
|
||||||
|
await self._do_plain_http_tunnel(host, port, reader, writer)
|
||||||
else:
|
else:
|
||||||
await self.fronter.tunnel(host, port, reader, writer)
|
await self.fronter.tunnel(host, port, reader, writer)
|
||||||
|
|
||||||
@@ -271,25 +503,132 @@ class ProxyServer:
|
|||||||
# Only domains whose SNI the ISP does NOT block — direct tunnel is safe.
|
# Only domains whose SNI the ISP does NOT block — direct tunnel is safe.
|
||||||
# YouTube/googlevideo SNIs are blocked; they go through _do_sni_rewrite_tunnel
|
# YouTube/googlevideo SNIs are blocked; they go through _do_sni_rewrite_tunnel
|
||||||
# via the hosts map instead.
|
# via the hosts map instead.
|
||||||
_GOOGLE_SUFFIXES = (
|
_GOOGLE_OWNED_SUFFIXES = (
|
||||||
".google.com", ".google.co",
|
".google.com", ".google.co",
|
||||||
".googleapis.com", ".gstatic.com",
|
".googleapis.com", ".gstatic.com",
|
||||||
".googleusercontent.com",
|
".googleusercontent.com",
|
||||||
)
|
)
|
||||||
_GOOGLE_EXACT = {
|
_GOOGLE_OWNED_EXACT = {
|
||||||
"google.com", "gstatic.com", "googleapis.com",
|
"google.com", "gstatic.com", "googleapis.com",
|
||||||
}
|
}
|
||||||
|
|
||||||
def _is_google_domain(self, host: str) -> bool:
|
def _is_google_domain(self, host: str) -> bool:
|
||||||
"""Return True if host is a Google-owned domain."""
|
"""Return True if host should use the raw direct Google shortcut."""
|
||||||
h = host.lower().rstrip(".")
|
h = host.lower().rstrip(".")
|
||||||
if h in self._GOOGLE_EXACT:
|
if self._is_direct_google_excluded(h):
|
||||||
|
return False
|
||||||
|
if not self._is_google_owned_domain(h):
|
||||||
|
return False
|
||||||
|
return self._is_direct_google_allowed(h)
|
||||||
|
|
||||||
|
def _is_google_owned_domain(self, host: str) -> bool:
|
||||||
|
if host in self._GOOGLE_OWNED_EXACT:
|
||||||
return True
|
return True
|
||||||
for suffix in self._GOOGLE_SUFFIXES:
|
for suffix in self._GOOGLE_OWNED_SUFFIXES:
|
||||||
if h.endswith(suffix):
|
if host.endswith(suffix):
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _is_direct_google_excluded(self, host: str) -> bool:
|
||||||
|
if host in self._direct_google_exclude:
|
||||||
|
return True
|
||||||
|
for suffix in self._GOOGLE_DIRECT_SUFFIX_EXCLUDE:
|
||||||
|
if host.endswith(suffix):
|
||||||
|
return True
|
||||||
|
for token in self._direct_google_exclude:
|
||||||
|
if token.startswith(".") and host.endswith(token):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _is_direct_google_allowed(self, host: str) -> bool:
|
||||||
|
if host in self._direct_google_allow:
|
||||||
|
return True
|
||||||
|
for suffix in self._GOOGLE_DIRECT_ALLOW_SUFFIXES:
|
||||||
|
if host.endswith(suffix):
|
||||||
|
return True
|
||||||
|
for token in self._direct_google_allow:
|
||||||
|
if token.startswith(".") and host.endswith(token):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _direct_temporarily_disabled(self, host: str) -> bool:
|
||||||
|
h = host.lower().rstrip(".")
|
||||||
|
now = time.time()
|
||||||
|
disabled = False
|
||||||
|
for key in self._direct_failure_keys(h):
|
||||||
|
until = self._direct_fail_until.get(key, 0)
|
||||||
|
if until > now:
|
||||||
|
disabled = True
|
||||||
|
else:
|
||||||
|
self._direct_fail_until.pop(key, None)
|
||||||
|
return disabled
|
||||||
|
|
||||||
|
def _remember_direct_failure(self, host: str, ttl: int = 600):
|
||||||
|
until = time.time() + ttl
|
||||||
|
for key in self._direct_failure_keys(host.lower().rstrip(".")):
|
||||||
|
self._direct_fail_until[key] = until
|
||||||
|
|
||||||
|
def _direct_failure_keys(self, host: str) -> tuple[str, ...]:
|
||||||
|
keys = [host]
|
||||||
|
if host.endswith(".google.com") or host == "google.com":
|
||||||
|
keys.append("*.google.com")
|
||||||
|
if host.endswith(".googleapis.com") or host == "googleapis.com":
|
||||||
|
keys.append("*.googleapis.com")
|
||||||
|
if host.endswith(".gstatic.com") or host == "gstatic.com":
|
||||||
|
keys.append("*.gstatic.com")
|
||||||
|
if host.endswith(".googleusercontent.com") or host == "googleusercontent.com":
|
||||||
|
keys.append("*.googleusercontent.com")
|
||||||
|
return tuple(dict.fromkeys(keys))
|
||||||
|
|
||||||
|
async def _open_tcp_connection(self, target: str, port: int,
|
||||||
|
timeout: float = 10.0):
|
||||||
|
"""Connect with IPv4-first resolution and clearer failure reporting."""
|
||||||
|
errors: list[str] = []
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
try:
|
||||||
|
ipaddress.ip_address(target)
|
||||||
|
candidates = [(0, target)]
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
infos = await asyncio.wait_for(
|
||||||
|
loop.getaddrinfo(
|
||||||
|
target,
|
||||||
|
port,
|
||||||
|
family=socket.AF_UNSPEC,
|
||||||
|
type=socket.SOCK_STREAM,
|
||||||
|
),
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
raise OSError(f"dns lookup failed for {target}: {exc!r}") from exc
|
||||||
|
|
||||||
|
candidates = []
|
||||||
|
seen = set()
|
||||||
|
for family, _type, _proto, _canon, sockaddr in infos:
|
||||||
|
ip = sockaddr[0]
|
||||||
|
key = (family, ip)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
candidates.append((family, ip))
|
||||||
|
|
||||||
|
candidates.sort(key=lambda item: 0 if item[0] == socket.AF_INET else 1)
|
||||||
|
|
||||||
|
for family, ip in candidates:
|
||||||
|
try:
|
||||||
|
return await asyncio.wait_for(
|
||||||
|
asyncio.open_connection(ip, port, family=family or 0),
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
fam = "ipv4" if family == socket.AF_INET else (
|
||||||
|
"ipv6" if family == socket.AF_INET6 else "auto"
|
||||||
|
)
|
||||||
|
errors.append(f"{ip} ({fam}): {exc!r}")
|
||||||
|
|
||||||
|
raise OSError("; ".join(errors) or f"connect failed for {target}:{port}")
|
||||||
|
|
||||||
# ── Direct tunnel (no MITM) ───────────────────────────────────
|
# ── Direct tunnel (no MITM) ───────────────────────────────────
|
||||||
|
|
||||||
async def _do_direct_tunnel(self, host: str, port: int,
|
async def _do_direct_tunnel(self, host: str, port: int,
|
||||||
@@ -300,17 +639,17 @@ class ProxyServer:
|
|||||||
|
|
||||||
connect_ip overrides DNS: the TCP connection goes to that IP
|
connect_ip overrides DNS: the TCP connection goes to that IP
|
||||||
while the browser's TLS (SNI=host) is piped through unchanged.
|
while the browser's TLS (SNI=host) is piped through unchanged.
|
||||||
Defaults to the configured google_ip for Google-category domains.
|
Without an override we connect to the real hostname so browser-safe
|
||||||
|
Google properties (Gemini assets, Play, Accounts, etc.) use their
|
||||||
|
normal edge instead of being forced onto the fronting IP.
|
||||||
"""
|
"""
|
||||||
target_ip = connect_ip or self.fronter.connect_host
|
target_ip = connect_ip or host
|
||||||
try:
|
try:
|
||||||
r_remote, w_remote = await asyncio.wait_for(
|
r_remote, w_remote = await self._open_tcp_connection(target_ip, port, timeout=10)
|
||||||
asyncio.open_connection(target_ip, port), timeout=10
|
|
||||||
)
|
|
||||||
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)
|
||||||
return
|
return False
|
||||||
|
|
||||||
async def pipe(src, dst, label):
|
async def pipe(src, dst, label):
|
||||||
try:
|
try:
|
||||||
@@ -334,6 +673,7 @@ class ProxyServer:
|
|||||||
pipe(reader, w_remote, f"client→{host}"),
|
pipe(reader, w_remote, f"client→{host}"),
|
||||||
pipe(r_remote, writer, f"{host}→client"),
|
pipe(r_remote, writer, f"{host}→client"),
|
||||||
)
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
# ── SNI-rewrite tunnel ────────────────────────────────────────
|
# ── SNI-rewrite tunnel ────────────────────────────────────────
|
||||||
|
|
||||||
@@ -407,6 +747,11 @@ class ProxyServer:
|
|||||||
|
|
||||||
# ── MITM CONNECT (apps_script mode) ───────────────────────────
|
# ── MITM CONNECT (apps_script mode) ───────────────────────────
|
||||||
|
|
||||||
|
async def _do_plain_http_tunnel(self, host: str, port: int, reader, writer):
|
||||||
|
"""Handle plain HTTP over SOCKS5 in apps_script mode."""
|
||||||
|
log.info("Plain HTTP relay → %s:%d", host, port)
|
||||||
|
await self._relay_http_stream(host, port, reader, writer)
|
||||||
|
|
||||||
async def _do_mitm_connect(self, host: str, port: int, reader, writer):
|
async def _do_mitm_connect(self, host: str, port: int, reader, writer):
|
||||||
"""Intercept TLS, decrypt HTTP, and relay through Apps Script."""
|
"""Intercept TLS, decrypt HTTP, and relay through Apps Script."""
|
||||||
ssl_ctx = self.mitm.get_server_context(host)
|
ssl_ctx = self.mitm.get_server_context(host)
|
||||||
@@ -433,6 +778,10 @@ class ProxyServer:
|
|||||||
# Update writer to use the new TLS transport
|
# Update writer to use the new TLS transport
|
||||||
writer._transport = new_transport
|
writer._transport = new_transport
|
||||||
|
|
||||||
|
await self._relay_http_stream(host, port, reader, writer)
|
||||||
|
|
||||||
|
async def _relay_http_stream(self, host: str, port: int, reader, writer):
|
||||||
|
"""Read decrypted/origin-form HTTP requests and relay them."""
|
||||||
# Read and relay HTTP requests from the browser (now decrypted)
|
# Read and relay HTTP requests from the browser (now decrypted)
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
@@ -471,11 +820,16 @@ 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()
|
||||||
|
|
||||||
# Build full URL (browser sends just the path in CONNECT)
|
# MITM traffic arrives as origin-form paths; SOCKS/plain HTTP can
|
||||||
if port == 443:
|
# also send absolute-form requests. Normalize both to full URLs.
|
||||||
|
if path.startswith("http://") or path.startswith("https://"):
|
||||||
|
url = path
|
||||||
|
elif port == 443:
|
||||||
url = f"https://{host}{path}"
|
url = f"https://{host}{path}"
|
||||||
|
elif port == 80:
|
||||||
|
url = f"http://{host}{path}"
|
||||||
else:
|
else:
|
||||||
url = f"https://{host}:{port}{path}"
|
url = f"http://{host}:{port}{path}"
|
||||||
|
|
||||||
log.info("MITM → %s %s", method, url)
|
log.info("MITM → %s %s", method, url)
|
||||||
|
|
||||||
@@ -502,7 +856,7 @@ class ProxyServer:
|
|||||||
|
|
||||||
# Check local cache first (GET only)
|
# Check local cache first (GET only)
|
||||||
response = None
|
response = None
|
||||||
if method == "GET" and not body:
|
if self._cache_allowed(method, url, headers, body):
|
||||||
response = self._cache.get(url)
|
response = self._cache.get(url)
|
||||||
if response:
|
if response:
|
||||||
log.debug("Cache HIT: %s", url[:60])
|
log.debug("Cache HIT: %s", url[:60])
|
||||||
@@ -522,7 +876,7 @@ class ProxyServer:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Cache successful GET responses
|
# Cache successful GET responses
|
||||||
if method == "GET" and not body and response:
|
if self._cache_allowed(method, url, headers, body) and response:
|
||||||
ttl = ResponseCache.parse_ttl(response, url)
|
ttl = ResponseCache.parse_ttl(response, url)
|
||||||
if ttl > 0:
|
if ttl > 0:
|
||||||
self._cache.put(url, response, ttl)
|
self._cache.put(url, response, ttl)
|
||||||
@@ -533,6 +887,8 @@ class ProxyServer:
|
|||||||
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)
|
||||||
|
|
||||||
writer.write(response)
|
writer.write(response)
|
||||||
await writer.drain()
|
await writer.drain()
|
||||||
|
|
||||||
@@ -692,7 +1048,7 @@ class ProxyServer:
|
|||||||
|
|
||||||
# Cache check for GET
|
# Cache check for GET
|
||||||
response = None
|
response = None
|
||||||
if method == "GET" and not body:
|
if self._cache_allowed(method, url, headers, body):
|
||||||
response = self._cache.get(url)
|
response = self._cache.get(url)
|
||||||
if response:
|
if response:
|
||||||
log.debug("Cache HIT (HTTP): %s", url[:60])
|
log.debug("Cache HIT (HTTP): %s", url[:60])
|
||||||
@@ -700,7 +1056,7 @@ class ProxyServer:
|
|||||||
if response is None:
|
if response is None:
|
||||||
response = await self._relay_smart(method, url, headers, body)
|
response = await self._relay_smart(method, url, headers, body)
|
||||||
# Cache successful GET
|
# Cache successful GET
|
||||||
if method == "GET" and not body and response:
|
if self._cache_allowed(method, url, headers, body) and response:
|
||||||
ttl = ResponseCache.parse_ttl(response, url)
|
ttl = ResponseCache.parse_ttl(response, url)
|
||||||
if ttl > 0:
|
if ttl > 0:
|
||||||
self._cache.put(url, response, ttl)
|
self._cache.put(url, response, ttl)
|
||||||
@@ -708,6 +1064,7 @@ class ProxyServer:
|
|||||||
# Inject CORS headers for cross-origin requests
|
# 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)
|
||||||
elif self.mode in ("google_fronting", "custom_domain", "domain_fronting"):
|
elif self.mode in ("google_fronting", "custom_domain", "domain_fronting"):
|
||||||
# Use WebSocket tunnel for ALL traffic (much faster than forward())
|
# Use WebSocket tunnel for ALL traffic (much faster than forward())
|
||||||
response = await self._tunnel_http(header_block, body)
|
response = await self._tunnel_http(header_block, body)
|
||||||
|
|||||||
Reference in New Issue
Block a user