From e00873557ab07ff28dcf62b86ff644010b06d4f5 Mon Sep 17 00:00:00 2001 From: Mohammad Amin jahani Date: Sun, 10 May 2026 00:49:08 +0300 Subject: [PATCH 1/5] rotate script IDs when one returns a bad response --- src/domain_fronter.py | 162 ++++++++++++++++++++++++++++++++---------- 1 file changed, 125 insertions(+), 37 deletions(-) diff --git a/src/domain_fronter.py b/src/domain_fronter.py index d59a738..19c5071 100644 --- a/src/domain_fronter.py +++ b/src/domain_fronter.py @@ -60,6 +60,10 @@ class HostStat: errors: int = 0 +class _RelayBadResponse(Exception): + """Raised when a relay response indicates the chosen script ID is unhealthy.""" + + def _build_sni_pool(front_domain: str, overrides: list | None) -> list[str]: """Build the list of SNIs to rotate through on new outbound TLS handshakes. @@ -401,6 +405,16 @@ class DomainFronter: if force or until <= now: self._sid_blacklist.pop(sid, None) + def _next_alt_sid(self, tried: set[str]) -> str | None: + """Pick a script ID not already tried and not blacklisted, or None.""" + for sid in self._script_ids: + if sid in tried: + continue + if self._is_sid_blacklisted(sid): + continue + return sid + return None + def _pick_fanout_sids(self, key: str | None) -> list[str]: """Pick up to `parallel_relay` distinct non-blacklisted script IDs. @@ -842,8 +856,18 @@ class DomainFronter: {"m": "HEAD", "u": "http://example.com/", "k": self.auth_key} ).encode() hdrs = {"content-type": "application/json"} - sid = self._script_ids[0] + for sid in list(self._script_ids): + if self._is_sid_blacklisted(sid): + continue + if await self._prewarm_one_sid(sid, payload, hdrs): + return + self._blacklist_sid(sid, reason="prewarm") + log.debug("Pre-warm exhausted all script IDs") + + async def _prewarm_one_sid(self, sid: str, payload: bytes, + hdrs: dict) -> bool: + """Try /dev fast-path detection then /exec warmup for one sid.""" # Test /dev endpoint — returns data inline (no 302 redirect). # If it works, saves ~400ms per request by eliminating one round trip. try: @@ -857,19 +881,21 @@ class DomainFronter: timeout=15, ) dt = (time.perf_counter() - t0) * 1000 - data = json.loads(body.decode(errors="replace")) - if "s" in data: - self._dev_available = True - log.info("/dev fast path active (%.0fms, no redirect)", dt) - return + if status == 200: + data = json.loads(body.decode(errors="replace")) + if "s" in data: + self._dev_available = True + log.info("/dev fast path active (%.0fms, no redirect)", dt) + return True except Exception as e: - log.debug("/dev test failed: %s", e) + log.debug("/dev test failed for sid %s: %s", + sid[-8:] if len(sid) > 8 else sid, e) # Fallback: warm up with /exec try: exec_path = f"/macros/s/{sid}/exec" t0 = time.perf_counter() - await asyncio.wait_for( + status, _, _ = await asyncio.wait_for( self._h2.request( method="POST", path=exec_path, host=self.http_host, headers=hdrs, body=payload, @@ -877,9 +903,16 @@ class DomainFronter: timeout=15, ) dt = (time.perf_counter() - t0) * 1000 + if status != 200: + log.debug("Pre-warm /exec returned %d for sid %s", + status, sid[-8:] if len(sid) > 8 else sid) + return False log.info("Apps Script pre-warmed in %.0fms", dt) + return True except Exception as e: - log.debug("Pre-warm failed: %s", e) + log.debug("Pre-warm failed for sid %s: %s", + sid[-8:] if len(sid) > 8 else sid, e) + return False async def _keepalive_loop(self): """Send periodic pings to keep Apps Script warm + H2 connection alive.""" @@ -1665,6 +1698,15 @@ class DomainFronter: async def _relay_with_retry(self, payload: dict) -> bytes: """Single relay with one retry on failure. Uses H2 if available.""" attempts = self._retry_attempts_for_payload(payload) + host_key = self._host_key(payload.get("u")) + tried_sids: set[str] = set() + + def pick_sid() -> str: + if not tried_sids: + return self._script_id_for_key(host_key) + alt = self._next_alt_sid(tried_sids) + return alt if alt is not None else self._script_id_for_key(host_key) + # Fan-out: race N Apps Script instances when enabled and H2 is up. # Cuts tail latency when one container is slow/cold. Only kicks in # if multiple script IDs are configured and the H2 transport is live. @@ -1686,12 +1728,23 @@ class DomainFronter: # Try HTTP/2 first — much faster (multiplexed, no pool checkout) if self._h2_available(): for attempt in range(attempts): + sid = pick_sid() + tried_sids.add(sid) try: result = await asyncio.wait_for( - self._relay_single_h2(payload), timeout=self._relay_timeout + self._relay_single_h2(payload, sid=sid), + timeout=self._relay_timeout, ) self._record_h2_success() return result + except _RelayBadResponse as e: + self._blacklist_sid(sid, reason=str(e)[:40]) + if (attempt < attempts - 1 + and self._next_alt_sid(tried_sids) is not None): + log.debug("H2 sid %s bad (%s), rotating", + sid[-8:] if len(sid) > 8 else sid, e) + continue + raise except Exception as e: self._record_h2_failure(e) if attempt < attempts - 1: @@ -1716,10 +1769,21 @@ class DomainFronter: # HTTP/1.1 fallback (pool-based) async with self._semaphore: for attempt in range(attempts): + sid = pick_sid() + tried_sids.add(sid) try: return await asyncio.wait_for( - self._relay_single(payload), timeout=self._relay_timeout + self._relay_single(payload, sid=sid), + timeout=self._relay_timeout, ) + except _RelayBadResponse as e: + self._blacklist_sid(sid, reason=str(e)[:40]) + if (attempt < attempts - 1 + and self._next_alt_sid(tried_sids) is not None): + log.debug("H1 sid %s bad (%s), rotating", + sid[-8:] if len(sid) > 8 else sid, e) + continue + raise except Exception as e: if attempt < attempts - 1: log.debug("Relay attempt %d failed (%s: %s), retrying", @@ -1776,33 +1840,15 @@ class DomainFronter: if pending: await asyncio.gather(*pending, return_exceptions=True) - async def _relay_single_h2(self, payload: dict) -> bytes: + async def _relay_single_h2(self, payload: dict, + sid: str | None = None) -> bytes: """Execute a relay through HTTP/2 multiplexing. Uses the shared H2 connection — no pool checkout needed. Many concurrent calls all share one TLS connection. """ - full_payload = dict(payload) - full_payload["k"] = self.auth_key - json_body = json.dumps(full_payload).encode() - - path = self._exec_path(payload.get("u")) - - status, headers, body = await self._h2.request( - method="POST", path=path, host=self.http_host, - headers={"content-type": "application/json"}, - body=json_body, - ) - - return self._parse_relay_response(body) - - async def _relay_single_h2_with_sid(self, payload: dict, - sid: str) -> bytes: - """Execute an H2 relay pinned to a specific Apps Script deployment. - - Used by `_relay_fanout` to race multiple script IDs in parallel. - Mirrors `_relay_single_h2` but ignores the stable-hash routing. - """ + if sid is None: + sid = self._script_id_for_key(self._host_key(payload.get("u"))) full_payload = dict(payload) full_payload["k"] = self.auth_key json_body = json.dumps(full_payload).encode() @@ -1815,16 +1861,32 @@ class DomainFronter: body=json_body, ) - return self._parse_relay_response(body) + if status != 200: + raise _RelayBadResponse( + f"upstream HTTP {status} from script " + f"{sid[-8:] if len(sid) > 8 else sid}", + ) + return self._parse_or_raise(body) - async def _relay_single(self, payload: dict) -> bytes: + async def _relay_single_h2_with_sid(self, payload: dict, + sid: str) -> bytes: + """Execute an H2 relay pinned to a specific Apps Script deployment. + + Used by `_relay_fanout` to race multiple script IDs in parallel. + """ + return await self._relay_single_h2(payload, sid=sid) + + async def _relay_single(self, payload: dict, + sid: str | None = None) -> bytes: """Execute a single relay POST → redirect → parse.""" # Add auth key + if sid is None: + sid = self._script_id_for_key(self._host_key(payload.get("u"))) full_payload = dict(payload) full_payload["k"] = self.auth_key json_body = json.dumps(full_payload).encode() - path = self._exec_path(payload.get("u")) + path = self._exec_path_for_sid(sid) reader, writer, created = await self._acquire() try: @@ -1872,7 +1934,12 @@ class DomainFronter: status, resp_headers, resp_body = await self._read_http_response(reader) await self._release(reader, writer, created) - return self._parse_relay_response(resp_body) + if status != 200: + raise _RelayBadResponse( + f"upstream HTTP {status} from script " + f"{sid[-8:] if len(sid) > 8 else sid}", + ) + return self._parse_or_raise(resp_body) except Exception: try: @@ -2136,6 +2203,27 @@ class DomainFronter: return self._parse_relay_json(data) + def _parse_or_raise(self, body: bytes) -> bytes: + """Like `_parse_relay_response` but raises `_RelayBadResponse` on failure.""" + text = body.decode(errors="replace").strip() + if not text: + raise _RelayBadResponse("empty response") + + try: + data = json.loads(text) + except json.JSONDecodeError: + m = re.search(r'\{.*\}', text, re.DOTALL) + if not m: + raise _RelayBadResponse(f"non-JSON: {text[:120]}") + try: + data = json.loads(m.group()) + except json.JSONDecodeError: + raise _RelayBadResponse(f"bad JSON: {text[:120]}") + + if "e" in data: + raise _RelayBadResponse(f"relay error: {data['e']}") + return self._parse_relay_json(data) + def _parse_relay_json(self, data: dict) -> bytes: """Convert a parsed relay JSON dict to raw HTTP response bytes.""" if "e" in data: From 931753e9f0d07df36fe584e9b0e797c2a1f31420 Mon Sep 17 00:00:00 2001 From: Mohammad Amin jahani Date: Sun, 10 May 2026 14:58:54 +0300 Subject: [PATCH 2/5] Add forwarder_hosts config to scope upstream forwarder routing per host --- README.md | 13 ++++++++++ README_FA.md | 13 ++++++++++ config.example.json | 1 + deploy/cloudflare-worker/worker.js | 4 +++- deploy/gas/Code.gs | 4 +++- src/domain_fronter.py | 38 ++++++++++++++++++++++++++++++ 6 files changed, 71 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3400b51..65a0cee 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,19 @@ Browse `https://httpbin.org/ip` through the proxy — you should see the **VPS's > The forwarder must require auth. Without `AUTH_KEY` it refuses to start. Anyone with the URL and key can use it as a relay, so keep both secret. +### 4. Scope the forwarder to specific hosts (optional) + +By default every request the Worker handles routes through the forwarder, so unrelated traffic also burns VPS bandwidth. To send only the sites that need a stable exit IP through the VPS, list them in `forwarder_hosts` in `config.json` — same syntax as `bypass_hosts` (exact hostname or `.suffix`). Anything not matched falls back to direct `fetch()` on the Worker. + +```json +"forwarder_hosts": [ + "example.com", + ".cf-protected-suffix" +] +``` + +Leave the list empty (or remove the key) to keep the historical "forward everything" behavior. + --- ## Disclaimer diff --git a/README_FA.md b/README_FA.md index 4954c35..14e7efa 100644 --- a/README_FA.md +++ b/README_FA.md @@ -519,6 +519,19 @@ curl -X POST https://forwarder.example.com/fwd \ > forwarder بدون `AUTH_KEY` راه‌اندازی نمی‌شود. هر کسی که آدرس و کلید را داشته باشد می‌تواند از آن به‌عنوان رله استفاده کند، بنابراین هر دو را محرمانه نگه دارید. +### ۴. محدود کردن forwarder به میزبان‌های خاص (اختیاری) + +به‌صورت پیش‌فرض همهٔ درخواست‌هایی که Worker پردازش می‌کند از طریق forwarder عبور می‌کنند، در نتیجه ترافیک غیرمرتبط هم پهنای باند VPS را مصرف می‌کند. اگر فقط می‌خواهید سایت‌هایی که به IP خروجی پایدار نیاز دارند از مسیر VPS رد شوند، آن‌ها را در `forwarder_hosts` در `config.json` فهرست کنید — همان نحو `bypass_hosts` (نام دقیق دامنه یا الگوی `.suffix`). هر چه با این لیست تطبیق نخورد، روی Worker با `fetch()` مستقیم ارسال می‌شود. + +```json +"forwarder_hosts": [ + "example.com", + ".cf-protected-suffix" +] +``` + +اگر این لیست خالی باشد (یا کلید را حذف کنید)، رفتار قبلی یعنی «forward همه» حفظ می‌شود. + --- ## تنظیمات پیشرفته config.json diff --git a/config.example.json b/config.example.json index 3714eca..0b2b1c4 100644 --- a/config.example.json +++ b/config.example.json @@ -60,6 +60,7 @@ ".lan", ".home.arpa" ], + "forwarder_hosts": [], "direct_google_exclude": [ "gemini.google.com", "aistudio.google.com", diff --git a/deploy/cloudflare-worker/worker.js b/deploy/cloudflare-worker/worker.js index 85449c8..2f25b30 100644 --- a/deploy/cloudflare-worker/worker.js +++ b/deploy/cloudflare-worker/worker.js @@ -38,7 +38,9 @@ export default { } const upstreamUrl = (env && env.UPSTREAM_FORWARDER_URL) || ""; - if (upstreamUrl) { + // f === 1: forward; f === 0: skip; missing: legacy client → forward (compat). + const wantForward = (req.f === 1) || (req.f === undefined); + if (upstreamUrl && wantForward) { const upstreamResp = await forwardViaUpstream(req, env, upstreamUrl); if (upstreamResp) return upstreamResp; // fall through to direct fetch only when fail-mode is open diff --git a/deploy/gas/Code.gs b/deploy/gas/Code.gs index 96edc3c..894c571 100644 --- a/deploy/gas/Code.gs +++ b/deploy/gas/Code.gs @@ -105,7 +105,7 @@ function _buildWorkerPayload(req) { } } - return { + var out = { u: req.u, m: (req.m || "GET").toUpperCase(), h: headers, @@ -113,6 +113,8 @@ function _buildWorkerPayload(req) { ct: req.ct || null, r: req.r !== false }; + if (typeof req.f === "number") out.f = req.f; + return out; } function doGet(e) { diff --git a/src/domain_fronter.py b/src/domain_fronter.py index d59a738..c27954c 100644 --- a/src/domain_fronter.py +++ b/src/domain_fronter.py @@ -150,6 +150,10 @@ class DomainFronter: minimum=1024, ) + self._forwarder_hosts = self._load_host_rules( + config.get("forwarder_hosts", []) + ) + # Connection pool — TTL-based, pre-warmed, with concurrency control self._pool: list[tuple[asyncio.StreamReader, asyncio.StreamWriter, float]] = [] self._pool_lock = asyncio.Lock() @@ -224,6 +228,33 @@ class DomainFronter: value = default return max(minimum, value) + @staticmethod + def _load_host_rules(raw) -> tuple[set[str], tuple[str, ...]]: + """Parse host strings into (exact_set, suffix_tuple). Mirrors ProxyServer._load_host_rules.""" + exact: set[str] = set() + suffixes: list[str] = [] + for item in raw or []: + h = str(item).strip().lower().rstrip(".") + if not h: + continue + if h.startswith("."): + suffixes.append(h) + else: + exact.add(h) + return exact, tuple(suffixes) + + @staticmethod + def _host_matches_rules(host: str, + rules: tuple[set[str], tuple[str, ...]]) -> bool: + exact, suffixes = rules + h = host.lower().rstrip(".") + if h in exact: + return True + for s in suffixes: + if h.endswith(s): + return True + return False + def _ssl_ctx(self) -> ssl.SSLContext: ctx = ssl.create_default_context() if certifi is not None: @@ -1515,6 +1546,13 @@ class DomainFronter: ct = headers.get("Content-Type") or headers.get("content-type") if ct: payload["ct"] = ct + # Only emit 'f' when scoped; Worker treats missing 'f' as forward (legacy compat). + exact, suffixes = self._forwarder_hosts + if exact or suffixes: + host = urlparse(url).hostname or "" + payload["f"] = 1 if self._host_matches_rules( + host, self._forwarder_hosts + ) else 0 return payload @classmethod From 0a58baa6ad579834fce13e8f22e876997ef04f10 Mon Sep 17 00:00:00 2001 From: "Nima Taheri | (NT)" <76235233+NTcompanyYT@users.noreply.github.com> Date: Sun, 10 May 2026 21:29:54 +0330 Subject: [PATCH 3/5] New Video link added --- README_FA.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README_FA.md b/README_FA.md index 4954c35..f98f5ef 100644 --- a/README_FA.md +++ b/README_FA.md @@ -13,7 +13,7 @@ [![Watch the video](https://img.youtube.com/vi/L3lJZrAqqUQ/maxresdefault.jpg)](https://youtu.be/L3lJZrAqqUQ) - لینک یوتیوب: https://youtu.be/L3lJZrAqqUQ -- لینک داخلی دانلود ویدیو: https://nc.thearthur.ir/s/YaCp4zAzepHJKi2 +- لینک داخلی دانلود ویدیو: https://cdn.vayrex.ir/vasls/8440130/1777611424961-86c092e3-mhrv-cfw.mp4 --- From b13f778cb6b9942ed55b69b70e12dfb24c36b51a Mon Sep 17 00:00:00 2001 From: denuitt1 Date: Sun, 10 May 2026 12:28:57 -0700 Subject: [PATCH 4/5] Added Contributors section Updated README to include additional sections and formatting. --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 65a0cee..07956c4 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,11 @@ Leave the list empty (or remove the key) to keep the historical "forward everyth --- +## Contributors +- Special thanks to [onlymaj](https://github.com/onlymaj) + +--- + ## Sources - This project is based on [MasterHttpRelayVPN](https://github.com/masterking32/MasterHttpRelayVPN) From c935b8729392455bb727e5da7d21c4717f932719 Mon Sep 17 00:00:00 2001 From: denuitt1 Date: Sun, 10 May 2026 12:33:23 -0700 Subject: [PATCH 5/5] Revert "feat(forwarder): scope upstream forwarder via forwarder_hosts config" --- README.md | 13 ---------- README_FA.md | 13 ---------- config.example.json | 1 - deploy/cloudflare-worker/worker.js | 4 +--- deploy/gas/Code.gs | 4 +--- src/domain_fronter.py | 38 ------------------------------ 6 files changed, 2 insertions(+), 71 deletions(-) diff --git a/README.md b/README.md index 07956c4..59df19b 100644 --- a/README.md +++ b/README.md @@ -179,19 +179,6 @@ Browse `https://httpbin.org/ip` through the proxy — you should see the **VPS's > The forwarder must require auth. Without `AUTH_KEY` it refuses to start. Anyone with the URL and key can use it as a relay, so keep both secret. -### 4. Scope the forwarder to specific hosts (optional) - -By default every request the Worker handles routes through the forwarder, so unrelated traffic also burns VPS bandwidth. To send only the sites that need a stable exit IP through the VPS, list them in `forwarder_hosts` in `config.json` — same syntax as `bypass_hosts` (exact hostname or `.suffix`). Anything not matched falls back to direct `fetch()` on the Worker. - -```json -"forwarder_hosts": [ - "example.com", - ".cf-protected-suffix" -] -``` - -Leave the list empty (or remove the key) to keep the historical "forward everything" behavior. - --- ## Disclaimer diff --git a/README_FA.md b/README_FA.md index 33b87d8..f98f5ef 100644 --- a/README_FA.md +++ b/README_FA.md @@ -519,19 +519,6 @@ curl -X POST https://forwarder.example.com/fwd \ > forwarder بدون `AUTH_KEY` راه‌اندازی نمی‌شود. هر کسی که آدرس و کلید را داشته باشد می‌تواند از آن به‌عنوان رله استفاده کند، بنابراین هر دو را محرمانه نگه دارید. -### ۴. محدود کردن forwarder به میزبان‌های خاص (اختیاری) - -به‌صورت پیش‌فرض همهٔ درخواست‌هایی که Worker پردازش می‌کند از طریق forwarder عبور می‌کنند، در نتیجه ترافیک غیرمرتبط هم پهنای باند VPS را مصرف می‌کند. اگر فقط می‌خواهید سایت‌هایی که به IP خروجی پایدار نیاز دارند از مسیر VPS رد شوند، آن‌ها را در `forwarder_hosts` در `config.json` فهرست کنید — همان نحو `bypass_hosts` (نام دقیق دامنه یا الگوی `.suffix`). هر چه با این لیست تطبیق نخورد، روی Worker با `fetch()` مستقیم ارسال می‌شود. - -```json -"forwarder_hosts": [ - "example.com", - ".cf-protected-suffix" -] -``` - -اگر این لیست خالی باشد (یا کلید را حذف کنید)، رفتار قبلی یعنی «forward همه» حفظ می‌شود. - --- ## تنظیمات پیشرفته config.json diff --git a/config.example.json b/config.example.json index 0b2b1c4..3714eca 100644 --- a/config.example.json +++ b/config.example.json @@ -60,7 +60,6 @@ ".lan", ".home.arpa" ], - "forwarder_hosts": [], "direct_google_exclude": [ "gemini.google.com", "aistudio.google.com", diff --git a/deploy/cloudflare-worker/worker.js b/deploy/cloudflare-worker/worker.js index 2f25b30..85449c8 100644 --- a/deploy/cloudflare-worker/worker.js +++ b/deploy/cloudflare-worker/worker.js @@ -38,9 +38,7 @@ export default { } const upstreamUrl = (env && env.UPSTREAM_FORWARDER_URL) || ""; - // f === 1: forward; f === 0: skip; missing: legacy client → forward (compat). - const wantForward = (req.f === 1) || (req.f === undefined); - if (upstreamUrl && wantForward) { + if (upstreamUrl) { const upstreamResp = await forwardViaUpstream(req, env, upstreamUrl); if (upstreamResp) return upstreamResp; // fall through to direct fetch only when fail-mode is open diff --git a/deploy/gas/Code.gs b/deploy/gas/Code.gs index 894c571..96edc3c 100644 --- a/deploy/gas/Code.gs +++ b/deploy/gas/Code.gs @@ -105,7 +105,7 @@ function _buildWorkerPayload(req) { } } - var out = { + return { u: req.u, m: (req.m || "GET").toUpperCase(), h: headers, @@ -113,8 +113,6 @@ function _buildWorkerPayload(req) { ct: req.ct || null, r: req.r !== false }; - if (typeof req.f === "number") out.f = req.f; - return out; } function doGet(e) { diff --git a/src/domain_fronter.py b/src/domain_fronter.py index c27954c..d59a738 100644 --- a/src/domain_fronter.py +++ b/src/domain_fronter.py @@ -150,10 +150,6 @@ class DomainFronter: minimum=1024, ) - self._forwarder_hosts = self._load_host_rules( - config.get("forwarder_hosts", []) - ) - # Connection pool — TTL-based, pre-warmed, with concurrency control self._pool: list[tuple[asyncio.StreamReader, asyncio.StreamWriter, float]] = [] self._pool_lock = asyncio.Lock() @@ -228,33 +224,6 @@ class DomainFronter: value = default return max(minimum, value) - @staticmethod - def _load_host_rules(raw) -> tuple[set[str], tuple[str, ...]]: - """Parse host strings into (exact_set, suffix_tuple). Mirrors ProxyServer._load_host_rules.""" - exact: set[str] = set() - suffixes: list[str] = [] - for item in raw or []: - h = str(item).strip().lower().rstrip(".") - if not h: - continue - if h.startswith("."): - suffixes.append(h) - else: - exact.add(h) - return exact, tuple(suffixes) - - @staticmethod - def _host_matches_rules(host: str, - rules: tuple[set[str], tuple[str, ...]]) -> bool: - exact, suffixes = rules - h = host.lower().rstrip(".") - if h in exact: - return True - for s in suffixes: - if h.endswith(s): - return True - return False - def _ssl_ctx(self) -> ssl.SSLContext: ctx = ssl.create_default_context() if certifi is not None: @@ -1546,13 +1515,6 @@ class DomainFronter: ct = headers.get("Content-Type") or headers.get("content-type") if ct: payload["ct"] = ct - # Only emit 'f' when scoped; Worker treats missing 'f' as forward (legacy compat). - exact, suffixes = self._forwarder_hosts - if exact or suffixes: - host = urlparse(url).hostname or "" - payload["f"] = 1 if self._host_matches_rules( - host, self._forwarder_hosts - ) else 0 return payload @classmethod