From 792719df71ace8a80716d311cf2ec25037bba3a0 Mon Sep 17 00:00:00 2001 From: Abolfazl Date: Wed, 22 Apr 2026 03:56:17 +0330 Subject: [PATCH] Refactor documentation and code for Apps Script relay mode; remove WebSocket support. --- PULL_REQUEST_DRAFT.md | 2 +- README.md | 15 +-- README_FA.md | 15 +-- domain_fronter.py | 211 +++++--------------------------------- main.py | 105 ++++++++----------- proxy_server.py | 232 ++++++++++++++---------------------------- ws.py | 76 -------------- 7 files changed, 147 insertions(+), 509 deletions(-) delete mode 100644 ws.py diff --git a/PULL_REQUEST_DRAFT.md b/PULL_REQUEST_DRAFT.md index c701cc8..bdb0b37 100644 --- a/PULL_REQUEST_DRAFT.md +++ b/PULL_REQUEST_DRAFT.md @@ -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: diff --git a/README.md b/README.md index a38fa15..af6edc7 100644 --- a/README.md +++ b/README.md @@ -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` | diff --git a/README_FA.md b/README_FA.md index b7febeb..c8d6ddd 100644 --- a/README_FA.md +++ b/README_FA.md @@ -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` | فایل نمونه تنظیمات | diff --git a/domain_fronter.py b/domain_fronter.py index 6f2bec3..0d96730 100644 --- a/domain_fronter.py +++ b/domain_fronter.py @@ -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, diff --git a/main.py b/main.py index a191b58..546d8a4 100644 --- a/main.py +++ b/main.py @@ -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"), diff --git a/proxy_server.py b/proxy_server.py index ae033df..4ff4cf3 100644 --- a/proxy_server.py +++ b/proxy_server.py @@ -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" diff --git a/ws.py b/ws.py deleted file mode 100644 index 0c3ed22..0000000 --- a/ws.py +++ /dev/null @@ -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