mithwire 0.51.2__tar.gz → 0.51.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. {mithwire-0.51.2/mithwire.egg-info → mithwire-0.51.4}/PKG-INFO +1 -1
  2. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/__init__.py +1 -1
  3. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/proxy/health.py +246 -58
  4. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/stealth/controller.py +6 -1
  5. {mithwire-0.51.2 → mithwire-0.51.4/mithwire.egg-info}/PKG-INFO +1 -1
  6. {mithwire-0.51.2 → mithwire-0.51.4}/pyproject.toml +1 -1
  7. mithwire-0.51.4/tests/test_proxy.py +348 -0
  8. mithwire-0.51.2/tests/test_proxy.py +0 -167
  9. {mithwire-0.51.2 → mithwire-0.51.4}/LICENSE.txt +0 -0
  10. {mithwire-0.51.2 → mithwire-0.51.4}/MANIFEST.in +0 -0
  11. {mithwire-0.51.2 → mithwire-0.51.4}/NOTICE +0 -0
  12. {mithwire-0.51.2 → mithwire-0.51.4}/README.md +0 -0
  13. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/__main__.py +0 -0
  14. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/README.md +0 -0
  15. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/__init__.py +0 -0
  16. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/accessibility.py +0 -0
  17. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/animation.py +0 -0
  18. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/audits.py +0 -0
  19. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/autofill.py +0 -0
  20. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/background_service.py +0 -0
  21. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/bluetooth_emulation.py +0 -0
  22. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/browser.py +0 -0
  23. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/cache_storage.py +0 -0
  24. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/cast.py +0 -0
  25. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/console.py +0 -0
  26. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/crash_report_context.py +0 -0
  27. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/css.py +0 -0
  28. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/database.py +0 -0
  29. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/debugger.py +0 -0
  30. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/device_access.py +0 -0
  31. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/device_orientation.py +0 -0
  32. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/dom.py +0 -0
  33. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/dom_debugger.py +0 -0
  34. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/dom_snapshot.py +0 -0
  35. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/dom_storage.py +0 -0
  36. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/emulation.py +0 -0
  37. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/event_breakpoints.py +0 -0
  38. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/extensions.py +0 -0
  39. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/fed_cm.py +0 -0
  40. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/fetch.py +0 -0
  41. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/file_system.py +0 -0
  42. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/headless_experimental.py +0 -0
  43. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/heap_profiler.py +0 -0
  44. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/indexed_db.py +0 -0
  45. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/input_.py +0 -0
  46. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/inspector.py +0 -0
  47. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/io.py +0 -0
  48. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/layer_tree.py +0 -0
  49. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/log.py +0 -0
  50. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/media.py +0 -0
  51. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/memory.py +0 -0
  52. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/network.py +0 -0
  53. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/overlay.py +0 -0
  54. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/page.py +0 -0
  55. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/performance.py +0 -0
  56. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/performance_timeline.py +0 -0
  57. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/preload.py +0 -0
  58. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/profiler.py +0 -0
  59. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/pwa.py +0 -0
  60. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/py.typed +0 -0
  61. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/runtime.py +0 -0
  62. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/schema.py +0 -0
  63. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/security.py +0 -0
  64. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/service_worker.py +0 -0
  65. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/smart_card_emulation.py +0 -0
  66. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/storage.py +0 -0
  67. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/system_info.py +0 -0
  68. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/target.py +0 -0
  69. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/tethering.py +0 -0
  70. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/tracing.py +0 -0
  71. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/util.py +0 -0
  72. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/web_audio.py +0 -0
  73. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/web_authn.py +0 -0
  74. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/web_mcp.py +0 -0
  75. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/core/_contradict.py +0 -0
  76. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/core/browser.py +0 -0
  77. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/core/cf_templates/cf_dark_checkbox.png +0 -0
  78. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/core/cf_templates/cf_light_checkbox.png +0 -0
  79. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/core/config.py +0 -0
  80. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/core/connection.py +0 -0
  81. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/core/element.py +0 -0
  82. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/core/tab.py +0 -0
  83. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/core/util.py +0 -0
  84. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/core/virtual_display.py +0 -0
  85. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/fingerprint_gen.py +0 -0
  86. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/proxy/__init__.py +0 -0
  87. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/proxy/config.py +0 -0
  88. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/proxy/relay.py +0 -0
  89. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/stealth/__init__.py +0 -0
  90. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/stealth/cloakbrowser.py +0 -0
  91. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/stealth/fingerprint.py +0 -0
  92. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/stealth_diagnostic/__init__.py +0 -0
  93. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/stealth_diagnostic/probes.py +0 -0
  94. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/stealth_diagnostic/report.py +0 -0
  95. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/stealth_diagnostic/runner.py +0 -0
  96. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire.egg-info/SOURCES.txt +0 -0
  97. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire.egg-info/dependency_links.txt +0 -0
  98. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire.egg-info/entry_points.txt +0 -0
  99. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire.egg-info/requires.txt +0 -0
  100. {mithwire-0.51.2 → mithwire-0.51.4}/mithwire.egg-info/top_level.txt +0 -0
  101. {mithwire-0.51.2 → mithwire-0.51.4}/setup.cfg +0 -0
  102. {mithwire-0.51.2 → mithwire-0.51.4}/tests/test_compute_launch_args.py +0 -0
  103. {mithwire-0.51.2 → mithwire-0.51.4}/tests/test_connect_retry.py +0 -0
  104. {mithwire-0.51.2 → mithwire-0.51.4}/tests/test_stealth_diagnostic_cli.py +0 -0
  105. {mithwire-0.51.2 → mithwire-0.51.4}/tests/test_stealth_diagnostic_report.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mithwire
3
- Version: 0.51.2
3
+ Version: 0.51.4
4
4
  Summary: Dual-mode anti-detect browser framework — CDP-native stealth for local machines, binary-level patching for production servers. No WebDriver, no chromedriver.
5
5
  Author: Elendil Technologies
6
6
  Maintainer: Elendil Technologies
@@ -36,4 +36,4 @@ __all__ = [
36
36
  "run_stealth_diagnostic",
37
37
  ]
38
38
 
39
- __version__ = "0.51.2"
39
+ __version__ = "0.51.4"
@@ -24,6 +24,12 @@ Notes on the implementation:
24
24
  ``http`` endpoint 301-redirects to it), so we point at the ``http`` URL and
25
25
  let urllib follow the redirect through the proxy automatically — that
26
26
  exercises the proxy's CONNECT + TLS path the same way the real browser will.
27
+ * **Fallback for residential/mobile proxies**: many residential gateways don't
28
+ support CONNECT well (slow or timing out). When the primary HTTPS probe
29
+ fails on timeout or connection error (NOT auth 407), the probe automatically
30
+ retries with a plain-HTTP target (``ip-api.com/json/``) that requires only
31
+ an absolute-form GET — no CONNECT tunnel needed. The response is normalized
32
+ to the ipapi.is schema so downstream identity alignment is unaffected.
27
33
  * The synchronous ``urlopen`` runs in a worker thread; the surrounding
28
34
  ``asyncio.wait_for`` enforces an async-side deadline regardless of any
29
35
  socket-level timer.
@@ -49,13 +55,13 @@ from .config import ProxyConfig
49
55
 
50
56
  logger = logging.getLogger(__name__)
51
57
 
52
- # ipapi.is returns a JSON object with ``ip`` plus
53
- # ``location.{country,country_code,city,timezone,latitude,longitude}``. We use
54
- # the ``http://`` URL on purpose: urllib's ProxyHandler issues an absolute-form
55
- # GET to the proxy for plain HTTP, AND transparently follows the 301-to-https
56
- # the public service returns so we end up exercising the proxy's CONNECT +
57
- # TLS path in production, which is exactly what the real browser does too.
58
+ # Primary probe target. ipapi.is now 301-redirects http https. When urllib
59
+ # follows that redirect it attempts an HTTP CONNECT tunnel for the TLS leg,
60
+ # which many residential/mobile proxies don't support on their HTTP port —
61
+ # causing a silent hang. We disable auto-redirect and, if the primary hangs,
62
+ # fall back to ip-api.com which still serves plain HTTP without redirecting.
58
63
  _PROBE_TARGET_URL = "http://api.ipapi.is/"
64
+ _FALLBACK_PROBE_URL = "http://ip-api.com/json/"
59
65
 
60
66
  _DEFAULT_TIMEOUT_SECONDS = 8.0
61
67
 
@@ -161,12 +167,38 @@ async def probe_proxy(
161
167
  :class:`ProxyHealthError` with a redacted, actionable message on any
162
168
  failure — bad host/port, refused TCP, HTTP 407 (wrong credentials), or an
163
169
  unparseable response. The browser never starts on failure.
170
+
171
+ For HTTP/HTTPS proxies the primary probe goes to ipapi.is (HTTPS via
172
+ CONNECT). If the CONNECT path times out or fails — common with
173
+ residential/mobile gateways — an automatic fallback probe hits a plain-HTTP
174
+ endpoint (ip-api.com) that only requires an absolute-form GET, no CONNECT.
175
+ Auth failures (407) are never retried via fallback.
164
176
  """
165
177
  timeout = max(1.0, float(timeout_seconds))
166
178
  if proxy.is_socks:
167
179
  await _check_tcp(proxy, timeout=timeout)
168
180
  return {}
169
- return await _http_egress_probe(proxy, timeout=timeout)
181
+
182
+ try:
183
+ return await _http_egress_probe(proxy, timeout=timeout)
184
+ except ProxyHealthError as primary_exc:
185
+ if _is_auth_failure(primary_exc):
186
+ raise
187
+ logger.info(
188
+ "Primary probe (HTTPS/CONNECT) failed for %s: %s — "
189
+ "retrying with plain-HTTP fallback.",
190
+ proxy.redacted(),
191
+ primary_exc,
192
+ )
193
+ try:
194
+ return await _http_egress_probe_plaintext(proxy, timeout=timeout)
195
+ except ProxyHealthError:
196
+ raise primary_exc from None
197
+
198
+
199
+ def _is_auth_failure(exc: ProxyHealthError) -> bool:
200
+ """True when the error clearly indicates bad credentials (407)."""
201
+ return "407" in str(exc)
170
202
 
171
203
 
172
204
  async def _check_tcp(proxy: ProxyConfig, *, timeout: float) -> None:
@@ -214,23 +246,107 @@ def _proxy_url_for_urllib(proxy: ProxyConfig) -> str:
214
246
  return f"http://{netloc}"
215
247
 
216
248
 
249
+ class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
250
+ """Prevent urllib from following redirects (avoids CONNECT tunneling)."""
251
+
252
+ def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ARG002
253
+ return None
254
+
255
+
256
+ def _normalize_ip_api_response(data: dict[str, Any]) -> dict[str, Any]:
257
+ """Map an ip-api.com response to the ipapi.is schema."""
258
+ return {
259
+ "ip": data.get("query") or data.get("ip", ""),
260
+ "location": {
261
+ "country": data.get("country"),
262
+ "country_code": data.get("countryCode") or data.get("country_code"),
263
+ "city": data.get("city"),
264
+ "timezone": data.get("timezone"),
265
+ "latitude": data.get("lat") or data.get("latitude"),
266
+ "longitude": data.get("lon") or data.get("longitude"),
267
+ },
268
+ }
269
+
270
+
271
+ def _parse_egress_response(
272
+ proxy: ProxyConfig,
273
+ status: int,
274
+ body: bytes,
275
+ *,
276
+ schema: str = "ipapi",
277
+ ) -> dict[str, Any]:
278
+ """Validate and parse a successful egress probe response.
279
+
280
+ ``schema`` selects the normalisation: ``"ipapi"`` (api.ipapi.is, the
281
+ default) passes through as-is; ``"ip-api"`` maps ``http://ip-api.com``
282
+ fields to the canonical schema.
283
+ """
284
+ if status == 407:
285
+ raise ProxyHealthError(
286
+ f"Proxy {proxy.redacted()} rejected the supplied credentials "
287
+ "(HTTP 407 Proxy Authentication Required). Double-check username "
288
+ "and password."
289
+ )
290
+ if not (200 <= status < 300):
291
+ raise ProxyHealthError(
292
+ f"Proxy {proxy.redacted()} returned HTTP {status} for the egress "
293
+ f"probe."
294
+ )
295
+ if not body:
296
+ raise ProxyHealthError(
297
+ f"Proxy {proxy.redacted()} returned a 2xx response with an empty "
298
+ "body — egress probe could not read identity."
299
+ )
300
+
301
+ text = body.decode("utf-8", errors="replace").strip()
302
+ try:
303
+ data = json.loads(text)
304
+ except json.JSONDecodeError as exc:
305
+ snippet = text[:200].replace("\n", " ")
306
+ raise ProxyHealthError(
307
+ f"Proxy {proxy.redacted()} returned non-JSON for the egress probe "
308
+ f"(first 200 chars: {snippet!r})."
309
+ ) from exc
310
+
311
+ if not isinstance(data, dict):
312
+ raise ProxyHealthError(
313
+ f"Proxy {proxy.redacted()} returned a JSON value that is not an "
314
+ "object."
315
+ )
316
+
317
+ if schema == "ip-api":
318
+ data = _normalize_ip_api_response(data)
319
+
320
+ ip_field = data.get("ip")
321
+ if not (isinstance(ip_field, str) and ip_field.strip()):
322
+ raise ProxyHealthError(
323
+ f"Proxy {proxy.redacted()} responded but the egress probe could "
324
+ "not determine an exit IP (response did not match the expected "
325
+ "schema)."
326
+ )
327
+
328
+ return data
329
+
330
+
217
331
  async def _http_egress_probe(proxy: ProxyConfig, *, timeout: float) -> dict[str, Any]:
218
- """Drive a GET to ipapi.is through the proxy and return the egress JSON.
332
+ """Drive a GET to an IP-info service through the proxy and return egress JSON.
219
333
 
220
- Implementation lives in a sync helper so we can lean on urllib's proxy
221
- support (absolute-form GET for HTTP targets, CONNECT + TLS for HTTPS
222
- targets, transparent 301 follow). The helper runs in a worker thread; the
223
- outer ``wait_for`` keeps the async timeout authoritative.
334
+ Tries ``api.ipapi.is`` first (with redirects disabled to avoid CONNECT
335
+ tunneling through proxies that only support absolute-form HTTP). Falls back
336
+ to ``ip-api.com`` (plain HTTP, no redirect) when the primary times out or
337
+ fails a common scenario with residential/mobile HTTP proxies.
224
338
  """
225
339
  proxy_url = _proxy_url_for_urllib(proxy)
226
340
 
227
- def _fetch() -> tuple[int, bytes]:
228
- handler = urllib.request.ProxyHandler({"http": proxy_url, "https": proxy_url})
229
- opener = urllib.request.build_opener(handler)
341
+ def _fetch(url: str, *, follow_redirects: bool = False) -> tuple[int, bytes]:
342
+ handlers: list = [urllib.request.ProxyHandler({"http": proxy_url, "https": proxy_url})]
343
+ if not follow_redirects:
344
+ handlers.append(_NoRedirectHandler)
345
+ opener = urllib.request.build_opener(*handlers)
230
346
  request = urllib.request.Request(
231
- _PROBE_TARGET_URL,
347
+ url,
232
348
  headers={
233
- "User-Agent": "mithwire-mcp/proxy-probe",
349
+ "User-Agent": "mithwire/proxy-probe",
234
350
  "Accept": "application/json, */*",
235
351
  "Connection": "close",
236
352
  },
@@ -240,29 +356,50 @@ async def _http_egress_probe(proxy: ProxyConfig, *, timeout: float) -> dict[str,
240
356
  with opener.open(request, timeout=timeout) as response:
241
357
  return int(response.status), response.read()
242
358
  except urllib.error.HTTPError as exc:
243
- # Capture the body too — proxies often explain a 4xx in HTML.
244
359
  try:
245
360
  body = exc.read()
246
361
  except Exception: # noqa: BLE001
247
362
  body = b""
248
363
  return int(exc.code), body
249
364
 
365
+ primary_exc: Exception | None = None
366
+
367
+ # --- Attempt 1: api.ipapi.is (redirects disabled) ---
250
368
  try:
251
369
  status, body = await asyncio.wait_for(
252
- asyncio.to_thread(_fetch),
253
- timeout=timeout + 5.0,
370
+ asyncio.to_thread(_fetch, _PROBE_TARGET_URL, follow_redirects=False),
371
+ timeout=timeout + 2.0,
254
372
  )
373
+ if 300 <= status < 400:
374
+ logger.debug(
375
+ "Primary probe returned %d redirect — falling back to ip-api.com",
376
+ status,
377
+ )
378
+ raise ProxyHealthError("primary probe redirected")
379
+ return _parse_egress_response(proxy, status, body)
380
+ except (ProxyHealthError, asyncio.TimeoutError, urllib.error.URLError,
381
+ ConnectionError, OSError) as exc:
382
+ primary_exc = exc
383
+ logger.debug("Primary egress probe failed (%s), trying fallback", exc)
384
+
385
+ # --- Attempt 2: ip-api.com (plain HTTP, no redirect) ---
386
+ try:
387
+ status, body = await asyncio.wait_for(
388
+ asyncio.to_thread(_fetch, _FALLBACK_PROBE_URL, follow_redirects=True),
389
+ timeout=timeout + 2.0,
390
+ )
391
+ return _parse_egress_response(proxy, status, body, schema="ip-api")
255
392
  except asyncio.TimeoutError as exc:
256
393
  raise ProxyHealthError(
257
394
  f"Proxy {proxy.redacted()} did not complete the egress probe "
258
- f"within {timeout:.1f}s."
395
+ f"within {timeout:.1f}s (both primary and fallback timed out)."
259
396
  ) from exc
260
397
  except urllib.error.URLError as exc:
261
398
  reason = exc.reason
262
399
  if isinstance(reason, socket.timeout):
263
400
  raise ProxyHealthError(
264
401
  f"Proxy {proxy.redacted()} timed out during the egress probe "
265
- f"(socket timeout)."
402
+ f"(socket timeout on fallback)."
266
403
  ) from exc
267
404
  raise ProxyHealthError(
268
405
  f"Could not reach proxy {proxy.redacted()}: {reason}"
@@ -272,50 +409,101 @@ async def _http_egress_probe(proxy: ProxyConfig, *, timeout: float) -> dict[str,
272
409
  f"Proxy {proxy.redacted()} failed mid-probe: {exc}"
273
410
  ) from exc
274
411
 
275
- if status == 407:
276
- raise ProxyHealthError(
277
- f"Proxy {proxy.redacted()} rejected the supplied credentials "
278
- "(HTTP 407 Proxy Authentication Required). Double-check username "
279
- "and password."
280
- )
281
- if not (200 <= status < 300):
282
- raise ProxyHealthError(
283
- f"Proxy {proxy.redacted()} returned HTTP {status} for the egress "
284
- f"probe."
285
- )
286
412
 
287
- if not body:
288
- raise ProxyHealthError(
289
- f"Proxy {proxy.redacted()} returned a 2xx response with an empty "
290
- "body egress probe could not read identity."
413
+ async def _http_egress_probe_plaintext(
414
+ proxy: ProxyConfig, *, timeout: float
415
+ ) -> dict[str, Any]:
416
+ """Last-resort egress probe using raw sockets over plain HTTP.
417
+
418
+ When even urllib's ip-api.com fallback hangs (residential proxies that
419
+ keep connections alive regardless of ``Connection: close``), this probe
420
+ bypasses urllib entirely. It opens a raw TCP socket to the proxy, sends an
421
+ absolute-form ``GET`` with ``Proxy-Connection: Keep-Alive``, and reads
422
+ exactly ``Content-Length`` bytes — avoiding the EOF-wait hang.
423
+ """
424
+ import base64
425
+
426
+ def _fetch() -> tuple[int, bytes]:
427
+ auth_header = ""
428
+ if proxy.has_auth:
429
+ token = base64.b64encode(
430
+ f"{proxy.username}:{proxy.password}".encode()
431
+ ).decode("ascii")
432
+ auth_header = f"Proxy-Authorization: Basic {token}\r\n"
433
+
434
+ request = (
435
+ f"GET {_FALLBACK_PROBE_URL} HTTP/1.1\r\n"
436
+ f"Host: ip-api.com\r\n"
437
+ f"{auth_header}"
438
+ "User-Agent: mithwire-mcp/proxy-probe\r\n"
439
+ "Accept: application/json\r\n"
440
+ "Proxy-Connection: Keep-Alive\r\n"
441
+ "\r\n"
291
442
  )
292
443
 
293
- text = body.decode("utf-8", errors="replace").strip()
444
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
445
+ sock.settimeout(timeout)
446
+ try:
447
+ sock.connect((proxy.host, proxy.port))
448
+ sock.sendall(request.encode("ascii"))
449
+
450
+ buf = b""
451
+ while b"\r\n\r\n" not in buf:
452
+ chunk = sock.recv(4096)
453
+ if not chunk:
454
+ raise ProxyHealthError(
455
+ f"Proxy {proxy.redacted()} closed connection before "
456
+ "sending response headers (raw-socket probe)."
457
+ )
458
+ buf += chunk
459
+
460
+ header_end = buf.index(b"\r\n\r\n")
461
+ headers_raw = buf[:header_end].decode("ascii", errors="replace")
462
+ body_so_far = buf[header_end + 4 :]
463
+
464
+ status_code = 0
465
+ content_length = -1
466
+ for line in headers_raw.split("\r\n"):
467
+ if line.startswith("HTTP/"):
468
+ status_code = int(line.split()[1])
469
+ if line.lower().startswith("content-length:"):
470
+ content_length = int(line.split(":", 1)[1].strip())
471
+
472
+ if content_length >= 0:
473
+ while len(body_so_far) < content_length:
474
+ chunk = sock.recv(4096)
475
+ if not chunk:
476
+ break
477
+ body_so_far += chunk
478
+ else:
479
+ while True:
480
+ chunk = sock.recv(4096)
481
+ if not chunk:
482
+ break
483
+ body_so_far += chunk
484
+
485
+ return status_code, body_so_far
486
+ finally:
487
+ sock.close()
488
+
294
489
  try:
295
- data = json.loads(text)
296
- except json.JSONDecodeError as exc:
297
- snippet = text[:200].replace("\n", " ")
490
+ status, body = await asyncio.wait_for(
491
+ asyncio.to_thread(_fetch),
492
+ timeout=timeout + 5.0,
493
+ )
494
+ except asyncio.TimeoutError as exc:
298
495
  raise ProxyHealthError(
299
- f"Proxy {proxy.redacted()} returned non-JSON for the egress probe "
300
- f"(first 200 chars: {snippet!r})."
496
+ f"Proxy {proxy.redacted()} did not complete the raw-socket egress "
497
+ f"probe within {timeout:.1f}s."
301
498
  ) from exc
302
-
303
- if not isinstance(data, dict):
304
- raise ProxyHealthError(
305
- f"Proxy {proxy.redacted()} returned a JSON value that is not an "
306
- "object."
307
- )
308
-
309
- if not (isinstance(data.get("ip"), str) and data["ip"].strip()):
310
- # ipapi.is always includes the resolved ip on success; a missing/blank
311
- # one means we either hit a captive portal or got a different service.
499
+ except ProxyHealthError:
500
+ raise
501
+ except (ConnectionError, OSError) as exc:
312
502
  raise ProxyHealthError(
313
- f"Proxy {proxy.redacted()} responded but the egress probe could "
314
- "not determine an exit IP (response did not match the expected "
315
- "ipapi.is schema)."
316
- )
503
+ f"Proxy {proxy.redacted()} failed during raw-socket probe: {exc}"
504
+ ) from exc
317
505
 
318
- return data
506
+ return _parse_egress_response(proxy, status, body, schema="ip-api")
319
507
 
320
508
 
321
509
  def egress_summary(data: dict[str, Any] | None) -> dict[str, Any] | None:
@@ -837,7 +837,12 @@ class Stealth:
837
837
  # height; in headed (Xvfb) mode the real window geometry does
838
838
  # this naturally, so we just set screen dims + DPR.
839
839
  chrome_h = 85 + int(dpr * 10) # 85-100 px, realistic range
840
- vw = sw
840
+ # In headed mode the real OS window drives the layout viewport;
841
+ # width/height=0 disables the CDP override so innerWidth/Height
842
+ # follow the physical window while screen_width/screen_height
843
+ # and device_scale_factor still get spoofed. In headless there
844
+ # is no real window, so we must set explicit dimensions.
845
+ vw = sw if self.headless else 0
841
846
  vh = max(sh - chrome_h, sh // 2) if self.headless else 0
842
847
  await self.tab.send(
843
848
  emu.set_device_metrics_override(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mithwire
3
- Version: 0.51.2
3
+ Version: 0.51.4
4
4
  Summary: Dual-mode anti-detect browser framework — CDP-native stealth for local machines, binary-level patching for production servers. No WebDriver, no chromedriver.
5
5
  Author: Elendil Technologies
6
6
  Maintainer: Elendil Technologies
@@ -2,7 +2,7 @@
2
2
 
3
3
  name = "mithwire" # Required
4
4
 
5
- version = "0.51.2" # Required
5
+ version = "0.51.4" # Required
6
6
 
7
7
  description = "Dual-mode anti-detect browser framework — CDP-native stealth for local machines, binary-level patching for production servers. No WebDriver, no chromedriver."
8
8
  readme = {file = "README.md", content-type = "text/markdown"} # Optional