From 8a9e508b4d04c32caa9aacdf0d3e1e868b11c033 Mon Sep 17 00:00:00 2001 From: jehrr <79864894+jehrr@users.noreply.github.com> Date: Fri, 16 Oct 2026 06:33:53 +0000 Subject: [PATCH] Accept the dashboard's host:port:login:password proxy; refuse a gateway URL with no login AVITO_PROXY / --proxy / --proxy-file lines in the 2Captcha dashboard's colon form (with or without http://) are now normalised to scheme://login:password@host:port, login and password percent-encoded and decoded again before Playwright, pyppeteer, the forwarder and the solver see them. A gateway URL without a login and password is refused by name when a session must be minted or pinned, instead of a login being spliced in front of the scheme and the run dying with "not a 2Captcha gateway". The login is now replaced inside the netloc, and no message built from a proxy value repeats the credential. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CKsd7q25N19WzUZjrAdzx8 --- .env.example | 3 + CHANGELOG.md | 27 +++++ README.md | 14 +++ browser_bridge.py | 10 +- captcha_solver.py | 8 +- proxy_forwarder.py | 4 +- proxy_pool.py | 108 ++++++++++++++++---- puppeteer_scraper.py | 4 +- smoke_test.py | 176 ++++++++++++++++++++++++++++++++ tools/browser_profile_client.py | 31 ++++-- 10 files changed, 349 insertions(+), 36 deletions(-) diff --git a/.env.example b/.env.example index 39e4f81..ccd37e5 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,9 @@ TWOCAPTCHA_KEY= # solved from another exit was rejected (measured). # `tools/browser_profile_client.py connection --mint-session --write-env` # writes both values together. +# It needs the gateway login and password; the dashboard's colon form +# {host}:{port}:{login}:{password} is accepted too. An IP-whitelisted +# gateway URL with no login cannot be pinned to a session. AVITO_PROXY= # A Scraping Browser API CDP endpoint (Playwright and pyppeteer engines; diff --git a/CHANGELOG.md b/CHANGELOG.md index c19f579..ad50647 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,33 @@ entry in a blockquote. ## [Unreleased] +> **`AVITO_PROXY` / `--proxy` accept the dashboard's colon form.** +> `host:port:login:password` (with or without `http://`) is now read as +> `http://login:password@host:port` instead of being refused or misread. A +> 2Captcha gateway URL without a login is now refused when a session has to +> be minted or pinned — `tools/browser_profile_client.py connection +> --mint-session` exits 1 and the scrapers exit 2 — with a message naming the +> required form. + +### Fixed + +* **Session minting on a gateway URL with no login.** `connection + --mint-session` spliced the new login in front of `http://` when + `AVITO_PROXY` had an empty username, so the rebuilt URL had no host and the + run died with "the URL given is not one" of a 2Captcha gateway — a message + about the host, for a problem with the login. The login is now replaced in + the URL's netloc, and a gateway credential without a login and password is + refused by name. +* **The dashboard's `host:port:login:password` credential.** Read as a URL it + has no username and a "port" of `port:login:password`; it looked like an + IP-whitelisted gateway, and the parser's error about that port quoted the + login. It is now normalised to a URL wherever a proxy is read (`--proxy`, + `AVITO_PROXY`, `--proxy-file` lines, the browser-profile tool), and no + message built from it repeats the credential. +* **Percent-encoded proxy passwords.** The login and password are decoded + before they reach Playwright, pyppeteer, the local forwarder and the + captcha solver's proxy fields; they were passed on still encoded. + ## [1.1.0] — 2026-10-15 > **Exit codes and file modes changed.** A run that ends with zero rows for diff --git a/README.md b/README.md index 65fb9bc..0163876 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,20 @@ For a local browser (no endpoint), `AVITO_PROXY` is the browser's exit; a gateway login without `-session-` is pinned to one session for the run automatically. +`AVITO_PROXY` must carry the gateway's login and password, in either form: + +```text +http://LOGIN:PASSWORD@HOST:PORT # a URL +HOST:PORT:LOGIN:PASSWORD # as the 2Captcha dashboard exports it +``` + +The second form may also start with `http://`; it is rewritten to the first, +with the login and password percent-encoded, so a password containing `@`, +`:` or `/` works in either. A gateway URL without a login (the IP-whitelisted +form, `http://HOST:PORT`) is refused with a message saying +so: the `-session-` and `-sessTime-` ids that pin an exit live inside the +login, so such a URL cannot be minted or pinned. + `python3 env_config.py` shows what was picked up, without printing secrets. --- diff --git a/browser_bridge.py b/browser_bridge.py index 119e099..6f58f2f 100644 --- a/browser_bridge.py +++ b/browser_bridge.py @@ -1014,6 +1014,11 @@ def parse_args(parser, argv=None, *, cdp_supported: bool = True): except UsageError as exc: parser.error(str(exc)) + if args.proxy: + # The dashboard's host:port:login:password form becomes a URL here, + # once, so every engine and the solver read the same value. + args.proxy = proxy_pool.normalize_proxy(args.proxy) + if args.cdp_endpoint: # Never applied to the remote browser — it brings its own exit. args.solver_proxy = args.proxy @@ -1033,7 +1038,10 @@ def parse_args(parser, argv=None, *, cdp_supported: bool = True): and "-session-" not in args.proxy and not args.proxy_sessions: # A bare gateway login rotates per request; the solver must share # the browser's exit, so pin one session for the run. - args.proxy = proxy_pool.mint_sessions(args.proxy, 1)[0] + try: + args.proxy = proxy_pool.mint_sessions(args.proxy, 1)[0] + except proxy_pool.ProxyError as exc: + parser.error(str(exc)) try: args.pool = proxy_pool.from_args(args) except proxy_pool.ProxyError as exc: diff --git a/captcha_solver.py b/captcha_solver.py index eb0764b..b8d04bf 100644 --- a/captcha_solver.py +++ b/captcha_solver.py @@ -54,7 +54,7 @@ import re import time from typing import Optional -from urllib.parse import urlparse +from urllib.parse import unquote, urlparse import requests @@ -103,10 +103,12 @@ def proxy_task_fields(proxy: Optional[str]) -> dict: if not (p.scheme and p.hostname and port): return {} fields = {"proxyType": p.scheme, "proxyAddress": p.hostname, "proxyPort": port} + # Percent-decoded: a password with `@` or `:` in it travels encoded in + # the URL, and the API wants the password itself. if p.username: - fields["proxyLogin"] = p.username + fields["proxyLogin"] = unquote(p.username) if p.password: - fields["proxyPassword"] = p.password + fields["proxyPassword"] = unquote(p.password) return fields diff --git a/proxy_forwarder.py b/proxy_forwarder.py index b090b4d..d0f23ad 100644 --- a/proxy_forwarder.py +++ b/proxy_forwarder.py @@ -36,7 +36,7 @@ import socket import threading from typing import Optional, Tuple -from urllib.parse import urlparse +from urllib.parse import unquote, urlparse _BUF = 65536 @@ -47,7 +47,7 @@ def __init__(self, upstream_url: str): if not (parts.hostname and parts.port): raise ValueError("upstream proxy needs host and port") self._upstream: Tuple[str, int] = (parts.hostname, parts.port) - token = "%s:%s" % (parts.username or "", parts.password or "") + token = "%s:%s" % (unquote(parts.username or ""), unquote(parts.password or "")) self._auth = b"Proxy-Authorization: Basic " + base64.b64encode(token.encode()) + b"\r\n" self._server: Optional[socket.socket] = None self._thread: Optional[threading.Thread] = None diff --git a/proxy_pool.py b/proxy_pool.py index 090c128..d46770a 100644 --- a/proxy_pool.py +++ b/proxy_pool.py @@ -30,7 +30,7 @@ import re import string from typing import List, Optional -from urllib.parse import urlparse, urlunparse +from urllib.parse import quote, unquote, urlparse, urlunparse logger = logging.getLogger("proxy_pool") @@ -52,6 +52,38 @@ class ProxyError(ValueError): """A proxy list that cannot be used as given.""" +# The 2Captcha dashboard hands a proxy out as `host:port:login:password` +# (sometimes with an `http://` in front), not as a URL. Read as a URL that +# string has no username at all and a "port" of `port:login:password` -- +# so a gateway credential looked like an IP-whitelisted one, and the +# ValueError urlparse raises on that port quotes the login. Both forms are +# accepted; the colon form is rewritten to scheme://login:password@host:port +# with login and password percent-encoded, and every consumer decodes them. +_COLON_FORM_RE = re.compile( + r"^(?:(?P[A-Za-z][A-Za-z0-9+.-]*)://)?" + r"(?P[^:@/\s]+):(?P\d{1,5}):" + r"(?P[^:@/\s]+):(?P\S+)$") + + +def normalize_proxy(value: Optional[str]) -> Optional[str]: + """`value` as a proxy URL: the colon form rewritten, anything else as is.""" + if value is None: + return None + text = value.strip() + m = _COLON_FORM_RE.match(text) + if not m: + return text + scheme = (m.group("scheme") or "http").lower() + login = quote(m.group("login"), safe="-_.~") + password = quote(m.group("password"), safe="-_.~") + return f"{scheme}://{login}:{password}@{m.group('host')}:{m.group('port')}" + + +def credentials_of(parsed) -> tuple: + """(login, password) of a parsed proxy URL, percent-decoded ("" if absent).""" + return unquote(parsed.username or ""), unquote(parsed.password or "") + + def parse_proxy_line(line: str, source: str = "") -> Optional[str]: """Validate one proxy URL. Returns it, or None for a blank/comment line. @@ -62,6 +94,7 @@ def parse_proxy_line(line: str, source: str = "") -> Optional[str]: line = line.strip() if not line or line.startswith("#"): return None + line = normalize_proxy(line) # Every message below reports mask(line), never the line itself. These # strings hold a password, and an error message is a log: a CI run @@ -91,10 +124,9 @@ def parse_proxy_line(line: str, source: str = "") -> Optional[str]: parsed.port except ValueError: raise ProxyError( - f"{source}: {shown!r} — the port is not a number. If you copied " - f"this from a proxy list file, that format is " - f"scheme://host:port:login:password and this expects a URL: " - f"http://login:password@host:port") from None + f"{source}: {shown!r} — the port is not a number. Write the " + f"proxy as http://login:password@host:port or as " + f"host:port:login:password.") from None if parsed.scheme == "socks5" and (parsed.username or parsed.password): raise ProxyError( f"{source}: {shown!r} — Chromium cannot authenticate a SOCKS5 " @@ -135,7 +167,7 @@ def mask(url: Optional[str]) -> str: if not url: return "(none)" try: - parsed = urlparse(url) + parsed = urlparse(normalize_proxy(url)) host = parsed.hostname or "?" # `parsed.port` raises on a malformed authority; the raw netloc is # not safe to fall back to, because that is where the password is. @@ -179,10 +211,11 @@ def to_playwright(url: Optional[str]) -> Optional[dict]: parsed = urlparse(url) port = f":{parsed.port}" if parsed.port else "" proxy = {"server": f"{parsed.scheme}://{parsed.hostname}{port}"} - if parsed.username: - proxy["username"] = parsed.username - if parsed.password: - proxy["password"] = parsed.password + login, password = credentials_of(parsed) + if login: + proxy["username"] = login + if password: + proxy["password"] = password return proxy @@ -202,7 +235,7 @@ def split_credentials(url: Optional[str]): port = f":{parsed.port}" if parsed.port else "" scrubbed = f"{parsed.scheme}://{parsed.hostname}{port}" if parsed.username or parsed.password: - return scrubbed, (parsed.username or "", parsed.password or "") + return scrubbed, credentials_of(parsed) return scrubbed, None @@ -320,7 +353,7 @@ def is_2captcha_gateway(url: Optional[str]) -> bool: if not url: return False try: - host = (urlparse(url).hostname or "").lower() + host = (urlparse(normalize_proxy(url)).hostname or "").lower() except ValueError: return False return host.endswith(_GATEWAY_SUFFIXES) @@ -330,6 +363,45 @@ def _session_id(rng: random.Random) -> str: return "".join(rng.choice(_SESSION_CHARS) for _ in range(_SESSION_LEN)) +def require_gateway_login(url: str, name: str = "the proxy") -> str: + """`url` normalised, or ProxyError when it carries no login and password. + + The session id lives INSIDE the login, so a host-only (IP-whitelisted) + gateway URL has nowhere to put one. Splicing a login into such a URL + used to produce a string with no host at all, refused with a message + that blamed the host. The credential is never part of the message. + """ + url = normalize_proxy(url) + parts = urlparse(url) + if not (parts.username and parts.password): + raise ProxyError( + f"{name} has no login: session minting needs the gateway's " + f"LOGIN:PASSWORD form (http://LOGIN:PASSWORD@{_GATEWAY_SUFFIX}:PORT " + f"or {_GATEWAY_SUFFIX}:PORT:LOGIN:PASSWORD); an IP-whitelisted " + f"gateway URL cannot carry a -session-/-sessTime- id.") + return url + + +def with_login(url: str, login: str) -> str: + """The same proxy URL with its login replaced, rebuilt from its parts. + + Only the netloc's login changes. A str.replace on the whole URL put the + login in front of the scheme when the old login was empty. + """ + parts = urlparse(normalize_proxy(url)) + try: + port = f":{parts.port}" if parts.port else "" + except ValueError: + # The message from urlparse quotes the port text, which can hold + # the login and password -- never pass it on. + raise ProxyError(f"{mask(url)}: the port is not a number") from None + host = parts.hostname or "" + password = parts.password or "" + netloc = f"{login}:{password}@{host}{port}" if password else f"{login}@{host}{port}" + return urlunparse((parts.scheme, netloc, parts.path, parts.params, + parts.query, parts.fragment)) + + def with_session(url: str, session: str) -> str: """The same proxy URL with its login pinned to `session`. @@ -339,20 +411,15 @@ def with_session(url: str, session: str) -> str: nobody thought of, so this replaces the session in place, or appends one before `-sessTime-` when there is none. """ - parts = urlparse(url) - login = parts.username or "" + url = require_gateway_login(url) + login = urlparse(url).username or "" if _SESSION_RE.search(login): new_login = _SESSION_RE.sub("-session-" + session, login, count=1) elif _SESSTIME_RE.search(login): new_login = _SESSTIME_RE.sub("-session-" + session + r"\g<0>", login, count=1) else: new_login = login + "-session-" + session - password = parts.password or "" - host = parts.hostname or "" - port = f":{parts.port}" if parts.port else "" - netloc = f"{new_login}:{password}@{host}{port}" if password else f"{new_login}@{host}{port}" - return urlunparse((parts.scheme, netloc, parts.path, parts.params, - parts.query, parts.fragment)) + return with_login(url, new_login) def mint_sessions(url: str, count: int, rng: Optional[random.Random] = None) -> List[str]: @@ -369,6 +436,7 @@ def mint_sessions(url: str, count: int, rng: Optional[random.Random] = None) -> "--proxy-sessions only applies to a 2Captcha proxy gateway " f"(a host under {_GATEWAY_SUFFIX}); the URL given is not one. " "Use --proxy-file for proxies from anywhere else.") + url = require_gateway_login(url) rng = rng or random.Random() seen, out = set(), [] while len(out) < count: diff --git a/puppeteer_scraper.py b/puppeteer_scraper.py index ac42fd7..003d688 100644 --- a/puppeteer_scraper.py +++ b/puppeteer_scraper.py @@ -21,6 +21,7 @@ from urllib.parse import urlparse import browser_bridge +import proxy_pool # Module level, so the offline suite can skip this engine when pyppeteer is # absent instead of importing cleanly and failing on first launch. @@ -74,7 +75,8 @@ def start(self): options["args"].append("--proxy-server=%s://%s:%s" % (parts.scheme or "http", parts.hostname, parts.port)) if parts.username: - credentials = {"username": parts.username, "password": parts.password or ""} + login, password = proxy_pool.credentials_of(parts) + credentials = {"username": login, "password": password} self._browser = self._run(launch(**options)) self._page = self._run(self._browser.newPage()) if credentials: diff --git a/smoke_test.py b/smoke_test.py index f5f4cac..973e19e 100644 --- a/smoke_test.py +++ b/smoke_test.py @@ -1034,6 +1034,182 @@ def test_both_gateway_brands_are_recognised(): check("anything else is not", not proxy_pool.is_2captcha_gateway(PLACEHOLDER_PROXY)) +# A gateway credential as the 2Captcha dashboard exports it, and pieces of +# one with characters that need percent-encoding in a URL. Assembled from +# parts for the same reason as GATEWAY_PROXY. +GATEWAY_HOST = "ru.proxy." + "2captcha.com" +GATEWAY_LOGIN = "uEXAMPLE-zone-custom-region-RU" +ODD_PASSWORD = "EX@MPLE:p/ss%w#rd" + + +def _minted(value): + """tools/browser_profile_client.minted_proxy(60) on AVITO_PROXY=value: + ("ok", url) or ("exit", message). No .env is read and nothing is sent.""" + import importlib.util + import pathlib + spec = importlib.util.spec_from_file_location( + "_bpc_under_test", os.path.join(HERE, "tools", "browser_profile_client.py")) + bpc = importlib.util.module_from_spec(spec) + spec.loader.exec_module(bpc) + bpc.ENV = pathlib.Path(HERE, "no-such-dir", ".env") + saved = os.environ.get("AVITO_PROXY") + os.environ["AVITO_PROXY"] = value + try: + return "ok", _quiet(bpc.minted_proxy, 60) + except SystemExit as exc: + return "exit", str(exc.code) + except Exception as exc: # noqa: BLE001 -- the shape of the failure is the check + return "raised", "%s: %s" % (type(exc).__name__, exc) + finally: + if saved is None: + os.environ.pop("AVITO_PROXY", None) + else: + os.environ["AVITO_PROXY"] = saved + + +def test_a_gateway_url_without_a_login_is_refused_by_name(): + import proxy_pool + for label, value, secret in ( + ("host-only", "http://" + GATEWAY_HOST + ":2334", None), + ("empty username", "http://:" + "EXAMPLE-PASS" + "@" + GATEWAY_HOST + ":2334", + "EXAMPLE-PASS"), + ("login, no password", "http://" + GATEWAY_LOGIN + "@" + GATEWAY_HOST + ":2334", + GATEWAY_LOGIN), + ("three colon fields", "http://" + GATEWAY_HOST + ":2334:" + GATEWAY_LOGIN, + GATEWAY_LOGIN)): + kind, msg = _minted(value) + eq("minted_proxy, %s: exits" % label, kind, "exit") + check("minted_proxy, %s: says the login is missing" % label, + "AVITO_PROXY has no login" in msg and "LOGIN:PASSWORD" in msg, "(got %r)" % msg) + check("minted_proxy, %s: not the misleading not-a-gateway message" % label, + "the URL given is not one" not in msg) + if secret: + check("minted_proxy, %s: the credential is not in the message" % label, + secret not in msg) + try: + proxy_pool.mint_sessions(value, 1) + check("mint_sessions, %s: refused" % label, False) + except proxy_pool.ProxyError as exc: + check("mint_sessions, %s: refused for the login" % label, + "has no login" in str(exc), "(got %r)" % str(exc)) + if secret: + check("mint_sessions, %s: no credential in the message" % label, + secret not in str(exc)) + + +def test_minted_proxy_rebuilds_the_login_in_the_netloc(): + from urllib.parse import urlparse + import proxy_pool + url_form = "http://" + GATEWAY_LOGIN + ":" + "EXAMPLE-PASS" + "@" + GATEWAY_HOST + ":2334" + cases = ( + ("plain login", url_form, "http", GATEWAY_LOGIN, "EXAMPLE-PASS"), + ("login already pinned", + url_form.replace(GATEWAY_LOGIN, GATEWAY_LOGIN + "-session-OLDsess01-sessTime-30"), + "http", GATEWAY_LOGIN, "EXAMPLE-PASS"), + ("socks5", "socks5" + url_form[4:], "socks5", GATEWAY_LOGIN, "EXAMPLE-PASS"), + ("%-encoded password", + url_form.replace("EXAMPLE-PASS", "EX%40MPLE%3Ap"), "http", GATEWAY_LOGIN, "EX@MPLE:p"), + ("dashboard colon form", + GATEWAY_HOST + ":2334:" + GATEWAY_LOGIN + ":" + "EXAMPLE-PASS", + "http", GATEWAY_LOGIN, "EXAMPLE-PASS"), + ("dashboard colon form with http://", + "http://" + GATEWAY_HOST + ":2334:" + GATEWAY_LOGIN + ":" + "EXAMPLE-PASS", + "http", GATEWAY_LOGIN, "EXAMPLE-PASS"), + ("colon form, password with @ : / % #", + GATEWAY_HOST + ":2334:" + GATEWAY_LOGIN + ":" + ODD_PASSWORD, + "http", GATEWAY_LOGIN, ODD_PASSWORD), + ) + for label, value, scheme, login, password in cases: + kind, got = _minted(value) + eq("minted_proxy, %s: a URL" % label, kind, "ok") + if kind != "ok": + continue + p = urlparse(got) + eq("minted_proxy, %s: scheme kept" % label, p.scheme, scheme) + eq("minted_proxy, %s: gateway host kept" % label, p.hostname, GATEWAY_HOST) + eq("minted_proxy, %s: port kept" % label, p.port, 2334) + user, pw = proxy_pool.credentials_of(p) + eq("minted_proxy, %s: password intact" % label, pw, password) + check("minted_proxy, %s: login keeps its base" % label, user.startswith(login + "-"), + "(got %r)" % user) + eq("minted_proxy, %s: one sessTime, the requested one" % label, + (user.count("-sessTime-"), user.endswith("-sessTime-60")), (1, True)) + eq("minted_proxy, %s: one fresh session" % label, + (user.count("-session-"), "OLDsess01" in user), (1, False)) + + +def test_the_dashboard_colon_form_is_a_proxy_url(): + import captcha_solver + import proxy_pool + colon = GATEWAY_HOST + ":2334:" + GATEWAY_LOGIN + ":" + ODD_PASSWORD + for label, value in (("bare", colon), ("with http://", "http://" + colon)): + line = proxy_pool.parse_proxy_line(value) + check("colon form %s: parsed to a URL" % label, + line.startswith("http://") and line.endswith("@" + GATEWAY_HOST + ":2334"), + "(got %s)" % proxy_pool.mask(line)) + check("colon form %s: a gateway" % label, proxy_pool.is_2captcha_gateway(value)) + pw = proxy_pool.to_playwright(line) + eq("colon form %s: playwright gets the decoded login" % label, + pw.get("username"), GATEWAY_LOGIN) + eq("colon form %s: playwright gets the decoded password" % label, + pw.get("password"), ODD_PASSWORD) + eq("colon form %s: playwright server has no credential" % label, + pw.get("server"), "http://" + GATEWAY_HOST + ":2334") + fields = captcha_solver.proxy_task_fields(line) + eq("colon form %s: solver fields decoded" % label, + (fields.get("proxyAddress"), fields.get("proxyPort"), + fields.get("proxyLogin"), fields.get("proxyPassword")), + (GATEWAY_HOST, 2334, GATEWAY_LOGIN, ODD_PASSWORD)) + for shown in (proxy_pool.mask(value), proxy_pool.mask(line)): + check("colon form %s: mask hides the password" % label, + "p/ss" not in shown and GATEWAY_LOGIN not in shown, "(got %r)" % shown) + import base64 + import proxy_forwarder + token = base64.b64encode((GATEWAY_LOGIN + ":" + ODD_PASSWORD).encode()) + check("colon form %s: the forwarder sends the decoded credential" % label, + token in proxy_forwarder.ProxyForwarder(line)._auth) + minted = proxy_pool.mint_sessions(value, 2) + eq("colon form %s: sessions keep the password" % label, + [pw_ for _, pw_ in (proxy_pool.split_credentials(m)[1] for m in minted)], + [ODD_PASSWORD, ODD_PASSWORD]) + # A malformed port still says what is wrong without repeating the value. + bad = "http://" + GATEWAY_HOST + ":23x4:" + GATEWAY_LOGIN + ":" + "EXAMPLE-PASS" + try: + proxy_pool.parse_proxy_line(bad) + check("bad port refused", False) + except proxy_pool.ProxyError as exc: + check("bad port refused without the credential", + "EXAMPLE-PASS" not in str(exc) and GATEWAY_LOGIN not in str(exc), + "(got %r)" % str(exc)) + + +def test_the_colon_form_reaches_every_engine_as_a_url(): + import browser_bridge + import env_config + saved = {k: os.environ.get(k) for k in env_config.ENV_KEYS} + for k in env_config.ENV_KEYS: + os.environ[k] = "" + try: + parser = browser_bridge.build_parser("x") + colon = GATEWAY_HOST + ":2334:" + GATEWAY_LOGIN + ":" + "EXAMPLE-PASS" + b = _quiet(browser_bridge.parse_args, parser, ["--url", LISTING, "--proxy", colon]) + check("colon --proxy: pinned to one session as a URL", + b.proxy.startswith("http://" + GATEWAY_LOGIN + "-session-") + and b.proxy.endswith("@" + GATEWAY_HOST + ":2334")) + try: + _quiet(browser_bridge.parse_args, browser_bridge.build_parser("x"), + ["--url", LISTING, "--proxy", "http://" + GATEWAY_HOST + ":2334"]) + check("host-only gateway --proxy: refused", False) + except SystemExit as exc: + eq("host-only gateway --proxy: usage error", exc.code, 2) + finally: + for k, v in saved.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + def test_no_javascript_crosses_the_bridge(): """The one JS body lives in captcha_solver and is written as a function BODY (Selenium's dialect); each engine wraps it. The bridge carries none.""" diff --git a/tools/browser_profile_client.py b/tools/browser_profile_client.py index c3d66e0..7290b25 100644 --- a/tools/browser_profile_client.py +++ b/tools/browser_profile_client.py @@ -190,19 +190,22 @@ def pick_proxy(line: int | None): if not 1 <= line <= len(pool): sys.exit(f"!! --proxy-line must be 1..{len(pool)}") chosen, which = pool[line - 1], f"line {line} of {len(pool)}" + import proxy_pool + chosen = proxy_pool.normalize_proxy(chosen) p = urlparse(chosen) try: port = p.port except ValueError: port = None if not (p.scheme and p.hostname and port and p.username and p.password): - sys.exit(f"!! {POOL.name} {which} is not scheme://login:password@host:port") + sys.exit(f"!! {POOL.name} {which} is not scheme://login:password@host:port " + "or host:port:login:password") return { "type": p.scheme, "host": p.hostname, "port": port, - "login": p.username, - "password": p.password, + "login": proxy_pool.credentials_of(p)[0], + "password": proxy_pool.credentials_of(p)[1], }, which, chosen @@ -251,14 +254,22 @@ def minted_proxy(minutes: int) -> str: base = env_config.env_value("AVITO_PROXY") if not base: sys.exit("!! AVITO_PROXY is not set in .env") + # The dashboard's host:port:login:password form is accepted as well as + # the URL; both become one URL here before anything reads a login. + base = proxy_pool.normalize_proxy(base) if not proxy_pool.is_2captcha_gateway(base): sys.exit("!! AVITO_PROXY is not a 2Captcha gateway credential; " "--mint-session only applies to one.") - parts = urlparse(base) - login = _re.sub(r"-sessTime-\d+", "", parts.username or "") - login = _re.sub(r"-session-[A-Za-z0-9]+", "", login) + "-sessTime-%d" % minutes - rebuilt = base.replace(parts.username or "", login, 1) - return proxy_pool.mint_sessions(rebuilt, 1)[0] + try: + base = proxy_pool.require_gateway_login(base, "AVITO_PROXY") + login = urlparse(base).username or "" + login = _re.sub(r"-sessTime-\d+", "", login) + login = _re.sub(r"-session-[A-Za-z0-9]+", "", login) + "-sessTime-%d" % minutes + rebuilt = proxy_pool.with_login(base, login) + return proxy_pool.mint_sessions(rebuilt, 1)[0] + except proxy_pool.ProxyError as exc: + # ProxyError messages are built from masked values only. + sys.exit(f"!! {exc}") def dicts_in(obj): @@ -396,10 +407,12 @@ def cmd_connection_minted(args, key): """Build the endpoint on a fresh session AND store that session as AVITO_PROXY, so the browser and the solver share one exit.""" proxy_url = minted_proxy(args.session_minutes) + import proxy_pool p = urlparse(proxy_url) + login, password = proxy_pool.credentials_of(p) body = {"key": key, "accountId": args.account_id, "customProxy": {"type": p.scheme, "host": p.hostname, "port": p.port, - "login": p.username, "password": p.password}} + "login": login, "password": password}} pid = args.profile_id or profile_id_from_env() if pid: body["profileId"] = pid