fix(flow): harden proxy phone and checkout recovery paths
This commit is contained in:
+291
-5
@@ -48,6 +48,11 @@ const IP_PROXY_EXIT_PROBE_ENDPOINTS_711_STICKY = [
|
||||
// 与 curl 口径对齐,保持单端点,避免多站点探测引入额外波动与耗时。
|
||||
'https://ipinfo.io/json',
|
||||
];
|
||||
const IP_PROXY_TARGET_REACHABILITY_ENDPOINTS = [
|
||||
// Step 1 的真实目标站点;出口 IP 可用不代表该站点的 CONNECT/TLS 链路可用。
|
||||
'https://chatgpt.com/',
|
||||
];
|
||||
const IP_PROXY_TARGET_REACHABILITY_TIMEOUT_MS = 8000;
|
||||
const IP_PROXY_BACKGROUND_PROBE_MAX_ENDPOINTS = 4;
|
||||
const IP_PROXY_BACKGROUND_PROBE_PER_ENDPOINT_TIMEOUT_MS = 3500;
|
||||
const IP_PROXY_PAGE_CONTEXT_PROBE_URL = 'https://example.com/';
|
||||
@@ -1270,6 +1275,7 @@ function buildIpProxyRoutingStatePatch(status = {}) {
|
||||
const exitDetecting = Boolean(status?.exitDetecting);
|
||||
const exitError = String(status?.exitError || '').trim();
|
||||
const exitSource = String(status?.exitSource || '').trim().toLowerCase();
|
||||
const exitEndpoint = String(status?.exitEndpoint || status?.endpoint || '').trim();
|
||||
return {
|
||||
ipProxyApplied: applied,
|
||||
ipProxyAppliedReason: reason,
|
||||
@@ -1286,6 +1292,7 @@ function buildIpProxyRoutingStatePatch(status = {}) {
|
||||
ipProxyAppliedExitDetecting: exitDetecting,
|
||||
ipProxyAppliedExitError: exitError,
|
||||
ipProxyAppliedExitSource: exitSource,
|
||||
ipProxyAppliedExitEndpoint: exitEndpoint,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1317,6 +1324,23 @@ async function setIpProxyLeakGuardEnabled(enabled) {
|
||||
}).catch(() => { });
|
||||
}
|
||||
|
||||
function shouldEnableIpProxyLeakGuardForStatus(status = {}) {
|
||||
if (!status?.enabled) {
|
||||
return false;
|
||||
}
|
||||
if (status?.applied) {
|
||||
return false;
|
||||
}
|
||||
const reason = String(status?.reason || '').trim().toLowerCase();
|
||||
// connectivity_failed 表示 PAC/代理接管已经下发,只是探测或目标站点失败。
|
||||
// 此时继续启用 DNR 会把 chatgpt.com 变成 ERR_BLOCKED_BY_CLIENT,掩盖真实代理链路结果。
|
||||
return reason !== 'connectivity_failed';
|
||||
}
|
||||
|
||||
async function syncIpProxyLeakGuardForStatus(status = {}) {
|
||||
await setIpProxyLeakGuardEnabled(shouldEnableIpProxyLeakGuardForStatus(status));
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url, options = {}, timeoutMs = IP_PROXY_FETCH_TIMEOUT_MS) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), Math.max(1000, Number(timeoutMs) || IP_PROXY_FETCH_TIMEOUT_MS));
|
||||
@@ -1495,6 +1519,15 @@ function resolveExitProbeEndpoints(options = {}) {
|
||||
return IP_PROXY_EXIT_PROBE_ENDPOINTS.slice();
|
||||
}
|
||||
|
||||
function resolveTargetReachabilityEndpoints(options = {}) {
|
||||
const configured = Array.isArray(options?.targetReachabilityEndpoints)
|
||||
? options.targetReachabilityEndpoints.map((item) => String(item || '').trim()).filter(Boolean)
|
||||
: [];
|
||||
return configured.length
|
||||
? configured
|
||||
: IP_PROXY_TARGET_REACHABILITY_ENDPOINTS.slice();
|
||||
}
|
||||
|
||||
function applyExitRegionExpectation(status = {}, expectedRegion = '') {
|
||||
const exitIp = String(status?.exitIp || '').trim();
|
||||
const exitError = String(status?.exitError || '').trim();
|
||||
@@ -1656,6 +1689,48 @@ function applyExitBaselineExpectation(status = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function shouldVerifyIpProxyTargetReachability(status = {}) {
|
||||
if (!String(status?.exitIp || '').trim()) {
|
||||
return false;
|
||||
}
|
||||
if (status?.applied === false && String(status?.reason || '').trim().toLowerCase() === 'connectivity_failed') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildTargetReachabilityFailureMessage(status = {}, reachability = {}) {
|
||||
const exitIp = String(status?.exitIp || '').trim();
|
||||
const exitRegion = String(status?.exitRegion || '').trim();
|
||||
const endpoint = String(reachability?.endpoint || reachability?.url || IP_PROXY_TARGET_REACHABILITY_ENDPOINTS[0] || '').trim();
|
||||
const targetHost = extractProbeHostFromTabUrl(endpoint) || endpoint || 'chatgpt.com';
|
||||
const diagnostic = String(reachability?.error || reachability?.diagnostics || '').trim();
|
||||
const diagnosticSuffix = diagnostic ? ` 诊断:${diagnostic}` : '';
|
||||
const exitRegionSuffix = exitRegion ? ` [${exitRegion}]` : '';
|
||||
return `已检测到出口 IP ${exitIp}${exitRegionSuffix},但真实目标 ${targetHost} 不可达。`
|
||||
+ `这说明代理只通过了 IP 探测,无法打开步骤 1 的 ChatGPT 页面;请更换支持 chatgpt.com CONNECT/TLS 的节点。`
|
||||
+ diagnosticSuffix;
|
||||
}
|
||||
|
||||
function applyTargetReachabilityExpectation(status = {}, reachability = {}) {
|
||||
if (!shouldVerifyIpProxyTargetReachability(status)) {
|
||||
return status;
|
||||
}
|
||||
if (reachability?.reachable === true) {
|
||||
return status;
|
||||
}
|
||||
if (reachability?.skipped === true) {
|
||||
return status;
|
||||
}
|
||||
return {
|
||||
...status,
|
||||
applied: false,
|
||||
reason: 'connectivity_failed',
|
||||
warning: '',
|
||||
error: buildTargetReachabilityFailureMessage(status, reachability),
|
||||
};
|
||||
}
|
||||
|
||||
async function detectProxyExitInfoByBackgroundFetch(options = {}) {
|
||||
const timeoutMs = Number(options?.timeoutMs) > 0 ? Number(options.timeoutMs) : 10000;
|
||||
const errors = Array.isArray(options?.errors) ? options.errors : [];
|
||||
@@ -1954,6 +2029,35 @@ async function readExitProbeFromTabDocument(tabId) {
|
||||
return executionResults?.[0]?.result || null;
|
||||
}
|
||||
|
||||
function appendProbeCacheBuster(rawUrl = '', key = '_multipage_proxy_probe') {
|
||||
const text = String(rawUrl || '').trim();
|
||||
if (!text) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(text);
|
||||
parsed.searchParams.set(key, String(Date.now()));
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
const separator = text.includes('?') ? '&' : '?';
|
||||
return `${text}${separator}${key}=${Date.now()}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function readTargetReachabilityFromTabDocument(tabId) {
|
||||
const executionResults = await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
world: 'ISOLATED',
|
||||
func: () => ({
|
||||
href: String(location.href || ''),
|
||||
title: String(document.title || ''),
|
||||
readyState: String(document.readyState || ''),
|
||||
bodyLength: Number(document.body?.innerText?.length || document.documentElement?.innerText?.length || 0),
|
||||
}),
|
||||
});
|
||||
return executionResults?.[0]?.result || null;
|
||||
}
|
||||
|
||||
async function probeExitInfoByTabNavigation(tabId, timeoutMs = 10000, errors = []) {
|
||||
return probeExitInfoByTabNavigationWithEndpoints(tabId, timeoutMs, errors, []);
|
||||
}
|
||||
@@ -2208,6 +2312,101 @@ async function probeExitInfoViaExecuteScript(tabId, timeoutMs = 10000, endpoints
|
||||
return executionResults?.[0]?.result || null;
|
||||
}
|
||||
|
||||
async function detectIpProxyTargetReachabilityByPageContext(options = {}) {
|
||||
const timeoutMs = Number(options?.timeoutMs) > 0
|
||||
? Number(options.timeoutMs)
|
||||
: IP_PROXY_TARGET_REACHABILITY_TIMEOUT_MS;
|
||||
const errors = Array.isArray(options?.errors) ? options.errors : [];
|
||||
const endpoints = resolveTargetReachabilityEndpoints(options);
|
||||
if (!endpoints.length) {
|
||||
return { reachable: true, skipped: true, source: 'target_page_context', endpoint: '' };
|
||||
}
|
||||
if (!chrome.tabs?.create || !chrome.tabs?.update || !chrome.scripting?.executeScript) {
|
||||
errors.push('target:page_context:unavailable');
|
||||
return {
|
||||
reachable: false,
|
||||
endpoint: endpoints[0],
|
||||
source: 'target_page_context_unavailable',
|
||||
error: 'target:page_context:unavailable',
|
||||
};
|
||||
}
|
||||
|
||||
let tabId = null;
|
||||
let createdTabId = null;
|
||||
const navigationErrorTracker = createProbeNavigationErrorTracker(null);
|
||||
const perEndpointTimeoutMs = Math.max(2500, Math.min(IP_PROXY_TARGET_REACHABILITY_TIMEOUT_MS, timeoutMs));
|
||||
|
||||
try {
|
||||
for (let index = 0; index < endpoints.length; index += 1) {
|
||||
const endpoint = String(endpoints[index] || '').trim();
|
||||
if (!endpoint) {
|
||||
continue;
|
||||
}
|
||||
const targetUrl = appendProbeCacheBuster(endpoint, '_multipage_proxy_target');
|
||||
try {
|
||||
if (!Number.isInteger(tabId)) {
|
||||
const tab = await chrome.tabs.create({
|
||||
url: targetUrl,
|
||||
active: false,
|
||||
});
|
||||
tabId = Number(tab?.id) || null;
|
||||
createdTabId = tabId;
|
||||
navigationErrorTracker.setTabId(tabId);
|
||||
} else {
|
||||
await chrome.tabs.update(tabId, {
|
||||
url: targetUrl,
|
||||
active: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (!Number.isInteger(tabId)) {
|
||||
errors.push(`target:page_context:${endpoint}:no_tab`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const ready = await waitForPageContextProbeTabReady(tabId, perEndpointTimeoutMs);
|
||||
if (!ready) {
|
||||
errors.push(`target:page_context:${endpoint}:not_ready`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const documentResult = await readTargetReachabilityFromTabDocument(tabId);
|
||||
const href = String(documentResult?.href || '').trim();
|
||||
const host = extractProbeHostFromTabUrl(href);
|
||||
const expectedHost = extractProbeHostFromTabUrl(endpoint);
|
||||
if (host && expectedHost && (host === expectedHost || host.endsWith(`.${expectedHost}`))) {
|
||||
return {
|
||||
reachable: true,
|
||||
endpoint,
|
||||
url: href,
|
||||
source: 'target_page_context',
|
||||
};
|
||||
}
|
||||
errors.push(`target:page_context:${endpoint}:unexpected_url:${href || 'unknown'}`);
|
||||
} catch (error) {
|
||||
errors.push(`target:page_context:${endpoint}:${error?.message || error}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
navigationErrorTracker.appendDiagnostics(errors, 3);
|
||||
navigationErrorTracker.dispose();
|
||||
if (Number.isInteger(createdTabId)) {
|
||||
try {
|
||||
await chrome.tabs.remove(createdTabId);
|
||||
} catch {
|
||||
// ignore tab close failures
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
reachable: false,
|
||||
endpoint: endpoints[0],
|
||||
source: 'target_page_context',
|
||||
error: buildProbeDiagnosticsSummary(errors, IP_PROXY_DIAGNOSTICS_SUMMARY_MAX_ITEMS),
|
||||
};
|
||||
}
|
||||
|
||||
async function detectProxyExitInfoByPageContext(options = {}) {
|
||||
const timeoutMs = Number(options?.timeoutMs) > 0 ? Number(options.timeoutMs) : 10000;
|
||||
const errors = Array.isArray(options?.errors) ? options.errors : [];
|
||||
@@ -2675,6 +2874,16 @@ function buildIpProxyPacScript(entry) {
|
||||
}
|
||||
const targetPatterns = IP_PROXY_TARGET_HOST_PATTERNS.map((pattern) => `'${String(pattern).replace(/'/g, "\\'")}'`).join(', ');
|
||||
const bypassList = IP_PROXY_BYPASS_LIST.map((pattern) => `'${String(pattern).replace(/'/g, "\\'")}'`).join(', ');
|
||||
const forceDirectPatterns = (typeof IP_PROXY_FORCE_DIRECT_HOST_PATTERNS !== 'undefined' && Array.isArray(IP_PROXY_FORCE_DIRECT_HOST_PATTERNS)
|
||||
? IP_PROXY_FORCE_DIRECT_HOST_PATTERNS
|
||||
: [])
|
||||
.map((pattern) => `'${String(pattern).replace(/'/g, "\\'")}'`)
|
||||
.join(', ');
|
||||
const forceDirectFallback = String(
|
||||
typeof IP_PROXY_FORCE_DIRECT_FALLBACK !== 'undefined' && IP_PROXY_FORCE_DIRECT_FALLBACK
|
||||
? IP_PROXY_FORCE_DIRECT_FALLBACK
|
||||
: 'DIRECT'
|
||||
).replace(/"/g, '\\"');
|
||||
const proxyEndpoint = `${pacScheme} ${host}:${port}`;
|
||||
const routeAllLiteral = (typeof IP_PROXY_ROUTE_ALL_TRAFFIC !== 'undefined' && Boolean(IP_PROXY_ROUTE_ALL_TRAFFIC))
|
||||
? 'true'
|
||||
@@ -2690,6 +2899,22 @@ function FindProxyForURL(url, host) {
|
||||
}
|
||||
}
|
||||
|
||||
var forceDirectPatterns = [${forceDirectPatterns}];
|
||||
for (var fd = 0; fd < forceDirectPatterns.length; fd++) {
|
||||
var directPattern = forceDirectPatterns[fd];
|
||||
if (directPattern.indexOf('*.') === 0) {
|
||||
var directSuffix = directPattern.substring(1);
|
||||
var directHost = directPattern.substring(2);
|
||||
if (dnsDomainIs(host, directSuffix) || host === directHost) {
|
||||
return "${forceDirectFallback}";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (host === directPattern || dnsDomainIs(host, '.' + directPattern)) {
|
||||
return "${forceDirectFallback}";
|
||||
}
|
||||
}
|
||||
|
||||
var routeAllTraffic = ${routeAllLiteral};
|
||||
if (routeAllTraffic) {
|
||||
return "${proxyEndpoint}";
|
||||
@@ -3109,9 +3334,25 @@ async function applyIpProxySettingsFromState(state = {}, options = {}) {
|
||||
const expectedRegion = String(entry?.region || '').trim();
|
||||
let normalizedExitStatus = applyExitRegionExpectation(exitStatus, expectedRegion);
|
||||
normalizedExitStatus = applyExitBaselineExpectation(normalizedExitStatus);
|
||||
if (normalizedExitStatus.reason === 'connectivity_failed') {
|
||||
await setIpProxyLeakGuardEnabled(true);
|
||||
if (shouldVerifyIpProxyTargetReachability(normalizedExitStatus)) {
|
||||
const targetDiagnostics = [];
|
||||
const reachability = await detectIpProxyTargetReachabilityByPageContext({
|
||||
timeoutMs: IP_PROXY_TARGET_REACHABILITY_TIMEOUT_MS,
|
||||
errors: targetDiagnostics,
|
||||
}).catch((error) => ({
|
||||
reachable: false,
|
||||
endpoint: IP_PROXY_TARGET_REACHABILITY_ENDPOINTS[0],
|
||||
source: 'target_page_context',
|
||||
error: error?.message || String(error || 'target reachability failed'),
|
||||
}));
|
||||
normalizedExitStatus = applyTargetReachabilityExpectation(normalizedExitStatus, {
|
||||
...reachability,
|
||||
error: reachability?.reachable
|
||||
? ''
|
||||
: (reachability?.error || buildProbeDiagnosticsSummary(targetDiagnostics, IP_PROXY_DIAGNOSTICS_SUMMARY_MAX_ITEMS)),
|
||||
});
|
||||
}
|
||||
await syncIpProxyLeakGuardForStatus(normalizedExitStatus);
|
||||
await updateIpProxyRuntimeStatus(normalizedExitStatus);
|
||||
return normalizedExitStatus;
|
||||
}
|
||||
@@ -3425,6 +3666,32 @@ async function probeIpProxyExit(options = {}) {
|
||||
const probePromise = (async () => {
|
||||
const state = options.state || await getState();
|
||||
if (!state?.ipProxyEnabled) {
|
||||
if (options?.detectWhenDisabled) {
|
||||
const diagnostics = [];
|
||||
const exit = await detectProxyExitInfo({
|
||||
timeoutMs: Number(options?.timeoutMs) || 10000,
|
||||
errors: diagnostics,
|
||||
provider: normalizeIpProxyProviderValue(state?.ipProxyService),
|
||||
username: String(state?.ipProxyUsername || '').trim(),
|
||||
preferPageContext: true,
|
||||
allowBackgroundFallback: true,
|
||||
}).catch(() => ({ ip: '', region: '', endpoint: '', source: '' }));
|
||||
const status = {
|
||||
enabled: false,
|
||||
applied: false,
|
||||
reason: 'disabled_probe_only',
|
||||
provider: normalizeIpProxyProviderValue(state?.ipProxyService),
|
||||
exitDetecting: false,
|
||||
exitIp: String(exit?.ip || '').trim(),
|
||||
exitRegion: String(exit?.region || '').trim(),
|
||||
exitBaselineIp: String(exit?.baselineIp || '').trim(),
|
||||
exitEndpoint: String(exit?.endpoint || '').trim(),
|
||||
exitSource: String(exit?.source || '').trim().toLowerCase(),
|
||||
exitError: exit?.ip ? '' : buildProbeDiagnosticsSummary(diagnostics, IP_PROXY_DIAGNOSTICS_SUMMARY_MAX_ITEMS),
|
||||
error: '',
|
||||
};
|
||||
return { proxyRouting: status };
|
||||
}
|
||||
const status = {
|
||||
enabled: false,
|
||||
applied: false,
|
||||
@@ -3488,6 +3755,7 @@ async function probeIpProxyExit(options = {}) {
|
||||
exitDetecting: true,
|
||||
exitIp: '',
|
||||
exitRegion: '',
|
||||
exitEndpoint: '',
|
||||
exitError: '',
|
||||
exitSource: '',
|
||||
};
|
||||
@@ -3519,6 +3787,7 @@ async function probeIpProxyExit(options = {}) {
|
||||
exitIp: String(exit?.ip || '').trim(),
|
||||
exitRegion: String(exit?.region || '').trim(),
|
||||
exitBaselineIp: String(exit?.baselineIp || '').trim(),
|
||||
exitEndpoint: String(exit?.endpoint || '').trim(),
|
||||
exitError: exit?.ip ? '' : buildProbeDiagnosticsSummary(diagnostics, IP_PROXY_DIAGNOSTICS_SUMMARY_MAX_ITEMS),
|
||||
exitSource: String(exit?.source || '').trim().toLowerCase(),
|
||||
authDiagnostics: statusSeed?.hasAuth ? getIpProxyAuthDiagnosticsSummary() : '',
|
||||
@@ -3526,6 +3795,24 @@ async function probeIpProxyExit(options = {}) {
|
||||
const expectedRegion = String(statusSeed?.region || '').trim();
|
||||
let normalized = applyExitRegionExpectation(finalStatus, expectedRegion);
|
||||
normalized = applyExitBaselineExpectation(normalized);
|
||||
if (shouldVerifyIpProxyTargetReachability(normalized)) {
|
||||
const targetDiagnostics = [];
|
||||
const reachability = await detectIpProxyTargetReachabilityByPageContext({
|
||||
timeoutMs: IP_PROXY_TARGET_REACHABILITY_TIMEOUT_MS,
|
||||
errors: targetDiagnostics,
|
||||
}).catch((error) => ({
|
||||
reachable: false,
|
||||
endpoint: IP_PROXY_TARGET_REACHABILITY_ENDPOINTS[0],
|
||||
source: 'target_page_context',
|
||||
error: error?.message || String(error || 'target reachability failed'),
|
||||
}));
|
||||
normalized = applyTargetReachabilityExpectation(normalized, {
|
||||
...reachability,
|
||||
error: reachability?.reachable
|
||||
? ''
|
||||
: (reachability?.error || buildProbeDiagnosticsSummary(targetDiagnostics, IP_PROXY_DIAGNOSTICS_SUMMARY_MAX_ITEMS)),
|
||||
});
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
@@ -3584,6 +3871,7 @@ async function probeIpProxyExit(options = {}) {
|
||||
exitDetecting: true,
|
||||
exitIp: '',
|
||||
exitRegion: '',
|
||||
exitEndpoint: '',
|
||||
exitError: '',
|
||||
exitSource: '',
|
||||
};
|
||||
@@ -3612,9 +3900,7 @@ async function probeIpProxyExit(options = {}) {
|
||||
normalizedFinalStatus = recovered.status;
|
||||
}
|
||||
}
|
||||
if (normalizedFinalStatus.reason === 'connectivity_failed') {
|
||||
await setIpProxyLeakGuardEnabled(true);
|
||||
}
|
||||
await syncIpProxyLeakGuardForStatus(normalizedFinalStatus);
|
||||
await updateIpProxyRuntimeStatus(normalizedFinalStatus);
|
||||
return { proxyRouting: normalizedFinalStatus };
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user