Refactor documentation and code for Apps Script relay mode; remove WebSocket support.

This commit is contained in:
Abolfazl
2026-04-22 03:56:17 +03:30
parent 42b34afc16
commit 792719df71
7 changed files with 147 additions and 509 deletions
+1 -1
View File
@@ -35,7 +35,7 @@ This PR does not claim full compatibility for all websites. It focuses on making
Local verification: Local verification:
- `python3 -m py_compile main.py proxy_server.py domain_fronter.py mitm.py h2_transport.py ws.py cert_installer.py` - `python3 -m py_compile main.py proxy_server.py domain_fronter.py mitm.py h2_transport.py cert_installer.py`
Observed behavior during manual testing: Observed behavior during manual testing:
+2 -13
View File
@@ -176,14 +176,7 @@ Firefox uses its own certificate store, so even after OS-level install you need
## Modes Overview ## Modes Overview
| Mode | What You Need | Description | This project focuses entirely on the **Apps Script** relay — a free Google account is all you need, no server, no VPS, no Cloudflare setup. Everything is configured out of the box for this mode.
|------|--------------|-------------|
| `apps_script` | Free Google account | **Easiest.** Uses Google Apps Script as relay. No server needed. |
| `google_fronting` | Google Cloud Run service | Uses your own Cloud Run service behind Google's CDN. |
| `domain_fronting` | Cloudflare Worker | Uses a Cloudflare Worker as relay. |
| `custom_domain` | Custom domain on Cloudflare | Connects directly to your domain on Cloudflare. |
Most users should use **`apps_script`** mode — it's free and requires no server.
--- ---
@@ -193,7 +186,6 @@ Most users should use **`apps_script`** mode — it's free and requires no serve
| Setting | What It Does | | Setting | What It Does |
|---------|-------------| |---------|-------------|
| `mode` | Which relay type to use (see table above) |
| `auth_key` | Password shared between your computer and the relay | | `auth_key` | Password shared between your computer and the relay |
| `script_id` | Your Google Apps Script Deployment ID | | `script_id` | Your Google Apps Script Deployment ID |
| `listen_host` | Where to listen (`127.0.0.1` = only this computer) | | `listen_host` | Where to listen (`127.0.0.1` = only this computer) |
@@ -207,8 +199,6 @@ Most users should use **`apps_script`** mode — it's free and requires no serve
| `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 TLS certificates |
| `worker_host` | — | Hostname for Cloudflare/Cloud Run modes |
| `custom_domain` | — | Your custom domain on Cloudflare |
| `script_ids` | — | Multiple Script IDs for load balancing (array) | | `script_ids` | — | Multiple Script IDs for load balancing (array) |
### Load Balancing ### Load Balancing
@@ -269,11 +259,10 @@ python3 main.py --no-cert-check # Skip automatic CA install check on st
|------|-------------| |------|-------------|
| `main.py` | Starts the proxy | | `main.py` | Starts the proxy |
| `proxy_server.py` | Handles browser connections | | `proxy_server.py` | Handles browser connections |
| `domain_fronter.py` | Disguises traffic through CDN/Google | | `domain_fronter.py` | Apps Script relay client (fronted through Google) |
| `h2_transport.py` | Faster connections using HTTP/2 (optional) | | `h2_transport.py` | Faster connections using HTTP/2 (optional) |
| `mitm.py` | Handles HTTPS certificate generation | | `mitm.py` | Handles HTTPS certificate generation |
| `cert_installer.py` | Cross-platform CA certificate installer (Windows/macOS/Linux + Firefox) | | `cert_installer.py` | Cross-platform CA certificate installer (Windows/macOS/Linux + Firefox) |
| `ws.py` | WebSocket support |
| `Code.gs` | The relay script you deploy to Google Apps Script | | `Code.gs` | The relay script you deploy to Google Apps Script |
| `config.example.json` | Example config — copy to `config.json` | | `config.example.json` | Example config — copy to `config.json` |
+2 -13
View File
@@ -166,14 +166,7 @@ Firefox معمولا certificate store جداگانه دارد:
## حالت‌های موجود ## حالت‌های موجود
| حالت | نیازمندی | توضیح | این پروژه کاملاً روی حالت **Apps Script** تمرکز دارد. فقط به یک اکانت رایگان Google نیاز دارید — بدون VPS، بدون سرور، بدون Cloudflare. همه‌چیز برای همین حالت تنظیم شده است.
|------|----------|-------|
| `apps_script` | اکانت رایگان Google | ساده‌ترین حالت، بدون نیاز به سرور |
| `google_fronting` | Google Cloud Run | استفاده از سرویس Cloud Run خودتان |
| `domain_fronting` | Cloudflare Worker | استفاده از Worker روی Cloudflare |
| `custom_domain` | دامنه شخصی روی Cloudflare | اتصال مستقیم به دامنه خودتان |
برای اکثر کاربران، `apps_script` بهترین انتخاب است.
--- ---
@@ -181,7 +174,6 @@ Firefox معمولا certificate store جداگانه دارد:
| تنظیم | توضیح | | تنظیم | توضیح |
|------|-------| |------|-------|
| `mode` | نوع رله |
| `auth_key` | رمز مشترک بین برنامه و رله | | `auth_key` | رمز مشترک بین برنامه و رله |
| `script_id` | Deployment ID مربوط به Apps Script | | `script_id` | Deployment ID مربوط به Apps Script |
| `listen_host` | آدرس محلی برای اجرا | | `listen_host` | آدرس محلی برای اجرا |
@@ -195,8 +187,6 @@ Firefox معمولا certificate store جداگانه دارد:
| `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 |
| `worker_host` | - | برای حالت‌های Cloudflare/Cloud Run |
| `custom_domain` | - | دامنه شخصی شما |
| `script_ids` | - | چند Deployment ID برای load balancing | | `script_ids` | - | چند Deployment ID برای load balancing |
### استفاده از چند Script ID ### استفاده از چند Script ID
@@ -255,11 +245,10 @@ python3 main.py --no-cert-check # رد شدن از بررسی خودکار
|------|--------| |------|--------|
| `main.py` | اجرای برنامه | | `main.py` | اجرای برنامه |
| `proxy_server.py` | مدیریت اتصال مرورگر | | `proxy_server.py` | مدیریت اتصال مرورگر |
| `domain_fronter.py` | انجام domain fronting | | `domain_fronter.py` | کلاینت رله Apps Script (با عبور از Google) |
| `h2_transport.py` | ارتباط سریع‌تر با HTTP/2 | | `h2_transport.py` | ارتباط سریع‌تر با HTTP/2 |
| `mitm.py` | ساخت و مدیریت certificate | | `mitm.py` | ساخت و مدیریت certificate |
| `cert_installer.py` | نصب خودکار گواهی CA در ویندوز، مک، لینوکس و Firefox | | `cert_installer.py` | نصب خودکار گواهی CA در ویندوز، مک، لینوکس و Firefox |
| `ws.py` | پشتیبانی WebSocket |
| `Code.gs` | رله Apps Script | | `Code.gs` | رله Apps Script |
| `config.example.json` | فایل نمونه تنظیمات | | `config.example.json` | فایل نمونه تنظیمات |
+23 -188
View File
@@ -1,19 +1,10 @@
""" """
CDN Relay engine. Apps Script relay engine.
Modes: Domain fronting via Google Apps Script: POST JSON to script.google.com
1. custom_domain — SNI and Host both point to your custom domain on CF. (fronted through www.google.com). Apps Script fetches the target URL and
2. domain_fronting — SNI = front_domain (allowed), Host = worker_host. returns the response.
3. google_fronting — Connect to Google IP, SNI=google, Host=Cloud Run.
4. apps_script — Domain fronting via Google Apps Script relay.
POST JSON to script.google.com (fronted through www.google.com).
Apps Script fetches the target URL and returns the response.
Modes 1-3:
tunnel() — WebSocket-based TCP tunnel (HTTPS / any TCP)
forward() — HTTP request forwarding (plain HTTP)
Mode 4 (apps_script):
relay() — JSON-based HTTP relay through Apps Script relay() — JSON-based HTTP relay through Apps Script
""" """
@@ -23,14 +14,11 @@ import hashlib
import gzip import gzip
import json import json
import logging import logging
import os
import re import re
import ssl import ssl
import time import time
from urllib.parse import urlparse from urllib.parse import urlparse
from ws import ws_encode, ws_decode
log = logging.getLogger("Fronter") log = logging.getLogger("Fronter")
@@ -42,34 +30,16 @@ class DomainFronter:
) )
def __init__(self, config: dict): def __init__(self, config: dict):
mode = config.get("mode", "domain_fronting") self.connect_host = config.get("google_ip", "216.239.38.120")
self.sni_host = config.get("front_domain", "www.google.com")
self.http_host = "script.google.com"
# Multi-script round-robin for higher throughput
script = config.get("script_ids") or config.get("script_id")
self._script_ids = script if isinstance(script, list) else [script]
self._script_idx = 0
self.script_id = self._script_ids[0] # backward compat / logging
self._dev_available = False # True if /dev endpoint works (no redirect, ~400ms faster)
if mode == "custom_domain":
domain = config["custom_domain"]
self.connect_host = domain
self.sni_host = domain
self.http_host = domain
elif mode == "google_fronting":
self.connect_host = config.get("google_ip", "216.239.38.120")
self.sni_host = config.get("front_domain", "www.google.com")
self.http_host = config["worker_host"]
elif mode == "apps_script":
self.connect_host = config.get("google_ip", "216.239.38.120")
self.sni_host = config.get("front_domain", "www.google.com")
self.http_host = "script.google.com"
# Multi-script round-robin for higher throughput
script = config.get("script_ids") or config.get("script_id")
self._script_ids = script if isinstance(script, list) else [script]
self._script_idx = 0
self.script_id = self._script_ids[0] # backward compat / logging
self._dev_available = False # True if /dev endpoint works (no redirect, ~400ms faster)
else:
self.connect_host = config["front_domain"]
self.sni_host = config["front_domain"]
self.http_host = config["worker_host"]
self.mode = mode
self.worker_path = config.get("worker_path", "")
self.auth_key = config.get("auth_key", "") self.auth_key = config.get("auth_key", "")
self.verify_ssl = config.get("verify_ssl", True) self.verify_ssl = config.get("verify_ssl", True)
@@ -98,17 +68,16 @@ class DomainFronter:
# HTTP/2 multiplexing — one connection handles all requests # HTTP/2 multiplexing — one connection handles all requests
self._h2 = None self._h2 = None
if mode == "apps_script": try:
try: from h2_transport import H2Transport, H2_AVAILABLE
from h2_transport import H2Transport, H2_AVAILABLE if H2_AVAILABLE:
if H2_AVAILABLE: self._h2 = H2Transport(
self._h2 = H2Transport( self.connect_host, self.sni_host, self.verify_ssl
self.connect_host, self.sni_host, self.verify_ssl )
) log.info("HTTP/2 multiplexing available — "
log.info("HTTP/2 multiplexing available — " "all requests will share one connection")
"all requests will share one connection") except ImportError:
except ImportError: pass
pass
# ── helpers ─────────────────────────────────────────────────── # ── helpers ───────────────────────────────────────────────────
@@ -392,140 +361,6 @@ class DomainFronter:
def _auth_header(self) -> str: def _auth_header(self) -> str:
return f"X-Auth-Key: {self.auth_key}\r\n" if self.auth_key else "" return f"X-Auth-Key: {self.auth_key}\r\n" if self.auth_key else ""
# ── WebSocket tunnel (CONNECT / HTTPS) ────────────────────────
async def tunnel(self, target_host: str, target_port: int,
client_r: asyncio.StreamReader,
client_w: asyncio.StreamWriter):
"""Tunnel raw TCP bytes through a domain-fronted WebSocket."""
try:
remote_r, remote_w = await self._open()
except Exception as e:
log.error("TLS connect to %s failed: %s", self.connect_host, e)
return
try:
# ---- WebSocket upgrade ----
ws_key = base64.b64encode(os.urandom(16)).decode()
path = f"{self.worker_path}/tunnel?host={target_host}&port={target_port}"
handshake = (
f"GET {path} HTTP/1.1\r\n"
f"Host: {self.http_host}\r\n"
f"Upgrade: websocket\r\n"
f"Connection: Upgrade\r\n"
f"Sec-WebSocket-Key: {ws_key}\r\n"
f"Sec-WebSocket-Version: 13\r\n"
f"{self._auth_header()}"
f"\r\n"
)
remote_w.write(handshake.encode())
await remote_w.drain()
# Read the 101 Switching Protocols response
resp = b""
while b"\r\n\r\n" not in resp:
chunk = await asyncio.wait_for(remote_r.read(4096), timeout=15)
if not chunk:
raise ConnectionError("No WebSocket handshake response")
resp += chunk
status_line = resp.split(b"\r\n")[0]
if b"101" not in status_line:
raise ConnectionError(
f"WebSocket upgrade rejected: {status_line.decode(errors='replace')}"
)
log.info("Tunnel ready → %s:%d", target_host, target_port)
# ---- bidirectional relay ----
await asyncio.gather(
self._client_to_ws(client_r, remote_w),
self._ws_to_client(remote_r, client_w),
)
except Exception as e:
log.error("Tunnel error (%s:%d): %s", target_host, target_port, e)
finally:
try:
remote_w.close()
except Exception:
pass
async def _client_to_ws(self, src: asyncio.StreamReader,
dst: asyncio.StreamWriter):
"""Read plaintext from the browser, wrap in WS frames, send to CDN."""
try:
while True:
data = await src.read(16384)
if not data:
# Send a WS close frame
dst.write(ws_encode(b"", opcode=0x08))
await dst.drain()
break
dst.write(ws_encode(data))
await dst.drain()
except (ConnectionError, asyncio.CancelledError):
pass
async def _ws_to_client(self, src: asyncio.StreamReader,
dst: asyncio.StreamWriter):
"""Read WS frames from CDN, unwrap, write plaintext to browser."""
buf = b""
try:
while True:
chunk = await src.read(16384)
if not chunk:
break
buf += chunk
while buf:
result = ws_decode(buf)
if result is None:
break # need more data
opcode, payload, consumed = result
buf = buf[consumed:]
if opcode == 0x08: # close
return
if payload:
dst.write(payload)
await dst.drain()
except (ConnectionError, asyncio.CancelledError):
pass
# ── HTTP forwarding ───────────────────────────────────────────
async def forward(self, raw_request: bytes) -> bytes:
"""Forward a plain HTTP request through the domain-fronted channel.
Uses keep-alive connections from the pool for efficiency.
"""
try:
reader, writer, created = await self._acquire()
# Wrap the original HTTP request inside a POST to the worker.
request = (
f"POST {self.worker_path}/forward HTTP/1.1\r\n"
f"Host: {self.http_host}\r\n"
f"Content-Type: application/octet-stream\r\n"
f"Content-Length: {len(raw_request)}\r\n"
f"Connection: keep-alive\r\n"
f"{self._auth_header()}"
f"\r\n"
)
writer.write(request.encode() + raw_request)
await writer.drain()
status, resp_headers, resp_body = await self._read_http_response(reader)
await self._release(reader, writer, created)
# The worker wraps the target's response in its own HTTP
# envelope. The body IS the raw HTTP response from the target.
return resp_body
except Exception as e:
log.error("Forward failed: %s", e)
return b"HTTP/1.1 502 Bad Gateway\r\n\r\nDomain fronting request failed\r\n"
# ── Apps Script relay (apps_script mode) ────────────────────── # ── Apps Script relay (apps_script mode) ──────────────────────
async def relay(self, method: str, url: str, async def relay(self, method: str, url: str,
+41 -64
View File
@@ -1,10 +1,10 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
DomainFront Tunnel — Bypass DPI censorship via Domain Fronting. DomainFront Tunnel — Bypass DPI censorship via Google Apps Script.
Run a local HTTP proxy that tunnels all traffic through a CDN using Run a local HTTP proxy that tunnels all traffic through a Google Apps
domain fronting: the TLS SNI shows an allowed domain while the encrypted Script relay fronted by www.google.com (TLS SNI shows www.google.com
HTTP Host header routes to your Cloudflare Worker relay. while the encrypted Host header points at script.google.com).
""" """
import argparse import argparse
@@ -33,7 +33,7 @@ def setup_logging(level_name: str):
def parse_args(): def parse_args():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="domainfront-tunnel", prog="domainfront-tunnel",
description="Local HTTP proxy that tunnels traffic through domain fronting.", description="Local HTTP proxy that relays traffic through Google Apps Script.",
) )
parser.add_argument( parser.add_argument(
"-c", "--config", "-c", "--config",
@@ -136,25 +136,13 @@ def main():
print(f"Missing required config key: {key}") print(f"Missing required config key: {key}")
sys.exit(1) sys.exit(1)
mode = config.get("mode", "domain_fronting") # Always Apps Script mode — force-set for backward-compat configs.
if mode == "custom_domain" and "custom_domain" not in config: config["mode"] = "apps_script"
print("Mode 'custom_domain' requires 'custom_domain' in config") sid = config.get("script_ids") or config.get("script_id")
if not sid or (isinstance(sid, str) and sid == "YOUR_APPS_SCRIPT_DEPLOYMENT_ID"):
print("Missing 'script_id' in config.")
print("Deploy the Apps Script from Code.gs and paste the Deployment ID.")
sys.exit(1) sys.exit(1)
if mode == "domain_fronting":
for key in ("front_domain", "worker_host"):
if key not in config:
print(f"Mode 'domain_fronting' requires '{key}' in config")
sys.exit(1)
if mode == "google_fronting":
if "worker_host" not in config:
print("Mode 'google_fronting' requires 'worker_host' in config (your Cloud Run URL)")
sys.exit(1)
if mode == "apps_script":
sid = config.get("script_ids") or config.get("script_id")
if not sid or (isinstance(sid, str) and sid == "YOUR_APPS_SCRIPT_DEPLOYMENT_ID"):
print("Mode 'apps_script' requires 'script_id' in config.")
print("Deploy the Apps Script from appsscript/Code.gs and paste the Deployment ID.")
sys.exit(1)
# ── Certificate installation ────────────────────────────────────────── # ── Certificate installation ──────────────────────────────────────────
if args.install_cert: if args.install_cert:
@@ -167,49 +155,38 @@ def main():
setup_logging(config.get("log_level", "INFO")) setup_logging(config.get("log_level", "INFO"))
log = logging.getLogger("Main") log = logging.getLogger("Main")
mode = config.get("mode", "domain_fronting") log.info("DomainFront Tunnel starting (Apps Script relay)")
log.info("DomainFront Tunnel starting (mode: %s)", mode)
if mode == "custom_domain": log.info("Apps Script relay : SNI=%s → script.google.com",
log.info("Custom domain : %s", config["custom_domain"]) config.get("front_domain", "www.google.com"))
elif mode == "google_fronting": script_ids = config.get("script_ids") or config.get("script_id")
log.info("Google fronting : SNI=%s → Host=%s", if isinstance(script_ids, list):
config.get("front_domain", "www.google.com"), config["worker_host"]) log.info("Script IDs : %d scripts (sticky per-host)", len(script_ids))
log.info("Google IP : %s", config.get("google_ip", "216.239.38.120")) for i, sid in enumerate(script_ids):
elif mode == "apps_script": log.info(" [%d] %s", i + 1, sid)
log.info("Apps Script relay : SNI=%s → script.google.com",
config.get("front_domain", "www.google.com"))
script_ids = config.get("script_ids") or config.get("script_id")
if isinstance(script_ids, list):
log.info("Script IDs : %d scripts (sticky per-host)", len(script_ids))
for i, sid in enumerate(script_ids):
log.info(" [%d] %s", i + 1, sid)
else:
log.info("Script ID : %s", script_ids)
# Ensure CA file exists before checking / installing it.
# MITMCertManager generates ca/ca.crt on first instantiation.
if not os.path.exists(CA_CERT_FILE):
from mitm import MITMCertManager
MITMCertManager() # side-effect: creates ca/ca.crt + ca/ca.key
# Auto-install MITM CA if not already trusted
if not args.no_cert_check:
if not is_ca_trusted(CA_CERT_FILE):
log.warning("MITM CA is not trusted — attempting automatic installation…")
ok = install_ca(CA_CERT_FILE)
if ok:
log.info("CA certificate installed. You may need to restart your browser.")
else:
log.error(
"Auto-install failed. Run with --install-cert (may need admin/sudo) "
"or manually install ca/ca.crt as a trusted root CA."
)
else:
log.info("MITM CA is already trusted.")
else: else:
log.info("Front domain (SNI) : %s", config.get("front_domain", "?")) log.info("Script ID : %s", script_ids)
log.info("Worker host (Host) : %s", config.get("worker_host", "?"))
# Ensure CA file exists before checking / installing it.
# MITMCertManager generates ca/ca.crt on first instantiation.
if not os.path.exists(CA_CERT_FILE):
from mitm import MITMCertManager
MITMCertManager() # side-effect: creates ca/ca.crt + ca/ca.key
# Auto-install MITM CA if not already trusted
if not args.no_cert_check:
if not is_ca_trusted(CA_CERT_FILE):
log.warning("MITM CA is not trusted — attempting automatic installation…")
ok = install_ca(CA_CERT_FILE)
if ok:
log.info("CA certificate installed. You may need to restart your browser.")
else:
log.error(
"Auto-install failed. Run with --install-cert (may need admin/sudo) "
"or manually install ca/ca.crt as a trusted root CA."
)
else:
log.info("MITM CA is already trusted.")
log.info("HTTP proxy : %s:%d", log.info("HTTP proxy : %s:%d",
config.get("listen_host", "127.0.0.1"), config.get("listen_host", "127.0.0.1"),
+78 -154
View File
@@ -2,11 +2,8 @@
Local HTTP proxy server. Local HTTP proxy server.
Intercepts the user's browser traffic and forwards everything through Intercepts the user's browser traffic and forwards everything through
a domain-fronted connection to a CDN worker or Apps Script relay. the Apps Script relay (MITM-decrypts HTTPS locally, forwards requests
as JSON to script.google.com fronted through www.google.com).
Supports:
- CONNECT method → WebSocket tunnel (modes 1-3) or MITM relay (apps_script)
- GET / POST etc. → HTTP forwarding (modes 1-3) or JSON relay (apps_script)
""" """
import asyncio import asyncio
@@ -150,17 +147,11 @@ 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)
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] = {} self._direct_fail_until: dict[str, float] = {}
# Persistent HTTP tunnel cache for google_fronting mode
# Key: "host:port" → (tunnel_reader, tunnel_writer, lock)
self._http_tunnels: dict = {}
self._tunnel_lock = asyncio.Lock()
# 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", {})
@@ -181,14 +172,13 @@ class ProxyServer:
) )
} }
if self.mode == "apps_script": try:
try: from mitm import MITMCertManager
from mitm import MITMCertManager self.mitm = MITMCertManager()
self.mitm = MITMCertManager() except ImportError:
except ImportError: log.error("Apps Script relay requires the 'cryptography' package.")
log.error("apps_script mode requires 'cryptography' package.") log.error("Run: pip install cryptography")
log.error("Run: pip install cryptography") raise SystemExit(1)
raise SystemExit(1)
@staticmethod @staticmethod
def _header_value(headers: dict | None, name: str) -> str: def _header_value(headers: dict | None, name: str) -> str:
@@ -406,44 +396,41 @@ class ProxyServer:
async def _handle_target_tunnel(self, host: str, port: int, async def _handle_target_tunnel(self, host: str, port: int,
reader: asyncio.StreamReader, reader: asyncio.StreamReader,
writer: asyncio.StreamWriter): writer: asyncio.StreamWriter):
"""Route a target connection through the active relay mode.""" """Route a target connection through the Apps Script relay."""
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: # SNI-blocked domain: MITM-decrypt from browser, then
# SNI-blocked domain: MITM-decrypt from browser, then # re-connect to the override IP with SNI=front_domain so
# re-connect to the override IP with SNI=front_domain so # the ISP never sees the blocked hostname in the TLS handshake.
# the ISP never sees the blocked hostname in the TLS handshake. log.info("SNI-rewrite tunnel → %s via %s (SNI: %s)",
log.info("SNI-rewrite tunnel → %s via %s (SNI: %s)", host, override_ip, self.fronter.sni_host)
host, override_ip, self.fronter.sni_host) 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):
if self._direct_temporarily_disabled(host): log.info("Relay fallback → %s (direct tunnel 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)
ok = await self._do_direct_tunnel(host, port, reader, writer)
if ok:
return
self._remember_direct_failure(host)
log.warning("Direct tunnel fallback → %s (switching to relay)", host)
if port == 443: if port == 443:
await self._do_mitm_connect(host, port, reader, writer) await self._do_mitm_connect(host, port, reader, writer)
else: else:
await self._do_plain_http_tunnel(host, port, reader, writer) await self._do_plain_http_tunnel(host, port, reader, writer)
elif port == 443: return
log.info("Direct tunnel → %s (Google domain, skipping relay)", host)
ok = await self._do_direct_tunnel(host, port, reader, writer)
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) await self._do_mitm_connect(host, port, reader, writer)
else: else:
await self._do_plain_http_tunnel(host, port, reader, writer) await self._do_plain_http_tunnel(host, port, reader, writer)
elif port == 443:
await self._do_mitm_connect(host, port, reader, writer)
else: else:
await self.fronter.tunnel(host, port, reader, writer) await self._do_plain_http_tunnel(host, port, reader, writer)
# ── Hosts override (fake DNS) ───────────────────────────────── # ── Hosts override (fake DNS) ─────────────────────────────────
@@ -1028,117 +1015,54 @@ class ProxyServer:
first_line = header_block.split(b"\r\n")[0].decode(errors="replace") first_line = header_block.split(b"\r\n")[0].decode(errors="replace")
log.info("HTTP → %s", first_line) log.info("HTTP → %s", first_line)
if self.mode == "apps_script": # Parse request and relay through Apps Script
# Parse request and relay through Apps Script parts = first_line.strip().split(" ", 2)
parts = first_line.strip().split(" ", 2) method = parts[0] if parts else "GET"
method = parts[0] if parts else "GET" url = parts[1] if len(parts) > 1 else "/"
url = parts[1] if len(parts) > 1 else "/"
headers = {} headers = {}
for raw_line in header_block.split(b"\r\n")[1:]: for raw_line in header_block.split(b"\r\n")[1:]:
if b":" in raw_line: if b":" in raw_line:
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 = next(
(v for k, v in headers.items() if k.lower() == "origin"), "" (v for k, v in headers.items() if k.lower() == "origin"), ""
) )
acr_method = next( acr_method = next(
(v for k, v in headers.items() (v for k, v in headers.items()
if k.lower() == "access-control-request-method"), "" if k.lower() == "access-control-request-method"), ""
) )
acr_headers_val = next( acr_headers_val = next(
(v for k, v in headers.items() (v for k, v in headers.items()
if k.lower() == "access-control-request-headers"), "" 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_val))
await writer.drain() await writer.drain()
return 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):
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])
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 self._cache_allowed(method, url, headers, 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)
# 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) self._log_response_summary(url, response)
elif self.mode in ("google_fronting", "custom_domain", "domain_fronting"):
# Use WebSocket tunnel for ALL traffic (much faster than forward())
response = await self._tunnel_http(header_block, body)
else:
response = await self.fronter.forward(header_block + body)
writer.write(response) writer.write(response)
await writer.drain() await writer.drain()
async def _tunnel_http(self, header_block: bytes, body: bytes) -> bytes:
"""Forward plain HTTP via a persistent WebSocket tunnel.
Instead of opening a new TLS+HTTP connection for each request
(the old forward() path), this keeps a WebSocket tunnel open
to the target host and pipes raw HTTP through it.
Much faster for rapid-fire requests (e.g., Telegram API).
"""
# Parse target host:port from the raw HTTP request
host = ""
port = 80
for line in header_block.split(b"\r\n")[1:]:
if not line:
break
if line.lower().startswith(b"host:"):
host_val = line.split(b":", 1)[1].strip().decode(errors="replace")
if ":" in host_val:
h, p = host_val.rsplit(":", 1)
try:
host, port = h, int(p)
except ValueError:
host = host_val
else:
host = host_val
break
if not host:
return b"HTTP/1.1 400 Bad Request\r\n\r\nNo Host header\r\n"
# Rewrite the request line: browser sends absolute URL
# (e.g., "GET http://host/path HTTP/1.1") but the target
# server expects a relative path ("GET /path HTTP/1.1")
first_line = header_block.split(b"\r\n")[0]
first_str = first_line.decode(errors="replace")
parts = first_str.split(" ", 2)
if len(parts) >= 2 and parts[1].startswith("http://"):
from urllib.parse import urlparse
parsed = urlparse(parts[1])
rel_path = parsed.path or "/"
if parsed.query:
rel_path += "?" + parsed.query
new_first = f"{parts[0]} {rel_path}"
if len(parts) == 3:
new_first += f" {parts[2]}"
header_block = new_first.encode() + b"\r\n" + b"\r\n".join(header_block.split(b"\r\n")[1:])
raw_request = header_block + body
# Send through tunnel
try:
return await asyncio.wait_for(
self.fronter.forward(raw_request), timeout=30
)
except Exception as e:
log.error("Tunnel HTTP failed (%s:%d): %s", host, port, e)
return b"HTTP/1.1 502 Bad Gateway\r\n\r\nTunnel forward failed\r\n"
-76
View File
@@ -1,76 +0,0 @@
"""
Minimal WebSocket frame encoder / decoder (RFC 6455).
Only handles binary (opcode 0x02) and close (opcode 0x08) frames.
Client-to-server frames are always masked as required by the spec.
"""
import os
import struct
def ws_encode(data: bytes, opcode: int = 0x02) -> bytes:
"""Encode *data* into a masked binary WebSocket frame."""
head = bytearray([0x80 | opcode]) # FIN + opcode
length = len(data)
if length < 126:
head.append(0x80 | length)
elif length < 0x10000:
head.append(0x80 | 126)
head += struct.pack("!H", length)
else:
head.append(0x80 | 127)
head += struct.pack("!Q", length)
mask = os.urandom(4)
head += mask
masked = bytearray(data)
for i in range(len(masked)):
masked[i] ^= mask[i & 3]
return bytes(head) + bytes(masked)
def ws_decode(buf: bytes):
"""Try to decode one frame from *buf*.
Returns ``(opcode, payload, consumed_bytes)`` or ``None`` if the
buffer does not yet contain a complete frame.
"""
if len(buf) < 2:
return None
opcode = buf[0] & 0x0F
is_masked = buf[1] & 0x80
length = buf[1] & 0x7F
pos = 2
if length == 126:
if len(buf) < 4:
return None
length = struct.unpack("!H", buf[2:4])[0]
pos = 4
elif length == 127:
if len(buf) < 10:
return None
length = struct.unpack("!Q", buf[2:10])[0]
pos = 10
mask = None
if is_masked:
if len(buf) < pos + 4:
return None
mask = buf[pos : pos + 4]
pos += 4
if len(buf) < pos + length:
return None
payload = bytearray(buf[pos : pos + length])
if mask:
for i in range(len(payload)):
payload[i] ^= mask[i & 3]
return opcode, bytes(payload), pos + length