From 05f837df273494853d50e03414c2a399178e6c0e Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Fri, 4 Sep 2026 21:55:00 +0800 Subject: [PATCH] Improvements --- frontend/components/DnsLeaksTest.vue | 46 ++++++++------- frontend/utils/dnsleaks/index.js | 31 +++++++++- tests/dnsleaks.test.js | 84 ++++++++++++++++++++++++++-- 3 files changed, 133 insertions(+), 28 deletions(-) diff --git a/frontend/components/DnsLeaksTest.vue b/frontend/components/DnsLeaksTest.vue index 07a5868a..4ecb61cc 100644 --- a/frontend/components/DnsLeaksTest.vue +++ b/frontend/components/DnsLeaksTest.vue @@ -22,7 +22,7 @@
- @@ -33,8 +33,7 @@ #{{ index + 1 }}
- +

{{ leak.providerName }}

@@ -108,13 +107,15 @@ import FitText from '@/components/widgets/FitText.vue'; import InfoBanner from '@/components/widgets/InfoBanner.vue'; import { INLINE_TIERS } from '@/composables/use-fit-text.js'; import { - ipApi, surfshark, ipleak, fastly, runWithRetry, + ipApi, surfshark, ipleak, fastly, browserleaks, + buildFallbackChain, runWithFallback, } from '@/utils/dnsleaks'; -// One source of truth for which providers to run and in what order. Adding -// a new provider = create the file under utils/dnsleaks and append it here. -// (browserleaks stays available in utils/dnsleaks but is benched for now.) -const PROVIDERS = [ipApi, surfshark, ipleak, fastly]; +// All providers in priority order: the first SLOT_COUNT hold a card each, +// the rest are standbys. New provider = file under utils/dnsleaks + append. +const PROVIDERS = [ipApi, surfshark, ipleak, fastly, browserleaks]; +const SLOT_COUNT = 4; +const ACTIVE = PROVIDERS.slice(0, SLOT_COUNT); const { t } = useI18n(); @@ -151,8 +152,11 @@ const createDefaultCard = () => ({ org: t('dnsleaktest.StatusWait'), }); -const leakTest = reactive(PROVIDERS.map((p) => ({ +// `slot` is the card's stable key; `id` / `providerName` follow the +// provider that actually answered. +const leakTest = reactive(ACTIVE.map((p) => ({ ...createDefaultCard(), + slot: p.id, id: p.id, providerName: p.name, }))); @@ -178,12 +182,14 @@ const markLeakCardError = (index) => { leakTest[index].org = t('dnsleaktest.StatusError'); }; -// Run one provider against its card slot, with retry (3 attempts, fresh -// prefix per attempt — see `runWithRetry` in utils/dnsleaks). On full -// failure (all attempts threw) the card is flipped to the error state. -const runProvider = async (index, provider) => { +// Run one slot through its fallback chain (see utils/dnsleaks); the card +// flips to error only when the whole chain fails. +const runProvider = async (index) => { + const chain = buildFallbackChain(index, PROVIDERS, SLOT_COUNT); try { - const { ip } = await runWithRetry(provider); + const { ip, provider } = await runWithFallback(chain); + leakTest[index].id = provider.id; + leakTest[index].providerName = provider.name; leakTest[index].ip = ip; await applyMaxMindGeo(index, ip); } catch (error) { @@ -198,7 +204,9 @@ const checkAllDNSLeakTest = async (isRefresh) => { isStarted.value = true; if (isRefresh) { trackEvent('Section', 'RefreshClick', 'DNSLeakTest'); - leakTest.forEach((server) => { + leakTest.forEach((server, index) => { + server.id = ACTIVE[index].id; + server.providerName = ACTIVE[index].name; server.ip = t('dnsleaktest.StatusWait'); server.country = t('dnsleaktest.StatusWait'); server.country_code = ''; @@ -206,15 +214,13 @@ const checkAllDNSLeakTest = async (isRefresh) => { }); } - const delayedRun = (provider, index, delay) => new Promise((resolve) => { + const delayedRun = (index, delay) => new Promise((resolve) => { setTimeout(() => { - runProvider(index, provider).then(resolve, resolve); + runProvider(index).then(resolve, resolve); }, delay); }); - const promises = PROVIDERS.map((provider, index) => - delayedRun(provider, index, index * 200), - ); + const promises = ACTIVE.map((_, index) => delayedRun(index, index * 200)); const allSettledPromise = Promise.allSettled(promises); const timeoutPromise = new Promise((resolve) => setTimeout(resolve, 6000)); diff --git a/frontend/utils/dnsleaks/index.js b/frontend/utils/dnsleaks/index.js index 0b79e898..6780b121 100644 --- a/frontend/utils/dnsleaks/index.js +++ b/frontend/utils/dnsleaks/index.js @@ -1,4 +1,4 @@ -// Barrel + retry runner for the homepage DNS leak test providers. +// Barrel + retry / fallback runners for the homepage DNS leak test providers. // // Each provider in this directory is a self-describing object: // { id, name, run() } @@ -21,7 +21,7 @@ export { fastly } from './fastly.js'; // whose `ip` fails `isValidIP` counts as a failed attempt too — garbage // must never reach the MaxMind lookup downstream. Returns the first valid // result; throws the last error if every attempt failed. -export async function runWithRetry(provider, attempts = 3) { +export async function runWithRetry(provider, attempts = 2) { let lastError; for (let i = 0; i < attempts; i++) { try { @@ -36,3 +36,30 @@ export async function runWithRetry(provider, attempts = 3) { } throw lastError; } + +// Try order for card slot `index`: own provider → standbys (index ≥ +// slotCount) → the other slots' providers from 0. Each call derives a fresh +// hostname, so a neighbour's provider still yields an independent lookup. +export const buildFallbackChain = (index, providers, slotCount) => { + const primary = providers[index]; + if (!primary) return []; + const standbys = providers.slice(slotCount); + const neighbours = providers.slice(0, slotCount).filter((p) => p !== primary); + return [primary, ...standbys, ...neighbours]; +}; + +// Walk the chain once, each provider through `runWithRetry`; resolves +// `{ ip, provider }` so the caller can name the upstream that answered. +// Throws the last error. +export const runWithFallback = async (chain) => { + let lastError = new Error('dnsleak: empty provider chain'); + for (const provider of chain) { + try { + const { ip } = await runWithRetry(provider); + return { ip, provider }; + } catch (err) { + lastError = err; + } + } + throw lastError; +}; diff --git a/tests/dnsleaks.test.js b/tests/dnsleaks.test.js index 53dad045..1e539795 100644 --- a/tests/dnsleaks.test.js +++ b/tests/dnsleaks.test.js @@ -1,12 +1,14 @@ -// Exercises `runWithRetry` from frontend/utils/dnsleaks — the single entry -// point every DNS leak provider result flows through. Providers are stubbed; -// the behaviors under test are the retry loop and the isValidIP gate that -// keeps garbage upstream responses away from the MaxMind lookup. +// Exercises the runners in frontend/utils/dnsleaks with stubbed providers: +// the retry loop and its isValidIP gate (`runWithRetry`), the slot → +// standby → neighbour ordering (`buildFallbackChain`) and the chain walk +// that reports which provider answered (`runWithFallback`). import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { runWithRetry } from '../frontend/utils/dnsleaks/index.js'; +import { + runWithRetry, buildFallbackChain, runWithFallback, +} from '../frontend/utils/dnsleaks/index.js'; const providerReturning = (results) => { let call = 0; @@ -63,7 +65,7 @@ describe('runWithRetry', () => { () => runWithRetry(provider), /stub: invalid IP in response/, ); - assert.equal(provider.calls(), 3); + assert.equal(provider.calls(), 2); }); it('rejects a result missing the ip field entirely', async () => { @@ -80,3 +82,73 @@ describe('runWithRetry', () => { assert.equal(provider.calls(), 5); }); }); + +const provider = (id, results) => ({ ...providerReturning(results), id, name: `${id}.example` }); + +describe('buildFallbackChain', () => { + const a = provider('a', []); + const b = provider('b', []); + const c = provider('c', []); + const d = provider('d', []); + const e = provider('e', []); + const registry = [a, b, c, d, e]; + + it('orders own provider, then the standbys beyond the slots, then the other slots from 0', () => { + assert.deepEqual(buildFallbackChain(1, registry, 3), [b, d, e, a, c]); + }); + + it('keeps registry order among several standbys', () => { + assert.deepEqual(buildFallbackChain(0, registry, 2), [a, c, d, e, b]); + }); + + it('is just the neighbours when there are no standbys', () => { + assert.deepEqual(buildFallbackChain(2, [a, b, c], 3), [c, a, b]); + }); + + it('is only the provider itself when it is the whole registry', () => { + assert.deepEqual(buildFallbackChain(0, [a], 1), [a]); + }); + + it('returns an empty chain for an index outside the registry', () => { + assert.deepEqual(buildFallbackChain(5, registry, 4), []); + }); +}); + +describe('runWithFallback', () => { + it('resolves with the primary and never touches the rest of the chain', async () => { + const primary = provider('primary', [{ ip: '8.8.8.8' }]); + const spare = provider('spare', [{ ip: '1.1.1.1' }]); + const result = await runWithFallback([primary, spare]); + assert.deepEqual(result, { ip: '8.8.8.8', provider: primary }); + assert.equal(spare.calls(), 0); + }); + + it('falls through to the next provider once the primary exhausts its attempts', async () => { + const dead = provider('dead', [new Error('down')]); + const spare = provider('spare', [{ ip: '1.1.1.1' }]); + const result = await runWithFallback([dead, spare]); + assert.equal(result.ip, '1.1.1.1'); + assert.equal(result.provider, spare); + assert.equal(dead.calls(), 2); + assert.equal(spare.calls(), 1); + }); + + it('gives every provider in the chain the default retry budget', async () => { + const dead = provider('dead', [new Error('down')]); + const flaky = provider('flaky', [{ ip: 'garbage' }, { ip: '9.9.9.9' }]); + const result = await runWithFallback([dead, flaky]); + assert.equal(result.provider, flaky); + assert.equal(dead.calls(), 2); + assert.equal(flaky.calls(), 2); + }); + + it('throws the last error when the whole chain fails', async () => { + const first = provider('first', [new Error('first down')]); + const last = provider('last', [new Error('last down')]); + await assert.rejects(() => runWithFallback([first, last]), /last down/); + }); + + it('rejects an empty chain', async () => { + await assert.rejects(() => runWithFallback([]), /empty provider chain/); + }); +});