Назад в Центр знаний
Начало работы

Прокси Puppeteer: авторизация, SOCKS5 и сигналы

Разберите ошибки авторизации прокси и SOCKS5 в Puppeteer, затем сравните два разрешённых выхода с одним профилем, часовым поясом, языками и выводом canvas.

BotBrowser Team

Документация

Нужна структурированная документация по теме Начало работы?

Эта статья относится к редакционной библиотеке. Для пошаговой настройки, справки и постоянных обновлений переходите сразу в соответствующий раздел docs.

Прокси Puppeteer может изменить наблюдаемый адрес выхода, сохранив часовой пояс, язык и вывод canvas браузера. Сначала отделите сбой соединения от несоответствия окружения: исправлять их нужно на разных уровнях. BotBrowser позволяет задавать учётные данные прокси и явные параметры часового пояса, локали и языков вместе с профилем, но не может заставить вышестоящего провайдера принять учётные данные или гарантировать, как сайт назначения интерпретирует эти сигналы.

TL;DR

  • Настройте поддерживаемый параметр прокси Puppeteer или Chromium, затем проверьте, что запрос действительно прошёл этим маршрутом. launch({ proxy: ... }) не является документированной настройкой прокси Puppeteer.
  • page.authenticate() передаёт учётные данные HTTP. Этот метод не добавляет согласование имени пользователя и пароля SOCKS5 в обычный Chrome.
  • HTTPS-ресурс через HTTP-прокси и TLS-соединение с HTTPS-прокси представляют собой разные конфигурации. Рассматривайте схему прокси отдельно от URL назначения.
  • Сравнивайте значения браузера только после того, как подконтрольный наблюдатель подтвердит два разных публичных выхода. Смена IP не означает смену часового пояса, языка, canvas или cookies.

Содержание

Определите место сбоя до смены учётных данных

Зафиксируйте первую операцию, которая завершилась ошибкой: запуск браузера, соединение с прокси, HTTP-запрос учётных данных, TLS с сайтом назначения, HTTP-ответ или сравнение на стороне страницы. Ответ 407 Proxy Authentication Required относится к прокси. Ответ 401 от сайта назначения относится к этому серверу. Ошибка навигации без HTTP-ответа могла возникнуть до того, как любое из приложений смогло ответить. Увеличение тайм-аута страницы не добавляет неподдерживаемый метод аутентификации.

НаблюдениеЧто проверить сначалаНеобходимое подтверждение перед следующим шагом
Браузер запускается, но наблюдатель видит прямой маршрутФактическую область действия прокси и исключения для прямого маршрутаНужный прокси получил этот запрос, а наблюдатель сообщил его адрес выхода
HTTP-прокси повторно запрашивает учётные данныеУчётные данные, схему запроса и момент вызова page.authenticate()Прокси принял учётные данные и передал нужный запрос
SOCKS5 работает в другом клиенте, но не в ChromeСогласованные методы аутентификации SOCKSПоддерживаемое согласование клиента и сервера, а не только открытый TCP-порт
CONNECT успешен, но навигация завершается ошибкойTLS с сайтом назначения и передачу данных через туннельДействительное соединение с сайтом и реальный HTTP-ответ
Выход меняется, а часовой пояс или язык остаётся прежнимНастройки окружения браузераИзмеренное значение и его ожидаемый источник независимо от IP
После смены маршрута вывод canvas одинаковПрофиль, рендерер и параметры рисованияКонтролируемое повторение одного рисунка; вывода об идентичности из этого не следует

В параметрах контекста Puppeteer указаны proxyServer и proxyBypassList. Chromium также принимает аргумент запуска, например --proxy-server=http://127.0.0.1:8080. У этих способов настройки разная область действия; ни один из них не является несуществующим параметром proxy верхнего уровня в настройках запуска Puppeteer. Проверьте API установленной версии, прежде чем копировать объект запуска Playwright в Puppeteer.

Задавайте учётные данные HTTP до перехода к защищённому ресурсу. В документации Puppeteer по page.authenticate() также указано, что метод включает внутренний перехват запросов, что может повлиять на производительность. Не исследуйте эту задержку, отключая аутентификацию в середине сравнения, условия которого в остальном не контролируются. Не задавайте общий дополнительный заголовок Proxy-Authorization: у учётных данных прокси и заголовков запроса к сайту назначения разные получатели.

Параметры продукта и правила географических переопределений описаны в настройке прокси BotBrowser. Здесь важно различать стадии сбоя в обычном браузере под управлением Puppeteer: принятый маршрут, принятые учётные данные и доступное странице значение окружения представляют собой три отдельных наблюдения, а не одно событие успешной настройки.

Локально воспроизведите границы HTTP и SOCKS5

В локальном тесте аутентификация должна иметь наблюдаемое последствие. HTTP-прокси фиксирует, получил ли он запрос, совпали ли учётные данные и передал ли он запрос подконтрольному серверу назначения. Этот сервер возвращает маркер конкретного сценария. Неудачный вход или ошибка согласования не должны случайно приводить к появлению маркера через прямое соединение.

Используйте отдельные HTTP-сценарии для правильных, отсутствующих и неверных учётных данных. Успех требует одновременно принятого запроса на прокси и маркера сервера назначения. В отклонённых сценариях прокси должен запросить учётные данные, а переданный ответ сервера назначения должен отсутствовать. Одной строки ошибки браузера недостаточно: такой же сбой навигации возможен при закрытом порте, ошибке TLS или неподдерживаемом методе прокси.

Для SOCKS5 сравните сервер, принимающий метод без аутентификации, с сервером, требующим имя пользователя и пароль. Повторите второй сценарий с включённым page.authenticate(). Журнал приветствий прокси позволяет отличить неподдерживаемый метод от приложения, которое просто вернуло пустую страницу. Оставьте прокси и сервер назначения на loopback и явно учитывайте неявное исключение локальных адресов Chromium при проверке того, дошёл ли трафик до прокси.

Не делайте вывод о публичном выходе по этим тестам. Два локальных HTTP-слушателя могут передавать данные через один сетевой интерфейс. Два успешных приветствия SOCKS доказывают, что ответили два слушателя; они не доказывают разные публичные адреса или разных провайдеров. Для сравнения публичных маршрутов ниже предусмотрена отдельная проверка наблюдателя.

Следующий пример только для HTTP создаёт собственный loopback-сервер назначения и прокси с паролем. Сохраните его как local-auth.mjs, установите [email protected] и выполните CHROME=/absolute/path/to/chrome node local-auth.mjs. Запросы к адресу .test передаются локально; все остальные назначения прокси отклоняет. Три сценария выполняются одновременно, а отклонённый сценарий не должен обращаться к серверу назначения.

import assert from 'node:assert/strict';
import http from 'node:http';
import { randomBytes } from 'node:crypto';
import puppeteer from 'puppeteer-core';

const executablePath = process.env.CHROME;
assert.ok(executablePath, 'Set CHROME to the browser executable');
const flags = [
  '--password-store=basic',
  '--use-mock-keychain',
  '--disable-background-networking',
  '--disable-component-update',
  '--disable-sync',
  '--no-first-run',
  '--no-default-browser-check',
];
async function listen(server) {
  const sockets = new Set();
  server.on('connection', socket => {
    sockets.add(socket);
    socket.on('close', () => sockets.delete(socket));
    socket.on('error', () => {});
  });
  await new Promise((resolve, reject) => {
    server.once('error', reject);
    server.listen(0, '127.0.0.1', resolve);
  });
  return {
    port: server.address().port,
    async close() {
      for (const socket of sockets) socket.destroy();
      await new Promise(resolve => server.close(resolve));
    },
  };
}
async function runCase(name, password) {
  let origin, proxy, browser;
  const marker = randomBytes(12).toString('hex');
  const counts = { proxyRequests: 0, rejected: 0, accepted: 0, forwarded: 0, originHits: 0 };
  try {
    origin = await listen(
      http.createServer((req, res) => {
        counts.originHits++;
        res.writeHead(200, { 'Content-Type': 'text/html' });
        res.end(`<p>${marker}</p>`);
      })
    );
    const targetURL = `http://fixture.test:${origin.port}/`;
    const authorization = `Basic ${Buffer.from('local-user:local-password').toString('base64')}`;
    const server = http.createServer((req, res) => {
      if (req.url !== targetURL) {
        res.writeHead(403).end();
        return;
      }
      counts.proxyRequests++;
      if (req.headers['proxy-authorization'] !== authorization) {
        counts.rejected++;
        res.writeHead(407, { 'Proxy-Authenticate': 'Basic realm="local-test"' }).end();
        return;
      }
      counts.accepted++;
      counts.forwarded++;
      const upstream = http.request(
        { host: '127.0.0.1', port: origin.port, path: '/', headers: { Host: 'fixture.test' } },
        remote => {
          res.writeHead(remote.statusCode, remote.headers);
          remote.pipe(res);
        }
      );
      upstream.on('error', () => {
        if (!res.headersSent) res.writeHead(502);
        res.end();
      });
      req.pipe(upstream);
    });
    server.on('connect', (req, socket) => socket.end('HTTP/1.1 403 Forbidden\r\n\r\n'));
    proxy = await listen(server);
    browser = await puppeteer.launch({
      executablePath,
      headless: true,
      args: [...flags, `--proxy-server=http://127.0.0.1:${proxy.port}`],
    });
    const browserVersion = await browser.version();
    const page = await browser.newPage();
    if (password !== null) await page.authenticate({ username: 'local-user', password });
    await page.setRequestInterception(true);
    page.on('request', request => void (request.url() === targetURL ? request.continue() : request.abort()));
    let status = null,
      networkError = null;
    try {
      status = (
        await page.goto(targetURL, {
          waitUntil: 'domcontentloaded',
          timeout: 12000,
        })
      ).status();
    } catch (error) {
      networkError = String(error.message).match(/net::ERR_[A-Z_]+/)?.[0] || error.name;
    }
    const markerObserved = (await page.content()).includes(marker);
    const success = name === 'correct';
    assert.equal(markerObserved, success);
    assert.equal(counts.originHits, success ? 1 : 0);
    assert.equal(counts.forwarded, success ? 1 : 0);
    assert.equal(counts.accepted, success ? 1 : 0);
    assert.ok(counts.proxyRequests > 0 && counts.rejected > 0);
    if (success) assert.equal(status, 200);
    return { case: name, browserVersion, status, networkError, markerObserved, ...counts };
  } finally {
    if (browser) await browser.close();
    if (proxy) await proxy.close();
    if (origin) await origin.close();
  }
}
try {
  const cases = await Promise.allSettled([
    runCase('correct', 'local-password'),
    runCase('wrong', 'incorrect'),
    runCase('absent', null),
  ]);
  assert.ok(
    cases.every(result => result.status === 'fulfilled'),
    'A local assertion failed'
  );
  console.log(JSON.stringify({ passed: true, cases: cases.map(result => result.value) }, null, 2));
} catch (error) {
  console.error(JSON.stringify({ passed: false, error: error.name }));
  process.exitCode = 1;
}

Этот скрипт изолирует HTTP Basic-аутентификацию. Для проверки границы методов SOCKS5 по-прежнему нужна запись приветствия SOCKS: успешный HTTP-запрос не проверяет пароль SOCKS.

Разделяйте границы HTTPS, DNS и аутентификации

Документация Chromium по протоколам прокси различает транспорт до прокси и схему запрашиваемого ресурса. HTTP-прокси может обслуживать HTTPS-ресурс через CONNECT. HTTPS-прокси дополнительно защищает TLS-соединением участок от браузера до прокси. Простая замена URL назначения на https:// не превращает HTTP-прокси в HTTPS-прокси.

КонфигурацияУчасток от браузера до проксиСоответствующая граница
HTTP-прокси, HTTPS-ресурсСоединение с HTTP-прокси, затем туннель CONNECTАутентификация прокси и TLS с сайтом назначения остаются отдельными этапами
HTTPS-прокси, HTTPS-ресурсTLS с прокси, затем туннель к сайту назначенияОбоим соединениям, с прокси и с сайтом назначения, нужен действительный TLS
URL-запрос через SOCKS5 в обычном ChromeТранспорт SOCKS5 для TCP-запросовChrome документирует разрешение имён на стороне прокси и отсутствие поддерживаемых методов аутентификации SOCKS5

Последняя строка описывает границу реализации браузера, а не отсутствие аутентификации в самом протоколе SOCKS5. SOCKS-клиент на Python или Node может поддерживать методы с именем пользователя и паролем, которые обычный Chrome не согласовывает. Изменение page.authenticate() или добавление пароля в аргумент прокси Chromium не добавляет реализацию этого протокола. При этом Chrome уже передаёт разрешение имени сайта назначения прокси на маршруте SOCKS5; написание socks5h:// из другого клиента не заменяет проверку схемы, документированной для Chrome.

Если разрешённый сервис SOCKS5 требует учётные данные, выберите клиент с поддержкой этого метода или одобренный локальный адаптер, чей вышестоящий клиент его поддерживает. Зафиксируйте адаптер в эксперименте: Chrome подключается к адаптеру, а тот выполняет аутентифицированное SOCKS-соединение. Оставьте его слушатель закрытым для посторонних, ограничьте назначения и скрывайте учётные данные. Успешный запуск через адаптер не доказывает встроенную поддержку паролей SOCKS5 в Chrome.

Разрешение DNS для TCP-URL через прокси также не даёт полной картины WebRTC. Документированный URL-маршрут SOCKS5 в Chromium не передаёт UDP. Поэтому запрос страницы, пришедший через ожидаемый выход, не подтверждает, каким путём пойдёт последующая операция STUN или TURN. Проверяйте эти пути по отдельной одобренной сетевой политике, а не переносите на них выводы из CONNECT или HTTP-статуса страницы.

Сравните два выхода с одним неизменяемым исходным профилем

Сохраните неизменными исполняемый файл браузера, настройки запуска, исходный профиль, подконтрольный endpoint и метод запроса; в обоих запусках записывайте одинаковый набор полей. Меняйте только разрешённый маршрут прокси. Процесс проверки выхода в Launcher описывает аналогичный подход к подтверждению маршрута в графическом интерфейсе; при сравнении через Puppeteer те же переменные должны оставаться явно заданными.

Обычный каталог пользовательских данных Chromium доступен для записи и блокируется процессом. Для параллельных потоков A/B сначала закройте исходный браузер, затем создайте побайтово одинаковые копии его неизменяемого снимка в двух новых тестовых каталогах. Проверьте хеши до запуска. Так один исходный профиль сравнивается на двух маршрутах без одновременного открытия одного доступного для записи каталога двумя процессами. Два независимых пустых каталога не обеспечили бы одинаковое исходное состояние хранилища.

Используйте HTTPS-endpoint наблюдения на домене, который контролирует владелец процесса. Если домен обслуживает Cloudflare, его документированный endpoint /cdn-cgi/trace позволяет получить ограниченное сетевое наблюдение; в другой инфраструктуре можно предоставить собственный ответ с адресом подключившегося клиента. Прежде чем доверять полю адреса, проверьте статус, формат ответа и ожидаемый хост. Сохраняйте исходные адреса в тайне, а в публичных подтверждениях используйте метки маршрутов.

Требуйте два корректных наблюдения публичных адресов и проверяйте, что адреса различаются. Если один маршрут не работает, возвращает частный адрес или даёт тот же выход, что и другой, сравнение двух выходов не состоялось. Название страны у провайдера, отдельный порт или имя сессии не заменяют эту проверку. Адаптер также должен считать и ограничивать свои вышестоящие запросы, чтобы фоновый трафик браузера незаметно не расширял тест.

Собирайте доступные странице значения только после подтверждённого ответа. В обоих потоках используйте одинаковые размеры рисунка, текст, запрошенный шрифт и метод сравнения canvas. Отдельно записывайте Intl.DateTimeFormat().resolvedOptions().timeZone, определённую локаль Intl, navigator.languages и user agent. Сохраняйте исходные значения для проверки; вычисляйте равенство после завершения обоих потоков, а не предполагайте заранее, что должно остаться неизменным.

Сохраните этот независимый пример сравнения как two-exits.mjs рядом с той же установленной версией Puppeteer. Задайте в окружении CHROME, PROXY_A, PROXY_B, OBSERVER_URL и PROFILE_DIR, затем выполните node two-exits.mjs --pool 20. Контракт наблюдателя: HTTPS, HTTP 200, text/plain и одна строка ip=, как у подконтрольного trace-endpoint Cloudflare. Каталог профиля должен принадлежать полностью закрытому браузеру; проверка файла блокировки не заменяет закрытие. HTTP-прокси могут содержать учётные данные; входные параметры SOCKS5 должны быть без них. Для вышестоящего SOCKS5 с аутентификацией нужен отдельно одобренный адаптер.

import assert from 'node:assert/strict';
import { createHash, createHmac, randomBytes } from 'node:crypto';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs/promises';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import puppeteer from 'puppeteer-core';

const sha = value => createHash('sha256').update(value).digest('hex');
const safeError = error => ({
  name: error.name,
  networkCode: String(error.message).match(/net::ERR_[A-Z_]+/)?.[0] || null,
});
const flags = [
  '--password-store=basic',
  '--use-mock-keychain',
  '--disable-background-networking',
  '--disable-component-update',
  '--disable-sync',
  '--no-first-run',
  '--no-default-browser-check',
  '--window-size=1280,900',
];
async function resources() {
  const sample = () =>
    os.cpus().reduce(
      (sum, cpu) => {
        sum.idle += cpu.times.idle;
        sum.total += Object.values(cpu.times).reduce((a, b) => a + b, 0);
        return sum;
      },
      { idle: 0, total: 0 }
    );
  const before = sample();
  await new Promise(resolve => setTimeout(resolve, 250));
  const after = sample(),
    total = after.total - before.total;
  const unusedCpuCores = (os.cpus().length * (after.idle - before.idle)) / Math.max(1, total);
  let availableMemoryBytes = os.freemem();
  if (process.platform === 'darwin') {
    const vm = execFileSync('vm_stat', { encoding: 'utf8' });
    const pageSize = Number(vm.match(/page size of (\d+) bytes/)[1]);
    availableMemoryBytes = [...vm.matchAll(/^(?:Pages free|Pages inactive|Pages speculative):\s+(\d+)\./gm)].reduce(
      (sum, match) => sum + Number(match[1]) * pageSize,
      0
    );
  } else if (process.platform === 'linux') {
    availableMemoryBytes =
      Number((await fs.readFile('/proc/meminfo', 'utf8')).match(/^MemAvailable:\s+(\d+)/m)[1]) * 1024;
  }
  return {
    capturedAt: new Date().toISOString(),
    cpuCount: os.cpus().length,
    unusedCpuCores: Number(unusedCpuCores.toFixed(2)),
    availableMemoryBytes,
  };
}
async function treeHash(root) {
  const entries = [];
  async function walk(relative) {
    const children = await fs.readdir(path.join(root, relative), { withFileTypes: true });
    for (const entry of children.sort((a, b) => a.name.localeCompare(b.name))) {
      const name = path.join(relative, entry.name),
        file = path.join(root, name);
      if (entry.isDirectory()) await walk(name);
      else if (entry.isFile()) entries.push([name, sha(await fs.readFile(file))]);
      else if (entry.isSymbolicLink()) entries.push([name, 'symlink', await fs.readlink(file)]);
      else throw new Error('Unsupported profile entry');
    }
  }
  await walk('');
  return { sha256: sha(JSON.stringify(entries)), entries: entries.length };
}
function publicAddress(ip) {
  if (net.isIP(ip) === 4) {
    const value = ip.split('.').reduce((n, octet) => n * 256 + Number(octet), 0);
    const excluded = [
      ['0.0.0.0', 8],
      ['10.0.0.0', 8],
      ['100.64.0.0', 10],
      ['127.0.0.0', 8],
      ['169.254.0.0', 16],
      ['172.16.0.0', 12],
      ['192.0.0.0', 24],
      ['192.0.2.0', 24],
      ['192.88.99.0', 24],
      ['192.168.0.0', 16],
      ['198.18.0.0', 15],
      ['198.51.100.0', 24],
      ['203.0.113.0', 24],
      ['224.0.0.0', 3],
    ];
    return !excluded.some(
      ([base, bits]) =>
        Math.floor(value / 2 ** (32 - bits)) ===
        Math.floor(base.split('.').reduce((n, octet) => n * 256 + Number(octet), 0) / 2 ** (32 - bits))
    );
  }
  if (net.isIP(ip) !== 6) return false;
  const [first, second] = ip.split(':').map(part => parseInt(part || '0', 16));
  return (
    first >= 0x2000 &&
    first <= 0x3fff &&
    first !== 0x2002 &&
    !(first === 0x2001 && (second < 0x200 || second === 0xdb8)) &&
    !(first === 0x3fff && second < 0x1000)
  );
}
async function signals(page) {
  const measured = await page.evaluate(async () => {
    const canvas = document.createElement('canvas');
    canvas.width = 160;
    canvas.height = 50;
    const ctx = canvas.getContext('2d');
    ctx.fillStyle = '#e5ebf3';
    ctx.fillRect(0, 0, 160, 50);
    ctx.fillStyle = '#17243a';
    ctx.font = '16px Arial';
    ctx.fillText('Proxy fixture 2026', 5, 30);
    const webrtc = { candidateTypes: {}, candidateCount: 0, gatheringComplete: false, error: null };
    let pc;
    try {
      pc = new RTCPeerConnection({ iceServers: [] });
      pc.createDataChannel('owned-comparison');
      await new Promise(resolve => {
        const timer = setTimeout(resolve, 1800);
        pc.onicecandidate = event => {
          if (event.candidate) {
            const type = event.candidate.type || 'unknown';
            webrtc.candidateTypes[type] = (webrtc.candidateTypes[type] || 0) + 1;
            webrtc.candidateCount++;
          } else {
            webrtc.gatheringComplete = true;
            clearTimeout(timer);
            resolve();
          }
        };
        pc.createOffer()
          .then(offer => pc.setLocalDescription(offer))
          .catch(error => {
            webrtc.error = error.name;
            clearTimeout(timer);
            resolve();
          });
      });
    } catch (error) {
      webrtc.error = error.name;
    } finally {
      pc?.close();
    }
    return {
      timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
      intlLocale: Intl.DateTimeFormat().resolvedOptions().locale,
      languages: [...navigator.languages],
      userAgent: navigator.userAgent,
      canvas: canvas.toDataURL(),
      webrtc,
    };
  });
  const { canvas, ...rest } = measured;
  return { ...rest, canvasSha256: sha(canvas) };
}
async function main() {
  const { CHROME, PROFILE_DIR, OBSERVER_URL, PROXY_A, PROXY_B } = process.env;
  assert.ok(CHROME && PROFILE_DIR && OBSERVER_URL && PROXY_A && PROXY_B, 'Missing required input');
  const observer = new URL(OBSERVER_URL);
  assert.ok(observer.protocol === 'https:' && !observer.username && !observer.password && !observer.hash);
  const index = process.argv.indexOf('--pool');
  const requested = index < 0 ? 20 : Number(process.argv[index + 1]);
  assert.ok(Number.isInteger(requested) && requested >= 2, 'Two concurrent lanes need pool >= 2');
  const snapshot = await resources();
  const effective = Math.min(
    requested,
    2,
    Math.floor(snapshot.unusedCpuCores),
    Math.floor(snapshot.availableMemoryBytes / 1024 ** 3)
  );
  console.log(JSON.stringify({ resources: snapshot, pool: { requested, effective } }));
  assert.equal(effective, 2, 'Insufficient resources for two concurrent lanes; no observer request sent');
  const names = await fs.readdir(PROFILE_DIR);
  assert.ok(!names.some(name => /^Singleton(?:Lock|Socket|Cookie)$/.test(name)), 'Close the baseline browser');
  const input = await treeHash(PROFILE_DIR);
  const work = await fs.mkdtemp(path.join(os.tmpdir(), 'two-exits-'));
  const copies = ['A', 'B'].map(label => path.join(work, label));
  await Promise.all(
    copies.map(copy =>
      fs.cp(PROFILE_DIR, copy, {
        recursive: true,
        dereference: false,
        verbatimSymlinks: true,
        errorOnExist: true,
        force: false,
      })
    )
  );
  const prelaunch = await Promise.all(copies.map(treeHash));
  for (const copy of prelaunch) assert.deepEqual(copy, input);
  assert.deepEqual(await treeHash(PROFILE_DIR), input);
  const ipKey = randomBytes(32);
  let ready = 0,
    unblock,
    active = 0,
    maximumActive = 0;
  const gate = new Promise(resolve => {
    unblock = resolve;
  });
  async function lane(label, proxyInput, userDataDir) {
    let browser;
    const startedAt = new Date().toISOString();
    maximumActive = Math.max(maximumActive, ++active);
    try {
      const proxy = new URL(proxyInput);
      assert.ok(['http:', 'socks5:'].includes(proxy.protocol));
      assert.ok(proxy.hostname && (!proxy.pathname || proxy.pathname === '/') && !proxy.search && !proxy.hash);
      assert.ok(proxy.protocol !== 'socks5:' || (!proxy.username && !proxy.password), 'SOCKS5 auth needs an adapter');
      browser = await puppeteer.launch({
        executablePath: CHROME,
        userDataDir,
        headless: true,
        timeout: 25000,
        args: [...flags, `--proxy-server=${proxy.protocol}//${proxy.host}`],
      });
      const browserVersion = await browser.version(),
        page = await browser.newPage();
      if (proxy.username || proxy.password)
        await page.authenticate({
          username: decodeURIComponent(proxy.username),
          password: decodeURIComponent(proxy.password),
        });
      let observerRequests = 0;
      await page.setRequestInterception(true);
      page.on('request', request => {
        if (request.url() === observer.href && observerRequests === 0) {
          observerRequests++;
          void request.continue();
        } else void request.abort();
      });
      if (++ready === 2) unblock();
      await gate;
      assert.equal(ready, 2, 'Peer lane failed before navigation');
      const navigationStartedAt = new Date().toISOString();
      const response = await page.goto(observer.href, { waitUntil: 'domcontentloaded', timeout: 25000 });
      assert.ok(response && response.status() === 200 && response.url() === observer.href);
      assert.equal(response.request().redirectChain().length, 0);
      assert.ok(/^text\/plain(?:;|$)/i.test(response.headers()['content-type'] || ''));
      const body = await response.text();
      assert.ok(body.length <= 8192);
      const addresses = [...body.matchAll(/^ip=(.+)$/gm)];
      assert.equal(addresses.length, 1);
      const address = addresses[0][1].trim();
      assert.ok(publicAddress(address), 'Non-public observer address');
      const addressDigest = createHmac('sha256', ipKey).update(address).digest('hex');
      const observed = await signals(page);
      assert.equal(observerRequests, 1);
      return {
        label,
        startedAt,
        navigationStartedAt,
        finishedAt: new Date().toISOString(),
        browserVersion,
        observer: { status: 200, requests: observerRequests, publicAddressValidated: true, addressDigest },
        signals: observed,
      };
    } finally {
      unblock();
      if (browser) await browser.close();
      active--;
    }
  }
  const completed = await Promise.allSettled([lane('A', PROXY_A, copies[0]), lane('B', PROXY_B, copies[1])]);
  assert.deepEqual(await treeHash(PROFILE_DIR), input);
  if (completed.some(result => result.status === 'rejected')) {
    console.log(
      JSON.stringify({
        passed: false,
        lanes: completed.map((result, i) => ({
          label: ['A', 'B'][i],
          status: result.status,
          ...(result.status === 'rejected' ? { error: safeError(result.reason) } : { result: result.value }),
        })),
      })
    );
    process.exitCode = 1;
    return;
  }
  const routes = completed.map(result => result.value);
  assert.equal(maximumActive, 2);
  assert.equal(routes[0].browserVersion, routes[1].browserVersion);
  assert.notEqual(routes[0].observer.addressDigest, routes[1].observer.addressDigest, 'Both routes used one exit');
  const equality = Object.fromEntries(
    ['timezone', 'intlLocale', 'languages', 'canvasSha256', 'userAgent'].map(key => [
      key,
      JSON.stringify(routes[0].signals[key]) === JSON.stringify(routes[1].signals[key]),
    ])
  );
  console.log(
    JSON.stringify(
      {
        passed: true,
        differentPublicExits: true,
        maximumActive,
        input,
        prelaunch,
        baselineUnchanged: true,
        equality,
        routes,
        temporaryProfilesRetained: true,
      },
      null,
      2
    )
  );
}
main().catch(error => {
  console.error(JSON.stringify({ passed: false, error: safeError(error) }));
  process.exitCode = 1;
});

Правило ресурсов резервирует одно предположительно свободное ядро CPU и 1 GiB доступной памяти на браузер. Если двух слотов нет, скрипт отказывается превращать сравнение в последовательный запуск. Копии профиля остаются во временном каталоге операционной системы для проверки. Проверка адреса консервативно отклоняет диапазоны специального назначения; она не определяет геолокацию. Ключ HMAC конкретного запуска остаётся в памяти, поэтому выведенные дайджесты позволяют сравнивать равенство только внутри этого запуска.

Измеренное сравнение двух маршрутов

11 ноября 2026 года сравнение выполнили с Node 26.9.0, Puppeteer Core 25.10.0 и Chrome 157.0.8103.79 на macOS arm64, Darwin 24.6.0. Свежая выборка показала 2.21 предположительно свободного ядра CPU и 12,294,160,384 байта доступной памяти, поэтому запрошенный пул из 20 сократился до двух одновременных потоков. Их навигации начались с разницей в одну миллисекунду; выполнение скрипта заняло 4.702 секунды. Закрытый исходный профиль и обе копии до запуска содержали 147 совпадающих записей с SHA-256 4faf879627b7cff3c24aa9cded30e2f892d7772323939232061e72a036d33829, а исходный профиль после теста остался неизменным.

НаблюдениеМаршрут AМаршрут BПодтверждённое сравнение
Адрес у подконтрольного наблюдателяСкрытый выход AСкрытый выход BОба публичные и разные; один ответ HTTP 200 на маршрут
Часовой поясAmerica/TorontoAmerica/TorontoСовпадает
Локаль Intlen-USen-USСовпадает
Языки["en-US", "en"]["en-US", "en"]Совпадают
Префикс SHA-256 canvas7d6ee35f6cebcbf37d6ee35f6cebcbf3Полные хеши совпадают
Токен браузера в user agentHeadlessChrome/157.0.0.0HeadlessChrome/157.0.0.0Полные user agent совпадают
WebRTC с iceServers: []Один host-кандидатОдин host-кандидатСбор завершён; выход STUN/UDP не проверялся

Итоговый stdout содержал "differentPublicExits": true и "maximumActive": 2. Оба разрешённых вышестоящих сервиса требовали учётные данные SOCKS5, поэтому обёртка выполнения предоставляла частные адаптеры HTTP CONNECT, ограниченные назначением botbrowser.io:443; каждый разрешил ровно одно вышестоящее соединение. Адаптеры отклоняли фоновые назначения и одну дополнительную попытку CONNECT к тому же назначению на каждый поток. Chrome не согласовывал учётные данные вышестоящего SOCKS5. Ни исходные адреса, ни учётные данные, ни адреса кандидатов не сохранялись.

Записанный внешний запуск включал один дополнительный аргумент Chromium для маршрутизации loopback. В показанной версии этот ненужный для нелокального наблюдателя аргумент отсутствует. Её итоговое выполнение остановилось на проверке ресурсов: 1.28 предположительно свободного ядра CPU дали один слот, поэтому ни один маршрут не отправил запрос наблюдателю. Финальный отформатированный локальный HTTP-скрипт прошёл все три сценария: правильные учётные данные дали 200 и маркер, а при неверных и отсутствующих учётных данных число переданных запросов и обращений к серверу назначения было равно нулю. Таблица отражает предыдущее успешное внешнее измерение; после окончательного удаления аргумента запуска завершённого внешнего измерения нет.

У наблюдения WebRTC отдельные границы

Локальный RTCPeerConnection с iceServers: [] позволяет записать host-кандидатов, раскрытых в конкретном запуске, без обращения к стороннему STUN-сервису. Записывайте типы и количество кандидатов, а не частные адреса или полный SDP. Это не проверяет публичный путь STUN или TURN, не доказывает, что UDP идёт через HTTP-прокси, и не устанавливает, что публичный адрес WebRTC не может появиться в другой конфигурации. Пустой результат host-кандидатов или скрытие адресов через mDNS не являются универсальным заключением об «отсутствии утечки».

Изучите сравнение сигналов до изменения профиля

Записанное сравнение одинаковых копий закрытого профиля через два адаптера HTTP CONNECT к SOCKS5: подконтрольный наблюдатель видит разные выходы, а измеренные часовой пояс, языки и canvas совпадают; host ICE не доказывает выход STUN или UDP.

Полезный результат содержит сравнение каждого сигнала рядом с независимо проверенным результатом выхода. Часовой пояс и локаль Intl обычно определяются браузером или операционной системой; новый прокси сам по себе не меняет эти исходные параметры. Вывод canvas также зависит от заданного рисунка, шрифтов и среды рендеринга. Cookies и другие хранилища относятся к снимку профиля и последующей активности приложения. Ни один из этих сигналов не следует считать автоматическим производным пароля прокси или метки страны.

Если выход изменился, а navigator.languages остался прежним, проверьте, действительно ли процессу нужен другой язык. Существуют многоязычные пользователи и путешественники; одно географическое несоответствие не является ошибкой или результатом удалённого обнаружения. Для контролируемого регионального теста явно задайте ожидаемые локаль и часовой пояс, измените эту настройку как отдельную переменную и повторите проверку. Иначе смена IP вместе с несколькими изменениями окружения не оставляет подтверждения того, какой параметр вызвал другой результат приложения.

Одинаковые хеши canvas подтверждают меньше, чем утверждение «сайт знает, что это тот же компьютер». Два профиля могут давать одинаковые пиксели, а вывод одного окружения может измениться после обновления шрифта или рендерера. Изменившийся хеш canvas тоже не доказывает новую идентичность. Сохраняйте версию браузера и параметры рисования, чтобы воспроизвести наблюдение, не превращая один хеш в неподтверждённое утверждение об атрибуции.

Документированная настройка прокси BotBrowser принимает учётные данные через --proxy-server; явные переопределения часового пояса, локали и языков остаются независимыми параметрами. Эти средства позволяют поддерживать заявленное окружение, когда этого требует тест. Они не могут исправить пароль провайдера, подтвердить назначенную им страну или обещать, как сайт назначения связывает сессии. Тест с обычным Chrome не проверяет BotBrowser, модель риска сайта назначения или какой-либо результат авторизации аккаунта.

Выбирайте исправление по первому неисправному уровню. Исправьте неверный параметр прокси до проверки аутентификации; устраните HTTP-запрос учётных данных до сравнения выходов; потребуйте два реальных выхода до интерпретации сигналов A/B. Когда маршрут работает и отличается только ожидаемое окружение, меняйте заявленное окружение вместо многократной смены учётных данных. Не включайте в это воспроизведение посторонние изменения браузера и провайдера.

Вопросы и ответы

Поддерживает ли Puppeteer launch({ proxy })?

Это не документированный способ настройки запуска Puppeteer. Проверьте BrowserContextOptions.proxyServer установленной версии или передайте документированный аргумент запуска прокси Chromium. Успешный запуск браузера не доказывает, что неизвестный параметр JavaScript настроил сетевой маршрут.

Почему page.authenticate() не исправляет пароль SOCKS5?

Этот метод передаёт учётные данные HTTP. Chromium документирует отсутствие поддерживаемых методов аутентификации для своего встроенного пути SOCKS5. Уточните метод, требуемый прокси, затем используйте поддерживаемый клиент или одобренный адаптер вместо добавления HTTP-заголовков в согласование SOCKS.

Нужен ли HTTPS-прокси Puppeteer другой URL назначения?

Схема прокси описывает соединение с прокси; URL назначения описывает конечный ресурс. HTTP-прокси может туннелировать HTTPS-ресурс. HTTPS-прокси добавляет TLS на участке от браузера до прокси, которому нужны собственные действительный сертификат и поддержка.

Меняет ли смена прокси отпечаток браузера?

Она может изменить наблюдаемый сетевой выход. Для определения того, меняются ли часовой пояс, язык, canvas, хранилище или другие сигналы, нужны отдельные измерения. Ни совпадающий хеш, ни другой IP не устанавливают решение сайта назначения об идентичности.

Источники

Практический вывод

Рассматривайте аутентификацию прокси Puppeteer, адрес выхода у наблюдателя и настройки браузера как отдельные проверки. Поддерживаемое согласование и реальный ответ сервера назначения подтверждают больше, чем запуск браузера; два проверенных публичных выхода подтверждают больше, чем два настроенных имени прокси. Сравнивайте неизменные исходные профили и измеренные сигналы только после успешного завершения этих проверок. Явно меняйте окружение, когда этого требует процесс, затем проверяйте эффект, не делая выводов об анонимности или решении сайта.

#Puppeteer#Авторизация Прокси#SOCKS5#Идентичность Браузера

Эта статья входит в руководство: Начало работы с автоматизацией браузера: карта

Выполните ту же проверку в BotBrowser

Загрузите профиль, сохраните внутри браузера те сигналы, о которых только что прочитали, и подтвердите результат на своей машине до раскатки.