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.
- {mithwire-0.51.2/mithwire.egg-info → mithwire-0.51.4}/PKG-INFO +1 -1
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/__init__.py +1 -1
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/proxy/health.py +246 -58
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/stealth/controller.py +6 -1
- {mithwire-0.51.2 → mithwire-0.51.4/mithwire.egg-info}/PKG-INFO +1 -1
- {mithwire-0.51.2 → mithwire-0.51.4}/pyproject.toml +1 -1
- mithwire-0.51.4/tests/test_proxy.py +348 -0
- mithwire-0.51.2/tests/test_proxy.py +0 -167
- {mithwire-0.51.2 → mithwire-0.51.4}/LICENSE.txt +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/MANIFEST.in +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/NOTICE +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/README.md +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/__main__.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/README.md +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/__init__.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/accessibility.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/animation.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/audits.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/autofill.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/background_service.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/bluetooth_emulation.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/browser.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/cache_storage.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/cast.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/console.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/crash_report_context.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/css.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/database.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/debugger.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/device_access.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/device_orientation.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/dom.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/dom_debugger.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/dom_snapshot.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/dom_storage.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/emulation.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/event_breakpoints.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/extensions.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/fed_cm.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/fetch.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/file_system.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/headless_experimental.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/heap_profiler.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/indexed_db.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/input_.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/inspector.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/io.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/layer_tree.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/log.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/media.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/memory.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/network.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/overlay.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/page.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/performance.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/performance_timeline.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/preload.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/profiler.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/pwa.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/py.typed +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/runtime.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/schema.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/security.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/service_worker.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/smart_card_emulation.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/storage.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/system_info.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/target.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/tethering.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/tracing.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/util.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/web_audio.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/web_authn.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/cdp/web_mcp.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/core/_contradict.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/core/browser.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/core/cf_templates/cf_dark_checkbox.png +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/core/cf_templates/cf_light_checkbox.png +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/core/config.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/core/connection.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/core/element.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/core/tab.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/core/util.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/core/virtual_display.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/fingerprint_gen.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/proxy/__init__.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/proxy/config.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/proxy/relay.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/stealth/__init__.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/stealth/cloakbrowser.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/stealth/fingerprint.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/stealth_diagnostic/__init__.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/stealth_diagnostic/probes.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/stealth_diagnostic/report.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire/stealth_diagnostic/runner.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire.egg-info/SOURCES.txt +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire.egg-info/dependency_links.txt +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire.egg-info/entry_points.txt +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire.egg-info/requires.txt +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/mithwire.egg-info/top_level.txt +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/setup.cfg +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/tests/test_compute_launch_args.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/tests/test_connect_retry.py +0 -0
- {mithwire-0.51.2 → mithwire-0.51.4}/tests/test_stealth_diagnostic_cli.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
#
|
|
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
|
-
|
|
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
|
|
332
|
+
"""Drive a GET to an IP-info service through the proxy and return egress JSON.
|
|
219
333
|
|
|
220
|
-
|
|
221
|
-
support
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
229
|
-
|
|
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
|
-
|
|
347
|
+
url,
|
|
232
348
|
headers={
|
|
233
|
-
"User-Agent": "mithwire
|
|
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 +
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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()}
|
|
300
|
-
f"
|
|
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
|
-
|
|
304
|
-
|
|
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()}
|
|
314
|
-
|
|
315
|
-
"ipapi.is schema)."
|
|
316
|
-
)
|
|
503
|
+
f"Proxy {proxy.redacted()} failed during raw-socket probe: {exc}"
|
|
504
|
+
) from exc
|
|
317
505
|
|
|
318
|
-
return
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|