From 11138566ae27d8abb161fc2f9fe8280a651b6a1d Mon Sep 17 00:00:00 2001 From: softglazee Date: Tue, 29 Sep 2026 07:28:38 +0500 Subject: [PATCH 1/2] Stop Google serving a CAPTCHA to every profile, and fix four proxy issues Every profile got a Google CAPTCHA on every proxy, and on a clean home connection too, while untouched Chrome on the same IP searched normally. Each cause below was isolated by A/B runs on Chrome 153 (29 Sep 2026): 1. puppeteer launches Chrome with --remote-debugging-port=0, and Chrome treats an auto-assigned port as automation mode (navigator.webdriver becomes true even with --enable-automation stripped). Profiles now launch on a fixed free port. Port 0: CAPTCHA every run; fixed port: normal results. 2. The stealth plugin was written for headless Chromium. On a visible, genuine browser its patches are only detectable fakes: the plugin with zero evasions passed every run, the app's evasion set failed every run and CreepJS read "60% stealth". Visible profiles now run without it, including on the minimize-CDP engine, which used to add it even to the anti-detect build. Headless launches keep it. 3. The fingerprint script replaced navigator.webdriver even when Chrome already reported false. That one define() alone produced the CAPTCHA; it now only runs when the browser reports true. Its toString mask and getters are also rebuilt as methods, so they have no prototype and throw on new, like real built-ins. 4. The launch geo came from ipinfo alone, which put a US proxy in Lisbon and gave the profile Portuguese time and language. It is now the consensus of ipinfo, ip-api and geojs, with the pulled country as the tie-breaker, and the geo cache is keyed on the credentials so sticky sessions sharing one gateway no longer inherit each other's location. Also: proxy checks race ipinfo and geojs, so IPv6-only exits answer in about a second instead of ~31 s; a proxy created in the last 3 minutes that fails with 407 is retried before it is marked dead (Proxy-Seller activates new lists after ~20 s); and Proxy-Seller per-IP orders honour the requested count and skip addresses already in the pool. Result in the installed app with no proxy: Google normal on real Chrome and on fingerprint-chromium, CreepJS stealth 0-20% instead of 60%. --- src/main/browserEngine.js | 116 +++++++++++++++++++++++++++------- src/main/ipcHandlers.js | 83 +++++++++++++++--------- src/main/proxyVendorUtils.js | 27 ++++++++ test/captcha-fixes.test.js | 89 ++++++++++++++++++++++++++ test/fingerprintLeaks.test.js | 28 ++++++-- 5 files changed, 285 insertions(+), 58 deletions(-) create mode 100644 test/captcha-fixes.test.js diff --git a/src/main/browserEngine.js b/src/main/browserEngine.js index 5a3b3d6..aa88001 100644 --- a/src/main/browserEngine.js +++ b/src/main/browserEngine.js @@ -23,7 +23,21 @@ const PERSONA_AUTOFILL_SOURCE = buildAutofillBootstrap(); // proxy, so an authenticated one is routed through this instead (audit). const { startSocksAuthRelay } = require('./socksRelay'); const { startHttpAuthRelay } = require('./httpRelay'); -const { GEOJS_URL, normalizeGeoJs } = require('./proxyVendorUtils'); +const { GEOJS_URL, normalizeGeoJs, pickGeoConsensus } = require('./proxyVendorUtils'); + +// A free loopback TCP port, found by binding port 0 and releasing it. Used for Chrome's +// DevTools port, because an auto-assigned (0) port puts Chrome into automation mode. +function getFreeLocalPort() { + return new Promise((resolve, reject) => { + const srv = require('node:net').createServer(); + srv.unref(); + srv.on('error', reject); + srv.listen(0, '127.0.0.1', () => { + const { port } = srv.address(); + srv.close(() => resolve(port)); + }); + }); +} // Stealth hides automation tells, but several of its evasions set the SAME // properties we spoof ourselves (UA, WebGL vendor, hardwareConcurrency, @@ -64,20 +78,34 @@ puppeteer.use(makeStealth()); // CAPTCHA, so it stays opt-in until that A/B is run - but the reason it was off by // default no longer applies. Required lazily so a stock-engine launch never loads the // dependency. -let _runtimeFixPuppeteer = null; -function getRuntimeFixPuppeteer() { - if (_runtimeFixPuppeteer) return _runtimeFixPuppeteer; +// Two cached variants: with the stealth plugin (headless launches only) and without it +// (every visible profile). See useStealthFor() below for why visible profiles run clean. +const _runtimeFixPuppeteer = { stealth: null, clean: null }; +function getRuntimeFixPuppeteer({ stealth = false } = {}) { + const slot = stealth ? 'stealth' : 'clean'; + if (_runtimeFixPuppeteer[slot]) return _runtimeFixPuppeteer[slot]; // rebrowser reads this from process.env at runtime. enableDisable is the only mode // that keeps main-world page.evaluate working (addBinding is unwired in 22.15.0; // alwaysIsolated loses the main world). if (!process.env.REBROWSER_PATCHES_RUNTIME_FIX_MODE) process.env.REBROWSER_PATCHES_RUNTIME_FIX_MODE = 'enableDisable'; const { addExtra } = require('puppeteer-extra'); const engine = addExtra(require('rebrowser-puppeteer-core')); - engine.use(makeStealth()); - _runtimeFixPuppeteer = engine; + if (stealth) engine.use(makeStealth()); + _runtimeFixPuppeteer[slot] = engine; return engine; } +// The stealth evasions were written to disguise HEADLESS Chromium. A visible, genuine +// browser (real Chrome, Chrome for Testing, fingerprint-chromium) already has the real +// chrome.*, plugins, permissions and iframe behaviour, so the patches only add detectable +// fakes. Measured 29 Sep 2026 on Chrome 153, fixed DevTools port, clean home IP: the plugin +// with zero evasions -> Google results every run; the app's evasion set (with or without +// iframe.contentWindow, the worst single one) -> Google CAPTCHA every run, and CreepJS read +// "60% stealth". So only headless launches get the plugin. +function useStealthFor({ headless }) { + return Boolean(headless) && headless !== 'false'; +} + // Anti-detect (fingerprint-chromium) engine driver: a puppeteer-extra instance with NO // stealth plugin. fingerprint-chromium already hides navigator.webdriver + the @@ -656,7 +684,10 @@ function parseProxyInput(input) { host: input.host, port: Number.parseInt(String(input.port), 10), username: input.username || null, - password: input.password || null + password: input.password || null, + // The country the vendor pull targeted (or the last check saw). Only a tie-breaker + // for the launch geo lookup when the geo services disagree; never sent anywhere. + country: (input.country || input.lastCountry || null) }; } return null; @@ -1031,10 +1062,13 @@ function fingerprintScript(fp) { // every function we patch - and toString itself - look native, with the right .name. const _patched = new WeakSet(); const _origFnToString = Function.prototype.toString; - const _fnToString = function toString() { + // Built as an object METHOD, not a function expression: methods have no .prototype and + // throw on `new`, exactly like real built-ins. A plain function has both, which any + // detector can test in one line ('prototype' in Function.prototype.toString). + const _fnToString = ({ toString() { if (_patched.has(this)) return 'function ' + (this.name || '') + '() { [native code] }'; return _origFnToString.call(this); - }; + } }).toString; try { // Real Chrome: Function.prototype.toString.name === 'toString' and its OWN // toString() reports [native code]. Pin the name explicitly - otherwise it reports @@ -1063,7 +1097,8 @@ function fingerprintScript(fp) { const onProto = proto && Object.getOwnPropertyDescriptor(proto, prop); const target = onProto ? proto : obj; const existing = onProto || Object.getOwnPropertyDescriptor(obj, prop); - const getter = markNative(function () { return value; }, 'get ' + prop); + // An accessor-shorthand getter: no .prototype and not constructible, like the native one. + const getter = markNative(Object.getOwnPropertyDescriptor({ get [prop]() { return value; } }, prop).get, 'get ' + prop); Object.defineProperty(target, prop, { get: getter, configurable: true, enumerable: existing ? existing.enumerable : true }); } catch (e) { try { Object.defineProperty(obj, prop, { get: () => value, configurable: true }); } catch (e2) {} @@ -1071,9 +1106,11 @@ function fingerprintScript(fp) { }; try { - // navigator.webdriver === false, exposed on Navigator.prototype (the property - // EXISTS and is false). undefined - or an own instance prop - is itself a tell. - define(navigator, 'webdriver', false); + // Only touch navigator.webdriver when the browser itself reports true. Launched on a + // fixed DevTools port, Chrome already reports false natively, and replacing that native + // getter is what Google Search detected: this one define() alone produced a CAPTCHA on + // every run, and skipping it gave normal results (29 Sep 2026, Chrome 153). + if (navigator.webdriver === true) define(navigator, 'webdriver', false); } catch (e) {} if (fp.langs && fp.langs.length) { @@ -2389,14 +2426,32 @@ function lookupProxyGeoNode(proxy) { req.setTimeout(8000, () => { try { req.destroy(); } catch (e) {} done(null); }); }); - // ipinfo.io has no AAAA record, so an IPv6-only exit cannot reach it and the profile - // would launch with no geo, which breaks timezone matching. Retry on get.geojs.io, - // which answers over IPv4 and IPv6. - attempt(targetUrl, mapResponse) - .then((geo) => geo || attempt(GEOJS_URL, (j) => { + // Same, over plain HTTP (ip-api.com's free tier has no HTTPS). + const attemptHttp = (url) => new Promise((settle) => { + let settled = false; + const done = (v) => { if (!settled) { settled = true; settle(v); } }; + const req = require('node:http').get(url, { agent, headers: { 'User-Agent': 'Mozilla/5.0', Accept: 'application/json' } }, (res) => { + let data = ''; + res.on('data', (c) => { data += c; }); + res.on('end', () => { try { const j = JSON.parse(data); done(j && j.status === 'success' ? j : null); } catch (e) { done(null); } }); + }); + req.on('error', () => done(null)); + req.setTimeout(8000, () => { try { req.destroy(); } catch (e) {} done(null); }); + }); + + // Ask three geo services in parallel and take the consensus (pickGeoConsensus): a + // single database can misplace a leased proxy range in another country, and the + // profile's timezone and language follow whatever this returns. geojs answers over + // IPv4 and IPv6, so an IPv6-only exit still gets a result when the other two cannot. + Promise.all([ + attempt(targetUrl, mapResponse), + attemptHttp('http://ip-api.com/json/?fields=status,country,countryCode,regionName,city,timezone,lat,lon,isp,query'), + attempt(GEOJS_URL, (j) => { const g = normalizeGeoJs(j); return g ? { status: 'success', query: g.ip, countryCode: g.country, timezone: g.timezone, city: g.city, regionName: g.region, isp: g.isp, lat: g.lat, lon: g.lon } : null; - })) + }) + ]) + .then((answers) => pickGeoConsensus(answers, { countryHint: proxy.country })) .then(resolve, () => resolve(null)); return; } @@ -2439,7 +2494,12 @@ const geoNodeInflight = new Map(); // key -> Promise function proxyGeoKey(proxy) { if (!proxy || !proxy.host || !proxy.port) return null; - return `${String(proxy.type || '').toLowerCase()}://${formatProxyHost(proxy.host)}:${proxy.port}`; + // The username is part of the key: gateway vendors (CatProxies, RapidProxy, NOVADA, + // PacketStream...) put the sticky session and country in the username or password on ONE + // host:port, so keying on host:port alone handed every session the first one's location. + const who = `${proxy.username || ''}\u0000${proxy.password || ''}`; + const tag = require('node:crypto').createHash('sha256').update(who).digest('hex').slice(0, 16); + return `${String(proxy.type || '').toLowerCase()}://${tag}@${formatProxyHost(proxy.host)}:${proxy.port}`; } async function lookupProxyGeoNodeCached(proxy) { @@ -3053,6 +3113,13 @@ async function launchProfileSession(options = {}) { // timeout ("Network.enable timed out") on slower machines/proxies. protocolTimeout: 120000 }; + // Launch on a FIXED free DevTools port, never puppeteer's default --remote-debugging-port=0. + // Chrome treats an auto-assigned (0) debugging port as automation mode: it sets + // navigator.webdriver=true even with --enable-automation stripped, and Google Search then + // serves a CAPTCHA to every profile on every IP, including a clean home connection. + // Measured 29 Sep 2026 on Chrome 153: port 0 -> webdriver=true + CAPTCHA every run; + // a fixed port -> webdriver=false + normal results every run. + launchOptions.debuggingPort = await getFreeLocalPort(); if (resolvedBrowser && resolvedBrowser.exePath) launchOptions.executablePath = resolvedBrowser.exePath; // Set the timezone on the Chrome PROCESS via the TZ env var. Chromium honors it // for ICU/Date/Intl in EVERY context - main thread, dedicated workers, AND @@ -3062,9 +3129,12 @@ async function launchProfileSession(options = {}) { // Engine: stock puppeteer by default; the opt-in rebrowser engine (persistent // Runtime.enable dropped) when "minimize CDP footprint" is on for this launch. - const engine = browserSettings.minimizeCdpFootprint === true ? getRuntimeFixPuppeteer() - : usingAntidetect ? getNativeEngine() - : puppeteer; + // Visible profiles never get the stealth plugin (useStealthFor); that applies to the + // minimize-CDP engine too, which used to add it even on the anti-detect build. + const stealth = !usingAntidetect && useStealthFor({ headless }); + const engine = browserSettings.minimizeCdpFootprint === true ? getRuntimeFixPuppeteer({ stealth }) + : stealth ? puppeteer + : getNativeEngine(); let browser; try { browser = await engine.launch(launchOptions); diff --git a/src/main/ipcHandlers.js b/src/main/ipcHandlers.js index 36c90f6..6246e54 100644 --- a/src/main/ipcHandlers.js +++ b/src/main/ipcHandlers.js @@ -1964,7 +1964,7 @@ function psRotationLabel(rotation) { // the pull downloads what the account already owns from GET proxy/list/{type}, optionally // one order. Measured on his dashboard 15 Sep 2026: 200 IPv6 addresses, USA, all behind one // IPv4 entry host on separate ports, login authentication. -async function fetchProxySellerOrderPool(key, { product, proxyType, orderId }) { +async function fetchProxySellerOrderPool(key, { product, proxyType, orderId, count, existingPorts }) { const label = PROXY_SELLER_ORDER_TYPES[product]; const qs = new URLSearchParams(); const oid = String(orderId || '').trim(); @@ -1976,17 +1976,33 @@ async function fetchProxySellerOrderPool(key, { product, proxyType, orderId }) { if (!rows.length) { throw new Error(`Proxy-Seller: this account has no active ${label} proxies${oid ? ' in that order' : ''}.`); } - return rows.slice(0, 5000).map((r) => ({ + // Honour "how many": an order can hold hundreds of addresses, and a pull of 5 imported all + // 200 IPv6 ones (29 Sep 2026). Addresses already in the pool are skipped, so each pull adds + // the NEXT ones. A blank count keeps the old behaviour and imports the whole order. + let fresh = rows; + if (typeof existingPorts === 'function') { + const held = new Map(); + const kept = []; + for (const r of rows) { + const k = `${r.host}\u0000${r.username}`; + if (!held.has(k)) held.set(k, await existingPorts({ host: r.host, login: r.username }).catch(() => new Set())); + if (!held.get(k).has(r.port)) kept.push(r); + } + fresh = kept; + } + if (!fresh.length) throw new Error(`Proxy-Seller: every ${label} proxy on this account is already in your pool.`); + const wanted = count != null && String(count).trim() !== '' ? clampPoolCount(count, 5, 5000) : 5000; + return fresh.slice(0, wanted).map((r) => ({ type: r.type, host: r.host, port: r.port, username: r.username, password: r.password, label: r.label, country: null, rotationUrl: r.rotationUrl })); } -async function fetchProxySellerPool({ token, country, state, city, count, poolType, life, proxyType, listId, plan, orderId }) { +async function fetchProxySellerPool({ token, country, state, city, count, poolType, life, proxyType, listId, plan, orderId, existingPorts }) { const key = psKey(token); const product = String(plan || 'residential').toLowerCase(); if (PROXY_SELLER_ORDER_TYPES[product]) { - return fetchProxySellerOrderPool(key, { product, proxyType, orderId }); + return fetchProxySellerOrderPool(key, { product, proxyType, orderId, count, existingPorts }); } const pkg = await psPackage(key); if (pkg.is_active === false) throw new Error('Proxy-Seller: the residential package on this account is not active.'); @@ -3167,21 +3183,24 @@ async function testProxyConnectivity(proxy) { const started = Date.now(); - // Primary: HTTPS endpoint - works through both HTTP CONNECT tunnels and SOCKS5. + // Primary: two HTTPS services raced in parallel - both work through HTTP CONNECT tunnels + // and SOCKS5. ipinfo.io has no AAAA record, so an IPv6-only exit (Proxy-Seller IPv6) can + // only reach get.geojs.io; asking them one after another made every IPv6 check wait out + // ipinfo's timeout first (~31 s per proxy, measured 29 Sep 2026). The first answer wins. + const viaIpinfo = httpGetJson('https://ipinfo.io/json', agent, 15000).then((data) => { + if (!data || !data.ip) throw new Error('ipinfo.io returned no IP.'); + return { ip: data.ip, country: data.country || null, region: data.region || null, city: data.city || null, zip: data.postal || null, isp: data.org || null, timezone: data.timezone || null }; + }); + const viaGeojs = httpGetJson(GEOJS_URL, agent, 15000).then((raw) => { + const geo = normalizeGeoJs(raw); + if (!geo) throw new Error('get.geojs.io returned no IP.'); + return { ip: geo.ip, country: geo.country, region: geo.region, city: geo.city, zip: null, isp: geo.isp, timezone: geo.timezone }; + }); try { - const data = await httpGetJson('https://ipinfo.io/json', agent, 15000); - return { - success: true, - ip: data.ip || null, - country: data.country || null, - region: data.region || null, - city: data.city || null, - zip: data.postal || null, - isp: data.org || null, - timezone: data.timezone || null, - latencyMs: Date.now() - started - }; - } catch (primaryError) { + const first = await Promise.any([viaIpinfo, viaGeojs]); + return { success: true, ...first, latencyMs: Date.now() - started }; + } catch (raced) { + const primaryError = raced && raced.errors ? raced.errors[0] : raced; // Fallback: ip-api over plain HTTP (some proxies block 443 or SNI). try { const data = await httpGetJson( @@ -3204,18 +3223,7 @@ async function testProxyConnectivity(proxy) { latencyMs: Date.now() - started }; } catch (fallbackError) { - // Last resort: a dual-stack service. ipinfo.io and ip-api.com publish no AAAA record, - // so an IPv6-only exit (Proxy-Seller IPv6, for one) cannot reach either and a healthy - // proxy was reported dead. get.geojs.io answers on IPv4 and IPv6. - try { - const geo = normalizeGeoJs(await httpGetJson(GEOJS_URL, agent, 15000)); - if (geo) { - return { - success: true, ip: geo.ip, country: geo.country, region: geo.region, city: geo.city, - zip: null, isp: geo.isp, timezone: geo.timezone, latencyMs: Date.now() - started - }; - } - } catch (e) { /* fall through to the most informative earlier error */ } + // (The dual-stack geojs service already ran in the primary race above.) // Prefer whichever attempt named the gateway: the plain-HTTP fallback surfaces the // gateway's own body, while a failed HTTPS CONNECT usually yields only a generic // tunnel error. Fall back to the primary message when the secondary says nothing. @@ -3255,7 +3263,20 @@ async function checkProxy(payload) { if (!proxy || !proxy.host || !proxy.port) throw new Error('No valid proxy provided to check.'); - const result = await testProxyConnectivity(proxy); + let result = await testProxyConnectivity(proxy); + + // A vendor can take a few seconds to activate credentials it has just issued: every + // Proxy-Seller residential port answered HTTP 407 right after a pull and worked ~20 s later + // (29 Sep 2026). So a saved proxy created in the last 3 minutes that fails with 407 is + // retried a few times before it is recorded as dead. Older proxies are not retried. + if (savedId !== null && result && !result.success && /\b407\b/.test(String(result.error || ''))) { + const row = await getPrisma().proxy.findUnique({ where: { id: savedId }, select: { createdAt: true } }); + const ageMs = row && row.createdAt ? Date.now() - new Date(row.createdAt).getTime() : Infinity; + for (let attempt = 0; attempt < 4 && ageMs + attempt * 8000 < 180000 && !result.success && /\b407\b/.test(String(result.error || '')); attempt++) { + await new Promise((r) => setTimeout(r, 8000)); + result = await testProxyConnectivity(proxy); + } + } // Persist health for saved proxies so the pool shows durable status badges. if (savedId !== null) { diff --git a/src/main/proxyVendorUtils.js b/src/main/proxyVendorUtils.js index fc849c3..a1d1e49 100644 --- a/src/main/proxyVendorUtils.js +++ b/src/main/proxyVendorUtils.js @@ -151,6 +151,32 @@ function normalizeGeoJs(j) { }; } +// Pick one geo answer from several services queried through the same proxy. Geo databases +// disagree on leased proxy ranges: on 29 Sep 2026 ipinfo put 169.128.195.52 in Lisbon while +// ip-api and geojs both said Albuquerque, US, and trusting ipinfo alone launched a "US" proxy +// with Portuguese time and language, a loud bot signal. Rule: the country most services agree +// on wins; a tie goes to the country the user asked the vendor for (hint), then to the order +// the services were listed in. The winner's own record supplies timezone/city. Each input is +// the ip-api-shaped object the launch code already uses ({ countryCode, timezone, query, ... }). +function pickGeoConsensus(results, { countryHint } = {}) { + const valid = (Array.isArray(results) ? results : []).filter((r) => r && typeof r === 'object' && /^[A-Z]{2}$/i.test(String(r.countryCode || ''))); + if (!valid.length) return null; + const hint = String(countryHint || '').trim().toUpperCase(); + const votes = new Map(); + valid.forEach((r, i) => { + const cc = String(r.countryCode).toUpperCase(); + const v = votes.get(cc) || { n: 0, first: i }; + v.n += 1; + votes.set(cc, v); + }); + const ranked = [...votes.entries()].sort((a, b) => (b[1].n - a[1].n) + || ((b[0] === hint) - (a[0] === hint)) + || (a[1].first - b[1].first)); + const winner = ranked[0][0]; + const pick = valid.find((r) => String(r.countryCode).toUpperCase() === winner && r.timezone) || valid.find((r) => String(r.countryCode).toUpperCase() === winner); + return { ...pick, countryCode: winner, geoVotes: `${ranked[0][1].n}/${valid.length}` }; +} + // Proxy-Seller products that are sold per IP (an order of N addresses), as opposed to the // residential traffic package. All of them list through GET proxy/list/{type}. const PROXY_SELLER_ORDER_TYPES = Object.freeze({ @@ -804,6 +830,7 @@ function proxySolutionsPage(body, { now = Date.now() } = {}) { } module.exports = { + pickGeoConsensus, mobileProxySpaceRows, proxySolutionsPage, camelPlace, diff --git a/test/captcha-fixes.test.js b/test/captcha-fixes.test.js new file mode 100644 index 0000000..86d6a89 --- /dev/null +++ b/test/captcha-fixes.test.js @@ -0,0 +1,89 @@ +'use strict'; +// Regression tests for the 29 Sep 2026 CAPTCHA investigation. +// 1. Chrome launched with --remote-debugging-port=0 (puppeteer's default) runs in automation +// mode: navigator.webdriver=true and Google Search serves a CAPTCHA on every IP. A fixed +// free port does not. The launch must always pass debuggingPort. +// 2. Geo databases disagree on leased proxy ranges; the launch geo is a consensus of three +// services, with the vendor's target country as the tie-breaker. +// 3. The launch geo cache must not hand one sticky session's location to another session on +// the same gateway host:port. +// 4. Proxy checks race two HTTPS IP services, so IPv6-only exits no longer wait ~31 s. +// 5. A freshly pulled proxy answering 407 is retried briefly before it is marked dead. +// 6. Proxy-Seller per-IP orders honour the requested count and skip addresses already held. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const U = require('../src/main/proxyVendorUtils'); + +const ROOT = path.join(__dirname, '..'); +const ENGINE = fs.readFileSync(path.join(ROOT, 'src', 'main', 'browserEngine.js'), 'utf8'); +const IPC = fs.readFileSync(path.join(ROOT, 'src', 'main', 'ipcHandlers.js'), 'utf8'); + +test('1: every profile launch sets a fixed DevTools port, never port 0', () => { + assert.ok(ENGINE.includes('function getFreeLocalPort'), 'free-port helper exists'); + assert.match(ENGINE, /launchOptions\.debuggingPort = await getFreeLocalPort\(\)/); + const launchAt = ENGINE.indexOf('browser = await engine.launch(launchOptions)'); + const portAt = ENGINE.indexOf('launchOptions.debuggingPort = await getFreeLocalPort()'); + assert.ok(portAt > 0 && portAt < launchAt, 'the port is set before the launch call'); + assert.match(ENGINE, /ignoreDefaultArgs: \['--enable-automation'\]/, 'the automation flag stays stripped'); +}); + +test('2: pickGeoConsensus takes the majority country and its timezone', () => { + const ipinfo = { countryCode: 'PT', timezone: 'Europe/Lisbon', query: '169.128.195.52' }; + const ipapi = { countryCode: 'US', timezone: 'America/Denver', query: '169.128.195.52' }; + const geojs = { countryCode: 'US', timezone: 'America/Denver', query: '169.128.195.52' }; + const g = U.pickGeoConsensus([ipinfo, ipapi, geojs]); + assert.equal(g.countryCode, 'US'); + assert.equal(g.timezone, 'America/Denver'); + assert.equal(g.geoVotes, '2/3'); +}); + +test('2: a tie goes to the vendor target country, then to service order', () => { + const a = { countryCode: 'PT', timezone: 'Europe/Lisbon' }; + const b = { countryCode: 'US', timezone: 'America/Denver' }; + assert.equal(U.pickGeoConsensus([a, b], { countryHint: 'us' }).countryCode, 'US'); + assert.equal(U.pickGeoConsensus([a, b]).countryCode, 'PT', 'no hint: first service wins the tie'); + assert.equal(U.pickGeoConsensus([null, b, null]).countryCode, 'US', 'failed services are ignored'); + assert.equal(U.pickGeoConsensus([null, { countryCode: '' }]), null, 'nothing valid -> null'); +}); + +test('2: the launch lookup asks three services in parallel and passes the country hint', () => { + assert.match(ENGINE, /Promise\.all\(\[\s*attempt\(targetUrl, mapResponse\),\s*attemptHttp\('http:\/\/ip-api\.com/); + assert.match(ENGINE, /pickGeoConsensus\(answers, \{ countryHint: proxy\.country \}\)/); + assert.match(ENGINE, /country: \(input\.country \|\| input\.lastCountry \|\| null\)/); +}); + +test('3: the geo cache key includes the credentials, hashed', () => { + const body = ENGINE.slice(ENGINE.indexOf('function proxyGeoKey'), ENGINE.indexOf('async function lookupProxyGeoNodeCached')); + assert.match(body, /proxy\.username/); + assert.match(body, /createHash\('sha256'\)/, 'credentials never appear in the key in clear'); +}); + +test('4: proxy checks race ipinfo and geojs', () => { + assert.match(IPC, /await Promise\.any\(\[viaIpinfo, viaGeojs\]\)/); +}); + +test('5: a fresh proxy failing with 407 is retried, older ones are not', () => { + const body = IPC.slice(IPC.indexOf('async function checkProxy(payload)'), IPC.indexOf('async function checkProxy(payload)') + 2600); + assert.match(body, /\\b407\\b/); + assert.match(body, /ageMs \+ attempt \* 8000 < 180000/, 'only within 3 minutes of creation'); +}); + +test('6: Proxy-Seller per-IP pulls honour count and skip held addresses', () => { + const body = IPC.slice(IPC.indexOf('async function fetchProxySellerOrderPool'), IPC.indexOf('async function fetchProxySellerPool')); + assert.match(body, /existingPorts\(\{ host: r\.host, login: r\.username \}\)/); + assert.match(body, /fresh\.slice\(0, wanted\)/); + assert.match(IPC, /fetchProxySellerOrderPool\(key, \{ product, proxyType, orderId, count, existingPorts \}\)/); +}); + +test('7: visible profiles launch without the stealth plugin; only headless gets it', () => { + assert.ok(ENGINE.includes('function useStealthFor'), 'the headless-only rule exists'); + assert.match(ENGINE, /const stealth = !usingAntidetect && useStealthFor\(\{ headless \}\)/); + assert.match(ENGINE, /getRuntimeFixPuppeteer\(\{ stealth \}\)/, 'the minimize-CDP engine follows the same rule'); + assert.match(ENGINE, /if \(stealth\) engine\.use\(makeStealth\(\)\)/); + // getNativeEngine (the clean driver) never registers the plugin. + const native = ENGINE.slice(ENGINE.indexOf('function getNativeEngine'), ENGINE.indexOf('function getNativeEngine') + 300); + assert.doesNotMatch(native, /makeStealth/); +}); diff --git a/test/fingerprintLeaks.test.js b/test/fingerprintLeaks.test.js index 8647113..65dc013 100644 --- a/test/fingerprintLeaks.test.js +++ b/test/fingerprintLeaks.test.js @@ -20,14 +20,14 @@ const vm = require('node:vm'); const { fingerprintScript } = require('../src/main/browserEngine'); // Build a minimal browser-like global object and run the real script against it. -function runScriptInSandbox(fp) { +function runScriptInSandbox(fp, { webdriver = null } = {}) { // A fake navigator whose spoofable attributes live on its PROTOTYPE, exactly like a // real Navigator — so the script's prototype-vs-instance logic is exercised for real. const NavProto = {}; const proto = (name, val) => Object.defineProperty(NavProto, name, { get() { return val; }, configurable: true, enumerable: true }); ['webdriver', 'hardwareConcurrency', 'deviceMemory', 'languages', 'language', 'platform', 'vendor'] - .forEach((n) => proto(n, null)); + .forEach((n) => proto(n, n === 'webdriver' ? webdriver : null)); const navigator = Object.create(NavProto); const sandbox = { navigator, console }; @@ -63,8 +63,8 @@ test('Function.prototype.toString.name is "toString" (not the internal helper na assert.doesNotMatch(selfSrc, /_fnToString/); }); -test('navigator.webdriver is false and lives on the prototype (not an own instance prop)', () => { - const s = runScriptInSandbox(FP); +test('navigator.webdriver: a browser reporting true gets a native-looking false on the prototype', () => { + const s = runScriptInSandbox(FP, { webdriver: true }); assert.equal(s.navigator.webdriver, false); assert.equal(Object.prototype.hasOwnProperty.call(s.navigator, 'webdriver'), false, 'webdriver must not be an own property of the navigator instance'); @@ -74,6 +74,26 @@ test('navigator.webdriver is false and lives on the prototype (not an own instan assert.match(desc.get.toString(), /\[native code\]/); assert.doesNotMatch(desc.get.toString(), /=>/); assert.equal(desc.get.name, 'get webdriver'); + // Like a real built-in accessor: no .prototype and not constructible. + assert.equal('prototype' in desc.get, false); + assert.throws(() => new desc.get(), TypeError); +}); + +test('navigator.webdriver: a browser already reporting false is left untouched', () => { + // Replacing the native getter when it already says false was what Google Search + // detected (CAPTCHA on every run, 29 Sep 2026), so the script must not touch it. + const s = runScriptInSandbox(FP, { webdriver: false }); + const desc = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(s.navigator), 'webdriver'); + assert.equal(s.navigator.webdriver, false); + assert.doesNotMatch(desc.get.toString(), /native code/, 'the original getter is still in place'); +}); + +test('patched Function.prototype.toString has no prototype and is not constructible', () => { + const s = runScriptInSandbox(FP); + const ts = s.Function.prototype.toString; + assert.equal('prototype' in ts, false); + assert.throws(() => new ts(), TypeError); + assert.equal(ts.name, 'toString'); }); test('spoofed navigator values are applied and remain non-own', () => { From 6401d61170b7a8f5ded8cb49a6e45e9f9790d16f Mon Sep 17 00:00:00 2001 From: softglazee Date: Tue, 29 Sep 2026 08:52:16 +0500 Subject: [PATCH 2/2] Fix Froxy, Live Proxies and RapidProxy formats, and the provider panel Found while entering the vendor keys and checking each vendor's dashboard: - Proxies that differ only by password could not be saved: "Unique constraint failed on (type,host,port,username)" on Froxy, PacketStream and IPRoyal. The unique index now includes the password (migration 20260929000000). - Froxy: the connector replaced the dashboard password with a SOAX-style "wifi;us;;;" string. Froxy expects ";;;;" (help.froxy.com), and each port 9000-9199 is its own session, so a pull of 5 is now ports 9000-9004. Note: Froxy residential and mobile plans also need the client IP whitelisted on the subscription. - Live Proxies has no downloadable list URL. The connector now builds the same lines as the dashboard's Proxy Generation panel: b2b.liveproxies.io:7383, user LV-lv_, sticky rows on b2b-s with a numeric session. - RapidProxy: "GLOBAL" upper case and an 8-digit numeric session, as the dashboard generator uses; SOCKS5 on the same host:port. - "How many: 5" with a blank session returned one rotating row on the gateway vendors; it now mints that many sticky sessions (1 still means rotating). - Typed keys vanished on switching tab or provider and after a failed pull: the panel keeps a per-provider draft and saves credentials when Pull is clicked. The Live Proxies link and the CatProxies Plan ID are now saved too. - Airproxy, MobileProxy.Space and Proxy-Solutions no longer show a country picker they cannot use. --- .../migration.sql | 8 ++ prisma/schema.prisma | 2 +- src/main/ipcHandlers.js | 104 ++++++++++++------ src/main/proxyVendorUtils.js | 71 +++++++++--- src/renderer/components/ProxyProviders.jsx | 44 ++++++-- test/newProxyConnectors.test.js | 13 +-- test/panel-fixes.test.js | 65 +++++++++++ test/rapidproxy.test.js | 16 +-- 8 files changed, 250 insertions(+), 73 deletions(-) create mode 100644 prisma/migrations/20260929000000_proxy_unique_with_password/migration.sql create mode 100644 test/panel-fixes.test.js diff --git a/prisma/migrations/20260929000000_proxy_unique_with_password/migration.sql b/prisma/migrations/20260929000000_proxy_unique_with_password/migration.sql new file mode 100644 index 0000000..d0cce83 --- /dev/null +++ b/prisma/migrations/20260929000000_proxy_unique_with_password/migration.sql @@ -0,0 +1,8 @@ +-- Let one gateway hold several sticky sessions that differ only in the PASSWORD. +-- Froxy, PacketStream and IPRoyal keep the same username and put the session and country +-- in the password, so the old unique key (type, host, port, username) rejected every row +-- after the first: "Unique constraint failed on the fields: (type,host,port,username)". +-- The pull already de-duplicates on the password for those vendors (dedupeOnPassword); +-- the index now agrees. Index swap only, no table rebuild, no Proxy row is touched. +DROP INDEX IF EXISTS "Proxy_type_host_port_username_key"; +CREATE UNIQUE INDEX IF NOT EXISTS "Proxy_type_host_port_username_password_key" ON "Proxy"("type", "host", "port", "username", "password"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7a18c1d..62d18ed 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -58,7 +58,7 @@ model Proxy { @@index([createdAt]) @@index([ownerMemberId]) @@index([proxyGroupId]) - @@unique([type, host, port, username]) + @@unique([type, host, port, username, password]) } // Proxy categories/folders (e.g. "USA Proxies", "UK", "Japan"). A proxy belongs to diff --git a/src/main/ipcHandlers.js b/src/main/ipcHandlers.js index 6246e54..bb4771b 100644 --- a/src/main/ipcHandlers.js +++ b/src/main/ipcHandlers.js @@ -68,14 +68,15 @@ const { parseLoginList, csvFilter, asnList, FILTER_PATTERNS, proxySellerRotation, unwrapProxySeller, proxySellerGeoView, GEOJS_URL, normalizeGeoJs, PROXY_SELLER_ORDER_TYPES, proxySellerOrderRows, summarizeProxySellerOrders, parseIpRoyalLine, ipRoyalLifetime, ipRoyalLocation, pickIpRoyalPort, ipRoyalCountriesView, ipRoyalErrorMessage, - marsProxiesLocation, parseMarsProxiesList, nodeMavenUsername, nodeMavenTtl, froxyPassword, + marsProxiesLocation, parseMarsProxiesList, nodeMavenUsername, nodeMavenTtl, froxyPassword, froxyBasePassword, packetStreamPassword, airproxyRows, catProxiesResiUsername, catProxiesMobileUsername, catProxiesCreds, rapidProxyUsername, NOVADA_ZONES, novadaUsername, PROXIESSX_POOLS, proxiesSxSid, proxiesSxUsername, proxiesSxProxyCreds, mobileProxySpaceRows, proxySolutionsPage, parseProxidizePerProxy, proxidizePerGbUsername, - liveProxiesListUrl, liveProxiesRows + liveProxiesListUrl, liveProxiesRows, liveProxiesPlanLabel, + LIVEPROXIES_GATEWAY, liveProxiesBaseUser, liveProxiesUsername, liveProxiesStickyHost } = require('./proxyVendorUtils'); const CHANNELS = Object.freeze({ @@ -2403,13 +2404,12 @@ async function fetchNodeMavenPool({ username, password, country, state, city, se // delimiter against a live key. const FROXY_GATEWAY = Object.freeze({ host: 'proxy.froxy.com', port: 9000 }); -async function fetchFroxyPool({ username, password, country, state, city, session, count, poolType, proxyType }) { +async function fetchFroxyPool({ username, password, country, state, city, count, poolType, proxyType }) { const login = String(username || '').trim(); - const pasted = password != null ? String(password) : ''; - if (!login) throw new Error('Froxy: enter your proxy login (dashboard, Export Proxy List). Pick Residential or Mobile below.'); + const base = froxyBasePassword(password); + if (!login) throw new Error('Froxy: enter the Login from the subscription page (Manage, Login/Password Access).'); + if (!base) throw new Error('Froxy: enter the Password from the same page. Only the part before the first ";" is used; the app adds the country.'); const socks = String(proxyType || '').toLowerCase() === 'socks5'; - const host = FROXY_GATEWAY.host; - const port = FROXY_GATEWAY.port; const kindLabel = (() => { const p = String(poolType || '').toLowerCase(); if (p === 'mobile') return 'Mobile'; @@ -2417,30 +2417,18 @@ async function fetchFroxyPool({ username, password, country, state, city, sessio return 'Residential'; })(); const cc = normCountryCode(country); - const n = clampPoolCount(count, 1, 100); - if (!cc) { - // No app-side targeting: use the pasted credentials exactly as the dashboard gives them. - if (!pasted) throw new Error('Froxy: enter the proxy password from the dashboard, or pick a country to target.'); - return [{ type: socks ? 'SOCKS5' : 'HTTP', host, port, username: login, password: pasted, label: `Froxy • ${kindLabel} • gateway`, country: null }]; - } + const pw = froxyPassword(base, { country: cc, region: state, city }); + // One row per port: each port 9000-9199 is a separate session (its own exit IP). + const n = clampPoolCount(count, 1, 200); const rows = []; - const seen = new Set(); - const fixed = String(session || '').trim(); for (let i = 0; i < n; i++) { - const sid = fixed ? (n > 1 ? `${fixed}${i}` : fixed) : crypto.randomBytes(5).toString('hex'); - const pw = froxyPassword({ poolType, country: cc, region: state, city, session: sid }); - const key = `${host}:${port}:${login}:${pw}`; - if (seen.has(key)) continue; - seen.add(key); rows.push({ type: socks ? 'SOCKS5' : 'HTTP', - host, port, username: login, password: pw, - label: `Froxy • ${kindLabel} • ${cc} • sticky`, - country: cc, - dedupeOnPassword: true + host: FROXY_GATEWAY.host, port: FROXY_GATEWAY.port + i, username: login, password: pw, + label: `Froxy • ${kindLabel} • ${cc || 'Any country'} • port ${FROXY_GATEWAY.port + i}`, + country: cc || null }); } - if (!rows.length) throw new Error('Froxy: could not build any proxies.'); return rows; } @@ -2462,7 +2450,9 @@ async function fetchPacketStreamPool({ username, password, country, session, cou const port = socks ? PACKETSTREAM_GATEWAY.socks : PACKETSTREAM_GATEWAY.http; const cc = normCountryCode(country); const where = cc || 'Random'; - const fixed = String(session || '').trim(); + // Blank session + How many > 1: mint that many sticky sessions (one exit IP each), like + // Froxy/NodeMaven already do. Blank session + 1 keeps the single rotating gateway. + const fixed = String(session || '').trim() || (clampPoolCount(count, 1, 100) > 1 ? `sg${crypto.randomBytes(3).toString('hex')}` : ''); if (!fixed) { // Rotating: one gateway row, a fresh IP per request. const pw = packetStreamPassword(base, { country: cc }); @@ -2555,7 +2545,9 @@ async function fetchCatProxiesPool({ token, orderId, poolType, country, state, c const buildUser = (sid) => product.mobile ? catProxiesMobileUsername(username, { country: cc, city, session: sid, ttlMin: lifeMin }) : catProxiesResiUsername(username, { country: cc, state, city, session: sid, lifetimeMin: resiLife }); - const fixed = String(session || '').trim(); + // Blank session + How many > 1: mint that many sticky sessions (one exit IP each), like + // Froxy/NodeMaven already do. Blank session + 1 keeps the single rotating gateway. + const fixed = String(session || '').trim() || (clampPoolCount(count, 1, 100) > 1 ? `sg${crypto.randomBytes(3).toString('hex')}` : ''); if (!fixed) { const user = buildUser(''); return [{ type, host: product.host, port, username: user, password, label: `CatProxies • ${product.kind} • ${where} • rotating`, country: cc || null, dedupeOnPassword: true }]; @@ -2589,7 +2581,9 @@ function gatewayOverride(host, port, def, label) { // Mint `count` sticky rows (one session id each) or a single rotating row, for vendors whose // targeting lives in the username. buildUser(sid) returns the full username for that session. function mintGatewayRows({ type, host, port, password, session, count, buildUser, label }) { - const fixed = String(session || '').trim(); + // Blank session + How many > 1: mint that many sticky sessions (one exit IP each), like + // Froxy/NodeMaven already do. Blank session + 1 keeps the single rotating gateway. + const fixed = String(session || '').trim() || (clampPoolCount(count, 1, 100) > 1 ? `sg${crypto.randomBytes(3).toString('hex')}` : ''); if (!fixed) { return [{ type, host, port, username: buildUser(''), password, label: `${label} • rotating`, dedupeOnPassword: true }]; } @@ -2614,7 +2608,7 @@ function mintGatewayRows({ type, host, port, password, session, count, buildUser // vendor's own examples 2026-09-29; other regional nodes are reachable through the override. const RAPIDPROXY_GATEWAY = Object.freeze({ host: 'us.rapidproxy.io', port: 5001 }); -async function fetchRapidProxyPool({ username, password, country, state, city, session, life, count, host, port }) { +async function fetchRapidProxyPool({ username, password, country, state, city, session, life, count, host, port, poolType }) { const sub = String(username || '').trim(); const pw = password != null ? String(password) : ''; if (!sub || /[\s:@]/.test(sub)) throw new Error('RapidProxy: enter the sub-account username from the dashboard (Residential → sub-accounts).'); @@ -2625,7 +2619,8 @@ async function fetchRapidProxyPool({ username, password, country, state, city, s const gw = gatewayOverride(host, port, RAPIDPROXY_GATEWAY, 'RapidProxy'); const where = cc || 'Global'; const rows = mintGatewayRows({ - type: 'HTTP', host: gw.host, port: gw.port, password: pw, session, count, + // SOCKS5 runs on the same host:port (dashboard test command, 29 Sep 2026). + type: String(poolType || '').toLowerCase() === 'socks5' ? 'SOCKS5' : 'HTTP', host: gw.host, port: gw.port, password: pw, session, count, buildUser: (sid) => rapidProxyUsername(sub, { country: cc, state, city, session: sid, lifeMin: Number(life) }), label: `RapidProxy • ${where}` }).map((r) => ({ ...r, country: cc || null })); @@ -2844,7 +2839,10 @@ async function fetchProxidizePool({ token, username, host, country, state, city, // shape as username:password@host:port; see liveProxiesListUrl for why the URL comes from // the user's dashboard rather than a hardcoded endpoint). The list URL already carries the // account access code, so an optional Bearer token is sent only when the user supplies one. -async function fetchLiveProxiesPool({ apiUrl, token, country, proxyType, poolType }) { +async function fetchLiveProxiesPool({ apiUrl, token, username, password, country, session, count, proxyType, poolType }) { + // Gateway mode (what the dashboard actually offers): username LV + proxy password. + if (String(username || '').trim()) return mintLiveProxiesRows({ username, password, country, session, count, proxyType, poolType }); + // Legacy: a pasted list link, kept for anyone who saved one before this change. const url = liveProxiesListUrl(apiUrl); if (!url) { throw new Error('Live Proxies: paste the proxy list URL from your dashboard (it must be an https liveproxies.io link).'); @@ -2870,6 +2868,45 @@ async function fetchLiveProxiesPool({ apiUrl, token, country, proxyType, poolTyp return rows; } +// Live Proxies gateway rows, built the way the dashboard's Proxy Generation panel builds them +// (LIVEPROXIES_GATEWAY). Blank session + How many 1 = one rotating row; otherwise that many +// sticky rows, each on its own b2b-s server with its own numeric session (60 minutes). +function mintLiveProxiesRows({ username, password, country, session, count, proxyType, poolType }) { + const acct = liveProxiesBaseUser(username); + const pw = password != null ? String(password) : ''; + if (!acct) throw new Error('Live Proxies: enter the username from the dashboard (Proxy Information, for example LV58712).'); + if (!pw) throw new Error('Live Proxies: enter the proxy password from the dashboard (Proxy Information).'); + const cc = normCountryCode(country); + if (!cc) throw new Error('Live Proxies: pick a country. Every Live Proxies line carries one (for example United States).'); + const socks = String(proxyType || '').toLowerCase() === 'socks5'; + const kind = liveProxiesPlanLabel(poolType); + const n = clampPoolCount(count, 1, 100); + const typed = String(session || '').replace(/\D/g, '').slice(0, 7); + if (!typed && n <= 1) { + return [{ + type: socks ? 'SOCKS5' : 'HTTP', + host: socks ? LIVEPROXIES_GATEWAY.socksHost : LIVEPROXIES_GATEWAY.http, + port: socks ? LIVEPROXIES_GATEWAY.socksPort : LIVEPROXIES_GATEWAY.port, + username: liveProxiesUsername(acct, { country: cc }), password: pw, + label: `Live Proxies • ${kind} • ${cc} • rotating`, country: cc + }]; + } + const rows = []; + for (let i = 0; i < n; i++) { + // Up to 8 digits: a typed session number keeps its digits and adds the index; a blank one + // gets a random 6-digit id per row, like the dashboard. + const sid = typed ? `${typed}${i + 1}`.slice(0, 8) : String(100000 + crypto.randomInt(0, 900000)); + rows.push({ + type: socks ? 'SOCKS5' : 'HTTP', + host: socks ? LIVEPROXIES_GATEWAY.socksHost : liveProxiesStickyHost(i + 1), + port: socks ? LIVEPROXIES_GATEWAY.socksPort : LIVEPROXIES_GATEWAY.port, + username: liveProxiesUsername(acct, { country: cc, sid }), password: pw, + label: `Live Proxies • ${kind} • ${cc} • sticky ${sid}`, country: cc + }); + } + return rows; +} + const VENDOR_LOOKUPS = Object.freeze({ dataimpulse: lookupDataImpulse, proxyseller: lookupProxySeller, @@ -6622,8 +6659,9 @@ async function setGlobalSettings(payload) { // keyed by provider; secret fields are DPAPI-sealed via secretStore (same pattern // as IpProvider). Pre-filled into the Proxy Providers modal on open. // --------------------------------------------------------------------------- -const PROXY_CRED_SECRET_FIELDS = ['password', 'token', 'apiToken']; -const PROXY_CRED_PLAIN_FIELDS = ['username', 'zone', 'plan', 'host', 'port']; +// apiUrl is a secret: a Live Proxies list link carries the account access code. +const PROXY_CRED_SECRET_FIELDS = ['password', 'token', 'apiToken', 'apiUrl']; +const PROXY_CRED_PLAIN_FIELDS = ['username', 'zone', 'plan', 'host', 'port', 'orderId']; async function readProxyProviderCredMap() { const raw = await readSetting('proxyProviderCreds', {}); diff --git a/src/main/proxyVendorUtils.js b/src/main/proxyVendorUtils.js index a1d1e49..137b79c 100644 --- a/src/main/proxyVendorUtils.js +++ b/src/main/proxyVendorUtils.js @@ -429,11 +429,14 @@ function nodeMavenUsername(login, { country, region, city, isp, type, sid, ttl, } // --------------------------------------------------------------------------------------- -// Froxy (SOAX reseller; gateway proxy.froxy.com:9000). Targeting rides in the PASSWORD as -// ";;;;" (spaces -> '+'); type is wifi (residential), mobile, -// or fast (datacenter). The login authenticates. Verified against Froxy's own connection -// guide; the trailing sticky-session field follows the SOAX convention this gateway inherits -// (confirm the exact delimiter against a live key before relying on sticky). +// Froxy (gateway proxy.froxy.com, ports 9000-9199). Login/password access: the LOGIN is the +// package and the PASSWORD is the dashboard password followed by the geo fields, separated +// by ";": ";;;;" (help.froxy.com, Login-Password +// Authorization; blog example "password;country;;;"). The dashboard shows the bare password +// with empty fields, e.g. "qwerty;;;;", so only the part before the first ";" is kept as the +// base. Each port 9000-9199 is its own session, which is how a pull gets several exit IPs. +// The previous builder replaced the whole password with "wifi;;..." (a SOAX pattern), +// which Froxy rejected and reported as "your IP is not in the whitelist" (29 Sep 2026). // --------------------------------------------------------------------------------------- function froxyType(poolType) { const t = String(poolType || '').trim().toLowerCase(); @@ -444,13 +447,12 @@ function froxyType(poolType) { function froxyField(v) { return String(v || '').trim().toLowerCase().replace(/\s+/g, '+').replace(/[^a-z0-9+]/g, ''); } -function froxyPassword({ poolType, country, region, city, session } = {}) { +function froxyBasePassword(pasted) { + return String(pasted == null ? '' : pasted).split(';')[0].trim(); +} +function froxyPassword(base, { country, region, city } = {}) { const cc = String(country || '').trim().toLowerCase().replace(/[^a-z]/g, ''); - const parts = [froxyType(poolType), /^[a-z]{2}$/.test(cc) ? cc : '', '', froxyField(region), froxyField(city)]; - let pw = parts.join(';'); - const sid = String(session || '').trim().replace(/[^A-Za-z0-9]/g, ''); - if (sid) pw += `;sessionid;${sid}`; - return pw; + return [froxyBasePassword(base), /^[a-z]{2}$/.test(cc) ? cc : '', froxyField(region), froxyField(city), ''].join(';'); } // --------------------------------------------------------------------------------------- @@ -647,6 +649,33 @@ function liveProxiesRows(text, { socks = false, country = '', plan = 'residentia })); } +// --------------------------------------------------------------------------------------- +// Live Proxies gateway (read from the dashboard Proxy Generation panel, 29 Sep 2026). There +// is no downloadable list URL: the dashboard builds lines from one account username (LV) +// and one proxy password, and so does the app. +// Rotating: b2b.liveproxies.io:7383 user LV58712-lv_us +// Sticky: b2b-s.liveproxies.io:7383 user LV58712-lv_us- (60 min) +// SOCKS5: socks.liveproxies.io:1080 with the same username/password +// Country = "lv_" + lowercase ISO-2 code (the generator offers 59 countries). +const LIVEPROXIES_GATEWAY = Object.freeze({ http: 'b2b.liveproxies.io', port: 7383, socksHost: 'socks.liveproxies.io', socksPort: 1080 }); +function liveProxiesBaseUser(raw) { + // Accept "LV58712" or a full generated username ("LV58712-lv_us-123") and keep the account part. + const m = String(raw || '').trim().match(/^([A-Za-z]{2}\d{2,})/); + return m ? m[1].toUpperCase() : ''; +} +function liveProxiesUsername(base, { country, sid } = {}) { + const acct = liveProxiesBaseUser(base); + const cc = String(country || '').trim().toLowerCase().replace(/[^a-z]/g, ''); + if (!acct || !/^[a-z]{2}$/.test(cc)) return ''; + const s = String(sid || '').replace(/\D/g, '').slice(0, 8); + return `${acct}-lv_${cc}${s ? `-${s}` : ''}`; +} +// Sticky server for the Nth sticky proxy (1-based), exactly as the dashboard hands them out. +function liveProxiesStickyHost(n) { + const i = Math.max(1, Number.parseInt(String(n), 10) || 1); + return `b2b-s${i}.liveproxies.io`; +} + // Shared: "new york" / "New-York" -> "NewYork" (vendors that want CamelCase place names). function camelPlace(value) { return String(value || '').trim().split(/[\s_-]+/).filter(Boolean) @@ -664,18 +693,28 @@ function camelPlace(value) { // spaces; City is only documented under a State, and both need a country. stime (1-180 // minutes) only works with a session. Source: rapidproxy.io/proxy examples, 2026-09-29. const RAPIDPROXY_MAX_STIME = 180; +// The dashboard generator uses a random 8-digit NUMBER as the session id (sid). A typed or +// auto-generated name is kept if it is already 1-8 digits, else mapped to a stable 8-digit number. +function rapidProxySid(session) { + const raw = String(session || '').trim(); + if (!raw) return ''; + if (/^\d{1,8}$/.test(raw)) return raw; + let h = 2166136261; + for (const ch of raw) { h ^= ch.charCodeAt(0); h = Math.imul(h, 16777619) >>> 0; } + return String(10000000 + (h % 90000000)); +} function rapidProxyUsername(base, { country, state, city, session, lifeMin } = {}) { const sub = String(base || '').trim(); const cc = String(country || '').trim().toUpperCase().replace(/[^A-Z]/g, ''); const hasCc = /^[A-Z]{2}$/.test(cc); - let user = `${sub}-residential-${hasCc ? cc : 'global'}`; + let user = `${sub}-residential-${hasCc ? cc : 'GLOBAL'}`; const st = camelPlace(state); if (hasCc && st) { user += `-state-${st}`; const ct = camelPlace(city); if (ct) user += `-city-${ct}`; } - const sid = String(session || '').trim().replace(/[^A-Za-z0-9]/g, ''); + const sid = rapidProxySid(session); if (sid) { user += `-session-${sid}`; const m = Number(lifeMin); @@ -830,6 +869,12 @@ function proxySolutionsPage(body, { now = Date.now() } = {}) { } module.exports = { + rapidProxySid, + froxyBasePassword, + LIVEPROXIES_GATEWAY, + liveProxiesBaseUser, + liveProxiesUsername, + liveProxiesStickyHost, pickGeoConsensus, mobileProxySpaceRows, proxySolutionsPage, diff --git a/src/renderer/components/ProxyProviders.jsx b/src/renderer/components/ProxyProviders.jsx index 1a5cfa8..e4c10f4 100644 --- a/src/renderer/components/ProxyProviders.jsx +++ b/src/renderer/components/ProxyProviders.jsx @@ -65,19 +65,19 @@ export const PROVIDERS = [ // Live Proxies: the dashboard gives each plan its own proxy list link (the access code is // already inside that URL), so the pull takes the link rather than a guessed endpoint. // Credentials come back as username:password@host:port, which the shared parser handles. - { key: 'liveproxies', name: 'Live Proxies', initials: 'LV', color: '#10b981', referral: 'https://liveproxies.io/dashboard/overview', gateway: null, geoSync: { creds: [], apiUrl: true, poolType: [['residential', 'Rotating residential'], ['static', 'Static residential'], ['mobile', 'Rotating mobile']] } }, + { key: 'liveproxies', name: 'Live Proxies', initials: 'LV', color: '#10b981', referral: 'https://liveproxies.io/dashboard/overview', gateway: null, geoSync: { creds: ['username', 'password'], count: true, session: true, poolType: [['residential', 'Rotating residential'], ['static', 'Static residential'], ['mobile', 'Rotating mobile']] } }, // PacketStream: one residential gateway. Username + proxy password from the dashboard // Network Access page; country + sticky session ride in the password. No state/city. { key: 'packetstream', name: 'PacketStream', initials: 'PK', color: '#3b82f6', referral: 'https://packetstream.io/', gateway: { host: 'proxy.packetstream.io', port: 31112, type: 'HTTP' }, geoSync: { creds: ['username', 'password'], count: true, session: true } }, // Airproxy: dedicated mobile proxies. The API key lists the proxies already on the account // (GET /api/proxy/list/); location is fixed by the SIM, so no country/count controls. - { key: 'airproxy', name: 'Airproxy', initials: 'AR', color: '#fb923c', referral: 'https://airproxy.io/', gateway: null, geoSync: { creds: ['token'] } }, + { key: 'airproxy', name: 'Airproxy', initials: 'AR', color: '#fb923c', referral: 'https://airproxy.io/', gateway: null, geoSync: { creds: ['token'], noCountry: true } }, // CatProxies: the cp_ API key + a Plan ID (from Active Plans) fetch that plan's proxy // credentials; targeting rides in the username. Standard Residential + Rotating Mobile, US. { key: 'catproxies', name: 'CatProxies', initials: 'CP', color: '#eab308', referral: 'https://catproxies.com/', gateway: null, geoSync: { creds: ['token'], planId: true, poolType: [['residential', 'Standard Residential'], ['mobile', 'Rotating Mobile']], count: true, session: true, life: true } }, // RapidProxy: no management API, so rows are minted on us.rapidproxy.io:5001 from a dashboard // sub-account; country/state/city and a sticky session (up to 180 min) ride in the username. - { key: 'rapidproxy', name: 'RapidProxy', initials: 'RP', color: '#06b6d4', referral: 'https://www.rapidproxy.io/', gateway: { host: 'us.rapidproxy.io', port: 5001, type: 'HTTP' }, geoSync: { creds: ['username', 'password'], count: true, geo: true, life: true, gateway: true } }, + { key: 'rapidproxy', name: 'RapidProxy', initials: 'RP', color: '#06b6d4', referral: 'https://www.rapidproxy.io/', gateway: { host: 'us.rapidproxy.io', port: 5001, type: 'HTTP' }, geoSync: { creds: ['username', 'password'], poolType: [['http', 'HTTP'], ['socks5', 'SOCKS5']], count: true, geo: true, life: true, gateway: true } }, // NOVADA: a proxy user (not the login email) on super.novada.pro:7777; zone, country, state, // city and a sticky session (up to 120 min) ride in the username. Regional host overridable. { key: 'novada', name: 'NOVADA', initials: 'NV', color: '#2563eb', referral: 'https://dashboard.novada.com/', gateway: { host: 'super.novada.pro', port: 7777, type: 'HTTP' }, geoSync: { creds: ['username', 'password'], poolType: [['residential', 'Residential'], ['isp', 'Rotating ISP'], ['mobile', 'Mobile']], count: true, geo: true, life: true, gateway: true } }, @@ -85,9 +85,9 @@ export const PROVIDERS = [ // failover-strict so a country without stock fails instead of exiting elsewhere. { key: 'proxiessx', name: 'Proxies.sx', initials: 'SX', color: '#7c3aed', referral: 'https://client.proxies.sx/', gateway: null, geoSync: { creds: ['token'], poolType: [['mobile', 'Mobile (modems)'], ['residential', 'Residential (peers)']], count: true, session: true } }, // MobileProxy.Space: dedicated mobile proxies listed from the account with an API token. - { key: 'mobileproxyspace', name: 'MobileProxy.Space', initials: 'MS', color: '#16a34a', referral: 'https://mobileproxy.space/', gateway: null, geoSync: { creds: ['token'], poolType: [['http', 'HTTP'], ['socks5', 'SOCKS5']] } }, + { key: 'mobileproxyspace', name: 'MobileProxy.Space', initials: 'MS', color: '#16a34a', referral: 'https://mobileproxy.space/', gateway: null, geoSync: { creds: ['token'], noCountry: true, poolType: [['http', 'HTTP'], ['socks5', 'SOCKS5']] } }, // Proxy-Solutions: the anti-detect provider key lists every active proxy on the account. - { key: 'proxysolutions', name: 'Proxy-Solutions', initials: 'PN', color: '#dc2626', referral: 'https://proxy-solutions.net/', gateway: null, geoSync: { creds: ['token'], poolType: [['http', 'HTTP'], ['socks5', 'SOCKS5']] } } + { key: 'proxysolutions', name: 'Proxy-Solutions', initials: 'PN', color: '#dc2626', referral: 'https://proxy-solutions.net/', gateway: null, geoSync: { creds: ['token'], noCountry: true, poolType: [['http', 'HTTP'], ['socks5', 'SOCKS5']] } } ]; // IPRoyal sticky lifetimes, in the "{n}m" / "{n}h" format its API takes (1 second to 168 hours). @@ -129,8 +129,12 @@ export const PROXY_COUNTRIES = [ ['BR', 'Brazil'], ['MX', 'Mexico'], ['AR', 'Argentina'], ['CL', 'Chile'], ['CO', 'Colombia'], ['NZ', 'New Zealand'] ]; +// Per-provider form drafts for this app session. Module scope, so they survive the panel +// unmounting when the user opens another tab, and switching between providers. +const FORM_DRAFTS = new Map(); + const GEO_HINTS = { - liveproxies: 'Live Proxies gives each plan its own proxy list link in the dashboard, and your account access code is already inside that link, so nothing else needs entering. Open the dashboard, copy the proxy list URL for the plan you want, and paste it above; the link must be an https liveproxies.io one. Credentials arrive as username:password@host:port. Pick the plan type so the pulled proxies are labelled correctly. A rotating plan is one gateway endpoint that changes exit IP per request, so it imports as a single proxy; a static plan imports one row per IP.', + liveproxies: 'Live Proxies builds every proxy on its gateway (b2b.liveproxies.io:7383). Enter the Username (for example LV58712) and Password shown under Proxy Information on the dashboard, and pick a country. How many 1 with no session gives one rotating proxy; more than 1 gives that many sticky proxies, each on its own server with a 60 minute session. A sticky session is digits only.', apify: 'Apify residential routes through one gateway (proxy.apify.com:8000); the country and a sticky session are encoded into the username. Each pull mints that many sticky residential IPs you can assign to profiles. Use the password from Apify Console → Proxy → HTTP settings. Note: this vendor publishes no IPv6 option, so every exit is IPv4. Oxylabs is the one configured provider with a documented IPv6 selector.', smartproxyorg: 'Smartproxy.org (Long-Acting ISP) routes through isp.smartproxy.net:3100 and embeds area (country) + optional state/city + a sticky lifetime/session into the proxy username. Enter your sub-account username (smart-…) and its password. "Keep same IP" sets how long one exit IP stays fixed (5 min up to 24 h); leave it on "Different each time" with a blank session to mint several rotating IPs. If your dashboard shows a different host:port, override it below.', shopsocks5: 'ShopSocks5 pulls your purchased SOCKS5 (or HTTPS) list via its API, filtered to the chosen country/state/city. The API authenticates with your account username/email + API token TOGETHER - token alone returns “User or Api Token incorrect”. Pick the Plan that matches your subscription (Premium / List / Daily).', @@ -179,7 +183,11 @@ export default function ProxyProviders({ onSynced }) { // Proxy-Seller shares the protocol and session vocabulary. Sticky is the default for // both because an anti-detect profile needs an exit IP that stays put. const di = provider.key === 'dataimpulse' || provider.key === 'proxyseller' || provider.key === 'iproyal'; - setForm({ host: gw.host, port: String(gw.port), username: '', password: '', token: '', bdpm: false, apiToken: '', zone: '', country: '', count: '5', state: '', city: '', session: '', life: '', apiUrl: '', plan: di ? 'residential' : 'premium', proxyType: di ? 'http' : 'proxy_sock_5', poolType: di ? 'sticky' : 'residential', teamId: '', ipv6: false, zip: '', asn: '', excludeCountries: '', excludeAsns: '', listId: '', source: 'new', orderId: '', subuserHash: '' }); + // Restore what was typed for this provider earlier in the session (switching provider or + // leaving the tab used to wipe it, including pasted keys from a pull that had failed). + const draft = FORM_DRAFTS.get(provider.key); + if (draft) setForm({ ...draft }); + else setForm({ host: gw.host, port: String(gw.port), username: '', password: '', token: '', bdpm: false, apiToken: '', zone: '', country: '', count: '5', state: '', city: '', session: '', life: '', apiUrl: '', plan: di ? 'residential' : 'premium', proxyType: di ? 'http' : 'proxy_sock_5', poolType: di ? 'sticky' : 'residential', teamId: '', ipv6: false, zip: '', asn: '', excludeCountries: '', excludeAsns: '', listId: '', source: 'new', orderId: '', subuserHash: '' }); setAccount(null); setLookingUp(''); setGeoOptions({ state: [], city: [], region: [], country: [] }); @@ -197,7 +205,8 @@ export default function ProxyProviders({ onSynced }) { try { softglazeApi.proxies.getProviderCreds(provider.key) .then((c) => { - if (!live || !c || !c.found) return; + // A session draft holds what the user typed most recently, so it wins over saved creds. + if (!live || !c || !c.found || FORM_DRAFTS.has(provider.key)) return; setForm((f) => ({ ...f, ...(c.username != null ? { username: c.username } : {}), @@ -207,7 +216,9 @@ export default function ProxyProviders({ onSynced }) { ...(c.zone != null ? { zone: c.zone } : {}), ...(c.plan != null ? { plan: c.plan } : {}), ...(c.host ? { host: c.host } : {}), - ...(c.port != null ? { port: String(c.port) } : {}) + ...(c.port != null ? { port: String(c.port) } : {}), + ...(c.apiUrl != null ? { apiUrl: c.apiUrl } : {}), + ...(c.orderId != null ? { orderId: c.orderId } : {}) })); }) .catch(() => {}); @@ -215,7 +226,11 @@ export default function ProxyProviders({ onSynced }) { return () => { live = false; }; }, [provider]); - const set = (k, v) => setForm((f) => ({ ...f, [k]: v })); + const set = (k, v) => setForm((f) => { + const next = { ...f, [k]: v }; + FORM_DRAFTS.set(provider.key, next); + return next; + }); // Remember the entered credentials for this provider so they're pre-filled next // time. Best-effort; secrets are DPAPI-sealed in the main process. Empty fields @@ -226,7 +241,7 @@ export default function ProxyProviders({ onSynced }) { provider: provider.key, username: form.username, password: form.password, token: form.token, apiToken: form.apiToken, zone: form.zone, plan: form.plan, - host: form.host, port: form.port + host: form.host, port: form.port, apiUrl: form.apiUrl, orderId: form.orderId }).catch(() => {}); } catch (e) { /* ignore */ } } @@ -241,7 +256,7 @@ export default function ProxyProviders({ onSynced }) { provider: provider.key, username: form.username, password: form.password, token: form.token, apiToken: form.apiToken, zone: form.zone, plan: form.plan, - host: form.host, port: form.port + host: form.host, port: form.port, apiUrl: form.apiUrl, orderId: form.orderId }); setCredsSaved(true); setTimeout(() => setCredsSaved(false), 2500); @@ -319,6 +334,9 @@ export default function ProxyProviders({ onSynced }) { const fromList = g.ps && form.plan === 'residential' && form.source === 'existing'; if (fromList && !form.listId) { setErr(t('proxyProviders.ps.pickList', 'Pick one of your saved lists, or switch to New list.')); return; } setSyncing(true); + // Save what was typed as soon as Pull is clicked, not only after a successful pull, + // so a failed pull no longer throws the pasted keys away. + persistCreds(); try { const r = await softglazeApi.proxies.syncVendorPool({ provider: provider.key, @@ -972,6 +990,7 @@ export default function ProxyProviders({ onSynced }) { {t('proxyProviders.geo.title')} + {!provider.geoSync.noCountry && (
@@ -986,6 +1005,7 @@ export default function ProxyProviders({ onSynced }) {
)}
+ )} {provider.geoSync.poolType && (
diff --git a/test/newProxyConnectors.test.js b/test/newProxyConnectors.test.js index 4fbad9f..ea8d564 100644 --- a/test/newProxyConnectors.test.js +++ b/test/newProxyConnectors.test.js @@ -82,16 +82,15 @@ test('nodeMavenTtl validates the {n}s/m/h shape', () => { }); // --- Froxy ----------------------------------------------------------------------------- -test('froxyPassword builds the SOAX-style targeting string', () => { - assert.equal(U.froxyPassword({ poolType: 'residential', country: 'US', region: 'South Carolina', city: 'Myrtle Beach' }), - 'wifi;us;;south+carolina;myrtle+beach'); - assert.equal(U.froxyPassword({ poolType: 'mobile', country: 'US' }), 'mobile;us;;;'); - assert.equal(U.froxyPassword({}), 'wifi;;;;'); +test('froxyPassword appends the geo fields to the dashboard password (help.froxy.com format)', () => { + assert.equal(U.froxyPassword('qwerty', { country: 'US' }), 'qwerty;us;;;'); + assert.equal(U.froxyPassword('qwerty;;;;', { country: 'us' }), 'qwerty;us;;;', 'a pasted password with empty fields keeps only the base'); + assert.equal(U.froxyPassword('qwerty', { country: 'US', region: 'South Carolina', city: 'Myrtle Beach' }), 'qwerty;us;south+carolina;myrtle+beach;'); + assert.equal(U.froxyPassword('qwerty', {}), 'qwerty;;;;', 'no country = any location'); + assert.equal(U.froxyBasePassword('abc;de;;;'), 'abc'); assert.equal(U.froxyType('mobile'), 'mobile'); assert.equal(U.froxyType('datacenter'), 'fast'); assert.equal(U.froxyType('anything'), 'wifi'); - assert.equal(U.froxyPassword({ poolType: 'datacenter', country: 'us' }), 'fast;us;;;', 'datacenter -> fast'); - assert.equal(U.froxyPassword({ country: 'us', session: 'abc' }), 'wifi;us;;;;sessionid;abc'); }); // --- Proxidize ------------------------------------------------------------------------- diff --git a/test/panel-fixes.test.js b/test/panel-fixes.test.js new file mode 100644 index 0000000..49c8e1f --- /dev/null +++ b/test/panel-fixes.test.js @@ -0,0 +1,65 @@ +'use strict'; +// Provider-panel and pull fixes, 29 Sep 2026 (reported while entering vendor keys): +// - Froxy/PacketStream/IPRoyal rows differ only by password -> "Unique constraint failed +// on (type,host,port,username)". The unique index now includes the password. +// - Live Proxies has no list URL; the connector now builds the dashboard's own lines. +// - "How many: 5" with a blank session returned 1 row on gateway vendors. +// - Typed keys vanished on tab/provider switch and after a failed pull. +// - Airproxy / MobileProxy.Space / Proxy-Solutions showed a country picker they cannot use. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const U = require('../src/main/proxyVendorUtils'); + +const ROOT = path.join(__dirname, '..'); +const IPC = fs.readFileSync(path.join(ROOT, 'src', 'main', 'ipcHandlers.js'), 'utf8'); +const UI = fs.readFileSync(path.join(ROOT, 'src', 'renderer', 'components', 'ProxyProviders.jsx'), 'utf8'); +const SCHEMA = fs.readFileSync(path.join(ROOT, 'prisma', 'schema.prisma'), 'utf8'); +const MIG = fs.readFileSync(path.join(ROOT, 'prisma', 'migrations', '20260929000000_proxy_unique_with_password', 'migration.sql'), 'utf8'); + +test('Proxy unique key includes the password (schema + migration agree)', () => { + assert.match(SCHEMA, /@@unique\(\[type, host, port, username, password\]\)/); + assert.match(MIG, /DROP INDEX IF EXISTS "Proxy_type_host_port_username_key"/); + assert.match(MIG, /CREATE UNIQUE INDEX IF NOT EXISTS "Proxy_type_host_port_username_password_key" ON "Proxy"\("type", "host", "port", "username", "password"\)/); +}); + +test('Live Proxies usernames match the dashboard generator', () => { + assert.equal(U.liveProxiesUsername('LV58712', { country: 'US' }), 'LV58712-lv_us'); + assert.equal(U.liveProxiesUsername('LV58712', { country: 'gb', sid: '1936119' }), 'LV58712-lv_gb-1936119'); + assert.equal(U.liveProxiesUsername('LV58712-lv_us-123', { country: 'CA' }), 'LV58712-lv_ca', 'a pasted full username keeps only the account'); + assert.equal(U.liveProxiesUsername('LV58712', { country: 'US', sid: 'ab123456789' }), 'LV58712-lv_us-12345678', 'sid is digits only, max 8'); + assert.equal(U.liveProxiesUsername('LV58712', {}), '', 'a country is required'); + assert.equal(U.liveProxiesStickyHost(1), 'b2b-s1.liveproxies.io'); + assert.equal(U.liveProxiesStickyHost(10), 'b2b-s10.liveproxies.io'); + assert.equal(U.LIVEPROXIES_GATEWAY.port, 7383); +}); + +test('Live Proxies adapter uses gateway mode when a username is given', () => { + assert.match(IPC, /if \(String\(username \|\| ''\)\.trim\(\)\) return mintLiveProxiesRows\(/); + assert.match(IPC, /host: socks \? LIVEPROXIES_GATEWAY\.socksHost : liveProxiesStickyHost\(i \+ 1\)/); + assert.match(UI, /key: 'liveproxies'.*creds: \['username', 'password'\], count: true, session: true/); +}); + +test('blank session + How many > 1 mints sticky sessions on the gateway vendors', () => { + const hits = IPC.match(/const fixed = String\(session \|\| ''\)\.trim\(\) \|\| \(clampPoolCount\(count, 1, 100\) > 1 \? `sg\$\{crypto\.randomBytes\(3\)\.toString\('hex'\)\}` : ''\);/g) || []; + assert.equal(hits.length, 3, 'PacketStream, CatProxies and the shared gateway minter'); +}); + +test('typed provider fields survive tab/provider switches and failed pulls', () => { + assert.match(UI, /const FORM_DRAFTS = new Map\(\);/); + assert.match(UI, /FORM_DRAFTS\.set\(provider\.key, next\)/); + assert.match(UI, /const draft = FORM_DRAFTS\.get\(provider\.key\);/); + const pull = UI.slice(UI.indexOf('async function handleGeoSync'), UI.indexOf('async function handleLookup')); + assert.ok(pull.indexOf('persistCreds();') < pull.indexOf('syncVendorPool('), 'creds are saved before the pull runs'); + assert.match(IPC, /PROXY_CRED_SECRET_FIELDS = \['password', 'token', 'apiToken', 'apiUrl'\]/); + assert.match(IPC, /PROXY_CRED_PLAIN_FIELDS = \['username', 'zone', 'plan', 'host', 'port', 'orderId'\]/); +}); + +test('list-only providers hide the country picker', () => { + for (const key of ['airproxy', 'mobileproxyspace', 'proxysolutions']) { + assert.match(UI, new RegExp(`key: '${key}'.*noCountry: true`), key); + } + assert.match(UI, /\{!provider\.geoSync\.noCountry && \(/); +}); diff --git a/test/rapidproxy.test.js b/test/rapidproxy.test.js index 1ebda24..6fccd2b 100644 --- a/test/rapidproxy.test.js +++ b/test/rapidproxy.test.js @@ -16,20 +16,22 @@ const UI = fs.readFileSync(path.join(ROOT, 'src', 'renderer', 'components', 'Pro test('rapidProxyUsername matches the documented examples', () => { assert.equal(U.rapidProxyUsername('acc', { country: 'KR' }), 'acc-residential-KR'); - assert.equal(U.rapidProxyUsername('acc', {}), 'acc-residential-global', 'no country = global'); + assert.equal(U.rapidProxyUsername('acc', {}), 'acc-residential-GLOBAL', 'no country = global'); assert.equal(U.rapidProxyUsername('acc', { country: 'US', state: 'California' }), 'acc-residential-US-state-California'); assert.equal(U.rapidProxyUsername('acc', { country: 'US', state: 'Mississippi', city: 'Jackson' }), 'acc-residential-US-state-Mississippi-city-Jackson'); - assert.equal(U.rapidProxyUsername('acc', { session: '14675113' }), 'acc-residential-global-session-14675113'); - assert.equal(U.rapidProxyUsername('acc', { session: '14675113', lifeMin: 20 }), 'acc-residential-global-session-14675113-stime-20'); + assert.equal(U.rapidProxyUsername('acc', { session: '14675113' }), 'acc-residential-GLOBAL-session-14675113'); + assert.equal(U.rapidProxyUsername('acc', { session: '14675113', lifeMin: 20 }), 'acc-residential-GLOBAL-session-14675113-stime-20'); }); test('rapidProxyUsername normalises places, clamps stime and drops invalid parts', () => { assert.equal(U.rapidProxyUsername('acc', { country: 'us', state: 'north carolina', city: 'new-york' }), 'acc-residential-US-state-NorthCarolina-city-NewYork'); - assert.equal(U.rapidProxyUsername('acc', { state: 'Texas' }), 'acc-residential-global', 'a state without a country is dropped'); + assert.equal(U.rapidProxyUsername('acc', { state: 'Texas' }), 'acc-residential-GLOBAL', 'a state without a country is dropped'); assert.equal(U.rapidProxyUsername('acc', { country: 'US', city: 'Austin' }), 'acc-residential-US', 'a city without a state is dropped'); - assert.equal(U.rapidProxyUsername('acc', { session: 'a1', lifeMin: 1440 }), 'acc-residential-global-session-a1-stime-180', 'stime capped at 180'); - assert.equal(U.rapidProxyUsername('acc', { lifeMin: 30 }), 'acc-residential-global', 'stime needs a session'); - assert.equal(U.rapidProxyUsername('acc', { session: 'a b/c' }), 'acc-residential-global-session-abc'); + assert.match(U.rapidProxyUsername('acc', { session: 'a1', lifeMin: 1440 }), /^acc-residential-GLOBAL-session-\d{8}-stime-180$/, 'non-numeric session -> 8-digit sid; stime capped at 180'); + assert.equal(U.rapidProxyUsername('acc', { lifeMin: 30 }), 'acc-residential-GLOBAL', 'stime needs a session'); + assert.equal(U.rapidProxySid('a b/c'), U.rapidProxySid('a b/c'), 'stable'); + assert.match(U.rapidProxySid('a b/c'), /^\d{8}$/); + assert.equal(U.rapidProxySid('56987484'), '56987484', 'numeric ids kept'); }); test('RapidProxy is wired: adapter, both registries and a UI entry', () => {