mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
feat: native SOCKS5 proxy support in proxy= parameter
Route SOCKS5/SOCKS5h proxies via --proxy-server Chrome arg instead of Playwright's proxy dict (which rejects SOCKS5 with credentials). Handles string URLs, Playwright dicts, IPv6, bypass lists. SOCKS5 geoip exit IP resolution uses socks-proxy-agent (optional peer dep). Falls back to DNS if not installed.
This commit is contained in:
+5
-2
@@ -63,10 +63,13 @@ await browser.close();
|
||||
```javascript
|
||||
import { launch, launchContext, launchPersistentContext } from 'cloakbrowser';
|
||||
|
||||
// With proxy
|
||||
// With proxy (HTTP or SOCKS5)
|
||||
const browser = await launch({
|
||||
proxy: 'http://user:pass@proxy:8080',
|
||||
});
|
||||
const browser = await launch({
|
||||
proxy: 'socks5://user:pass@proxy:1080',
|
||||
});
|
||||
|
||||
// With proxy object (bypass, separate auth fields)
|
||||
const browser = await launch({
|
||||
@@ -211,7 +214,7 @@ const page = await browser.newPage();
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js >= 18
|
||||
- Node.js >= 20
|
||||
- One of: `playwright-core` >= 1.40 or `puppeteer-core` >= 21
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
Generated
+57
-9
@@ -1,31 +1,36 @@
|
||||
{
|
||||
"name": "cloakbrowser",
|
||||
"version": "0.3.9",
|
||||
"version": "0.3.23",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cloakbrowser",
|
||||
"version": "0.3.9",
|
||||
"version": "0.3.23",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tar": "^7.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"cloakbrowser": "dist/cli.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.0",
|
||||
"mmdb-lib": "^3.0.2",
|
||||
"playwright-core": "^1.40.0",
|
||||
"puppeteer-core": "^21.0.0",
|
||||
"socks-proxy-agent": "^10.0.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vitest": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"mmdb-lib": ">=2.0.0",
|
||||
"playwright-core": ">=1.40.0",
|
||||
"puppeteer-core": ">=21.0.0"
|
||||
"puppeteer-core": ">=21.0.0",
|
||||
"socks-proxy-agent": ">=8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"mmdb-lib": {
|
||||
@@ -36,6 +41,9 @@
|
||||
},
|
||||
"puppeteer-core": {
|
||||
"optional": true
|
||||
},
|
||||
"socks-proxy-agent": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2003,6 +2011,21 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/pac-proxy-agent/node_modules/socks-proxy-agent": {
|
||||
"version": "8.0.5",
|
||||
"resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz",
|
||||
"integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "^4.3.4",
|
||||
"socks": "^2.8.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/pac-resolver": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz",
|
||||
@@ -2164,6 +2187,21 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-agent/node_modules/socks-proxy-agent": {
|
||||
"version": "8.0.5",
|
||||
"resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz",
|
||||
"integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "^4.3.4",
|
||||
"socks": "^2.8.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
@@ -2332,18 +2370,28 @@
|
||||
}
|
||||
},
|
||||
"node_modules/socks-proxy-agent": {
|
||||
"version": "8.0.5",
|
||||
"resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz",
|
||||
"integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==",
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-10.0.0.tgz",
|
||||
"integrity": "sha512-pyp2YR3mNxAMu0mGLtzs4g7O3uT4/9sQOLAKcViAkaS9fJWkud7nmaf6ZREFqQEi24IPkBcjfHjXhPTUWjo3uA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"agent-base": "9.0.0",
|
||||
"debug": "^4.3.4",
|
||||
"socks": "^2.8.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/socks-proxy-agent/node_modules/agent-base": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz",
|
||||
"integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
|
||||
+7
-2
@@ -55,12 +55,13 @@
|
||||
},
|
||||
"homepage": "https://github.com/CloakHQ/cloakbrowser#javascript--nodejs",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"mmdb-lib": ">=2.0.0",
|
||||
"playwright-core": ">=1.40.0",
|
||||
"puppeteer-core": ">=21.0.0"
|
||||
"puppeteer-core": ">=21.0.0",
|
||||
"socks-proxy-agent": ">=10.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"playwright-core": {
|
||||
@@ -71,6 +72,9 @@
|
||||
},
|
||||
"mmdb-lib": {
|
||||
"optional": true
|
||||
},
|
||||
"socks-proxy-agent": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -79,6 +83,7 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.0",
|
||||
"mmdb-lib": "^3.0.2",
|
||||
"socks-proxy-agent": "^10.0.0",
|
||||
"playwright-core": "^1.40.0",
|
||||
"puppeteer-core": "^21.0.0",
|
||||
"typescript": "^5.3.0",
|
||||
|
||||
+60
-8
@@ -15,7 +15,7 @@ import dns from "node:dns/promises";
|
||||
import net from "node:net";
|
||||
import { getCacheDir } from "./config.js";
|
||||
import type { LaunchOptions } from "./types.js";
|
||||
import { ensureProxyScheme } from "./proxy.js";
|
||||
import { ensureProxyScheme, isSocksProxy, reconstructSocksUrl, type ProxyDict } from "./proxy.js";
|
||||
|
||||
// P3TERX mirror of MaxMind GeoLite2-City — no license key needed
|
||||
const GEOIP_DB_URL =
|
||||
@@ -129,9 +129,44 @@ const IP_ECHO_URLS = [
|
||||
];
|
||||
|
||||
async function resolveExitIp(proxyUrl: string): Promise<string | null> {
|
||||
// Node.js fetch doesn't support proxy natively — use a CONNECT tunnel via http
|
||||
// For simplicity, use a direct HTTP request to a plain-text IP echo service
|
||||
// through the proxy using Node's http module
|
||||
const isSocks = isSocksProxy(proxyUrl);
|
||||
|
||||
// SOCKS5: tunnel through the SOCKS5 proxy via socks-proxy-agent
|
||||
if (isSocks) {
|
||||
let SocksProxyAgent: typeof import("socks-proxy-agent").SocksProxyAgent;
|
||||
try {
|
||||
({ SocksProxyAgent } = await import("socks-proxy-agent"));
|
||||
} catch {
|
||||
console.warn("[cloakbrowser] socks-proxy-agent not installed — cannot resolve exit IP through SOCKS5 proxy. Install it: npm install socks-proxy-agent");
|
||||
return null;
|
||||
}
|
||||
const { default: https } = await import("node:https");
|
||||
const agent = new SocksProxyAgent(proxyUrl);
|
||||
|
||||
for (const echoUrl of IP_ECHO_URLS) {
|
||||
try {
|
||||
const ip = await new Promise<string | null>((resolve) => {
|
||||
const req = https.request(echoUrl, { agent, timeout: 10_000 }, (res) => {
|
||||
let data = "";
|
||||
res.on("data", (chunk: Buffer) => (data += chunk.toString()));
|
||||
res.on("end", () => {
|
||||
const ip = data.trim();
|
||||
resolve(net.isIP(ip) ? ip : null);
|
||||
});
|
||||
});
|
||||
req.on("error", () => resolve(null));
|
||||
req.on("timeout", () => { req.destroy(); resolve(null); });
|
||||
req.end();
|
||||
});
|
||||
if (ip) return ip;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// HTTP/HTTPS: use a CONNECT tunnel via http
|
||||
try {
|
||||
const { default: http } = await import("node:http");
|
||||
const { default: https } = await import("node:https");
|
||||
@@ -264,6 +299,22 @@ function maybeTriggerUpdate(dbPath: string): void {
|
||||
downloadGeoipDb(dbPath).catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a usable proxy URL from LaunchOptions.proxy.
|
||||
* For SOCKS5 dicts with separate credentials, reconstructs the full URL
|
||||
* with inline credentials so SOCKS5 auth works.
|
||||
*/
|
||||
function extractProxyUrl(proxy: string | ProxyDict | undefined): string | null {
|
||||
if (!proxy) return null;
|
||||
if (typeof proxy === "string") return ensureProxyScheme(proxy);
|
||||
const p = proxy as ProxyDict;
|
||||
if (!p.server) return null;
|
||||
if (p.username && isSocksProxy(p)) {
|
||||
return reconstructSocksUrl(p);
|
||||
}
|
||||
return ensureProxyScheme(p.server);
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-fill timezone/locale from proxy IP when geoip is enabled.
|
||||
* Also returns exitIp as a free bonus (reused for WebRTC spoofing).
|
||||
@@ -273,9 +324,8 @@ export async function maybeResolveGeoip(
|
||||
): Promise<{ timezone?: string; locale?: string; exitIp?: string }> {
|
||||
if (!options.geoip || !options.proxy) return { timezone: options.timezone, locale: options.locale };
|
||||
|
||||
let proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy.server;
|
||||
const proxyUrl = extractProxyUrl(options.proxy);
|
||||
if (!proxyUrl) return { timezone: options.timezone, locale: options.locale };
|
||||
proxyUrl = ensureProxyScheme(proxyUrl);
|
||||
|
||||
// When both tz/locale are explicit, still resolve exit IP for WebRTC
|
||||
if (options.timezone && options.locale) {
|
||||
@@ -304,13 +354,13 @@ export async function resolveWebrtcArgs(
|
||||
const idx = args.findIndex(a => a === "--fingerprint-webrtc-ip=auto");
|
||||
if (idx === -1) return args;
|
||||
|
||||
let proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy?.server;
|
||||
const proxyUrl = extractProxyUrl(options.proxy);
|
||||
if (!proxyUrl) {
|
||||
console.warn("[cloakbrowser] --fingerprint-webrtc-ip=auto requires a proxy; removing flag");
|
||||
const result = [...args];
|
||||
result.splice(idx, 1);
|
||||
return result;
|
||||
}
|
||||
proxyUrl = ensureProxyScheme(proxyUrl);
|
||||
|
||||
try {
|
||||
const ip = await resolveExitIp(proxyUrl);
|
||||
@@ -318,10 +368,12 @@ export async function resolveWebrtcArgs(
|
||||
if (ip) {
|
||||
result[idx] = `--fingerprint-webrtc-ip=${ip}`;
|
||||
} else {
|
||||
console.warn("[cloakbrowser] Could not resolve proxy exit IP for WebRTC spoofing; removing --fingerprint-webrtc-ip=auto");
|
||||
result.splice(idx, 1);
|
||||
}
|
||||
return result;
|
||||
} catch {
|
||||
console.warn("[cloakbrowser] Failed to resolve proxy exit IP for WebRTC spoofing; removing --fingerprint-webrtc-ip=auto");
|
||||
const result = [...args];
|
||||
result.splice(idx, 1);
|
||||
return result;
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOption
|
||||
import { DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS } from "./config.js";
|
||||
import { buildArgs } from "./args.js";
|
||||
import { ensureBinary } from "./download.js";
|
||||
import { parseProxyUrl } from "./proxy.js";
|
||||
import { resolveProxyConfig } from "./proxy.js";
|
||||
import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js";
|
||||
|
||||
/** @internal Accept both timezone and timezoneId — either works, no warning. Exported for testing. */
|
||||
@@ -39,20 +39,19 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
|
||||
|
||||
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
|
||||
const { exitIp, ...resolved } = await maybeResolveGeoip(options);
|
||||
const { proxyOption, proxyArgs } = resolveProxyConfig(options.proxy);
|
||||
let resolvedArgs = await resolveWebrtcArgs(options);
|
||||
if (exitIp && !(resolvedArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) {
|
||||
resolvedArgs = [...(resolvedArgs ?? []), `--fingerprint-webrtc-ip=${exitIp}`];
|
||||
}
|
||||
const args = buildArgs({ ...options, ...resolved, args: resolvedArgs });
|
||||
const args = buildArgs({ ...options, ...resolved, args: [...(resolvedArgs ?? []), ...proxyArgs] });
|
||||
|
||||
const browser = await chromium.launch({
|
||||
executablePath: binaryPath,
|
||||
headless: options.headless ?? true,
|
||||
args,
|
||||
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
|
||||
...(options.proxy
|
||||
? { proxy: typeof options.proxy === "string" ? parseProxyUrl(options.proxy) : options.proxy }
|
||||
: {}),
|
||||
...(proxyOption ? { proxy: proxyOption } : {}),
|
||||
...options.launchOptions,
|
||||
});
|
||||
|
||||
@@ -164,11 +163,12 @@ export async function launchPersistentContext(
|
||||
|
||||
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
|
||||
const { exitIp, ...resolved } = await maybeResolveGeoip(options);
|
||||
const { proxyOption, proxyArgs } = resolveProxyConfig(options.proxy);
|
||||
let resolvedArgs = await resolveWebrtcArgs(options);
|
||||
if (exitIp && !(resolvedArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) {
|
||||
resolvedArgs = [...(resolvedArgs ?? []), `--fingerprint-webrtc-ip=${exitIp}`];
|
||||
}
|
||||
const args = buildArgs({ ...options, ...resolved, args: resolvedArgs });
|
||||
const args = buildArgs({ ...options, ...resolved, args: [...(resolvedArgs ?? []), ...proxyArgs] });
|
||||
|
||||
// locale and timezone are set via binary flags (--lang, --fingerprint-timezone)
|
||||
// — NOT via Playwright context kwargs which use detectable CDP emulation.
|
||||
@@ -177,9 +177,7 @@ export async function launchPersistentContext(
|
||||
headless: options.headless ?? true,
|
||||
args,
|
||||
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
|
||||
...(options.proxy
|
||||
? { proxy: typeof options.proxy === "string" ? parseProxyUrl(options.proxy) : options.proxy }
|
||||
: {}),
|
||||
...(proxyOption ? { proxy: proxyOption } : {}),
|
||||
...(options.userAgent ? { userAgent: options.userAgent } : {}),
|
||||
viewport: options.viewport === undefined ? DEFAULT_VIEWPORT : options.viewport,
|
||||
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
|
||||
|
||||
@@ -23,6 +23,65 @@ export function ensureProxyScheme(proxyUrl: string): string {
|
||||
* Also handles: no credentials, URL-encoded special chars, socks5://, missing port,
|
||||
* and bare proxy strings without a scheme (e.g. "user:pass@host:port" -> treated as http).
|
||||
*/
|
||||
/** Proxy dict shape accepted by Playwright/Puppeteer wrappers. */
|
||||
export type ProxyDict = { server: string; bypass?: string; username?: string; password?: string };
|
||||
|
||||
/** Result of resolveProxyConfig — either Playwright dict OR Chrome arg, never both. */
|
||||
export interface ProxyConfig {
|
||||
/** Playwright proxy option (for HTTP proxies). */
|
||||
proxyOption?: ParsedProxy;
|
||||
/** Chrome CLI args (for SOCKS5 proxies, e.g. ["--proxy-server=socks5://..."]). */
|
||||
proxyArgs: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a proxy uses the SOCKS5 protocol.
|
||||
*/
|
||||
export function isSocksProxy(proxy: string | ProxyDict | undefined | null): boolean {
|
||||
if (!proxy) return false;
|
||||
const url = typeof proxy === "string" ? proxy : proxy.server;
|
||||
return /^socks5h?:\/\//i.test(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct a SOCKS5 URL with inline credentials from a proxy dict.
|
||||
*/
|
||||
export function reconstructSocksUrl(proxy: ProxyDict): string {
|
||||
const url = new URL(proxy.server);
|
||||
if (proxy.username) {
|
||||
url.username = encodeURIComponent(proxy.username);
|
||||
if (proxy.password) url.password = encodeURIComponent(proxy.password);
|
||||
}
|
||||
return url.href.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve proxy into Playwright option and/or Chrome args.
|
||||
*
|
||||
* Playwright rejects SOCKS5 proxies with credentials in its proxy dict,
|
||||
* so SOCKS5 is passed via --proxy-server Chrome arg instead.
|
||||
*/
|
||||
export function resolveProxyConfig(proxy: string | ProxyDict | undefined): ProxyConfig {
|
||||
if (!proxy) return { proxyArgs: [] };
|
||||
|
||||
if (isSocksProxy(proxy)) {
|
||||
// SOCKS5: bypass Playwright, pass directly to Chrome via --proxy-server.
|
||||
if (typeof proxy === "string") {
|
||||
return { proxyArgs: [`--proxy-server=${proxy}`] };
|
||||
}
|
||||
const socksUrl = reconstructSocksUrl(proxy);
|
||||
const args = [`--proxy-server=${socksUrl}`];
|
||||
if (proxy.bypass) args.push(`--proxy-bypass-list=${proxy.bypass}`);
|
||||
return { proxyArgs: args };
|
||||
}
|
||||
|
||||
// HTTP/HTTPS: use Playwright's proxy dict
|
||||
if (typeof proxy === "string") {
|
||||
return { proxyOption: parseProxyUrl(proxy), proxyArgs: [] };
|
||||
}
|
||||
return { proxyOption: proxy as ParsedProxy, proxyArgs: [] };
|
||||
}
|
||||
|
||||
export function parseProxyUrl(proxy: string): ParsedProxy {
|
||||
let url: URL;
|
||||
// Bare format: "user:pass@host:port" — new URL() throws without a scheme.
|
||||
|
||||
+9
-7
@@ -9,7 +9,7 @@ import type { LaunchOptions } from "./types.js";
|
||||
import { IGNORE_DEFAULT_ARGS } from "./config.js";
|
||||
import { buildArgs } from "./args.js";
|
||||
import { ensureBinary } from "./download.js";
|
||||
import { parseProxyUrl } from "./proxy.js";
|
||||
import { isSocksProxy, parseProxyUrl, resolveProxyConfig } from "./proxy.js";
|
||||
import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js";
|
||||
|
||||
/**
|
||||
@@ -39,25 +39,27 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
|
||||
const args = buildArgs({ ...options, ...resolved, args: resolvedArgs });
|
||||
|
||||
// Puppeteer handles proxy via CLI args, not a separate option.
|
||||
// Chromium's --proxy-server does NOT support inline credentials,
|
||||
// so we strip them and use page.authenticate() instead.
|
||||
// SOCKS5: Chrome supports inline credentials natively (RFC 1929 auth).
|
||||
// HTTP: Chrome does NOT support inline credentials — strip them and
|
||||
// use page.authenticate() for Proxy-Authorization headers instead.
|
||||
let proxyAuth: { username: string; password: string } | undefined;
|
||||
if (options.proxy) {
|
||||
if (typeof options.proxy === "string") {
|
||||
if (isSocksProxy(options.proxy)) {
|
||||
// SOCKS5: pass full URL with credentials to Chrome directly
|
||||
const { proxyArgs } = resolveProxyConfig(options.proxy);
|
||||
args.push(...proxyArgs);
|
||||
} else if (typeof options.proxy === "string") {
|
||||
const { server, username, password } = parseProxyUrl(options.proxy);
|
||||
args.push(`--proxy-server=${server}`);
|
||||
if (username) {
|
||||
proxyAuth = { username, password: password ?? "" };
|
||||
}
|
||||
} else {
|
||||
// Strip any inline credentials from the server URL — Chromium's
|
||||
// --proxy-server doesn't support them; use page.authenticate() instead.
|
||||
const parsed = parseProxyUrl(options.proxy.server);
|
||||
args.push(`--proxy-server=${parsed.server}`);
|
||||
if (options.proxy.bypass) {
|
||||
args.push(`--proxy-bypass-list=${options.proxy.bypass}`);
|
||||
}
|
||||
// Explicit username/password fields take precedence over inline creds
|
||||
const username = options.proxy.username ?? parsed.username;
|
||||
const password = options.proxy.password ?? parsed.password;
|
||||
if (username) {
|
||||
|
||||
+90
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseProxyUrl } from "../src/proxy.js";
|
||||
import { parseProxyUrl, isSocksProxy, resolveProxyConfig } from "../src/proxy.js";
|
||||
import type { LaunchOptions } from "../src/types.js";
|
||||
|
||||
describe("parseProxyUrl", () => {
|
||||
@@ -115,3 +115,92 @@ describe("bare proxy format (user:pass@host:port)", () => {
|
||||
expect(parseProxyUrl("proxy:8080")).toEqual({ server: "proxy:8080" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSocksProxy", () => {
|
||||
it("detects socks5 string", () => {
|
||||
expect(isSocksProxy("socks5://user:pass@host:1080")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects socks5h string", () => {
|
||||
expect(isSocksProxy("socks5h://host:1080")).toBe(true);
|
||||
});
|
||||
|
||||
it("case insensitive", () => {
|
||||
expect(isSocksProxy("SOCKS5://host:1080")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects http", () => {
|
||||
expect(isSocksProxy("http://host:8080")).toBe(false);
|
||||
});
|
||||
|
||||
it("detects socks5 dict", () => {
|
||||
expect(isSocksProxy({ server: "socks5://host:1080" })).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects http dict", () => {
|
||||
expect(isSocksProxy({ server: "http://host:8080" })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for undefined", () => {
|
||||
expect(isSocksProxy(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveProxyConfig", () => {
|
||||
it("returns empty for undefined", () => {
|
||||
const { proxyOption, proxyArgs } = resolveProxyConfig(undefined);
|
||||
expect(proxyOption).toBeUndefined();
|
||||
expect(proxyArgs).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns playwright dict for http string", () => {
|
||||
const { proxyOption, proxyArgs } = resolveProxyConfig("http://user:pass@proxy:8080");
|
||||
expect(proxyOption).toEqual({ server: "http://proxy:8080", username: "user", password: "pass" });
|
||||
expect(proxyArgs).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns playwright dict for http dict", () => {
|
||||
const proxy = { server: "http://proxy:8080", bypass: ".example.com" };
|
||||
const { proxyOption, proxyArgs } = resolveProxyConfig(proxy);
|
||||
expect(proxyOption).toEqual(proxy);
|
||||
expect(proxyArgs).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns chrome arg for socks5 string", () => {
|
||||
const { proxyOption, proxyArgs } = resolveProxyConfig("socks5://user:pass@host:1080");
|
||||
expect(proxyOption).toBeUndefined();
|
||||
expect(proxyArgs).toEqual(["--proxy-server=socks5://user:pass@host:1080"]);
|
||||
});
|
||||
|
||||
it("returns chrome arg for socks5 no auth", () => {
|
||||
const { proxyOption, proxyArgs } = resolveProxyConfig("socks5://host:1080");
|
||||
expect(proxyOption).toBeUndefined();
|
||||
expect(proxyArgs).toEqual(["--proxy-server=socks5://host:1080"]);
|
||||
});
|
||||
|
||||
it("returns chrome arg for socks5h string", () => {
|
||||
const { proxyOption, proxyArgs } = resolveProxyConfig("socks5h://user:pass@host:1080");
|
||||
expect(proxyOption).toBeUndefined();
|
||||
expect(proxyArgs).toEqual(["--proxy-server=socks5h://user:pass@host:1080"]);
|
||||
});
|
||||
|
||||
it("reconstructs URL from socks5 dict with auth", () => {
|
||||
const { proxyOption, proxyArgs } = resolveProxyConfig({
|
||||
server: "socks5://host:1080",
|
||||
username: "user",
|
||||
password: "p@ss",
|
||||
});
|
||||
expect(proxyOption).toBeUndefined();
|
||||
expect(proxyArgs.length).toBe(1);
|
||||
expect(proxyArgs[0]).toContain("--proxy-server=socks5://user:p%40ss@host:1080");
|
||||
});
|
||||
|
||||
it("includes bypass for socks5 dict", () => {
|
||||
const { proxyArgs } = resolveProxyConfig({
|
||||
server: "socks5://host:1080",
|
||||
bypass: ".example.com",
|
||||
});
|
||||
expect(proxyArgs).toContain("--proxy-server=socks5://host:1080");
|
||||
expect(proxyArgs).toContain("--proxy-bypass-list=.example.com");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -113,4 +113,29 @@ describe("puppeteer launch", () => {
|
||||
expect(callArgs.args).toContain("--disable-gpu");
|
||||
expect(callArgs.args).toContain("--no-first-run");
|
||||
});
|
||||
|
||||
it("keeps SOCKS5 credentials in --proxy-server URL", async () => {
|
||||
const { launch } = await import("../src/puppeteer.js");
|
||||
const browser = await launch({ proxy: "socks5://user:pass@proxy:1080" });
|
||||
|
||||
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
|
||||
expect(callArgs.args).toContain("--proxy-server=socks5://user:pass@proxy:1080");
|
||||
|
||||
// Should NOT set up page.authenticate for SOCKS5
|
||||
const page = await browser.newPage();
|
||||
expect(page.authenticate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reconstructs SOCKS5 dict with auth into --proxy-server URL", async () => {
|
||||
const { launch } = await import("../src/puppeteer.js");
|
||||
const browser = await launch({
|
||||
proxy: { server: "socks5://proxy:1080", username: "user", password: "p@ss" },
|
||||
});
|
||||
|
||||
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
|
||||
expect(callArgs.args).toContain("--proxy-server=socks5://user:p%40ss@proxy:1080");
|
||||
|
||||
const page = await browser.newPage();
|
||||
expect(page.authenticate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user