mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
feat: native SOCKS5 proxy support in proxy= parameter
Route SOCKS5/SOCKS5h proxies via --proxy-server Chrome arg instead of Playwright's proxy dict (which rejects SOCKS5 with credentials). Handles string URLs, Playwright dicts, IPv6, bypass lists. SOCKS5 geoip exit IP resolution uses socks-proxy-agent (optional peer dep). Falls back to DNS if not installed.
This commit is contained in:
@@ -242,8 +242,9 @@ browser = launch()
|
||||
# Headed mode (see the browser window)
|
||||
browser = launch(headless=False)
|
||||
|
||||
# With proxy
|
||||
# With proxy (HTTP or SOCKS5)
|
||||
browser = launch(proxy="http://user:pass@proxy:8080")
|
||||
browser = launch(proxy="socks5://user:pass@proxy:1080")
|
||||
|
||||
# With proxy dict (bypass, separate auth fields)
|
||||
browser = launch(proxy={"server": "http://proxy:8080", "bypass": ".google.com", "username": "user", "password": "pass"})
|
||||
@@ -1028,7 +1029,7 @@ A: Camoufox patches Firefox. We patch Chromium. Chromium means native Playwright
|
||||
A: Possibly. Bot detection is an arms race. Source-level patches are harder to detect than config-level patches, but not impossible. We actively monitor and update when detection evolves.
|
||||
|
||||
**Q: Can I use my own proxy?**
|
||||
A: Yes. Pass `proxy="http://user:pass@host:port"` to `launch()`.
|
||||
A: Yes. Pass `proxy="http://user:pass@host:port"` or `proxy="socks5://user:pass@host:port"` to `launch()`. Both HTTP and SOCKS5 proxies are supported natively.
|
||||
|
||||
## Roadmap
|
||||
|
||||
|
||||
+85
-21
@@ -17,7 +17,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Literal, TypedDict
|
||||
from urllib.parse import unquote, urlparse, urlunparse
|
||||
from urllib.parse import quote, unquote, urlparse, urlunparse
|
||||
|
||||
from .config import DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS, get_default_stealth_args
|
||||
from .download import ensure_binary
|
||||
@@ -105,11 +105,12 @@ def launch(
|
||||
|
||||
binary_path = ensure_binary()
|
||||
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy)
|
||||
args = _resolve_webrtc_args(args, proxy)
|
||||
if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)):
|
||||
args = list(args or [])
|
||||
args.append(f"--fingerprint-webrtc-ip={exit_ip}")
|
||||
chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
|
||||
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless)
|
||||
|
||||
logger.debug("Launching stealth Chromium (headless=%s, args=%d)", headless, len(chrome_args))
|
||||
|
||||
@@ -119,7 +120,7 @@ def launch(
|
||||
headless=headless,
|
||||
args=chrome_args,
|
||||
ignore_default_args=IGNORE_DEFAULT_ARGS,
|
||||
**_build_proxy_kwargs(proxy),
|
||||
**proxy_kwargs,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@@ -194,11 +195,12 @@ async def launch_async( # noqa: C901
|
||||
|
||||
binary_path = ensure_binary()
|
||||
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy)
|
||||
args = _resolve_webrtc_args(args, proxy)
|
||||
if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)):
|
||||
args = list(args or [])
|
||||
args.append(f"--fingerprint-webrtc-ip={exit_ip}")
|
||||
chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
|
||||
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless)
|
||||
|
||||
logger.debug("Launching stealth Chromium async (headless=%s, args=%d)", headless, len(chrome_args))
|
||||
|
||||
@@ -208,7 +210,7 @@ async def launch_async( # noqa: C901
|
||||
headless=headless,
|
||||
args=chrome_args,
|
||||
ignore_default_args=IGNORE_DEFAULT_ARGS,
|
||||
**_build_proxy_kwargs(proxy),
|
||||
**proxy_kwargs,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@@ -297,11 +299,12 @@ def launch_persistent_context(
|
||||
|
||||
binary_path = ensure_binary()
|
||||
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy)
|
||||
args = _resolve_webrtc_args(args, proxy)
|
||||
if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)):
|
||||
args = list(args or [])
|
||||
args.append(f"--fingerprint-webrtc-ip={exit_ip}")
|
||||
chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
|
||||
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless)
|
||||
|
||||
logger.debug(
|
||||
"Launching persistent stealth Chromium (headless=%s, user_data_dir=%s)",
|
||||
@@ -331,7 +334,7 @@ def launch_persistent_context(
|
||||
headless=headless,
|
||||
args=chrome_args,
|
||||
ignore_default_args=IGNORE_DEFAULT_ARGS,
|
||||
**_build_proxy_kwargs(proxy),
|
||||
**proxy_kwargs,
|
||||
**context_kwargs,
|
||||
)
|
||||
|
||||
@@ -422,11 +425,12 @@ async def launch_persistent_context_async(
|
||||
|
||||
binary_path = ensure_binary()
|
||||
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy)
|
||||
args = _resolve_webrtc_args(args, proxy)
|
||||
if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)):
|
||||
args = list(args or [])
|
||||
args.append(f"--fingerprint-webrtc-ip={exit_ip}")
|
||||
chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
|
||||
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless)
|
||||
|
||||
logger.debug(
|
||||
"Launching persistent stealth Chromium async (headless=%s, user_data_dir=%s)",
|
||||
@@ -456,7 +460,7 @@ async def launch_persistent_context_async(
|
||||
headless=headless,
|
||||
args=chrome_args,
|
||||
ignore_default_args=IGNORE_DEFAULT_ARGS,
|
||||
**_build_proxy_kwargs(proxy),
|
||||
**proxy_kwargs,
|
||||
**context_kwargs,
|
||||
)
|
||||
|
||||
@@ -631,14 +635,42 @@ def _ensure_proxy_scheme(proxy_url: str) -> str:
|
||||
return proxy_url if "://" in proxy_url else f"http://{proxy_url}"
|
||||
|
||||
|
||||
def _reconstruct_socks_url(proxy: ProxySettings) -> str:
|
||||
"""Reconstruct a SOCKS5 URL with inline credentials from a Playwright proxy dict."""
|
||||
server = proxy.get("server", "")
|
||||
username = proxy.get("username", "")
|
||||
password = proxy.get("password", "")
|
||||
if not username:
|
||||
return server
|
||||
parsed = urlparse(server)
|
||||
creds = quote(username, safe="")
|
||||
if password:
|
||||
creds += f":{quote(password, safe='')}"
|
||||
host = parsed.hostname or ""
|
||||
if ":" in host: # IPv6 literal — re-add brackets
|
||||
host = f"[{host}]"
|
||||
netloc = f"{creds}@{host}"
|
||||
if parsed.port:
|
||||
netloc += f":{parsed.port}"
|
||||
return urlunparse((parsed.scheme, netloc, parsed.path, "", "", ""))
|
||||
|
||||
|
||||
def _extract_proxy_url(proxy: str | ProxySettings | None) -> str | None:
|
||||
"""Extract and normalize proxy URL string from proxy param."""
|
||||
"""Extract and normalize proxy URL string from proxy param.
|
||||
|
||||
For SOCKS5 dicts with separate username/password fields, reconstructs
|
||||
the full URL with inline credentials so SOCKS5 auth works.
|
||||
"""
|
||||
if proxy is None:
|
||||
return None
|
||||
raw = proxy.get("server") if isinstance(proxy, dict) else proxy
|
||||
if not raw:
|
||||
return None
|
||||
return _ensure_proxy_scheme(raw)
|
||||
if isinstance(proxy, dict):
|
||||
server = proxy.get("server", "")
|
||||
if not server:
|
||||
return None
|
||||
if _is_socks_proxy(proxy):
|
||||
return _reconstruct_socks_url(proxy)
|
||||
return _ensure_proxy_scheme(server)
|
||||
return _ensure_proxy_scheme(proxy)
|
||||
|
||||
|
||||
def maybe_resolve_geoip(
|
||||
@@ -694,7 +726,7 @@ def _resolve_webrtc_args(
|
||||
return args
|
||||
proxy_url = _extract_proxy_url(proxy)
|
||||
if not proxy_url:
|
||||
logger.debug("--fingerprint-webrtc-ip=auto but no proxy set — removing flag")
|
||||
logger.warning("--fingerprint-webrtc-ip=auto requires a proxy; removing flag")
|
||||
args = list(args)
|
||||
del args[idx]
|
||||
return args
|
||||
@@ -702,7 +734,7 @@ def _resolve_webrtc_args(
|
||||
from .geoip import _resolve_exit_ip
|
||||
exit_ip = _resolve_exit_ip(proxy_url)
|
||||
except Exception:
|
||||
logger.debug("WebRTC IP resolution failed — removing flag")
|
||||
logger.warning("Failed to resolve proxy exit IP for WebRTC spoofing; removing --fingerprint-webrtc-ip=auto")
|
||||
args = list(args)
|
||||
del args[idx]
|
||||
return args
|
||||
@@ -710,6 +742,7 @@ def _resolve_webrtc_args(
|
||||
args = list(args)
|
||||
args[idx] = f"--fingerprint-webrtc-ip={exit_ip}"
|
||||
else:
|
||||
logger.warning("Could not resolve proxy exit IP for WebRTC spoofing; removing --fingerprint-webrtc-ip=auto")
|
||||
args = list(args)
|
||||
del args[idx]
|
||||
return args
|
||||
@@ -799,10 +832,41 @@ def _parse_proxy_url(proxy: str) -> dict[str, Any]:
|
||||
return result
|
||||
|
||||
|
||||
def _build_proxy_kwargs(proxy: str | ProxySettings | None) -> dict[str, Any]:
|
||||
"""Build proxy kwargs for Playwright launch."""
|
||||
def _is_socks_proxy(proxy: str | ProxySettings | None) -> bool:
|
||||
"""Check if the proxy uses SOCKS5 protocol."""
|
||||
if proxy is None:
|
||||
return {}
|
||||
return False
|
||||
url = proxy.get("server", "") if isinstance(proxy, dict) else proxy
|
||||
return url.lower().startswith(("socks5://", "socks5h://"))
|
||||
|
||||
|
||||
def _resolve_proxy_config(
|
||||
proxy: str | ProxySettings | None,
|
||||
) -> tuple[dict[str, Any], list[str]]:
|
||||
"""Resolve proxy into Playwright kwargs and Chrome args.
|
||||
|
||||
Playwright rejects SOCKS5 proxies with credentials in its proxy dict,
|
||||
so SOCKS5 is passed via --proxy-server Chrome arg instead.
|
||||
|
||||
Returns:
|
||||
(proxy_kwargs, extra_chrome_args) — one or both will be empty.
|
||||
"""
|
||||
if proxy is None:
|
||||
return {}, []
|
||||
|
||||
if _is_socks_proxy(proxy):
|
||||
# SOCKS5: bypass Playwright, pass directly to Chrome via --proxy-server.
|
||||
# Chrome handles SOCKS5 auth natively from the URL.
|
||||
if isinstance(proxy, dict):
|
||||
url = _reconstruct_socks_url(proxy)
|
||||
extra_args = [f"--proxy-server={url}"]
|
||||
if proxy.get("bypass"):
|
||||
extra_args.append(f"--proxy-bypass-list={proxy['bypass']}")
|
||||
return {}, extra_args
|
||||
# String URL — pass as-is (Chrome handles user:pass@ in the URL)
|
||||
return {}, [f"--proxy-server={proxy}"]
|
||||
|
||||
# HTTP/HTTPS: use Playwright's proxy dict as before
|
||||
if isinstance(proxy, dict):
|
||||
return {"proxy": proxy}
|
||||
return {"proxy": _parse_proxy_url(proxy)}
|
||||
return {"proxy": proxy}, []
|
||||
return {"proxy": _parse_proxy_url(proxy)}, []
|
||||
|
||||
@@ -96,7 +96,7 @@ def resolve_proxy_geo_with_ip(
|
||||
)
|
||||
return timezone, locale, ip
|
||||
except Exception as exc:
|
||||
logger.debug("GeoIP lookup failed for %s: %s", ip, exc)
|
||||
logger.warning("GeoIP lookup failed for %s: %s", ip, exc)
|
||||
return None, None, ip
|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@ def _resolve_proxy_ip(proxy_url: str) -> str | None:
|
||||
return ip
|
||||
return None
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to resolve proxy hostname: %s", exc)
|
||||
logger.warning("Failed to resolve proxy hostname: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
@@ -165,9 +165,14 @@ def _resolve_exit_ip(proxy_url: str) -> str | None:
|
||||
ipaddress.ip_address(ip)
|
||||
logger.debug("Exit IP via %s: %s", url, ip)
|
||||
return ip
|
||||
except httpx.UnsupportedProtocol:
|
||||
logger.warning(
|
||||
"SOCKS5 proxy requires socksio: pip install cloakbrowser[geoip]"
|
||||
)
|
||||
return None
|
||||
except Exception:
|
||||
continue
|
||||
logger.debug("Failed to discover exit IP through proxy")
|
||||
logger.warning("Failed to discover exit IP through proxy")
|
||||
return None
|
||||
|
||||
|
||||
|
||||
+5
-2
@@ -63,10 +63,13 @@ await browser.close();
|
||||
```javascript
|
||||
import { launch, launchContext, launchPersistentContext } from 'cloakbrowser';
|
||||
|
||||
// With proxy
|
||||
// With proxy (HTTP or SOCKS5)
|
||||
const browser = await launch({
|
||||
proxy: 'http://user:pass@proxy:8080',
|
||||
});
|
||||
const browser = await launch({
|
||||
proxy: 'socks5://user:pass@proxy:1080',
|
||||
});
|
||||
|
||||
// With proxy object (bypass, separate auth fields)
|
||||
const browser = await launch({
|
||||
@@ -211,7 +214,7 @@ const page = await browser.newPage();
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js >= 18
|
||||
- Node.js >= 20
|
||||
- One of: `playwright-core` >= 1.40 or `puppeteer-core` >= 21
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
Generated
+57
-9
@@ -1,31 +1,36 @@
|
||||
{
|
||||
"name": "cloakbrowser",
|
||||
"version": "0.3.9",
|
||||
"version": "0.3.23",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cloakbrowser",
|
||||
"version": "0.3.9",
|
||||
"version": "0.3.23",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tar": "^7.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"cloakbrowser": "dist/cli.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.0",
|
||||
"mmdb-lib": "^3.0.2",
|
||||
"playwright-core": "^1.40.0",
|
||||
"puppeteer-core": "^21.0.0",
|
||||
"socks-proxy-agent": "^10.0.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vitest": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"mmdb-lib": ">=2.0.0",
|
||||
"playwright-core": ">=1.40.0",
|
||||
"puppeteer-core": ">=21.0.0"
|
||||
"puppeteer-core": ">=21.0.0",
|
||||
"socks-proxy-agent": ">=8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"mmdb-lib": {
|
||||
@@ -36,6 +41,9 @@
|
||||
},
|
||||
"puppeteer-core": {
|
||||
"optional": true
|
||||
},
|
||||
"socks-proxy-agent": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2003,6 +2011,21 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/pac-proxy-agent/node_modules/socks-proxy-agent": {
|
||||
"version": "8.0.5",
|
||||
"resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz",
|
||||
"integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "^4.3.4",
|
||||
"socks": "^2.8.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/pac-resolver": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz",
|
||||
@@ -2164,6 +2187,21 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-agent/node_modules/socks-proxy-agent": {
|
||||
"version": "8.0.5",
|
||||
"resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz",
|
||||
"integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "^4.3.4",
|
||||
"socks": "^2.8.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
@@ -2332,18 +2370,28 @@
|
||||
}
|
||||
},
|
||||
"node_modules/socks-proxy-agent": {
|
||||
"version": "8.0.5",
|
||||
"resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz",
|
||||
"integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==",
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-10.0.0.tgz",
|
||||
"integrity": "sha512-pyp2YR3mNxAMu0mGLtzs4g7O3uT4/9sQOLAKcViAkaS9fJWkud7nmaf6ZREFqQEi24IPkBcjfHjXhPTUWjo3uA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"agent-base": "9.0.0",
|
||||
"debug": "^4.3.4",
|
||||
"socks": "^2.8.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/socks-proxy-agent/node_modules/agent-base": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz",
|
||||
"integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
|
||||
+7
-2
@@ -55,12 +55,13 @@
|
||||
},
|
||||
"homepage": "https://github.com/CloakHQ/cloakbrowser#javascript--nodejs",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"mmdb-lib": ">=2.0.0",
|
||||
"playwright-core": ">=1.40.0",
|
||||
"puppeteer-core": ">=21.0.0"
|
||||
"puppeteer-core": ">=21.0.0",
|
||||
"socks-proxy-agent": ">=10.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"playwright-core": {
|
||||
@@ -71,6 +72,9 @@
|
||||
},
|
||||
"mmdb-lib": {
|
||||
"optional": true
|
||||
},
|
||||
"socks-proxy-agent": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -79,6 +83,7 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.0",
|
||||
"mmdb-lib": "^3.0.2",
|
||||
"socks-proxy-agent": "^10.0.0",
|
||||
"playwright-core": "^1.40.0",
|
||||
"puppeteer-core": "^21.0.0",
|
||||
"typescript": "^5.3.0",
|
||||
|
||||
+60
-8
@@ -15,7 +15,7 @@ import dns from "node:dns/promises";
|
||||
import net from "node:net";
|
||||
import { getCacheDir } from "./config.js";
|
||||
import type { LaunchOptions } from "./types.js";
|
||||
import { ensureProxyScheme } from "./proxy.js";
|
||||
import { ensureProxyScheme, isSocksProxy, reconstructSocksUrl, type ProxyDict } from "./proxy.js";
|
||||
|
||||
// P3TERX mirror of MaxMind GeoLite2-City — no license key needed
|
||||
const GEOIP_DB_URL =
|
||||
@@ -129,9 +129,44 @@ const IP_ECHO_URLS = [
|
||||
];
|
||||
|
||||
async function resolveExitIp(proxyUrl: string): Promise<string | null> {
|
||||
// Node.js fetch doesn't support proxy natively — use a CONNECT tunnel via http
|
||||
// For simplicity, use a direct HTTP request to a plain-text IP echo service
|
||||
// through the proxy using Node's http module
|
||||
const isSocks = isSocksProxy(proxyUrl);
|
||||
|
||||
// SOCKS5: tunnel through the SOCKS5 proxy via socks-proxy-agent
|
||||
if (isSocks) {
|
||||
let SocksProxyAgent: typeof import("socks-proxy-agent").SocksProxyAgent;
|
||||
try {
|
||||
({ SocksProxyAgent } = await import("socks-proxy-agent"));
|
||||
} catch {
|
||||
console.warn("[cloakbrowser] socks-proxy-agent not installed — cannot resolve exit IP through SOCKS5 proxy. Install it: npm install socks-proxy-agent");
|
||||
return null;
|
||||
}
|
||||
const { default: https } = await import("node:https");
|
||||
const agent = new SocksProxyAgent(proxyUrl);
|
||||
|
||||
for (const echoUrl of IP_ECHO_URLS) {
|
||||
try {
|
||||
const ip = await new Promise<string | null>((resolve) => {
|
||||
const req = https.request(echoUrl, { agent, timeout: 10_000 }, (res) => {
|
||||
let data = "";
|
||||
res.on("data", (chunk: Buffer) => (data += chunk.toString()));
|
||||
res.on("end", () => {
|
||||
const ip = data.trim();
|
||||
resolve(net.isIP(ip) ? ip : null);
|
||||
});
|
||||
});
|
||||
req.on("error", () => resolve(null));
|
||||
req.on("timeout", () => { req.destroy(); resolve(null); });
|
||||
req.end();
|
||||
});
|
||||
if (ip) return ip;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// HTTP/HTTPS: use a CONNECT tunnel via http
|
||||
try {
|
||||
const { default: http } = await import("node:http");
|
||||
const { default: https } = await import("node:https");
|
||||
@@ -264,6 +299,22 @@ function maybeTriggerUpdate(dbPath: string): void {
|
||||
downloadGeoipDb(dbPath).catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a usable proxy URL from LaunchOptions.proxy.
|
||||
* For SOCKS5 dicts with separate credentials, reconstructs the full URL
|
||||
* with inline credentials so SOCKS5 auth works.
|
||||
*/
|
||||
function extractProxyUrl(proxy: string | ProxyDict | undefined): string | null {
|
||||
if (!proxy) return null;
|
||||
if (typeof proxy === "string") return ensureProxyScheme(proxy);
|
||||
const p = proxy as ProxyDict;
|
||||
if (!p.server) return null;
|
||||
if (p.username && isSocksProxy(p)) {
|
||||
return reconstructSocksUrl(p);
|
||||
}
|
||||
return ensureProxyScheme(p.server);
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-fill timezone/locale from proxy IP when geoip is enabled.
|
||||
* Also returns exitIp as a free bonus (reused for WebRTC spoofing).
|
||||
@@ -273,9 +324,8 @@ export async function maybeResolveGeoip(
|
||||
): Promise<{ timezone?: string; locale?: string; exitIp?: string }> {
|
||||
if (!options.geoip || !options.proxy) return { timezone: options.timezone, locale: options.locale };
|
||||
|
||||
let proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy.server;
|
||||
const proxyUrl = extractProxyUrl(options.proxy);
|
||||
if (!proxyUrl) return { timezone: options.timezone, locale: options.locale };
|
||||
proxyUrl = ensureProxyScheme(proxyUrl);
|
||||
|
||||
// When both tz/locale are explicit, still resolve exit IP for WebRTC
|
||||
if (options.timezone && options.locale) {
|
||||
@@ -304,13 +354,13 @@ export async function resolveWebrtcArgs(
|
||||
const idx = args.findIndex(a => a === "--fingerprint-webrtc-ip=auto");
|
||||
if (idx === -1) return args;
|
||||
|
||||
let proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy?.server;
|
||||
const proxyUrl = extractProxyUrl(options.proxy);
|
||||
if (!proxyUrl) {
|
||||
console.warn("[cloakbrowser] --fingerprint-webrtc-ip=auto requires a proxy; removing flag");
|
||||
const result = [...args];
|
||||
result.splice(idx, 1);
|
||||
return result;
|
||||
}
|
||||
proxyUrl = ensureProxyScheme(proxyUrl);
|
||||
|
||||
try {
|
||||
const ip = await resolveExitIp(proxyUrl);
|
||||
@@ -318,10 +368,12 @@ export async function resolveWebrtcArgs(
|
||||
if (ip) {
|
||||
result[idx] = `--fingerprint-webrtc-ip=${ip}`;
|
||||
} else {
|
||||
console.warn("[cloakbrowser] Could not resolve proxy exit IP for WebRTC spoofing; removing --fingerprint-webrtc-ip=auto");
|
||||
result.splice(idx, 1);
|
||||
}
|
||||
return result;
|
||||
} catch {
|
||||
console.warn("[cloakbrowser] Failed to resolve proxy exit IP for WebRTC spoofing; removing --fingerprint-webrtc-ip=auto");
|
||||
const result = [...args];
|
||||
result.splice(idx, 1);
|
||||
return result;
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOption
|
||||
import { DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS } from "./config.js";
|
||||
import { buildArgs } from "./args.js";
|
||||
import { ensureBinary } from "./download.js";
|
||||
import { parseProxyUrl } from "./proxy.js";
|
||||
import { resolveProxyConfig } from "./proxy.js";
|
||||
import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js";
|
||||
|
||||
/** @internal Accept both timezone and timezoneId — either works, no warning. Exported for testing. */
|
||||
@@ -39,20 +39,19 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
|
||||
|
||||
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
|
||||
const { exitIp, ...resolved } = await maybeResolveGeoip(options);
|
||||
const { proxyOption, proxyArgs } = resolveProxyConfig(options.proxy);
|
||||
let resolvedArgs = await resolveWebrtcArgs(options);
|
||||
if (exitIp && !(resolvedArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) {
|
||||
resolvedArgs = [...(resolvedArgs ?? []), `--fingerprint-webrtc-ip=${exitIp}`];
|
||||
}
|
||||
const args = buildArgs({ ...options, ...resolved, args: resolvedArgs });
|
||||
const args = buildArgs({ ...options, ...resolved, args: [...(resolvedArgs ?? []), ...proxyArgs] });
|
||||
|
||||
const browser = await chromium.launch({
|
||||
executablePath: binaryPath,
|
||||
headless: options.headless ?? true,
|
||||
args,
|
||||
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
|
||||
...(options.proxy
|
||||
? { proxy: typeof options.proxy === "string" ? parseProxyUrl(options.proxy) : options.proxy }
|
||||
: {}),
|
||||
...(proxyOption ? { proxy: proxyOption } : {}),
|
||||
...options.launchOptions,
|
||||
});
|
||||
|
||||
@@ -164,11 +163,12 @@ export async function launchPersistentContext(
|
||||
|
||||
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
|
||||
const { exitIp, ...resolved } = await maybeResolveGeoip(options);
|
||||
const { proxyOption, proxyArgs } = resolveProxyConfig(options.proxy);
|
||||
let resolvedArgs = await resolveWebrtcArgs(options);
|
||||
if (exitIp && !(resolvedArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) {
|
||||
resolvedArgs = [...(resolvedArgs ?? []), `--fingerprint-webrtc-ip=${exitIp}`];
|
||||
}
|
||||
const args = buildArgs({ ...options, ...resolved, args: resolvedArgs });
|
||||
const args = buildArgs({ ...options, ...resolved, args: [...(resolvedArgs ?? []), ...proxyArgs] });
|
||||
|
||||
// locale and timezone are set via binary flags (--lang, --fingerprint-timezone)
|
||||
// — NOT via Playwright context kwargs which use detectable CDP emulation.
|
||||
@@ -177,9 +177,7 @@ export async function launchPersistentContext(
|
||||
headless: options.headless ?? true,
|
||||
args,
|
||||
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
|
||||
...(options.proxy
|
||||
? { proxy: typeof options.proxy === "string" ? parseProxyUrl(options.proxy) : options.proxy }
|
||||
: {}),
|
||||
...(proxyOption ? { proxy: proxyOption } : {}),
|
||||
...(options.userAgent ? { userAgent: options.userAgent } : {}),
|
||||
viewport: options.viewport === undefined ? DEFAULT_VIEWPORT : options.viewport,
|
||||
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
|
||||
|
||||
@@ -23,6 +23,65 @@ export function ensureProxyScheme(proxyUrl: string): string {
|
||||
* Also handles: no credentials, URL-encoded special chars, socks5://, missing port,
|
||||
* and bare proxy strings without a scheme (e.g. "user:pass@host:port" -> treated as http).
|
||||
*/
|
||||
/** Proxy dict shape accepted by Playwright/Puppeteer wrappers. */
|
||||
export type ProxyDict = { server: string; bypass?: string; username?: string; password?: string };
|
||||
|
||||
/** Result of resolveProxyConfig — either Playwright dict OR Chrome arg, never both. */
|
||||
export interface ProxyConfig {
|
||||
/** Playwright proxy option (for HTTP proxies). */
|
||||
proxyOption?: ParsedProxy;
|
||||
/** Chrome CLI args (for SOCKS5 proxies, e.g. ["--proxy-server=socks5://..."]). */
|
||||
proxyArgs: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a proxy uses the SOCKS5 protocol.
|
||||
*/
|
||||
export function isSocksProxy(proxy: string | ProxyDict | undefined | null): boolean {
|
||||
if (!proxy) return false;
|
||||
const url = typeof proxy === "string" ? proxy : proxy.server;
|
||||
return /^socks5h?:\/\//i.test(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct a SOCKS5 URL with inline credentials from a proxy dict.
|
||||
*/
|
||||
export function reconstructSocksUrl(proxy: ProxyDict): string {
|
||||
const url = new URL(proxy.server);
|
||||
if (proxy.username) {
|
||||
url.username = encodeURIComponent(proxy.username);
|
||||
if (proxy.password) url.password = encodeURIComponent(proxy.password);
|
||||
}
|
||||
return url.href.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve proxy into Playwright option and/or Chrome args.
|
||||
*
|
||||
* Playwright rejects SOCKS5 proxies with credentials in its proxy dict,
|
||||
* so SOCKS5 is passed via --proxy-server Chrome arg instead.
|
||||
*/
|
||||
export function resolveProxyConfig(proxy: string | ProxyDict | undefined): ProxyConfig {
|
||||
if (!proxy) return { proxyArgs: [] };
|
||||
|
||||
if (isSocksProxy(proxy)) {
|
||||
// SOCKS5: bypass Playwright, pass directly to Chrome via --proxy-server.
|
||||
if (typeof proxy === "string") {
|
||||
return { proxyArgs: [`--proxy-server=${proxy}`] };
|
||||
}
|
||||
const socksUrl = reconstructSocksUrl(proxy);
|
||||
const args = [`--proxy-server=${socksUrl}`];
|
||||
if (proxy.bypass) args.push(`--proxy-bypass-list=${proxy.bypass}`);
|
||||
return { proxyArgs: args };
|
||||
}
|
||||
|
||||
// HTTP/HTTPS: use Playwright's proxy dict
|
||||
if (typeof proxy === "string") {
|
||||
return { proxyOption: parseProxyUrl(proxy), proxyArgs: [] };
|
||||
}
|
||||
return { proxyOption: proxy as ParsedProxy, proxyArgs: [] };
|
||||
}
|
||||
|
||||
export function parseProxyUrl(proxy: string): ParsedProxy {
|
||||
let url: URL;
|
||||
// Bare format: "user:pass@host:port" — new URL() throws without a scheme.
|
||||
|
||||
+9
-7
@@ -9,7 +9,7 @@ import type { LaunchOptions } from "./types.js";
|
||||
import { IGNORE_DEFAULT_ARGS } from "./config.js";
|
||||
import { buildArgs } from "./args.js";
|
||||
import { ensureBinary } from "./download.js";
|
||||
import { parseProxyUrl } from "./proxy.js";
|
||||
import { isSocksProxy, parseProxyUrl, resolveProxyConfig } from "./proxy.js";
|
||||
import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js";
|
||||
|
||||
/**
|
||||
@@ -39,25 +39,27 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
|
||||
const args = buildArgs({ ...options, ...resolved, args: resolvedArgs });
|
||||
|
||||
// Puppeteer handles proxy via CLI args, not a separate option.
|
||||
// Chromium's --proxy-server does NOT support inline credentials,
|
||||
// so we strip them and use page.authenticate() instead.
|
||||
// SOCKS5: Chrome supports inline credentials natively (RFC 1929 auth).
|
||||
// HTTP: Chrome does NOT support inline credentials — strip them and
|
||||
// use page.authenticate() for Proxy-Authorization headers instead.
|
||||
let proxyAuth: { username: string; password: string } | undefined;
|
||||
if (options.proxy) {
|
||||
if (typeof options.proxy === "string") {
|
||||
if (isSocksProxy(options.proxy)) {
|
||||
// SOCKS5: pass full URL with credentials to Chrome directly
|
||||
const { proxyArgs } = resolveProxyConfig(options.proxy);
|
||||
args.push(...proxyArgs);
|
||||
} else if (typeof options.proxy === "string") {
|
||||
const { server, username, password } = parseProxyUrl(options.proxy);
|
||||
args.push(`--proxy-server=${server}`);
|
||||
if (username) {
|
||||
proxyAuth = { username, password: password ?? "" };
|
||||
}
|
||||
} else {
|
||||
// Strip any inline credentials from the server URL — Chromium's
|
||||
// --proxy-server doesn't support them; use page.authenticate() instead.
|
||||
const parsed = parseProxyUrl(options.proxy.server);
|
||||
args.push(`--proxy-server=${parsed.server}`);
|
||||
if (options.proxy.bypass) {
|
||||
args.push(`--proxy-bypass-list=${options.proxy.bypass}`);
|
||||
}
|
||||
// Explicit username/password fields take precedence over inline creds
|
||||
const username = options.proxy.username ?? parsed.username;
|
||||
const password = options.proxy.password ?? parsed.password;
|
||||
if (username) {
|
||||
|
||||
+90
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseProxyUrl } from "../src/proxy.js";
|
||||
import { parseProxyUrl, isSocksProxy, resolveProxyConfig } from "../src/proxy.js";
|
||||
import type { LaunchOptions } from "../src/types.js";
|
||||
|
||||
describe("parseProxyUrl", () => {
|
||||
@@ -115,3 +115,92 @@ describe("bare proxy format (user:pass@host:port)", () => {
|
||||
expect(parseProxyUrl("proxy:8080")).toEqual({ server: "proxy:8080" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSocksProxy", () => {
|
||||
it("detects socks5 string", () => {
|
||||
expect(isSocksProxy("socks5://user:pass@host:1080")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects socks5h string", () => {
|
||||
expect(isSocksProxy("socks5h://host:1080")).toBe(true);
|
||||
});
|
||||
|
||||
it("case insensitive", () => {
|
||||
expect(isSocksProxy("SOCKS5://host:1080")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects http", () => {
|
||||
expect(isSocksProxy("http://host:8080")).toBe(false);
|
||||
});
|
||||
|
||||
it("detects socks5 dict", () => {
|
||||
expect(isSocksProxy({ server: "socks5://host:1080" })).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects http dict", () => {
|
||||
expect(isSocksProxy({ server: "http://host:8080" })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for undefined", () => {
|
||||
expect(isSocksProxy(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveProxyConfig", () => {
|
||||
it("returns empty for undefined", () => {
|
||||
const { proxyOption, proxyArgs } = resolveProxyConfig(undefined);
|
||||
expect(proxyOption).toBeUndefined();
|
||||
expect(proxyArgs).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns playwright dict for http string", () => {
|
||||
const { proxyOption, proxyArgs } = resolveProxyConfig("http://user:pass@proxy:8080");
|
||||
expect(proxyOption).toEqual({ server: "http://proxy:8080", username: "user", password: "pass" });
|
||||
expect(proxyArgs).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns playwright dict for http dict", () => {
|
||||
const proxy = { server: "http://proxy:8080", bypass: ".example.com" };
|
||||
const { proxyOption, proxyArgs } = resolveProxyConfig(proxy);
|
||||
expect(proxyOption).toEqual(proxy);
|
||||
expect(proxyArgs).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns chrome arg for socks5 string", () => {
|
||||
const { proxyOption, proxyArgs } = resolveProxyConfig("socks5://user:pass@host:1080");
|
||||
expect(proxyOption).toBeUndefined();
|
||||
expect(proxyArgs).toEqual(["--proxy-server=socks5://user:pass@host:1080"]);
|
||||
});
|
||||
|
||||
it("returns chrome arg for socks5 no auth", () => {
|
||||
const { proxyOption, proxyArgs } = resolveProxyConfig("socks5://host:1080");
|
||||
expect(proxyOption).toBeUndefined();
|
||||
expect(proxyArgs).toEqual(["--proxy-server=socks5://host:1080"]);
|
||||
});
|
||||
|
||||
it("returns chrome arg for socks5h string", () => {
|
||||
const { proxyOption, proxyArgs } = resolveProxyConfig("socks5h://user:pass@host:1080");
|
||||
expect(proxyOption).toBeUndefined();
|
||||
expect(proxyArgs).toEqual(["--proxy-server=socks5h://user:pass@host:1080"]);
|
||||
});
|
||||
|
||||
it("reconstructs URL from socks5 dict with auth", () => {
|
||||
const { proxyOption, proxyArgs } = resolveProxyConfig({
|
||||
server: "socks5://host:1080",
|
||||
username: "user",
|
||||
password: "p@ss",
|
||||
});
|
||||
expect(proxyOption).toBeUndefined();
|
||||
expect(proxyArgs.length).toBe(1);
|
||||
expect(proxyArgs[0]).toContain("--proxy-server=socks5://user:p%40ss@host:1080");
|
||||
});
|
||||
|
||||
it("includes bypass for socks5 dict", () => {
|
||||
const { proxyArgs } = resolveProxyConfig({
|
||||
server: "socks5://host:1080",
|
||||
bypass: ".example.com",
|
||||
});
|
||||
expect(proxyArgs).toContain("--proxy-server=socks5://host:1080");
|
||||
expect(proxyArgs).toContain("--proxy-bypass-list=.example.com");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -113,4 +113,29 @@ describe("puppeteer launch", () => {
|
||||
expect(callArgs.args).toContain("--disable-gpu");
|
||||
expect(callArgs.args).toContain("--no-first-run");
|
||||
});
|
||||
|
||||
it("keeps SOCKS5 credentials in --proxy-server URL", async () => {
|
||||
const { launch } = await import("../src/puppeteer.js");
|
||||
const browser = await launch({ proxy: "socks5://user:pass@proxy:1080" });
|
||||
|
||||
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
|
||||
expect(callArgs.args).toContain("--proxy-server=socks5://user:pass@proxy:1080");
|
||||
|
||||
// Should NOT set up page.authenticate for SOCKS5
|
||||
const page = await browser.newPage();
|
||||
expect(page.authenticate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reconstructs SOCKS5 dict with auth into --proxy-server URL", async () => {
|
||||
const { launch } = await import("../src/puppeteer.js");
|
||||
const browser = await launch({
|
||||
proxy: { server: "socks5://proxy:1080", username: "user", password: "p@ss" },
|
||||
});
|
||||
|
||||
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
|
||||
expect(callArgs.args).toContain("--proxy-server=socks5://user:p%40ss@proxy:1080");
|
||||
|
||||
const page = await browser.newPage();
|
||||
expect(page.authenticate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -54,7 +54,7 @@ dependencies = [
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
geoip = ["geoip2>=4.0"]
|
||||
geoip = ["geoip2>=4.0", "socksio>=1.0"] # socksio: SOCKS5 transport for httpx
|
||||
patchright = ["patchright>=1.40"]
|
||||
serve = ["aiohttp>=3.9", "websockets>=12.0"]
|
||||
dev = ["pytest>=7.0", "pytest-asyncio>=0.23"]
|
||||
|
||||
+127
-15
@@ -2,7 +2,12 @@
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from cloakbrowser.browser import _build_proxy_kwargs, maybe_resolve_geoip, _parse_proxy_url
|
||||
from cloakbrowser.browser import (
|
||||
_is_socks_proxy,
|
||||
_parse_proxy_url,
|
||||
_resolve_proxy_config,
|
||||
maybe_resolve_geoip,
|
||||
)
|
||||
|
||||
|
||||
class TestParseProxyUrl:
|
||||
@@ -38,23 +43,30 @@ class TestParseProxyUrl:
|
||||
|
||||
|
||||
class TestBuildProxyKwargs:
|
||||
"""Tests for _resolve_proxy_config (formerly _build_proxy_kwargs) HTTP path."""
|
||||
|
||||
def test_none(self):
|
||||
assert _build_proxy_kwargs(None) == {}
|
||||
kwargs, args = _resolve_proxy_config(None)
|
||||
assert kwargs == {}
|
||||
assert args == []
|
||||
|
||||
def test_simple_proxy(self):
|
||||
result = _build_proxy_kwargs("http://proxy:8080")
|
||||
assert result == {"proxy": {"server": "http://proxy:8080"}}
|
||||
kwargs, args = _resolve_proxy_config("http://proxy:8080")
|
||||
assert kwargs == {"proxy": {"server": "http://proxy:8080"}}
|
||||
assert args == []
|
||||
|
||||
def test_proxy_with_auth(self):
|
||||
result = _build_proxy_kwargs("http://user:pass@proxy:8080")
|
||||
assert result == {
|
||||
kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080")
|
||||
assert kwargs == {
|
||||
"proxy": {"server": "http://proxy:8080", "username": "user", "password": "pass"}
|
||||
}
|
||||
assert args == []
|
||||
|
||||
def test_proxy_dict_passthrough(self):
|
||||
proxy_dict = {"server": "http://proxy:8080", "bypass": ".google.com,localhost"}
|
||||
result = _build_proxy_kwargs(proxy_dict)
|
||||
assert result == {"proxy": proxy_dict}
|
||||
kwargs, args = _resolve_proxy_config(proxy_dict)
|
||||
assert kwargs == {"proxy": proxy_dict}
|
||||
assert args == []
|
||||
|
||||
def test_proxy_dict_with_auth(self):
|
||||
proxy_dict = {
|
||||
@@ -63,8 +75,9 @@ class TestBuildProxyKwargs:
|
||||
"password": "pass",
|
||||
"bypass": ".example.com",
|
||||
}
|
||||
result = _build_proxy_kwargs(proxy_dict)
|
||||
assert result == {"proxy": proxy_dict}
|
||||
kwargs, args = _resolve_proxy_config(proxy_dict)
|
||||
assert kwargs == {"proxy": proxy_dict}
|
||||
assert args == []
|
||||
|
||||
|
||||
class TestMaybeResolveGeoip:
|
||||
@@ -117,6 +130,27 @@ class TestMaybeResolveGeoip:
|
||||
mock_geo.assert_called_once_with("http://proxy:8080")
|
||||
assert tz == "America/New_York"
|
||||
|
||||
@patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("Europe/Berlin", "de-DE", "5.6.7.8"))
|
||||
def test_geoip_socks5_dict_reconstructs_credentials(self, mock_geo):
|
||||
proxy_dict = {"server": "socks5://proxy:1080", "username": "user", "password": "pass"}
|
||||
tz, locale, ip = maybe_resolve_geoip(True, proxy_dict, None, None)
|
||||
mock_geo.assert_called_once_with("socks5://user:pass@proxy:1080")
|
||||
assert tz == "Europe/Berlin"
|
||||
assert locale == "de-DE"
|
||||
|
||||
@patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("Europe/Berlin", "de-DE", "5.6.7.8"))
|
||||
def test_geoip_socks5_dict_no_auth_uses_server(self, mock_geo):
|
||||
proxy_dict = {"server": "socks5://proxy:1080"}
|
||||
tz, locale, ip = maybe_resolve_geoip(True, proxy_dict, None, None)
|
||||
mock_geo.assert_called_once_with("socks5://proxy:1080")
|
||||
|
||||
@patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("Europe/London", "en-GB", "1.1.1.1"))
|
||||
def test_geoip_http_dict_does_not_inline_creds(self, mock_geo):
|
||||
# HTTP dict: credentials stay separate, only server URL passed
|
||||
proxy_dict = {"server": "http://proxy:8080", "username": "user", "password": "pass"}
|
||||
tz, locale, ip = maybe_resolve_geoip(True, proxy_dict, None, None)
|
||||
mock_geo.assert_called_once_with("http://proxy:8080")
|
||||
|
||||
|
||||
class TestBareProxyFormat:
|
||||
"""_parse_proxy_url must handle bare 'user:pass@host:port' strings (no scheme)."""
|
||||
@@ -149,8 +183,86 @@ class TestBareProxyFormat:
|
||||
r = _parse_proxy_url("proxy:8080")
|
||||
assert r == {"server": "proxy:8080"}
|
||||
|
||||
def test_build_proxy_kwargs_bare(self):
|
||||
r = _build_proxy_kwargs("user:pass@proxy:8080")
|
||||
assert r["proxy"]["username"] == "user"
|
||||
assert r["proxy"]["password"] == "pass"
|
||||
assert "user" not in r["proxy"]["server"]
|
||||
def test_resolve_proxy_config_bare(self):
|
||||
kwargs, args = _resolve_proxy_config("user:pass@proxy:8080")
|
||||
assert kwargs["proxy"]["username"] == "user"
|
||||
assert kwargs["proxy"]["password"] == "pass"
|
||||
assert "user" not in kwargs["proxy"]["server"]
|
||||
|
||||
|
||||
class TestIsSocksProxy:
|
||||
def test_socks5_string(self):
|
||||
assert _is_socks_proxy("socks5://user:pass@host:1080") is True
|
||||
|
||||
def test_socks5h_string(self):
|
||||
assert _is_socks_proxy("socks5h://host:1080") is True
|
||||
|
||||
def test_socks5_uppercase(self):
|
||||
assert _is_socks_proxy("SOCKS5://host:1080") is True
|
||||
|
||||
def test_http_string(self):
|
||||
assert _is_socks_proxy("http://host:8080") is False
|
||||
|
||||
def test_dict_socks5(self):
|
||||
assert _is_socks_proxy({"server": "socks5://host:1080"}) is True
|
||||
|
||||
def test_dict_http(self):
|
||||
assert _is_socks_proxy({"server": "http://host:8080"}) is False
|
||||
|
||||
def test_none(self):
|
||||
assert _is_socks_proxy(None) is False
|
||||
|
||||
|
||||
class TestResolveProxyConfig:
|
||||
def test_none(self):
|
||||
kwargs, args = _resolve_proxy_config(None)
|
||||
assert kwargs == {}
|
||||
assert args == []
|
||||
|
||||
def test_http_string_returns_playwright_dict(self):
|
||||
kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080")
|
||||
assert "proxy" in kwargs
|
||||
assert kwargs["proxy"]["server"] == "http://proxy:8080"
|
||||
assert kwargs["proxy"]["username"] == "user"
|
||||
assert args == []
|
||||
|
||||
def test_http_dict_passthrough(self):
|
||||
proxy = {"server": "http://proxy:8080", "bypass": ".example.com"}
|
||||
kwargs, args = _resolve_proxy_config(proxy)
|
||||
assert kwargs == {"proxy": proxy}
|
||||
assert args == []
|
||||
|
||||
def test_socks5_string_returns_chrome_arg(self):
|
||||
kwargs, args = _resolve_proxy_config("socks5://user:pass@host:1080")
|
||||
assert kwargs == {}
|
||||
assert args == ["--proxy-server=socks5://user:pass@host:1080"]
|
||||
|
||||
def test_socks5_no_auth_returns_chrome_arg(self):
|
||||
kwargs, args = _resolve_proxy_config("socks5://host:1080")
|
||||
assert kwargs == {}
|
||||
assert args == ["--proxy-server=socks5://host:1080"]
|
||||
|
||||
def test_socks5h_returns_chrome_arg(self):
|
||||
kwargs, args = _resolve_proxy_config("socks5h://user:pass@host:1080")
|
||||
assert kwargs == {}
|
||||
assert args == ["--proxy-server=socks5h://user:pass@host:1080"]
|
||||
|
||||
def test_socks5_dict_reconstructs_url(self):
|
||||
proxy = {"server": "socks5://host:1080", "username": "user", "password": "p@ss"}
|
||||
kwargs, args = _resolve_proxy_config(proxy)
|
||||
assert kwargs == {}
|
||||
assert len(args) == 1
|
||||
assert args[0].startswith("--proxy-server=socks5://user:p%40ss@host:1080")
|
||||
|
||||
def test_socks5_dict_ipv6_preserves_brackets(self):
|
||||
proxy = {"server": "socks5://[::1]:1080", "username": "user", "password": "pass"}
|
||||
kwargs, args = _resolve_proxy_config(proxy)
|
||||
assert kwargs == {}
|
||||
assert "[::1]" in args[0]
|
||||
|
||||
def test_socks5_dict_with_bypass(self):
|
||||
proxy = {"server": "socks5://host:1080", "bypass": ".example.com"}
|
||||
kwargs, args = _resolve_proxy_config(proxy)
|
||||
assert kwargs == {}
|
||||
assert "--proxy-server=socks5://host:1080" in args
|
||||
assert "--proxy-bypass-list=.example.com" in args
|
||||
|
||||
Reference in New Issue
Block a user