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:
- `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:
+2 -13
View File
@@ -176,14 +176,7 @@ Firefox uses its own certificate store, so even after OS-level install you need
## Modes Overview
| Mode | What You Need | Description |
|------|--------------|-------------|
| `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.
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.
---
@@ -193,7 +186,6 @@ Most users should use **`apps_script`** mode — it's free and requires no serve
| Setting | What It Does |
|---------|-------------|
| `mode` | Which relay type to use (see table above) |
| `auth_key` | Password shared between your computer and the relay |
| `script_id` | Your Google Apps Script Deployment ID |
| `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 |
| `front_domain` | `www.google.com` | Domain shown to the firewall/filter |
| `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) |
### Load Balancing
@@ -269,11 +259,10 @@ python3 main.py --no-cert-check # Skip automatic CA install check on st
|------|-------------|
| `main.py` | Starts the proxy |
| `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) |
| `mitm.py` | Handles HTTPS certificate generation |
| `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 |
| `config.example.json` | Example config — copy to `config.json` |
+2 -13
View File
@@ -166,14 +166,7 @@ Firefox معمولا certificate store جداگانه دارد:
## حالت‌های موجود
| حالت | نیازمندی | توضیح |
|------|----------|-------|
| `apps_script` | اکانت رایگان Google | ساده‌ترین حالت، بدون نیاز به سرور |
| `google_fronting` | Google Cloud Run | استفاده از سرویس Cloud Run خودتان |
| `domain_fronting` | Cloudflare Worker | استفاده از Worker روی Cloudflare |
| `custom_domain` | دامنه شخصی روی Cloudflare | اتصال مستقیم به دامنه خودتان |
برای اکثر کاربران، `apps_script` بهترین انتخاب است.
این پروژه کاملاً روی حالت **Apps Script** تمرکز دارد. فقط به یک اکانت رایگان Google نیاز دارید — بدون VPS، بدون سرور، بدون Cloudflare. همه‌چیز برای همین حالت تنظیم شده است.
---
@@ -181,7 +174,6 @@ Firefox معمولا certificate store جداگانه دارد:
| تنظیم | توضیح |
|------|-------|
| `mode` | نوع رله |
| `auth_key` | رمز مشترک بین برنامه و رله |
| `script_id` | Deployment ID مربوط به Apps Script |
| `listen_host` | آدرس محلی برای اجرا |
@@ -195,8 +187,6 @@ Firefox معمولا certificate store جداگانه دارد:
| `google_ip` | `216.239.38.120` | IP مورد استفاده برای مسیر Google |
| `front_domain` | `www.google.com` | دامنه‌ای که فیلتر می‌بیند |
| `verify_ssl` | `true` | بررسی اعتبار TLS |
| `worker_host` | - | برای حالت‌های Cloudflare/Cloud Run |
| `custom_domain` | - | دامنه شخصی شما |
| `script_ids` | - | چند Deployment ID برای load balancing |
### استفاده از چند Script ID
@@ -255,11 +245,10 @@ python3 main.py --no-cert-check # رد شدن از بررسی خودکار
|------|--------|
| `main.py` | اجرای برنامه |
| `proxy_server.py` | مدیریت اتصال مرورگر |
| `domain_fronter.py` | انجام domain fronting |
| `domain_fronter.py` | کلاینت رله Apps Script (با عبور از Google) |
| `h2_transport.py` | ارتباط سریع‌تر با HTTP/2 |
| `mitm.py` | ساخت و مدیریت certificate |
| `cert_installer.py` | نصب خودکار گواهی CA در ویندوز، مک، لینوکس و Firefox |
| `ws.py` | پشتیبانی WebSocket |
| `Code.gs` | رله Apps Script |
| `config.example.json` | فایل نمونه تنظیمات |
+23 -188
View File
@@ -1,19 +1,10 @@
"""
CDN Relay engine.
Apps Script relay engine.
Modes:
1. custom_domain — SNI and Host both point to your custom domain on CF.
2. domain_fronting — SNI = front_domain (allowed), Host = worker_host.
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.
Domain fronting via Google Apps Script: 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
"""
@@ -23,14 +14,11 @@ import hashlib
import gzip
import json
import logging
import os
import re
import ssl
import time
from urllib.parse import urlparse
from ws import ws_encode, ws_decode
log = logging.getLogger("Fronter")
@@ -42,34 +30,16 @@ class DomainFronter:
)
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.verify_ssl = config.get("verify_ssl", True)
@@ -98,17 +68,16 @@ class DomainFronter:
# HTTP/2 multiplexing — one connection handles all requests
self._h2 = None
if mode == "apps_script":
try:
from h2_transport import H2Transport, H2_AVAILABLE
if H2_AVAILABLE:
self._h2 = H2Transport(
self.connect_host, self.sni_host, self.verify_ssl
)
log.info("HTTP/2 multiplexing available — "
"all requests will share one connection")
except ImportError:
pass
try:
from h2_transport import H2Transport, H2_AVAILABLE
if H2_AVAILABLE:
self._h2 = H2Transport(
self.connect_host, self.sni_host, self.verify_ssl
)
log.info("HTTP/2 multiplexing available — "
"all requests will share one connection")
except ImportError:
pass
# ── helpers ───────────────────────────────────────────────────
@@ -392,140 +361,6 @@ class DomainFronter:
def _auth_header(self) -> str:
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) ──────────────────────
async def relay(self, method: str, url: str,
+41 -64
View File
@@ -1,10 +1,10 @@
#!/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
domain fronting: the TLS SNI shows an allowed domain while the encrypted
HTTP Host header routes to your Cloudflare Worker relay.
Run a local HTTP proxy that tunnels all traffic through a Google Apps
Script relay fronted by www.google.com (TLS SNI shows www.google.com
while the encrypted Host header points at script.google.com).
"""
import argparse
@@ -33,7 +33,7 @@ def setup_logging(level_name: str):
def parse_args():
parser = argparse.ArgumentParser(
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(
"-c", "--config",
@@ -136,25 +136,13 @@ def main():
print(f"Missing required config key: {key}")
sys.exit(1)
mode = config.get("mode", "domain_fronting")
if mode == "custom_domain" and "custom_domain" not in config:
print("Mode 'custom_domain' requires 'custom_domain' in config")
# Always Apps Script mode — force-set for backward-compat configs.
config["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("Missing 'script_id' in config.")
print("Deploy the Apps Script from Code.gs and paste the Deployment ID.")
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 ──────────────────────────────────────────
if args.install_cert:
@@ -167,49 +155,38 @@ def main():
setup_logging(config.get("log_level", "INFO"))
log = logging.getLogger("Main")
mode = config.get("mode", "domain_fronting")
log.info("DomainFront Tunnel starting (mode: %s)", mode)
log.info("DomainFront Tunnel starting (Apps Script relay)")
if mode == "custom_domain":
log.info("Custom domain : %s", config["custom_domain"])
elif mode == "google_fronting":
log.info("Google fronting : SNI=%s → Host=%s",
config.get("front_domain", "www.google.com"), config["worker_host"])
log.info("Google IP : %s", config.get("google_ip", "216.239.38.120"))
elif mode == "apps_script":
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.")
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("Front domain (SNI) : %s", config.get("front_domain", "?"))
log.info("Worker host (Host) : %s", config.get("worker_host", "?"))
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.")
log.info("HTTP proxy : %s:%d",
config.get("listen_host", "127.0.0.1"),
+78 -154
View File
@@ -2,11 +2,8 @@
Local HTTP proxy server.
Intercepts the user's browser traffic and forwards everything through
a domain-fronted connection to a CDN worker or Apps Script relay.
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)
the Apps Script relay (MITM-decrypts HTTPS locally, forwards requests
as JSON to script.google.com fronted through www.google.com).
"""
import asyncio
@@ -150,17 +147,11 @@ class ProxyServer:
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.fronter = DomainFronter(config)
self.mitm = None
self._cache = ResponseCache(max_mb=50)
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
# Checked before any real DNS lookup; supports exact and suffix matching.
self._hosts: dict[str, str] = config.get("hosts", {})
@@ -181,14 +172,13 @@ class ProxyServer:
)
}
if self.mode == "apps_script":
try:
from mitm import MITMCertManager
self.mitm = MITMCertManager()
except ImportError:
log.error("apps_script mode requires 'cryptography' package.")
log.error("Run: pip install cryptography")
raise SystemExit(1)
try:
from mitm import MITMCertManager
self.mitm = MITMCertManager()
except ImportError:
log.error("Apps Script relay requires the 'cryptography' package.")
log.error("Run: pip install cryptography")
raise SystemExit(1)
@staticmethod
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,
reader: asyncio.StreamReader,
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)
if override_ip:
# SNI-blocked domain: MITM-decrypt from browser, then
# re-connect to the override IP with SNI=front_domain so
# the ISP never sees the blocked hostname in the TLS handshake.
log.info("SNI-rewrite tunnel → %s via %s (SNI: %s)",
host, override_ip, self.fronter.sni_host)
await self._do_sni_rewrite_tunnel(host, port, reader, writer,
connect_ip=override_ip)
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)
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)
override_ip = self._sni_rewrite_ip(host)
if override_ip:
# SNI-blocked domain: MITM-decrypt from browser, then
# re-connect to the override IP with SNI=front_domain so
# the ISP never sees the blocked hostname in the TLS handshake.
log.info("SNI-rewrite tunnel → %s via %s (SNI: %s)",
host, override_ip, self.fronter.sni_host)
await self._do_sni_rewrite_tunnel(host, port, reader, writer,
connect_ip=override_ip)
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)
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)
else:
await self._do_plain_http_tunnel(host, port, reader, writer)
elif port == 443:
await self._do_mitm_connect(host, port, reader, writer)
else:
await self.fronter.tunnel(host, port, reader, writer)
await self._do_plain_http_tunnel(host, port, reader, writer)
# ── Hosts override (fake DNS) ─────────────────────────────────
@@ -1028,117 +1015,54 @@ class ProxyServer:
first_line = header_block.split(b"\r\n")[0].decode(errors="replace")
log.info("HTTP → %s", first_line)
if self.mode == "apps_script":
# Parse request and relay through Apps Script
parts = first_line.strip().split(" ", 2)
method = parts[0] if parts else "GET"
url = parts[1] if len(parts) > 1 else "/"
# Parse request and relay through Apps Script
parts = first_line.strip().split(" ", 2)
method = parts[0] if parts else "GET"
url = parts[1] if len(parts) > 1 else "/"
headers = {}
for raw_line in header_block.split(b"\r\n")[1:]:
if b":" in raw_line:
k, v = raw_line.decode(errors="replace").split(":", 1)
headers[k.strip()] = v.strip()
headers = {}
for raw_line in header_block.split(b"\r\n")[1:]:
if b":" in raw_line:
k, v = raw_line.decode(errors="replace").split(":", 1)
headers[k.strip()] = v.strip()
# ── CORS preflight over plain HTTP ────────────────────────────
origin = next(
(v for k, v in headers.items() if k.lower() == "origin"), ""
)
acr_method = next(
(v for k, v in headers.items()
if k.lower() == "access-control-request-method"), ""
)
acr_headers_val = next(
(v for k, v in headers.items()
if k.lower() == "access-control-request-headers"), ""
)
if method.upper() == "OPTIONS" and acr_method:
log.debug("CORS preflight (HTTP) → %s (responding locally)", url[:60])
writer.write(self._cors_preflight_response(origin, acr_method, acr_headers_val))
await writer.drain()
return
# ── CORS preflight over plain HTTP ────────────────────────────
origin = next(
(v for k, v in headers.items() if k.lower() == "origin"), ""
)
acr_method = next(
(v for k, v in headers.items()
if k.lower() == "access-control-request-method"), ""
)
acr_headers_val = next(
(v for k, v in headers.items()
if k.lower() == "access-control-request-headers"), ""
)
if method.upper() == "OPTIONS" and acr_method:
log.debug("CORS preflight (HTTP) → %s (responding locally)", url[:60])
writer.write(self._cors_preflight_response(origin, acr_method, acr_headers_val))
await writer.drain()
return
# Cache check for GET
response = None
if self._cache_allowed(method, url, headers, body):
response = self._cache.get(url)
if response:
log.debug("Cache HIT (HTTP): %s", url[:60])
# Cache check for GET
response = None
if self._cache_allowed(method, url, headers, body):
response = self._cache.get(url)
if response:
log.debug("Cache HIT (HTTP): %s", url[:60])
if response is None:
response = await self._relay_smart(method, url, headers, body)
# Cache successful GET
if self._cache_allowed(method, url, headers, body) and response:
ttl = ResponseCache.parse_ttl(response, url)
if ttl > 0:
self._cache.put(url, response, ttl)
if response is None:
response = await self._relay_smart(method, url, headers, body)
# Cache successful GET
if self._cache_allowed(method, url, headers, body) and response:
ttl = ResponseCache.parse_ttl(response, url)
if ttl > 0:
self._cache.put(url, response, ttl)
# Inject CORS headers for cross-origin requests
if origin and response:
response = self._inject_cors_headers(response, origin)
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)
# Inject CORS headers for cross-origin requests
if origin and response:
response = self._inject_cors_headers(response, origin)
self._log_response_summary(url, response)
writer.write(response)
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