Cases / #69 · 2026-10-24 · Medium
VPN Tunnel vs a plain residential proxy: WebRTC, HTTP/3 and two bot-protection pages
The same sticky residential exit with and without `vpn_tunnel`, on headful Chrome and Camoufox: which public IP WebRTC shows, whether HTTP/3 (QUIC) gets used, and what Cloudflare's challenge test page and DataDome's own site make of each.
Run on production on 2026-10-24: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go
The problem
A proxy carries TCP, so a normal proxied session sends no UDP: WebRTC can't reach a STUN server and HTTP/3 (which runs over UDP) never starts. A real home connection does both, and a site can notice the difference. vpn_tunnel gives the browser a network of its own whose only way out is the residential exit, UDP included. What actually changes, and does it matter to bot protection?
What we used, and why
| What | Why |
|---|---|
cdpfleet-resi-country-us-session-<id> | One sticky Tier 1 exit per browser, used for both rows of that browser, so the only difference between them is the tunnel. vpn_tunnel needs a Tier 1 or 2 token and isn't combined with proxy_rules. |
vpn_tunnel: true | The browser's traffic, UDP included, leaves through the residential exit. |
| WebRTC leak test | ICE candidates against stun:stun.l.google.com:19302, as in case 54: a srflx candidate is the public IP the STUN server saw. |
nextHopProtocol on cloudflare-quic.com | Four loads in fresh tabs: the first is announced over HTTP/2 (Alt-Svc); a browser that can use QUIC switches to h3 on a later one. |
| Two bot-protection pages | Cloudflare's challenge test page from case 64 and DataDome's own site from case 65, one load each per row, nothing clicked. |
How it works
- For each browser, launch a session on its sticky exit without the tunnel, then one with
vpn_tunnel: trueon the same exit. - Read the exit IP over HTTP, run the WebRTC leak test and record every public address it shows.
- Load cloudflare-quic.com four times in fresh tabs, 2.5 s apart, and record each navigation's protocol.
- Load scrapingcourse.com's Cloudflare challenge (watched 20 s) and datadome.co (watched 12 s) in fresh contexts and classify each as content, cleared, challenge, blocked or error.
The code
The same program in five languages (also on GitHub, with the raw output). Set these environment variables first:
CDPFLEET_API_KEY— your API key (dashboard → API keys)
// npm install [email protected]
// env: CDPFLEET_API_KEY (sticky US exits from the cdpfleet residential proxy, Tier 1; one load per bot-protection page)
import { randomBytes } from 'node:crypto';
import { chromium, firefox } from 'playwright';
const KEY = process.env.CDPFLEET_API_KEY;
const RUN = randomBytes(3).toString('hex');
// One sticky exit per setup, used with and without the tunnel: the only difference between the two rows is vpn_tunnel.
const exitFor = (id) => `cdpfleet-resi-country-us-session-t${id}${RUN}-lifetime-30`;
const SETUPS = {
chrome: { id: 'c', setup: 'Google Chrome, headful', engine: 'chrome', type: chromium, headless: false },
camoufox: { id: 'f', setup: 'Camoufox, defaults', engine: 'camoufox', type: firefox, headless: true },
};
const NETWORKS = {
proxy: { label: 'residential proxy', body: {} },
tunnel: { label: 'residential proxy + vpn_tunnel', body: { vpn_tunnel: true } },
};
// The WebRTC leak test from case 54: ICE candidates against a public STUN server. A "srflx" candidate is the public IP
// the STUN server saw. The same expression runs in every language.
const GATHER = `async () => {
if (typeof RTCPeerConnection === 'undefined') return { api: false, candidates: [] };
const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
const candidates = [];
pc.onicecandidate = (e) => { if (e.candidate && e.candidate.candidate) candidates.push(e.candidate.candidate); };
pc.createDataChannel('probe');
await pc.setLocalDescription(await pc.createOffer());
await new Promise((done) => {
const timer = setTimeout(done, 8000);
pc.onicegatheringstatechange = () => { if (pc.iceGatheringState === 'complete') { clearTimeout(timer); done(); } };
});
pc.close();
return { api: true, candidates };
}`;
// The protocol of the page's own request: h2 over TCP, or h3 over QUIC (UDP).
const PROTOCOL = `performance.getEntriesByType('navigation')[0]?.nextHopProtocol || null`;
// Cloudflare's interstitial or block page, or the real page.
const CF_STATE = `(() => {
const t = (document.title + ' ' + (document.body ? document.body.innerText : '').slice(0, 400)).replace(/\\s+/g, ' ');
return /sorry, you have been blocked|attention required|error 1020|access denied/i.test(t) ? 'blocked'
: /just a moment|performing security verification|verify you are human|checking your browser/i.test(t) ? 'challenge' : 'page';
})()`;
// DataDome's iframe from captcha-delivery.com: a device check, a CAPTCHA, or a block (t=bv) — as in case 65.
const DD_STATE = `(() => {
const f = [...document.querySelectorAll('iframe')].map((i) => i.src).find((s) => /captcha-delivery\\.com/.test(s)) || '';
const text = (document.body ? document.body.innerText : '').trim();
return /[?&]t=bv(&|$)/.test(f) ? 'blocked' : /\\/captcha\\//.test(f) ? 'challenge' : f ? 'checking' : text.length > 200 ? 'page' : 'loading';
})()`;
// 503 means the fleet is momentarily busy: wait Retry-After and try again.
async function launch(s, network) {
for (let attempt = 1; ; attempt++) {
const res = await fetch(`https://starter.cdpfleet.com/${s.engine}/session`, {
method: 'POST',
headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
body: JSON.stringify({ proxy: exitFor(s.id), headless: s.headless, record: false, ...NETWORKS[network].body }),
});
if (res.ok) return s.type.connect((await res.json()).wsUrl, { headers: { 'x-api-key': KEY } });
if (![429, 503].includes(res.status) || attempt === 6) throw new Error(`launch ${res.status} ${await res.text()}`);
await new Promise((r) => setTimeout(r, (Number(res.headers.get('retry-after')) || 5) * 1000));
}
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Residential peers drop a connection now and then: one retry.
async function go(tab, url) {
try {
return await tab.goto(url, { waitUntil: 'domcontentloaded', timeout: 45000 });
} catch {
return tab.goto(url, { waitUntil: 'domcontentloaded', timeout: 45000 });
}
}
// One load, then watch the page once a second for 20 s (Cloudflare) or 12 s (DataDome) without touching it.
async function watch(context, url, kind) {
const tab = await context.newPage();
const t0 = Date.now();
try {
try {
await go(tab, url);
} catch {
return { outcome: 'error', seconds: null };
}
let seen = false;
const until = Date.now() + (kind === 'cf' ? 20000 : 12000);
while (Date.now() < until) {
const s = await tab.evaluate(kind === 'cf' ? CF_STATE : DD_STATE).catch(() => null);
if (s === 'blocked') return { outcome: 'blocked', seconds: null };
if (s === 'challenge' && kind === 'dd') return { outcome: 'challenge', seconds: null };
if (s === 'challenge' || s === 'checking') seen = true;
if (s === 'page') return seen ? { outcome: 'cleared', seconds: Math.round((Date.now() - t0) / 1000) } : { outcome: 'content', seconds: null };
await sleep(1000);
}
return { outcome: seen ? 'challenge' : 'error', seconds: null };
} finally {
await tab.close();
}
}
async function row(setupKey, network) {
const s = SETUPS[setupKey];
const browser = await launch(s, network);
const out = { setup: s.setup, network: NETWORKS[network].label };
try {
const context = await browser.newContext();
const tab = await context.newPage();
await go(tab, 'https://api.ipify.org/?format=json');
out.exit_ip = JSON.parse(await tab.textContent('body')).ip;
const { api, candidates } = await tab.evaluate(`(${GATHER})()`);
// candidate:<foundation> <component> <transport> <priority> <address> <port> typ <type> …
const parsed = candidates.map((c) => { const f = c.split(' '); return { address: f[4], type: f[7] }; });
out.webrtc_api = api;
out.webrtc_ips = [...new Set(parsed.filter((c) => /^\d+\.\d+\.\d+\.\d+$/.test(c.address) && c.address !== '0.0.0.0').map((c) => c.address))];
out.mdns_only = out.webrtc_ips.length === 0 && parsed.some((c) => c.address.endsWith('.local'));
await tab.close();
// HTTP/3: a page announces it over HTTP/2 (Alt-Svc); the browser can switch to QUIC on a later request. Four loads
// in fresh tabs, a pause between them.
out.protocols = [];
for (let i = 0; i < 4; i++) {
const t = await context.newPage();
await go(t, 'https://cloudflare-quic.com/').catch(() => {});
out.protocols.push(await t.evaluate(PROTOCOL).catch(() => null));
await t.close();
await sleep(2500);
}
await context.close();
// The bot-protection pages, each in a fresh context: Cloudflare's challenge test page (case 64) and DataDome's
// own site (case 65).
for (const [key, url, kind] of [['scrapingcourse', 'https://www.scrapingcourse.com/cloudflare-challenge', 'cf'], ['datadome', 'https://datadome.co/', 'dd']]) {
const c = await browser.newContext();
out[key] = await watch(c, url, kind);
await c.close();
}
} finally {
await browser.close();
}
return out;
}
const rows = [];
for (const setupKey of Object.keys(SETUPS)) {
for (const network of Object.keys(NETWORKS)) rows.push(await row(setupKey, network));
}
console.log(JSON.stringify({ measured_at: new Date().toISOString().slice(0, 10), rows }, null, 2));
# pip install playwright==1.60.0 requests
# env: CDPFLEET_API_KEY (sticky US exits from the cdpfleet residential proxy, Tier 1; one load per bot-protection page)
import json
import re
import secrets
import time
import os
from datetime import datetime, timezone
import requests
from playwright.sync_api import sync_playwright
KEY = os.environ["CDPFLEET_API_KEY"]
RUN = secrets.token_hex(3)
# One sticky exit per setup, used with and without the tunnel: the only difference between the two rows is vpn_tunnel.
def exit_for(sid):
return f"cdpfleet-resi-country-us-session-t{sid}{RUN}-lifetime-30"
SETUPS = {
"chrome": {"id": "c", "setup": "Google Chrome, headful", "engine": "chrome", "type": "chromium", "headless": False},
"camoufox": {"id": "f", "setup": "Camoufox, defaults", "engine": "camoufox", "type": "firefox", "headless": True},
}
NETWORKS = {
"proxy": {"label": "residential proxy", "body": {}},
"tunnel": {"label": "residential proxy + vpn_tunnel", "body": {"vpn_tunnel": True}},
}
# The WebRTC leak test from case 54: ICE candidates against a public STUN server. A "srflx" candidate is the public IP
# the STUN server saw. The same expression runs in every language.
GATHER = r"""async () => {
if (typeof RTCPeerConnection === 'undefined') return { api: false, candidates: [] };
const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
const candidates = [];
pc.onicecandidate = (e) => { if (e.candidate && e.candidate.candidate) candidates.push(e.candidate.candidate); };
pc.createDataChannel('probe');
await pc.setLocalDescription(await pc.createOffer());
await new Promise((done) => {
const timer = setTimeout(done, 8000);
pc.onicegatheringstatechange = () => { if (pc.iceGatheringState === 'complete') { clearTimeout(timer); done(); } };
});
pc.close();
return { api: true, candidates };
}"""
# The protocol of the page's own request: h2 over TCP, or h3 over QUIC (UDP).
PROTOCOL = "performance.getEntriesByType('navigation')[0]?.nextHopProtocol || null"
# Cloudflare's interstitial or block page, or the real page.
CF_STATE = r"""(() => {
const t = (document.title + ' ' + (document.body ? document.body.innerText : '').slice(0, 400)).replace(/\s+/g, ' ');
return /sorry, you have been blocked|attention required|error 1020|access denied/i.test(t) ? 'blocked'
: /just a moment|performing security verification|verify you are human|checking your browser/i.test(t) ? 'challenge' : 'page';
})()"""
# DataDome's iframe from captcha-delivery.com: a device check, a CAPTCHA, or a block (t=bv) — as in case 65.
DD_STATE = r"""(() => {
const f = [...document.querySelectorAll('iframe')].map((i) => i.src).find((s) => /captcha-delivery\.com/.test(s)) || '';
const text = (document.body ? document.body.innerText : '').trim();
return /[?&]t=bv(&|$)/.test(f) ? 'blocked' : /\/captcha\//.test(f) ? 'challenge' : f ? 'checking' : text.length > 200 ? 'page' : 'loading';
})()"""
IPV4 = re.compile(r"^\d+\.\d+\.\d+\.\d+$")
# 503 means the fleet is momentarily busy: wait Retry-After and try again.
def launch(p, s, network):
body = {"proxy": exit_for(s["id"]), "headless": s["headless"], "record": False, **NETWORKS[network]["body"]}
for attempt in range(1, 7):
res = requests.post(f"https://starter.cdpfleet.com/{s['engine']}/session", headers={"x-api-key": KEY}, json=body, timeout=120)
if res.ok:
return getattr(p, s["type"]).connect(res.json()["wsUrl"], headers={"x-api-key": KEY})
if res.status_code not in (429, 503) or attempt == 6:
raise RuntimeError(f"launch {res.status_code} {res.text}")
time.sleep(int(res.headers.get("retry-after") or 5))
# Residential peers drop a connection now and then: one retry.
def go(tab, url):
try:
return tab.goto(url, wait_until="domcontentloaded", timeout=45000)
except Exception:
return tab.goto(url, wait_until="domcontentloaded", timeout=45000)
def evaluate(tab, expr):
try:
return tab.evaluate(expr)
except Exception:
return None
# One load, then watch the page once a second for 20 s (Cloudflare) or 12 s (DataDome) without touching it.
def watch(context, url, kind):
tab = context.new_page()
t0 = time.time()
try:
try:
go(tab, url)
except Exception:
return {"outcome": "error", "seconds": None}
seen = False
until = time.time() + (20 if kind == "cf" else 12)
while time.time() < until:
s = evaluate(tab, CF_STATE if kind == "cf" else DD_STATE)
if s == "blocked":
return {"outcome": "blocked", "seconds": None}
if s == "challenge" and kind == "dd":
return {"outcome": "challenge", "seconds": None}
if s in ("challenge", "checking"):
seen = True
if s == "page":
return {"outcome": "cleared", "seconds": round(time.time() - t0)} if seen else {"outcome": "content", "seconds": None}
time.sleep(1)
return {"outcome": "challenge" if seen else "error", "seconds": None}
finally:
tab.close()
def row(p, setup_key, network):
s = SETUPS[setup_key]
browser = launch(p, s, network)
out = {"setup": s["setup"], "network": NETWORKS[network]["label"]}
try:
context = browser.new_context()
tab = context.new_page()
go(tab, "https://api.ipify.org/?format=json")
out["exit_ip"] = json.loads(tab.text_content("body"))["ip"]
g = tab.evaluate(f"({GATHER})()")
# candidate:<foundation> <component> <transport> <priority> <address> <port> typ <type> …
parsed = [{"address": c.split(" ")[4], "type": c.split(" ")[7]} for c in g["candidates"]]
out["webrtc_api"] = g["api"]
ips = []
for c in parsed:
if IPV4.match(c["address"]) and c["address"] != "0.0.0.0" and c["address"] not in ips:
ips.append(c["address"])
out["webrtc_ips"] = ips
out["mdns_only"] = not ips and any(c["address"].endswith(".local") for c in parsed)
tab.close()
# HTTP/3: a page announces it over HTTP/2 (Alt-Svc); the browser can switch to QUIC on a later request. Four loads
# in fresh tabs, a pause between them.
out["protocols"] = []
for _ in range(4):
t = context.new_page()
try:
go(t, "https://cloudflare-quic.com/")
except Exception:
pass
out["protocols"].append(evaluate(t, PROTOCOL))
t.close()
time.sleep(2.5)
context.close()
# The bot-protection pages, each in a fresh context: Cloudflare's challenge test page (case 64) and DataDome's
# own site (case 65).
for key, url, kind in (("scrapingcourse", "https://www.scrapingcourse.com/cloudflare-challenge", "cf"), ("datadome", "https://datadome.co/", "dd")):
c = browser.new_context()
out[key] = watch(c, url, kind)
c.close()
finally:
browser.close()
return out
with sync_playwright() as p:
rows = [row(p, s, n) for s in SETUPS for n in NETWORKS]
print(json.dumps({"measured_at": datetime.now(timezone.utc).strftime("%Y-%m-%d"), "rows": rows}, indent=2))
// Maven: com.microsoft.playwright:playwright:1.60.0, com.google.code.gson:gson:2.11.0
// Run with PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1.
// env: CDPFLEET_API_KEY (sticky US exits from the cdpfleet residential proxy, Tier 1; one load per bot-protection page)
import com.google.gson.*;
import com.microsoft.playwright.*;
import com.microsoft.playwright.options.WaitUntilState;
import java.net.URI;
import java.net.http.*;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.*;
public class Main {
static final String KEY = System.getenv("CDPFLEET_API_KEY");
static final Gson GSON = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().serializeNulls().create();
static final HttpClient HTTP = HttpClient.newHttpClient();
static final String RUN = String.format("%06x", new SecureRandom().nextInt(1 << 24));
record Setup(String id, String name, String engine, String type, boolean headless) {}
record Network(String label, boolean tunnel) {}
// One sticky exit per setup, used with and without the tunnel: the only difference between the two rows is vpn_tunnel.
static String exitFor(String id) { return "cdpfleet-resi-country-us-session-t" + id + RUN + "-lifetime-30"; }
static final List<Setup> SETUPS = List.of(
new Setup("c", "Google Chrome, headful", "chrome", "chromium", false),
new Setup("f", "Camoufox, defaults", "camoufox", "firefox", true));
static final List<Network> NETWORKS = List.of(new Network("residential proxy", false), new Network("residential proxy + vpn_tunnel", true));
// The WebRTC leak test from case 54: ICE candidates against a public STUN server. A "srflx" candidate is the public IP
// the STUN server saw. The same expression runs in every language.
static final String GATHER = """
async () => {
if (typeof RTCPeerConnection === 'undefined') return { api: false, candidates: [] };
const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
const candidates = [];
pc.onicecandidate = (e) => { if (e.candidate && e.candidate.candidate) candidates.push(e.candidate.candidate); };
pc.createDataChannel('probe');
await pc.setLocalDescription(await pc.createOffer());
await new Promise((done) => {
const timer = setTimeout(done, 8000);
pc.onicegatheringstatechange = () => { if (pc.iceGatheringState === 'complete') { clearTimeout(timer); done(); } };
});
pc.close();
return { api: true, candidates };
}""";
// The protocol of the page's own request: h2 over TCP, or h3 over QUIC (UDP).
static final String PROTOCOL = "performance.getEntriesByType('navigation')[0]?.nextHopProtocol || null";
// Cloudflare's interstitial or block page, or the real page.
static final String CF_STATE = """
(() => {
const t = (document.title + ' ' + (document.body ? document.body.innerText : '').slice(0, 400)).replace(/\\s+/g, ' ');
return /sorry, you have been blocked|attention required|error 1020|access denied/i.test(t) ? 'blocked'
: /just a moment|performing security verification|verify you are human|checking your browser/i.test(t) ? 'challenge' : 'page';
})()""";
// DataDome's iframe from captcha-delivery.com: a device check, a CAPTCHA, or a block (t=bv) — as in case 65.
static final String DD_STATE = """
(() => {
const f = [...document.querySelectorAll('iframe')].map((i) => i.src).find((s) => /captcha-delivery\\.com/.test(s)) || '';
const text = (document.body ? document.body.innerText : '').trim();
return /[?&]t=bv(&|$)/.test(f) ? 'blocked' : /\\/captcha\\//.test(f) ? 'challenge' : f ? 'checking' : text.length > 200 ? 'page' : 'loading';
})()""";
static final Page.NavigateOptions NAV = new Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED).setTimeout(45000);
static Playwright playwright;
// 503 means the fleet is momentarily busy: wait Retry-After and try again.
static Browser launch(Setup s, Network n) throws Exception {
JsonObject body = new JsonObject();
body.addProperty("proxy", exitFor(s.id()));
body.addProperty("headless", s.headless());
body.addProperty("record", false);
if (n.tunnel()) body.addProperty("vpn_tunnel", true);
for (int attempt = 1; ; attempt++) {
HttpResponse<String> res = HTTP.send(HttpRequest.newBuilder(URI.create("https://starter.cdpfleet.com/" + s.engine() + "/session"))
.header("x-api-key", KEY).header("content-type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body.toString())).build(), HttpResponse.BodyHandlers.ofString());
if (res.statusCode() == 200) {
String wsUrl = JsonParser.parseString(res.body()).getAsJsonObject().get("wsUrl").getAsString();
BrowserType type = s.type().equals("firefox") ? playwright.firefox() : playwright.chromium();
return type.connect(wsUrl, new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)));
}
if ((res.statusCode() != 429 && res.statusCode() != 503) || attempt == 6) throw new RuntimeException("launch " + res.statusCode() + " " + res.body());
Thread.sleep(Long.parseLong(res.headers().firstValue("retry-after").orElse("5")) * 1000);
}
}
// Residential peers drop a connection now and then: one retry.
static void go(Page tab, String url) {
try { tab.navigate(url, NAV); } catch (PlaywrightException e) { tab.navigate(url, NAV); }
}
static String str(Page tab, String expr) {
try { Object v = tab.evaluate(expr); return v instanceof String s ? s : null; } catch (PlaywrightException e) { return null; }
}
static JsonObject cell(String outcome, Integer seconds) {
JsonObject c = new JsonObject();
c.addProperty("outcome", outcome);
c.addProperty("seconds", seconds);
return c;
}
// One load, then watch the page once a second for 20 s (Cloudflare) or 12 s (DataDome) without touching it.
static JsonObject watch(BrowserContext context, String url, String kind) throws Exception {
Page tab = context.newPage();
long t0 = System.currentTimeMillis();
try {
try { go(tab, url); } catch (PlaywrightException e) { return cell("error", null); }
boolean seen = false;
long until = System.currentTimeMillis() + (kind.equals("cf") ? 20000 : 12000);
while (System.currentTimeMillis() < until) {
String s = str(tab, kind.equals("cf") ? CF_STATE : DD_STATE);
if ("blocked".equals(s)) return cell("blocked", null);
if ("challenge".equals(s) && kind.equals("dd")) return cell("challenge", null);
if ("challenge".equals(s) || "checking".equals(s)) seen = true;
if ("page".equals(s)) return seen ? cell("cleared", (int) Math.round((System.currentTimeMillis() - t0) / 1000.0)) : cell("content", null);
Thread.sleep(1000);
}
return cell(seen ? "challenge" : "error", null);
} finally {
tab.close();
}
}
static JsonObject row(Setup s, Network n) throws Exception {
Browser browser = launch(s, n);
JsonObject out = new JsonObject();
out.addProperty("setup", s.name());
out.addProperty("network", n.label());
try {
BrowserContext context = browser.newContext();
Page tab = context.newPage();
go(tab, "https://api.ipify.org/?format=json");
out.addProperty("exit_ip", JsonParser.parseString(tab.textContent("body")).getAsJsonObject().get("ip").getAsString());
Map<?, ?> g = (Map<?, ?>) tab.evaluate("(" + GATHER + ")()");
List<String> ips = new ArrayList<>();
boolean sawMdns = false;
for (Object c : (List<?>) g.get("candidates")) {
// candidate:<foundation> <component> <transport> <priority> <address> <port> typ <type> …
String[] f = ((String) c).split(" ");
if (f.length < 8) continue;
if (f[4].endsWith(".local")) sawMdns = true;
if (f[4].matches("\\d+\\.\\d+\\.\\d+\\.\\d+") && !f[4].equals("0.0.0.0") && !ips.contains(f[4])) ips.add(f[4]);
}
out.addProperty("webrtc_api", (Boolean) g.get("api"));
JsonArray ipArr = new JsonArray();
ips.forEach(ipArr::add);
out.add("webrtc_ips", ipArr);
out.addProperty("mdns_only", ips.isEmpty() && sawMdns);
tab.close();
// HTTP/3: a page announces it over HTTP/2 (Alt-Svc); the browser can switch to QUIC on a later request. Four loads
// in fresh tabs, a pause between them.
JsonArray protocols = new JsonArray();
for (int i = 0; i < 4; i++) {
Page t = context.newPage();
try { go(t, "https://cloudflare-quic.com/"); } catch (PlaywrightException e) { /* recorded as null */ }
String p = str(t, PROTOCOL);
if (p == null) protocols.add(JsonNull.INSTANCE); else protocols.add(p);
t.close();
Thread.sleep(2500);
}
out.add("protocols", protocols);
context.close();
// The bot-protection pages, each in a fresh context: Cloudflare's challenge test page (case 64) and DataDome's
// own site (case 65).
String[][] pages = { { "scrapingcourse", "https://www.scrapingcourse.com/cloudflare-challenge", "cf" }, { "datadome", "https://datadome.co/", "dd" } };
for (String[] pg : pages) {
BrowserContext c = browser.newContext();
out.add(pg[0], watch(c, pg[1], pg[2]));
c.close();
}
} finally {
browser.close();
}
return out;
}
public static void main(String[] args) throws Exception {
JsonObject result = new JsonObject();
try (Playwright pw = Playwright.create()) {
playwright = pw;
JsonArray rows = new JsonArray();
for (Setup s : SETUPS)
for (Network n : NETWORKS) rows.add(row(s, n));
result.addProperty("measured_at", Instant.now().toString().substring(0, 10));
result.add("rows", rows);
}
System.out.println(GSON.toJson(result));
}
}
// dotnet add package Microsoft.Playwright --version 1.60.0
// env: CDPFLEET_API_KEY (sticky US exits from the cdpfleet residential proxy, Tier 1; one load per bot-protection page)
using System.Diagnostics;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Microsoft.Playwright;
var key = Environment.GetEnvironmentVariable("CDPFLEET_API_KEY")!;
var run = Convert.ToHexString(RandomNumberGenerator.GetBytes(3)).ToLowerInvariant();
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", key);
using var playwright = await Playwright.CreateAsync();
// One sticky exit per setup, used with and without the tunnel: the only difference between the two rows is vpn_tunnel.
string ExitFor(string id) => $"cdpfleet-resi-country-us-session-t{id}{run}-lifetime-30";
var setups = new (string Id, string Name, string Engine, IBrowserType Type, bool Headless)[]
{
("c", "Google Chrome, headful", "chrome", playwright.Chromium, false),
("f", "Camoufox, defaults", "camoufox", playwright.Firefox, true),
};
var networks = new (string Label, bool Tunnel)[] { ("residential proxy", false), ("residential proxy + vpn_tunnel", true) };
// The WebRTC leak test from case 54: ICE candidates against a public STUN server. A "srflx" candidate is the public IP
// the STUN server saw. The same expression runs in every language.
const string Gather = """
async () => {
if (typeof RTCPeerConnection === 'undefined') return { api: false, candidates: [] };
const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
const candidates = [];
pc.onicecandidate = (e) => { if (e.candidate && e.candidate.candidate) candidates.push(e.candidate.candidate); };
pc.createDataChannel('probe');
await pc.setLocalDescription(await pc.createOffer());
await new Promise((done) => {
const timer = setTimeout(done, 8000);
pc.onicegatheringstatechange = () => { if (pc.iceGatheringState === 'complete') { clearTimeout(timer); done(); } };
});
pc.close();
return { api: true, candidates };
}
""";
// The protocol of the page's own request: h2 over TCP, or h3 over QUIC (UDP).
const string Protocol = "performance.getEntriesByType('navigation')[0]?.nextHopProtocol || null";
// Cloudflare's interstitial or block page, or the real page.
const string CfState = """
(() => {
const t = (document.title + ' ' + (document.body ? document.body.innerText : '').slice(0, 400)).replace(/\s+/g, ' ');
return /sorry, you have been blocked|attention required|error 1020|access denied/i.test(t) ? 'blocked'
: /just a moment|performing security verification|verify you are human|checking your browser/i.test(t) ? 'challenge' : 'page';
})()
""";
// DataDome's iframe from captcha-delivery.com: a device check, a CAPTCHA, or a block (t=bv) — as in case 65.
const string DdState = """
(() => {
const f = [...document.querySelectorAll('iframe')].map((i) => i.src).find((s) => /captcha-delivery\.com/.test(s)) || '';
const text = (document.body ? document.body.innerText : '').trim();
return /[?&]t=bv(&|$)/.test(f) ? 'blocked' : /\/captcha\//.test(f) ? 'challenge' : f ? 'checking' : text.length > 200 ? 'page' : 'loading';
})()
""";
var ipv4 = new Regex(@"^\d+\.\d+\.\d+\.\d+$");
var nav = new PageGotoOptions { WaitUntil = WaitUntilState.DOMContentLoaded, Timeout = 45000 };
// 503 means the fleet is momentarily busy: wait Retry-After and try again.
async Task<IBrowser> Launch((string Id, string Name, string Engine, IBrowserType Type, bool Headless) s, bool tunnel)
{
var body = new JsonObject { ["proxy"] = ExitFor(s.Id), ["headless"] = s.Headless, ["record"] = false };
if (tunnel) body["vpn_tunnel"] = true;
for (var attempt = 1; ; attempt++)
{
var res = await http.PostAsJsonAsync($"https://starter.cdpfleet.com/{s.Engine}/session", body);
if (res.IsSuccessStatusCode)
{
var wsUrl = (await res.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("wsUrl").GetString()!;
return await s.Type.ConnectAsync(wsUrl, new() { Headers = new Dictionary<string, string> { ["x-api-key"] = key } });
}
var code = (int)res.StatusCode;
if ((code != 429 && code != 503) || attempt == 6) throw new Exception($"launch {code} {await res.Content.ReadAsStringAsync()}");
await Task.Delay(TimeSpan.FromSeconds(res.Headers.RetryAfter?.Delta?.TotalSeconds ?? 5));
}
}
// Residential peers drop a connection now and then: one retry.
async Task Go(IPage tab, string url)
{
try { await tab.GotoAsync(url, nav); }
catch (PlaywrightException) { await tab.GotoAsync(url, nav); }
}
async Task<string?> Str(IPage tab, string expr)
{
try { return await tab.EvaluateAsync<string?>(expr); }
catch (Exception) { return null; }
}
// One load, then watch the page once a second for 20 s (Cloudflare) or 12 s (DataDome) without touching it.
async Task<JsonObject> Watch(IBrowserContext context, string url, string kind)
{
var tab = await context.NewPageAsync();
var clock = Stopwatch.StartNew();
try
{
try { await Go(tab, url); }
catch (Exception) { return new JsonObject { ["outcome"] = "error", ["seconds"] = null }; }
var seen = false;
var until = Stopwatch.StartNew();
var limit = kind == "cf" ? 20000 : 12000;
while (until.ElapsedMilliseconds < limit)
{
var s = await Str(tab, kind == "cf" ? CfState : DdState);
if (s == "blocked") return new JsonObject { ["outcome"] = "blocked", ["seconds"] = null };
if (s == "challenge" && kind == "dd") return new JsonObject { ["outcome"] = "challenge", ["seconds"] = null };
if (s == "challenge" || s == "checking") seen = true;
if (s == "page")
return seen ? new JsonObject { ["outcome"] = "cleared", ["seconds"] = (int)Math.Round(clock.Elapsed.TotalSeconds) } : new JsonObject { ["outcome"] = "content", ["seconds"] = null };
await Task.Delay(1000);
}
return new JsonObject { ["outcome"] = seen ? "challenge" : "error", ["seconds"] = null };
}
finally { await tab.CloseAsync(); }
}
async Task<JsonObject> Row((string Id, string Name, string Engine, IBrowserType Type, bool Headless) s, (string Label, bool Tunnel) n)
{
var browser = await Launch(s, n.Tunnel);
var output = new JsonObject { ["setup"] = s.Name, ["network"] = n.Label };
try
{
var context = await browser.NewContextAsync();
var tab = await context.NewPageAsync();
await Go(tab, "https://api.ipify.org/?format=json");
output["exit_ip"] = JsonDocument.Parse(await tab.TextContentAsync("body") ?? "{}").RootElement.GetProperty("ip").GetString();
var g = await tab.EvaluateAsync<JsonElement>($"({Gather})()");
var ips = new List<string>();
var sawMdns = false;
foreach (var c in g.GetProperty("candidates").EnumerateArray())
{
// candidate:<foundation> <component> <transport> <priority> <address> <port> typ <type> …
var f = c.GetString()!.Split(' ');
if (f.Length < 8) continue;
if (f[4].EndsWith(".local")) sawMdns = true;
if (ipv4.IsMatch(f[4]) && f[4] != "0.0.0.0" && !ips.Contains(f[4])) ips.Add(f[4]);
}
output["webrtc_api"] = g.GetProperty("api").GetBoolean();
output["webrtc_ips"] = new JsonArray(ips.Select(ip => (JsonNode?)JsonValue.Create(ip)).ToArray());
output["mdns_only"] = ips.Count == 0 && sawMdns;
await tab.CloseAsync();
// HTTP/3: a page announces it over HTTP/2 (Alt-Svc); the browser can switch to QUIC on a later request. Four loads
// in fresh tabs, a pause between them.
var protocols = new JsonArray();
for (var i = 0; i < 4; i++)
{
var t = await context.NewPageAsync();
try { await Go(t, "https://cloudflare-quic.com/"); } catch (Exception) { }
protocols.Add((JsonNode?)JsonValue.Create(await Str(t, Protocol)));
await t.CloseAsync();
await Task.Delay(2500);
}
output["protocols"] = protocols;
await context.CloseAsync();
// The bot-protection pages, each in a fresh context: Cloudflare's challenge test page (case 64) and DataDome's
// own site (case 65).
foreach (var (k, url, kind) in new[] { ("scrapingcourse", "https://www.scrapingcourse.com/cloudflare-challenge", "cf"), ("datadome", "https://datadome.co/", "dd") })
{
var c = await browser.NewContextAsync();
output[k] = await Watch(c, url, kind);
await c.CloseAsync();
}
}
finally { await browser.CloseAsync(); }
return output;
}
var rows = new JsonArray();
foreach (var s in setups)
foreach (var n in networks)
rows.Add(await Row(s, n));
var result = new JsonObject { ["measured_at"] = DateTime.UtcNow.ToString("yyyy-MM-dd"), ["rows"] = rows };
Console.WriteLine(result.ToJsonString(new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }));
// go get github.com/playwright-community/[email protected]
// Driver: build playwright-core 1.60.0 from npm and set PLAYWRIGHT_DRIVER_PATH (see /docs/quickstart).
// env: CDPFLEET_API_KEY (sticky US exits from the cdpfleet residential proxy, Tier 1; one load per bot-protection page)
package main
import (
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"math"
"net/http"
"os"
"regexp"
"strconv"
"strings"
"time"
"github.com/playwright-community/playwright-go"
)
var key = os.Getenv("CDPFLEET_API_KEY")
var runID = func() string { b := make([]byte, 3); rand.Read(b); return hex.EncodeToString(b) }()
// One sticky exit per setup, used with and without the tunnel: the only difference between the two rows is vpn_tunnel.
func exitFor(id string) string { return "cdpfleet-resi-country-us-session-t" + id + runID + "-lifetime-30" }
type setup struct {
ID, Name, Engine, Type string
Headless bool
}
var setups = []setup{
{"c", "Google Chrome, headful", "chrome", "chromium", false},
{"f", "Camoufox, defaults", "camoufox", "firefox", true},
}
type network struct {
Label string
Tunnel bool
}
var networks = []network{{"residential proxy", false}, {"residential proxy + vpn_tunnel", true}}
// The WebRTC leak test from case 54: ICE candidates against a public STUN server. A "srflx" candidate is the public IP
// the STUN server saw. The same expression runs in every language.
const gather = `async () => {
if (typeof RTCPeerConnection === 'undefined') return { api: false, candidates: [] };
const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
const candidates = [];
pc.onicecandidate = (e) => { if (e.candidate && e.candidate.candidate) candidates.push(e.candidate.candidate); };
pc.createDataChannel('probe');
await pc.setLocalDescription(await pc.createOffer());
await new Promise((done) => {
const timer = setTimeout(done, 8000);
pc.onicegatheringstatechange = () => { if (pc.iceGatheringState === 'complete') { clearTimeout(timer); done(); } };
});
pc.close();
return { api: true, candidates };
}`
// The protocol of the page's own request: h2 over TCP, or h3 over QUIC (UDP).
const protocol = `performance.getEntriesByType('navigation')[0]?.nextHopProtocol || null`
// Cloudflare's interstitial or block page, or the real page.
const cfState = `(() => {
const t = (document.title + ' ' + (document.body ? document.body.innerText : '').slice(0, 400)).replace(/\s+/g, ' ');
return /sorry, you have been blocked|attention required|error 1020|access denied/i.test(t) ? 'blocked'
: /just a moment|performing security verification|verify you are human|checking your browser/i.test(t) ? 'challenge' : 'page';
})()`
// DataDome's iframe from captcha-delivery.com: a device check, a CAPTCHA, or a block (t=bv) — as in case 65.
const ddState = `(() => {
const f = [...document.querySelectorAll('iframe')].map((i) => i.src).find((s) => /captcha-delivery\.com/.test(s)) || '';
const text = (document.body ? document.body.innerText : '').trim();
return /[?&]t=bv(&|$)/.test(f) ? 'blocked' : /\/captcha\//.test(f) ? 'challenge' : f ? 'checking' : text.length > 200 ? 'page' : 'loading';
})()`
var ipv4 = regexp.MustCompile(`^\d+\.\d+\.\d+\.\d+$`)
type cell struct {
Outcome string `json:"outcome"`
Seconds *int `json:"seconds"`
}
type row struct {
Setup string `json:"setup"`
Network string `json:"network"`
ExitIP string `json:"exit_ip"`
WebrtcAPI bool `json:"webrtc_api"`
WebrtcIPs []string `json:"webrtc_ips"`
MdnsOnly bool `json:"mdns_only"`
Protocols []*string `json:"protocols"`
Scrapingcourse cell `json:"scrapingcourse"`
Datadome cell `json:"datadome"`
}
var pw *playwright.Playwright
// 503 means the fleet is momentarily busy: wait Retry-After and try again.
func launch(s setup, n network) playwright.Browser {
body := map[string]any{"proxy": exitFor(s.ID), "headless": s.Headless, "record": false}
if n.Tunnel {
body["vpn_tunnel"] = true
}
payload, _ := json.Marshal(body)
for attempt := 1; ; attempt++ {
req, _ := http.NewRequest("POST", "https://starter.cdpfleet.com/"+s.Engine+"/session", bytes.NewReader(payload))
req.Header.Set("x-api-key", key)
req.Header.Set("content-type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
msg, _ := io.ReadAll(res.Body)
res.Body.Close()
if res.StatusCode == 200 {
var session struct{ WsURL string }
json.Unmarshal(msg, &session)
bt := pw.Chromium
if s.Type == "firefox" {
bt = pw.Firefox
}
browser, err := bt.Connect(session.WsURL, playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}})
if err != nil {
log.Fatal(err)
}
return browser
}
if (res.StatusCode != 429 && res.StatusCode != 503) || attempt == 6 {
log.Fatalf("launch %d %s", res.StatusCode, msg)
}
wait, _ := strconv.Atoi(res.Header.Get("retry-after"))
if wait == 0 {
wait = 5
}
time.Sleep(time.Duration(wait) * time.Second)
}
}
// Residential peers drop a connection now and then: one retry.
func goTo(tab playwright.Page, url string) error {
opts := playwright.PageGotoOptions{WaitUntil: playwright.WaitUntilStateDomcontentloaded, Timeout: playwright.Float(45000)}
if _, err := tab.Goto(url, opts); err == nil {
return nil
}
_, err := tab.Goto(url, opts)
return err
}
func str(tab playwright.Page, expr string) string {
v, err := tab.Evaluate(expr)
if err != nil {
return ""
}
s, _ := v.(string)
return s
}
// One load, then watch the page once a second for 20 s (Cloudflare) or 12 s (DataDome) without touching it.
func watch(ctx playwright.BrowserContext, url, kind string) cell {
tab, _ := ctx.NewPage()
defer tab.Close()
t0 := time.Now()
if err := goTo(tab, url); err != nil {
return cell{"error", nil}
}
seen := false
window, expr := 12*time.Second, ddState
if kind == "cf" {
window, expr = 20*time.Second, cfState
}
until := time.Now().Add(window)
for time.Now().Before(until) {
s := str(tab, expr)
if s == "blocked" {
return cell{"blocked", nil}
}
if s == "challenge" && kind == "dd" {
return cell{"challenge", nil}
}
if s == "challenge" || s == "checking" {
seen = true
}
if s == "page" {
if seen {
sec := int(math.Round(time.Since(t0).Seconds()))
return cell{"cleared", &sec}
}
return cell{"content", nil}
}
time.Sleep(time.Second)
}
if seen {
return cell{"challenge", nil}
}
return cell{"error", nil}
}
func measure(s setup, n network) row {
browser := launch(s, n)
defer browser.Close()
out := row{Setup: s.Name, Network: n.Label, WebrtcIPs: []string{}, Protocols: []*string{}}
ctx, _ := browser.NewContext()
tab, _ := ctx.NewPage()
if err := goTo(tab, "https://api.ipify.org/?format=json"); err != nil {
log.Fatal(err)
}
text, _ := tab.TextContent("body")
var ip struct{ IP string }
json.Unmarshal([]byte(text), &ip)
out.ExitIP = ip.IP
v, err := tab.Evaluate("(" + gather + ")()")
if err != nil {
log.Fatal(err)
}
g := v.(map[string]any)
out.WebrtcAPI, _ = g["api"].(bool)
sawMdns := false
cands, _ := g["candidates"].([]any)
for _, c := range cands {
// candidate:<foundation> <component> <transport> <priority> <address> <port> typ <type> …
f := strings.Split(c.(string), " ")
if len(f) < 8 {
continue
}
addr := f[4]
if strings.HasSuffix(addr, ".local") {
sawMdns = true
}
if ipv4.MatchString(addr) && addr != "0.0.0.0" {
dup := false
for _, x := range out.WebrtcIPs {
dup = dup || x == addr
}
if !dup {
out.WebrtcIPs = append(out.WebrtcIPs, addr)
}
}
}
out.MdnsOnly = len(out.WebrtcIPs) == 0 && sawMdns
tab.Close()
// HTTP/3: a page announces it over HTTP/2 (Alt-Svc); the browser can switch to QUIC on a later request. Four loads
// in fresh tabs, a pause between them.
for i := 0; i < 4; i++ {
t, _ := ctx.NewPage()
goTo(t, "https://cloudflare-quic.com/")
var p *string
if s := str(t, protocol); s != "" {
p = &s
}
out.Protocols = append(out.Protocols, p)
t.Close()
time.Sleep(2500 * time.Millisecond)
}
ctx.Close()
// The bot-protection pages, each in a fresh context: Cloudflare's challenge test page (case 64) and DataDome's own
// site (case 65).
c1, _ := browser.NewContext()
out.Scrapingcourse = watch(c1, "https://www.scrapingcourse.com/cloudflare-challenge", "cf")
c1.Close()
c2, _ := browser.NewContext()
out.Datadome = watch(c2, "https://datadome.co/", "dd")
c2.Close()
return out
}
func main() {
var err error
pw, err = playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true})
if err != nil {
log.Fatal(err)
}
defer pw.Stop()
rows := []row{}
for _, s := range setups {
for _, n := range networks {
rows = append(rows, measure(s, n))
}
}
out, _ := json.MarshalIndent(map[string]any{"measured_at": time.Now().UTC().Format("2006-01-02"), "rows": rows}, "", " ")
fmt.Println(string(out))
}
What we got
| Setup | Network | Exit IP (HTTP) | WebRTC public IP | cloudflare-quic.com, 4 loads | Cloudflare challenge | datadome.co | Measured |
|---|---|---|---|---|---|---|---|
| Google Chrome, headful | residential proxy | 203.0.113.1 | none (mDNS name only) | h2 → h2 → h2 → h2 | challenge | challenge | 2026-10-24 |
| Google Chrome, headful | residential proxy + vpn_tunnel | 203.0.113.1 | 203.0.113.1 (the exit) | h2 → h2 → h3 → h3 | challenge | challenge | 2026-10-24 |
| Camoufox, defaults | residential proxy | 203.0.113.2 | 203.0.113.2 (the exit) | h2 → h2 → h2 → h2 | cleared (9 s) | content | 2026-10-24 |
| Camoufox, defaults | residential proxy + vpn_tunnel | 203.0.113.2 | 203.0.113.2 (the exit) | h2 → h3 → h3 → h3 | cleared (10 s) | content | 2026-10-24 |
From the Node.js run on 2026-10-24. IP addresses are replaced with placeholders (203.0.113.x); equal addresses stay equal. The other languages produced the same findings.
Raw output (Node.js)
{
"measured_at": "2026-10-24",
"rows": [
{
"setup": "Google Chrome, headful",
"network": "residential proxy",
"exit_ip": "203.0.113.1",
"webrtc_api": true,
"webrtc_ips": [],
"mdns_only": true,
"protocols": [
"h2",
"h2",
"h2",
"h2"
],
"scrapingcourse": {
"outcome": "challenge",
"seconds": null
},
"datadome": {
"outcome": "challenge",
"seconds": null
}
},
{
"setup": "Google Chrome, headful",
"network": "residential proxy + vpn_tunnel",
"exit_ip": "203.0.113.1",
"webrtc_api": true,
"webrtc_ips": [
"203.0.113.1"
],
"mdns_only": false,
"protocols": [
"h2",
"h2",
"h3",
"h3"
],
"scrapingcourse": {
"outcome": "challenge",
"seconds": null
},
"datadome": {
"outcome": "challenge",
"seconds": null
}
},
{
"setup": "Camoufox, defaults",
"network": "residential proxy",
"exit_ip": "203.0.113.2",
"webrtc_api": true,
"webrtc_ips": [
"203.0.113.2"
],
"mdns_only": false,
"protocols": [
"h2",
"h2",
"h2",
"h2"
],
"scrapingcourse": {
"outcome": "cleared",
"seconds": 9
},
"datadome": {
"outcome": "content",
"seconds": null
}
},
{
"setup": "Camoufox, defaults",
"network": "residential proxy + vpn_tunnel",
"exit_ip": "203.0.113.2",
"webrtc_api": true,
"webrtc_ips": [
"203.0.113.2"
],
"mdns_only": false,
"protocols": [
"h2",
"h3",
"h3",
"h3"
],
"scrapingcourse": {
"outcome": "cleared",
"seconds": 10
},
"datadome": {
"outcome": "content",
"seconds": null
}
}
]
}Takeaways
- The tunnel changes what WebRTC shows, never adds a second IP. On 2026-10-24, across five runs, headful Chrome on a plain residential proxy showed no public address at all (only an anonymous mDNS name, as in case 54); with
vpn_tunnelon the same exit it showed exactly the exit IP, every time. No row in any run showed an address other than its exit. - HTTP/3 only happens through the tunnel. Without it, all four loads of cloudflare-quic.com used
h2in every row of every run. With it, Chrome switched toh3from the third load and Camoufox from the second: the first load is announced over HTTP/2, and the browser moves to QUIC once it has seen the announcement. - An exit that doesn't carry UDP looks like no tunnel at all. In one of the five runs, Camoufox's tunnel row showed only an mDNS name and never used HTTP/3: that residential exit didn't pass UDP, so WebRTC and QUIC failed quietly, and HTTP kept working. The server's own IP is never used as a fallback. Keep the exit sticky and check WebRTC once if it matters.
- Camoufox already reports the exit over WebRTC without the tunnel (its
geoipdefault), so for Camoufox the tunnel's visible gain is HTTP/3, and real UDP behind the address it reports. - The two bot-protection pages didn't change their verdict for the tunnel. Chrome stayed on Cloudflare's challenge test page and met DataDome's CAPTCHA in all ten of its rows, tunnel or not; Camoufox got DataDome's site as content in all ten. On Cloudflare's challenge Camoufox passed by itself in all five tunnel rows (in 4–10 s) and in two of five without; five runs aren't enough to credit the tunnel for that. The browser decided, as in case 64 and case 65.