- AGPL-3.0 License
+ MIT License
Open-source and auditable. Anyone can inspect the full application source code for total safety verification.
diff --git a/src/components/TrafficMonitor.tsx b/src/components/TrafficMonitor.tsx
index f1c5b0f..ed86336 100644
--- a/src/components/TrafficMonitor.tsx
+++ b/src/components/TrafficMonitor.tsx
@@ -20,6 +20,7 @@ import {
Tooltip,
CartesianGrid,
} from 'recharts';
+import { MetricValue } from './MetricValue';
export interface CapturedResource {
id: string;
@@ -233,12 +234,18 @@ export const TrafficMonitor: React.FC = () => {
const currentRequestsPerSec = activeSamples.length
? Math.round(activeSamples.reduce((acc, s) => acc + s.requestsCount, 0) / activeSamples.length)
: 0;
- const currentAvgLatency = activeSamples.length
- ? Math.round(
- activeSamples.reduce((acc, s) => acc + s.avgLatencyMs, 0) /
- (activeSamples.filter((s) => s.requestsCount > 0).length || 1)
- )
- : 0;
+ // Only samples that actually contain a request carry a latency. With none, the
+ // answer is "no latency to average", not "0 ms" β the `|| 1` denominator here
+ // used to divide a sum of zeros by one and render a confident 0 ms while the
+ // machine was offline and nothing had been requested at all.
+ const samplesWithRequests = activeSamples.filter((s) => s.requestsCount > 0);
+ const currentAvgLatency: number | null =
+ samplesWithRequests.length > 0
+ ? Math.round(
+ samplesWithRequests.reduce((acc, s) => acc + s.avgLatencyMs, 0) /
+ samplesWithRequests.length,
+ )
+ : null;
const currentKbps = activeSamples.length
? Math.round(activeSamples.reduce((acc, s) => acc + s.throughputKbps, 0) / activeSamples.length)
: 0;
@@ -353,9 +360,21 @@ export const TrafficMonitor: React.FC = () => {
Last 5s rolling average
diff --git a/src/components/TriagePanel.tsx b/src/components/TriagePanel.tsx
new file mode 100644
index 0000000..54f57f4
--- /dev/null
+++ b/src/components/TriagePanel.tsx
@@ -0,0 +1,380 @@
+import React, { useRef, useState } from 'react';
+import {
+ Stethoscope,
+ Play,
+ Square,
+ CheckCircle2,
+ XCircle,
+ HelpCircle,
+ MinusCircle,
+ Loader2,
+ Wrench,
+ ScanSearch,
+} from 'lucide-react';
+import type { HistoryItem } from '../types';
+import type {
+ Attribution,
+ Confidence,
+ Finding,
+ Layer,
+ Severity,
+ TriageStep,
+ TriageVerdict,
+} from '../analysis/types';
+import { describeLayer } from '../analysis/engine';
+import { runTriage, summariseVerdict } from '../analysis/triage';
+import type { TriageProgress } from '../analysis/triage';
+import { RULES } from '../analysis/rules';
+import { FailureNotice } from './MetricValue';
+import { saveHistoryItem } from '../utils/storage';
+import {
+ ResponsibleNetworkingModal,
+ isResponsibleNetworkingAccepted,
+} from './ResponsibleNetworkingModal';
+
+/**
+ * "Is it me or the internet?" β the answer layer's front end.
+ *
+ * The reasoning behind everything shown here is deterministic, offline and in
+ * this repository: a table of rules in `src/analysis/rules.ts`, each with a
+ * predicate over named measurements. No model is consulted and no request is
+ * made to reach a conclusion. Every finding shows the measurements that
+ * produced it, so a user can disagree with the tool on the evidence rather than
+ * having to trust it.
+ */
+
+interface TriagePanelProps {
+ onHistoryUpdate: () => void;
+}
+
+const STATUS_STYLE: Record<
+ TriageStep['status'],
+ { icon: React.FC<{ className?: string }>; tone: string; label: string }
+> = {
+ pass: { icon: CheckCircle2, tone: 'text-emerald-400', label: 'pass' },
+ fail: { icon: XCircle, tone: 'text-rose-400', label: 'fail' },
+ inconclusive: { icon: HelpCircle, tone: 'text-amber-400', label: 'inconclusive' },
+ skipped: { icon: MinusCircle, tone: 'text-slate-500', label: 'not run' },
+ running: { icon: Loader2, tone: 'text-cyan-400 animate-spin', label: 'running' },
+ pending: { icon: MinusCircle, tone: 'text-slate-600', label: 'pending' },
+};
+
+const ATTRIBUTION_TONE: Record
= {
+ 'this-device': 'border-rose-500/40 bg-rose-500/10 text-rose-200',
+ 'local-network': 'border-amber-500/40 bg-amber-500/10 text-amber-200',
+ isp: 'border-orange-500/40 bg-orange-500/10 text-orange-200',
+ internet: 'border-indigo-500/40 bg-indigo-500/10 text-indigo-200',
+ destination: 'border-purple-500/40 bg-purple-500/10 text-purple-200',
+ 'no-fault-found': 'border-emerald-500/40 bg-emerald-500/10 text-emerald-200',
+ indeterminate: 'border-slate-600 bg-slate-800/60 text-slate-300',
+};
+
+const CONFIDENCE_COPY: Record = {
+ confirmed: 'The measurement is the finding β nothing is inferred.',
+ likely: 'One cause dominates this pattern, but a browser cannot see it directly.',
+ possible: 'Consistent with this, and with other explanations too.',
+};
+
+const CONFIDENCE_TONE: Record = {
+ confirmed: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30',
+ likely: 'bg-amber-500/15 text-amber-300 border-amber-500/30',
+ possible: 'bg-slate-700/50 text-slate-300 border-slate-600',
+};
+
+const SEVERITY_TONE: Record = {
+ blocking: 'bg-rose-500/15 text-rose-300 border-rose-500/30',
+ degrading: 'bg-amber-500/15 text-amber-300 border-amber-500/30',
+ informational: 'bg-slate-700/50 text-slate-300 border-slate-600',
+};
+
+const LAYER_TONE: Record = {
+ 'this-device': 'bg-rose-500/10 text-rose-300 border-rose-500/25',
+ 'local-network': 'bg-amber-500/10 text-amber-300 border-amber-500/25',
+ isp: 'bg-orange-500/10 text-orange-300 border-orange-500/25',
+ internet: 'bg-indigo-500/10 text-indigo-300 border-indigo-500/25',
+ destination: 'bg-purple-500/10 text-purple-300 border-purple-500/25',
+};
+
+const StepRow: React.FC<{ step: TriageStep; isActive: boolean }> = ({ step, isActive }) => {
+ const style = STATUS_STYLE[isActive ? 'running' : step.status];
+ const Icon = style.icon;
+ return (
+
+
+
+
+ {step.label}
+
+ {style.label}
+
+
+
{step.question}
+ {step.note !== null && (
+
{step.note}
+ )}
+
+
+ );
+};
+
+const FindingCard: React.FC<{ finding: Finding; rank: number }> = ({ finding, rank }) => {
+ const rule = RULES.find((r) => r.id === finding.ruleId);
+ return (
+
+
+
+ {rank}
+
+
{finding.title}
+
+ {finding.severity}
+
+
+ {finding.confidence}
+
+
+ {describeLayer(finding.layer)}
+
+
+
+
{finding.verdict}
+
+
+
+
+ What to do
+
+
+ {finding.remediation.map((r) => (
+
+ -
+ {r}
+
+ ))}
+
+
+
+ {finding.evidence.length > 0 && (
+
+
+ Evidence
+
+
+ {finding.evidence.map((e) => (
+
+ {e.metric}
+ {e.observation}
+
+ ))}
+
+ {rule && (
+
+ rule {rule.id} Β· reads {rule.consumes.join(', ')}
+
+ )}
+
+ )}
+
+ );
+};
+
+export const TriagePanel: React.FC = ({ onHistoryUpdate }) => {
+ const [verdict, setVerdict] = useState(null);
+ const [progress, setProgress] = useState(null);
+ const [isRunning, setIsRunning] = useState(false);
+ const [error, setError] = useState(null);
+ const [showConsent, setShowConsent] = useState(false);
+ const abortRef = useRef(null);
+
+ const execute = async () => {
+ setIsRunning(true);
+ setError(null);
+ setVerdict(null);
+ setProgress(null);
+
+ const controller = new AbortController();
+ abortRef.current = controller;
+
+ try {
+ const result = await runTriage({
+ onProgress: setProgress,
+ signal: controller.signal,
+ });
+ setVerdict(result);
+
+ const item: HistoryItem = {
+ id: result.id,
+ type: 'triage',
+ timestamp: result.timestamp,
+ title: `Triage: ${result.headline}`,
+ summary: summariseVerdict(result),
+ data: result,
+ };
+ saveHistoryItem(item);
+ onHistoryUpdate();
+ } catch (e) {
+ setError(
+ e instanceof Error ? `The triage run could not finish: ${e.message}` : 'The triage run could not finish.',
+ );
+ } finally {
+ abortRef.current = null;
+ setIsRunning(false);
+ setProgress(null);
+ }
+ };
+
+ const handleRun = () => {
+ if (!isResponsibleNetworkingAccepted()) {
+ setShowConsent(true);
+ return;
+ }
+ execute();
+ };
+
+ const passed = verdict?.steps.filter((s) => s.status === 'pass').length ?? 0;
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
Is it me or the internet?
+
+ One run walks a decision tree β link, name resolution, interception, address
+ families, four unrelated content networks, then the link itself β and applies{' '}
+ {RULES.length} rules to what it found. The reasoning is a table of predicates in this
+ repository, not a model: it is instant, it works offline, and every finding carries
+ the measurements that produced it.
+
+
+
+
+
+
+ {isRunning ? (
+ <>
+
+ {progress?.label ?? 'Running triageβ¦'}
+ >
+ ) : (
+ <>
+
+ {verdict ? 'Run triage again' : 'Run triage'}
+ >
+ )}
+
+
+ {isRunning && (
+
abortRef.current?.abort()}
+ className="flex items-center space-x-2 px-4 py-2 rounded-xl text-xs font-semibold bg-white/5 hover:bg-white/10 border border-white/10 text-slate-300 transition-colors"
+ >
+
+ Stop
+
+ )}
+
+ {isRunning && progress?.detail !== null && progress?.detail !== undefined && (
+
{progress.detail}
+ )}
+
+
+ {isRunning ? (progress?.label ?? 'Running triage') : ''}
+
+
+
+
+ A full run transfers roughly 25 MB through the Cloudflare edge for the bandwidth
+ step, and contacts the providers listed under Privacy & Safety. If nothing
+ out there answers, the bandwidth step is skipped rather than run to produce a guaranteed
+ failure.
+
+
+
+ {error !== null && (
+
+ {error}
+
+ )}
+
+ {/* Verdict */}
+ {verdict && (
+
+
+
+ Verdict
+
+ Β· {passed} of {verdict.steps.length} checks passed Β· {verdict.totalTimeMs} ms
+
+
+
{verdict.headline}
+
{verdict.summary}
+
+ )}
+
+ {/* Findings */}
+ {verdict && verdict.findings.length > 0 && (
+
+
+ Probable causes, ranked
+
+ {verdict.findings.map((f, i) => (
+
+ ))}
+
+ )}
+
+ {/* Decision tree */}
+ {(verdict || isRunning) && (
+
+
+ What was checked
+
+
+ {(verdict?.steps ?? []).map((step) => (
+
+ ))}
+
+ {isRunning && verdict === null && (
+
+ {progress?.label ?? 'Startingβ¦'}
+ {progress?.detail !== null && progress?.detail !== undefined
+ ? ` β ${progress.detail}`
+ : ''}
+
+ )}
+
+ )}
+
+ {verdict && verdict.failures.length > 0 &&
}
+
+
setShowConsent(false)}
+ onConfirm={() => {
+ setShowConsent(false);
+ execute();
+ }}
+ />
+
+ );
+};
diff --git a/src/types.ts b/src/types.ts
index c06981e..4f576a8 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,5 +1,8 @@
export type ToolTab =
| 'dashboard'
+ | 'triage'
+ | 'dualstack'
+ | 'captive'
| 'edgepath'
| 'tracert'
| 'portscanner'
@@ -45,6 +48,13 @@ export type FailureReason =
| 'unsupported-api'
| 'insufficient-samples'
| 'aborted'
+ /** A page served over https: may not open a plaintext http: connection. Some
+ * checks β the classic captive-portal `generate_204` probe, anything aimed at
+ * a LAN device with no certificate β are therefore impossible from the
+ * deployed build and possible only from a local http: origin. That is a
+ * browser rule, not a network result, and is reported as its own reason so it
+ * is never confused with "the target did not answer". */
+ | 'mixed-content-blocked'
| 'not-attempted';
export interface MeasurementFailure {
@@ -407,6 +417,155 @@ export interface EdgePathResult {
failures: MeasurementFailure[];
}
+// ---------------------------------------------------------------------------
+// Dual-stack reachability
+//
+// A browser cannot enumerate the interfaces it has, or ask which address family
+// a connection used. What it can do is connect to hostnames that publish only an
+// A record or only an AAAA record, and see which ones answer. That is a direct
+// observation of whether traffic in each family leaves this machine and comes
+// back β nothing is inferred from `navigator.connection` or from the shape of an
+// address the page never received.
+// ---------------------------------------------------------------------------
+
+export type AddressFamily = 'ipv4' | 'ipv6';
+
+/** One family-pinned endpoint and what happened when the browser called it. */
+export interface FamilyProbe {
+ family: AddressFamily;
+ /** Hostname published with only A records (ipv4) or only AAAA records (ipv6). */
+ host: string;
+ url: string;
+ /**
+ * `answered` means a response came back, which proves the family works
+ * end-to-end. `no-response` means it did not, which is *not* the same as
+ * proving the family is unavailable β the probe host could itself be down.
+ * The distinction is preserved all the way to the UI copy.
+ */
+ outcome: 'answered' | 'no-response';
+ /** Round-trip in ms; null when nothing came back. */
+ roundTripMs: number | null;
+ /** Address the endpoint reported seeing, when it is readable cross-origin. */
+ observedIp: string | null;
+ error: string | null;
+}
+
+export interface DualStackResult {
+ id: string;
+ timestamp: number;
+ probes: FamilyProbe[];
+ /** True when at least one endpoint in that family answered, false when every
+ * one of them was tried and none did, null when none was tried at all. A
+ * family that was never probed is not a family that failed. */
+ ipv4Reachable: boolean | null;
+ ipv6Reachable: boolean | null;
+ /**
+ * Which family the browser actually chose for a dual-stack host, read from
+ * the address that host reported seeing. Null when no dual-stack host
+ * answered, or when its answer was not readable.
+ */
+ preferredFamily: AddressFamily | null;
+ preferredFamilySource: string | null;
+ verdict:
+ | 'dual-stack'
+ | 'ipv4-only'
+ | 'ipv6-only'
+ | 'neither-family-answered'
+ | null;
+ explanation: string;
+ totalTimeMs: number;
+ failures: MeasurementFailure[];
+}
+
+// ---------------------------------------------------------------------------
+// Captive portal and DNS integrity
+//
+// From an https: page a captive portal cannot rewrite a response without
+// breaking TLS, so its signature is not a redirect the page can read β it is
+// that secure connections stop completing while the browser still believes it is
+// online. These checks look for exactly that, and for the one form of DNS
+// tampering a browser genuinely can observe: a hostname failing while the same
+// server's literal IP still answers.
+// ---------------------------------------------------------------------------
+
+/** A request whose correct response is known in advance, so a wrong one is
+ * evidence of interception rather than of a slow network. */
+export interface IntegrityProbe {
+ label: string;
+ url: string;
+ /** What a correct response looks like, in one phrase, for the UI. */
+ expectation: string;
+ outcome:
+ /** Reached the endpoint and the response was exactly as expected. */
+ | 'verified'
+ /** Reached something, but it did not return what this endpoint returns. */
+ | 'content-mismatch'
+ /** Nothing came back at all. */
+ | 'no-response'
+ /** Could not be attempted from this origin β see `note`. */
+ | 'not-attempted';
+ roundTripMs: number | null;
+ note: string | null;
+}
+
+export interface CaptivePortalResult {
+ id: string;
+ timestamp: number;
+ /** `location.protocol` at the time of the run. The plaintext generate_204
+ * probe is only available from an http: origin. */
+ pageProtocol: string;
+ probes: IntegrityProbe[];
+ verdict:
+ /** Every endpoint returned exactly its own content. */
+ | 'no-interception-detected'
+ /** Something answered in place of a known endpoint. */
+ | 'content-substituted'
+ /** Browser says online, yet no HTTPS endpoint completed. */
+ | 'https-blocked'
+ /** Some answered and some did not β not a clean signature either way. */
+ | 'mixed'
+ | null;
+ explanation: string;
+ totalTimeMs: number;
+ failures: MeasurementFailure[];
+}
+
+/** One name resolved through two independent DNS-over-HTTPS providers. */
+export interface DohComparison {
+ name: string;
+ /** Sorted record data from each provider, or null when the query failed. */
+ cloudflare: string[] | null;
+ google: string[] | null;
+ /** Null when either side is missing β absence is not disagreement. */
+ agrees: boolean | null;
+}
+
+export interface DnsIntegrityResult {
+ id: string;
+ timestamp: number;
+ /**
+ * Reaching a host by name exercises the system resolver. Reaching the same
+ * server by literal IP does not. Comparing the two outcomes is the only way a
+ * web page can test the resolver it is not allowed to read.
+ */
+ hostnameReachable: boolean | null;
+ literalIpReachable: boolean | null;
+ hostnameProbed: string;
+ literalProbed: string;
+ comparisons: DohComparison[];
+ verdict:
+ | 'resolver-working'
+ /** Names fail while the same server answers on its literal IP. */
+ | 'resolver-failing'
+ /** Providers returned different addresses for a name that is the same
+ * everywhere. */
+ | 'answers-diverge'
+ | null;
+ explanation: string;
+ totalTimeMs: number;
+ failures: MeasurementFailure[];
+}
+
export interface HistoryItem {
id: string;
type:
@@ -421,7 +580,10 @@ export interface HistoryItem {
| 'portscanner'
| 'tracert'
| 'geoip'
- | 'edgepath';
+ | 'edgepath'
+ | 'triage'
+ | 'dualstack'
+ | 'captive';
timestamp: number;
title: string;
summary: string;
diff --git a/src/utils/captivePortal.test.ts b/src/utils/captivePortal.test.ts
new file mode 100644
index 0000000..26e0bd7
--- /dev/null
+++ b/src/utils/captivePortal.test.ts
@@ -0,0 +1,134 @@
+import { describe, it, expect } from 'vitest';
+import { canProbePlaintext, classifyInterception, classifyDnsIntegrity } from './captivePortal';
+import type { DohComparison, IntegrityProbe } from '../types';
+
+const probe = (outcome: IntegrityProbe['outcome'], label = 'endpoint'): IntegrityProbe => ({
+ label,
+ url: `https://${label}.example.test/`,
+ expectation: 'its own content',
+ outcome,
+ roundTripMs: outcome === 'verified' ? 40 : null,
+ note: null,
+});
+
+describe('canProbePlaintext', () => {
+ it('permits the plaintext probe only from an http origin', () => {
+ expect(canProbePlaintext('http:')).toBe(true);
+ expect(canProbePlaintext('https:')).toBe(false);
+ expect(canProbePlaintext('file:')).toBe(false);
+ });
+});
+
+describe('classifyInterception', () => {
+ it('passes when every endpoint returns its own content', () => {
+ const r = classifyInterception([probe('verified', 'a'), probe('verified', 'b')], true);
+ expect(r.verdict).toBe('no-interception-detected');
+ // The claim must stay bounded to what was tested.
+ expect(r.explanation).toMatch(/does not certify the whole network/);
+ });
+
+ it('flags substituted content ahead of everything else', () => {
+ const r = classifyInterception(
+ [probe('verified', 'a'), probe('content-mismatch', 'b'), probe('no-response', 'c')],
+ true,
+ );
+ expect(r.verdict).toBe('content-substituted');
+ });
+
+ it('reads total HTTPS silence while online as a portal signature', () => {
+ const r = classifyInterception([probe('no-response', 'a'), probe('no-response', 'b')], true);
+ expect(r.verdict).toBe('https-blocked');
+ expect(r.explanation).toMatch(/captive portal/);
+ });
+
+ it('refuses to call it interception when the browser is offline', () => {
+ // Nothing answering because there is no connection is not evidence of a
+ // portal. Conflating the two would be the diagnostic equivalent of an
+ // invented measurement.
+ const r = classifyInterception([probe('no-response', 'a'), probe('no-response', 'b')], false);
+ expect(r.verdict).toBeNull();
+ expect(r.explanation).toMatch(/no connection/);
+ });
+
+ it('reports a mixed result as mixed rather than as a fault', () => {
+ const r = classifyInterception([probe('verified', 'a'), probe('no-response', 'b')], true);
+ expect(r.verdict).toBe('mixed');
+ });
+
+ it('ignores probes that could not be attempted', () => {
+ // The plaintext probe is unavailable on https:. It must not dilute the
+ // verdict in either direction.
+ const r = classifyInterception(
+ [probe('not-attempted', 'plaintext'), probe('verified', 'a'), probe('verified', 'b')],
+ true,
+ );
+ expect(r.verdict).toBe('no-interception-detected');
+ expect(r.explanation).toMatch(/All 2 endpoints/);
+ });
+
+ it('returns null when nothing was attempted at all', () => {
+ const r = classifyInterception([probe('not-attempted', 'plaintext')], true);
+ expect(r.verdict).toBeNull();
+ });
+});
+
+describe('classifyDnsIntegrity', () => {
+ const agree: DohComparison = {
+ name: 'one.one.one.one',
+ cloudflare: ['1.0.0.1', '1.1.1.1'],
+ google: ['1.0.0.1', '1.1.1.1'],
+ agrees: true,
+ };
+ const disagree: DohComparison = {
+ name: 'dns.google',
+ cloudflare: ['8.8.8.8'],
+ google: ['203.0.113.9'],
+ agrees: false,
+ };
+ const unknown: DohComparison = {
+ name: 'dns.google',
+ cloudflare: null,
+ google: ['8.8.8.8'],
+ agrees: null,
+ };
+
+ it('identifies a failing resolver from the hostname/literal split', () => {
+ const r = classifyDnsIntegrity(false, true, [agree]);
+ expect(r.verdict).toBe('resolver-failing');
+ expect(r.explanation).toMatch(/literal IP/);
+ });
+
+ it('ranks a failing resolver above provider disagreement', () => {
+ const r = classifyDnsIntegrity(false, true, [disagree]);
+ expect(r.verdict).toBe('resolver-failing');
+ });
+
+ it('reports divergence between providers', () => {
+ const r = classifyDnsIntegrity(true, true, [agree, disagree]);
+ expect(r.verdict).toBe('answers-diverge');
+ expect(r.explanation).toContain('dns.google');
+ });
+
+ it('passes when names resolve and providers agree', () => {
+ const r = classifyDnsIntegrity(true, true, [agree]);
+ expect(r.verdict).toBe('resolver-working');
+ });
+
+ it('does not treat a missing answer as disagreement', () => {
+ // One provider not answering is absent data. Calling it divergence would
+ // manufacture a finding out of a gap.
+ const r = classifyDnsIntegrity(true, true, [unknown]);
+ expect(r.verdict).toBe('resolver-working');
+ });
+
+ it('blames nothing when neither path worked', () => {
+ const r = classifyDnsIntegrity(false, false, []);
+ expect(r.verdict).toBeNull();
+ expect(r.explanation).toMatch(/says nothing about DNS/);
+ });
+
+ it('returns null when the check did not complete', () => {
+ const r = classifyDnsIntegrity(null, null, []);
+ expect(r.verdict).toBeNull();
+ });
+});
diff --git a/src/utils/captivePortal.ts b/src/utils/captivePortal.ts
new file mode 100644
index 0000000..3ac89cd
--- /dev/null
+++ b/src/utils/captivePortal.ts
@@ -0,0 +1,524 @@
+import type {
+ CaptivePortalResult,
+ DnsIntegrityResult,
+ DohComparison,
+ IntegrityProbe,
+ MeasurementFailure,
+} from '../types';
+import { createId, queryDnsOverHttps } from './network';
+
+/**
+ * Captive-portal and DNS-hijack detection.
+ *
+ * The textbook captive-portal check β request `http://β¦/generate_204` and see
+ * whether a portal answers with a redirect instead of an empty 204 β is not
+ * available to this app as deployed. A page served over https: may not open a
+ * plaintext http: connection at all, so the request never leaves the browser.
+ * That limitation is reported (`mixed-content-blocked`), not worked around, and
+ * the probe *is* run when NetReady is opened from a local http: origin.
+ *
+ * Over https: the observable signature is different, and this is the part worth
+ * understanding: a captive portal cannot rewrite an HTTPS response without
+ * breaking the certificate chain. So it does not show up as tampered content β
+ * it shows up as secure connections failing while `navigator.onLine` still
+ * reports true. Two checks follow from that:
+ *
+ * 1. **Known-content probes.** Endpoints whose correct response is known in
+ * advance. `verified` means the real server answered. `content-mismatch`
+ * means something answered *in its place*, which on https: implies a
+ * certificate the machine has been made to trust. `no-response` across
+ * every endpoint, while the browser believes it is online, is the portal
+ * signature.
+ *
+ * 2. **Hostname versus literal IP.** A browser cannot read what the system
+ * resolver returned. It can, however, reach one server two ways: by name,
+ * which uses the resolver, and by literal address, which does not. If the
+ * literal answers and the name does not, the resolver is the broken link.
+ * That is a real, low-ambiguity signal, and it is the only DNS test of the
+ * *system* resolver a web page can perform.
+ */
+
+const PROBE_TIMEOUT_MS = 6000;
+
+/** Endpoints whose exact response shape is known, so a wrong answer is a
+ * finding rather than noise. Each is already contacted by other NetReady
+ * tools; the check adds no new third party. */
+interface KnownEndpoint {
+ label: string;
+ url: string;
+ expectation: string;
+ /** Returns true when the body is unmistakably this endpoint's own. */
+ verify: (body: string) => boolean;
+}
+
+const KNOWN_ENDPOINTS: KnownEndpoint[] = [
+ {
+ label: 'Cloudflare edge metadata',
+ url: 'https://speed.cloudflare.com/meta',
+ expectation: 'JSON naming the Cloudflare edge that served the request',
+ verify: (body) => hasJsonKey(body, 'colo'),
+ },
+ {
+ label: 'Google DNS-over-HTTPS',
+ url: 'https://dns.google/resolve?name=one.one.one.one&type=A',
+ expectation: 'a DNS-over-HTTPS answer with a numeric Status field',
+ verify: (body) => hasJsonKey(body, 'Status'),
+ },
+ {
+ label: 'jsDelivr package metadata',
+ url: 'https://cdn.jsdelivr.net/npm/tiny-inflate@1.0.3/package.json',
+ expectation: 'the published package.json for tiny-inflate',
+ verify: (body) => {
+ try {
+ return JSON.parse(body)?.name === 'tiny-inflate';
+ } catch {
+ return false;
+ }
+ },
+ },
+];
+
+/** Plaintext captive-portal endpoint, usable only from an http: origin. */
+const GENERATE_204_URL = 'http://cp.cloudflare.com/generate_204';
+
+/** Same Cloudflare server, reached two ways. The literal needs no DNS. */
+const DNS_HOSTNAME_PROBE = 'https://one.one.one.one/cdn-cgi/trace';
+const DNS_LITERAL_PROBE = 'https://1.1.1.1/cdn-cgi/trace';
+
+/**
+ * Names whose correct answer is the same everywhere on earth.
+ *
+ * This matters more than it looks. Comparing two resolvers on an ordinary CDN
+ * hostname produces disagreement constantly and legitimately, because the whole
+ * point of a CDN is to answer differently by location. Anycast infrastructure
+ * names do not do that, so a divergence here is signal rather than geography.
+ */
+const STABLE_NAMES = ['one.one.one.one', 'dns.google'];
+
+function hasJsonKey(body: string, key: string): boolean {
+ try {
+ const parsed = JSON.parse(body);
+ return parsed !== null && typeof parsed === 'object' && key in parsed;
+ } catch {
+ return false;
+ }
+}
+
+function timeoutSignal(ms: number, external?: AbortSignal): { signal: AbortSignal; done: () => void } {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), ms);
+ const onAbort = () => controller.abort();
+ external?.addEventListener('abort', onAbort);
+ return {
+ signal: controller.signal,
+ done: () => {
+ clearTimeout(timer);
+ external?.removeEventListener('abort', onAbort);
+ },
+ };
+}
+
+/** True when the current page may open plaintext http: connections. */
+export function canProbePlaintext(protocol: string): boolean {
+ return protocol === 'http:';
+}
+
+async function probeKnownEndpoint(
+ endpoint: KnownEndpoint,
+ signal?: AbortSignal,
+): Promise {
+ const url = `${endpoint.url}${endpoint.url.includes('?') ? '&' : '?'}_nr=${Date.now()}`;
+ const started = performance.now();
+ const gate = timeoutSignal(PROBE_TIMEOUT_MS, signal);
+
+ try {
+ const res = await fetch(url, { cache: 'no-store', mode: 'cors', signal: gate.signal });
+ const body = await res.text();
+ const roundTripMs = Math.round(performance.now() - started);
+ const verified = res.ok && endpoint.verify(body);
+
+ return {
+ label: endpoint.label,
+ url: endpoint.url,
+ expectation: endpoint.expectation,
+ outcome: verified ? 'verified' : 'content-mismatch',
+ roundTripMs,
+ note: verified
+ ? null
+ : `Answered with HTTP ${res.status}, but the body was not ${endpoint.expectation}. ` +
+ 'Something responded in this endpointβs place.',
+ };
+ } catch (e) {
+ return {
+ label: endpoint.label,
+ url: endpoint.url,
+ expectation: endpoint.expectation,
+ outcome: 'no-response',
+ roundTripMs: null,
+ note: e instanceof Error ? e.message : 'Request failed',
+ };
+ } finally {
+ gate.done();
+ }
+}
+
+/**
+ * The classic plaintext probe, run only where the browser permits it.
+ *
+ * `redirect: 'error'` turns a portal's 302 into a rejected promise, which is
+ * the whole signal: the correct response is a bodyless 204 and nothing else.
+ */
+async function probeGenerate204(pageProtocol: string, signal?: AbortSignal): Promise {
+ const base: Omit = {
+ label: 'Plaintext captive-portal probe',
+ url: GENERATE_204_URL,
+ expectation: 'an empty HTTP 204 with no redirect',
+ };
+
+ if (!canProbePlaintext(pageProtocol)) {
+ return {
+ ...base,
+ outcome: 'not-attempted',
+ roundTripMs: null,
+ note:
+ `This page is served over ${pageProtocol.replace(':', '')}, and a secure page may not ` +
+ 'open a plaintext connection. The classic 204 redirect check is therefore unavailable ' +
+ 'here; it runs when NetReady is opened over plain http.',
+ };
+ }
+
+ const started = performance.now();
+ const gate = timeoutSignal(PROBE_TIMEOUT_MS, signal);
+ try {
+ const res = await fetch(`${GENERATE_204_URL}?_nr=${Date.now()}`, {
+ cache: 'no-store',
+ mode: 'cors',
+ redirect: 'error',
+ signal: gate.signal,
+ });
+ const roundTripMs = Math.round(performance.now() - started);
+ if (res.status === 204) {
+ return { ...base, outcome: 'verified', roundTripMs, note: null };
+ }
+ return {
+ ...base,
+ outcome: 'content-mismatch',
+ roundTripMs,
+ note: `Expected HTTP 204 and got HTTP ${res.status}. A captive portal answers exactly like this.`,
+ };
+ } catch (e) {
+ return {
+ ...base,
+ outcome: 'no-response',
+ roundTripMs: null,
+ note: e instanceof Error ? e.message : 'Request failed',
+ };
+ } finally {
+ gate.done();
+ }
+}
+
+export interface InterceptionClassification {
+ verdict: CaptivePortalResult['verdict'];
+ explanation: string;
+}
+
+/**
+ * Turns known-content probe outcomes into a verdict. Pure and exhaustively
+ * tested, because this is the function whose wording a user acts on.
+ *
+ * `browserOnline` is a parameter rather than a `navigator` read so the
+ * distinction that matters β offline versus online-but-blocked β can be tested
+ * without a browser.
+ */
+export function classifyInterception(
+ probes: readonly IntegrityProbe[],
+ browserOnline: boolean,
+): InterceptionClassification {
+ const attempted = probes.filter((p) => p.outcome !== 'not-attempted');
+
+ if (attempted.length === 0) {
+ return {
+ verdict: null,
+ explanation: 'No integrity probe ran, so nothing is known about interception on this network.',
+ };
+ }
+
+ const mismatched = attempted.filter((p) => p.outcome === 'content-mismatch');
+ const verified = attempted.filter((p) => p.outcome === 'verified');
+ const silent = attempted.filter((p) => p.outcome === 'no-response');
+
+ if (mismatched.length > 0) {
+ return {
+ verdict: 'content-substituted',
+ explanation:
+ `${mismatched.length} of ${attempted.length} endpoints returned something other than ` +
+ 'their own content. Over HTTPS that requires a certificate this machine has been made ' +
+ 'to trust, which means a filtering proxy, corporate inspection appliance, or captive ' +
+ 'portal is reading the traffic.',
+ };
+ }
+
+ if (verified.length === attempted.length) {
+ return {
+ verdict: 'no-interception-detected',
+ explanation:
+ `All ${attempted.length} endpoints returned exactly their own content, so nothing is ` +
+ 'standing in for them. This rules out substitution on these endpoints; it does not ' +
+ 'certify the whole network.',
+ };
+ }
+
+ if (silent.length === attempted.length) {
+ return {
+ verdict: browserOnline ? 'https-blocked' : null,
+ explanation: browserOnline
+ ? 'The browser reports a working connection, yet no HTTPS endpoint answered. That is the ' +
+ 'signature of a captive portal you have not signed in to, or of a firewall blocking ' +
+ 'outbound HTTPS β a portal cannot forge an HTTPS response, so it blocks instead.'
+ : 'Nothing answered, and the browser reports no network connection. There is no ' +
+ 'interception to detect here; there is no connection.',
+ };
+ }
+
+ return {
+ verdict: 'mixed',
+ explanation:
+ `${verified.length} of ${attempted.length} endpoints answered correctly and ` +
+ `${silent.length} did not answer at all. That is neither a clean pass nor the pattern a ` +
+ 'portal produces, and more often means one provider is unreachable from this network.',
+ };
+}
+
+/** Runs the interception checks. */
+export async function checkCaptivePortal(
+ onProgress?: (stage: string) => void,
+ signal?: AbortSignal,
+): Promise {
+ const started = performance.now();
+ const pageProtocol = typeof location === 'undefined' ? 'https:' : location.protocol;
+ const failures: MeasurementFailure[] = [];
+
+ onProgress?.('Checking whether known endpoints return their own content');
+ const probes: IntegrityProbe[] = [
+ await probeGenerate204(pageProtocol, signal),
+ ...(await Promise.all(KNOWN_ENDPOINTS.map((e) => probeKnownEndpoint(e, signal)))),
+ ];
+
+ const plaintext = probes[0];
+ if (plaintext.outcome === 'not-attempted') {
+ failures.push({
+ metric: 'generate204',
+ reason: 'mixed-content-blocked',
+ detail: plaintext.note ?? 'The plaintext captive-portal probe could not be attempted.',
+ });
+ }
+
+ const classification = classifyInterception(probes, navigator.onLine);
+ if (classification.verdict === null) {
+ failures.push({
+ metric: 'interception',
+ reason: navigator.onLine ? 'insufficient-samples' : 'network-offline',
+ detail: classification.explanation,
+ });
+ }
+
+ return {
+ id: createId('captive'),
+ timestamp: Date.now(),
+ pageProtocol,
+ probes,
+ verdict: classification.verdict,
+ explanation: classification.explanation,
+ totalTimeMs: Math.round(performance.now() - started),
+ failures,
+ };
+}
+
+/** Did a connection to this URL complete at all? Opaque responses count β the
+ * question is whether the browser got anywhere, not what it was told. */
+async function reachable(url: string, signal?: AbortSignal): Promise {
+ const gate = timeoutSignal(PROBE_TIMEOUT_MS, signal);
+ try {
+ await fetch(`${url}${url.includes('?') ? '&' : '?'}_nr=${Date.now()}`, {
+ method: 'HEAD',
+ cache: 'no-store',
+ mode: 'no-cors',
+ signal: gate.signal,
+ });
+ return true;
+ } catch {
+ return false;
+ } finally {
+ gate.done();
+ }
+}
+
+/** Sorted record data, so comparison is order-independent. */
+function answerSet(records: { data: string }[]): string[] {
+ return records.map((r) => r.data.trim()).sort();
+}
+
+export interface DnsClassification {
+ verdict: DnsIntegrityResult['verdict'];
+ explanation: string;
+}
+
+/**
+ * Turns the two DNS observations into a verdict. Pure.
+ *
+ * The precedence is deliberate: a resolver that cannot resolve is a bigger
+ * finding than two providers disagreeing, and "both paths failed" is reported as
+ * unknown rather than as a DNS fault, because a dead connection fails both.
+ */
+export function classifyDnsIntegrity(
+ hostnameReachable: boolean | null,
+ literalIpReachable: boolean | null,
+ comparisons: readonly DohComparison[],
+): DnsClassification {
+ if (literalIpReachable === true && hostnameReachable === false) {
+ return {
+ verdict: 'resolver-failing',
+ explanation:
+ 'The same server answered on its literal IP address but not by name. The connection ' +
+ 'works; name resolution on this network does not. That is a broken, blocked or ' +
+ 'redirected DNS resolver.',
+ };
+ }
+
+ const disagreements = comparisons.filter((c) => c.agrees === false);
+ if (disagreements.length > 0) {
+ return {
+ verdict: 'answers-diverge',
+ explanation:
+ `Two independent DNS-over-HTTPS providers returned different addresses for ` +
+ `${disagreements.map((d) => d.name).join(', ')}. These names answer identically ` +
+ 'worldwide, so a difference points at manipulation rather than at geography.',
+ };
+ }
+
+ if (hostnameReachable === true) {
+ const compared = comparisons.filter((c) => c.agrees === true).length;
+ return {
+ verdict: 'resolver-working',
+ explanation:
+ 'Hostnames resolved and the server answered by name.' +
+ (compared > 0
+ ? ` Two independent DNS-over-HTTPS providers also agreed on ${compared} name(s) whose ` +
+ 'correct answer is the same worldwide.'
+ : ''),
+ };
+ }
+
+ if (hostnameReachable === false && literalIpReachable === false) {
+ return {
+ verdict: null,
+ explanation:
+ 'Neither the hostname nor the literal IP answered. Nothing got out at all, so this says ' +
+ 'nothing about DNS specifically β fix reachability first.',
+ };
+ }
+
+ return {
+ verdict: null,
+ explanation: 'Not enough of the DNS check completed to reach a verdict.',
+ };
+}
+
+/** Runs the DNS integrity check. */
+export async function checkDnsIntegrity(
+ onProgress?: (stage: string) => void,
+ signal?: AbortSignal,
+): Promise {
+ const started = performance.now();
+ const failures: MeasurementFailure[] = [];
+
+ if (!navigator.onLine) {
+ return {
+ id: createId('dnscheck'),
+ timestamp: Date.now(),
+ hostnameReachable: null,
+ literalIpReachable: null,
+ hostnameProbed: DNS_HOSTNAME_PROBE,
+ literalProbed: DNS_LITERAL_PROBE,
+ comparisons: [],
+ verdict: null,
+ explanation:
+ 'The browser reports no network connection, so no name was resolved and no address was ' +
+ 'contacted.',
+ totalTimeMs: 0,
+ failures: [
+ {
+ metric: 'all',
+ reason: 'network-offline',
+ detail: 'The browser reports no network connection, so nothing was measured.',
+ },
+ ],
+ };
+ }
+
+ onProgress?.('Comparing name resolution against a literal address');
+ const [hostnameReachable, literalIpReachable] = await Promise.all([
+ reachable(DNS_HOSTNAME_PROBE, signal),
+ reachable(DNS_LITERAL_PROBE, signal),
+ ]);
+
+ onProgress?.('Cross-checking two DNS-over-HTTPS providers');
+ const comparisons: DohComparison[] = [];
+ for (const name of STABLE_NAMES) {
+ if (signal?.aborted) break;
+ const [cf, goog] = await Promise.all([
+ queryDnsOverHttps(name, 'A', 'cloudflare'),
+ queryDnsOverHttps(name, 'A', 'google'),
+ ]);
+
+ // Status 0 is NOERROR. Anything else means the provider did not answer the
+ // question, which is missing data β not a disagreement.
+ const cloudflare = cf.status === 0 && cf.records.length > 0 ? answerSet(cf.records) : null;
+ const google = goog.status === 0 && goog.records.length > 0 ? answerSet(goog.records) : null;
+
+ comparisons.push({
+ name,
+ cloudflare,
+ google,
+ agrees:
+ cloudflare === null || google === null
+ ? null
+ : cloudflare.length === google.length && cloudflare.every((v, i) => v === google[i]),
+ });
+ }
+
+ const unresolved = comparisons.filter((c) => c.agrees === null);
+ if (unresolved.length > 0) {
+ failures.push({
+ metric: 'dohComparison',
+ reason: 'api-unreachable',
+ detail:
+ `${unresolved.length} name(s) could not be compared because at least one DNS-over-HTTPS ` +
+ 'provider did not return an answer. A missing answer is not a disagreement.',
+ });
+ }
+
+ const classification = classifyDnsIntegrity(hostnameReachable, literalIpReachable, comparisons);
+ if (classification.verdict === null) {
+ failures.push({
+ metric: 'dnsVerdict',
+ reason: 'insufficient-samples',
+ detail: classification.explanation,
+ });
+ }
+
+ return {
+ id: createId('dnscheck'),
+ timestamp: Date.now(),
+ hostnameReachable,
+ literalIpReachable,
+ hostnameProbed: DNS_HOSTNAME_PROBE,
+ literalProbed: DNS_LITERAL_PROBE,
+ comparisons,
+ verdict: classification.verdict,
+ explanation: classification.explanation,
+ totalTimeMs: Math.round(performance.now() - started),
+ failures,
+ };
+}
diff --git a/src/utils/dualStack.test.ts b/src/utils/dualStack.test.ts
new file mode 100644
index 0000000..662ebe1
--- /dev/null
+++ b/src/utils/dualStack.test.ts
@@ -0,0 +1,155 @@
+import { describe, it, expect } from 'vitest';
+import {
+ familyOfIp,
+ classifyDualStack,
+ describeReachability,
+ FAMILY_ENDPOINTS,
+} from './dualStack';
+import type { AddressFamily, FamilyProbe } from '../types';
+
+const probe = (
+ family: AddressFamily,
+ outcome: FamilyProbe['outcome'],
+ host = `${family}.example.test`,
+): FamilyProbe => ({
+ family,
+ host,
+ url: `https://${host}/`,
+ outcome,
+ roundTripMs: outcome === 'answered' ? 30 : null,
+ observedIp: null,
+ error: null,
+});
+
+describe('familyOfIp', () => {
+ it('recognises IPv4 literals', () => {
+ expect(familyOfIp('1.1.1.1')).toBe('ipv4');
+ expect(familyOfIp('203.0.113.255')).toBe('ipv4');
+ expect(familyOfIp(' 8.8.4.4\n')).toBe('ipv4');
+ });
+
+ it('recognises IPv6 literals', () => {
+ expect(familyOfIp('2606:4700:4700::1111')).toBe('ipv6');
+ expect(familyOfIp('::1')).toBe('ipv6');
+ });
+
+ it('treats an IPv4-mapped IPv6 address as IPv6', () => {
+ // The connection that produced this was IPv6; the embedded v4 address does
+ // not change which family carried it.
+ expect(familyOfIp('::ffff:192.0.2.1')).toBe('ipv6');
+ });
+
+ it('returns null rather than guessing on anything malformed', () => {
+ // A truncated or error-page body must not be counted as evidence that a
+ // family works.
+ expect(familyOfIp('')).toBeNull();
+ expect(familyOfIp(null)).toBeNull();
+ expect(familyOfIp(undefined)).toBeNull();
+ expect(familyOfIp('not an address')).toBeNull();
+ expect(familyOfIp('1.2.3')).toBeNull();
+ expect(familyOfIp('1.2.3.4.5')).toBeNull();
+ expect(familyOfIp('999.1.1.1')).toBeNull();
+ expect(familyOfIp('error')).toBeNull();
+ });
+});
+
+describe('classifyDualStack', () => {
+ it('reports dual-stack when both families answer', () => {
+ const r = classifyDualStack(
+ [probe('ipv4', 'answered'), probe('ipv6', 'answered')],
+ 'ipv6',
+ );
+ expect(r.verdict).toBe('dual-stack');
+ expect(r.ipv4Reachable).toBe(true);
+ expect(r.ipv6Reachable).toBe(true);
+ expect(r.explanation).toMatch(/IPv6/);
+ });
+
+ it('needs only one endpoint per family to answer', () => {
+ // One provider being down must not become a verdict about the user.
+ const r = classifyDualStack(
+ [
+ probe('ipv4', 'no-response', 'a.example.test'),
+ probe('ipv4', 'answered', 'b.example.test'),
+ probe('ipv6', 'answered'),
+ ],
+ null,
+ );
+ expect(r.verdict).toBe('dual-stack');
+ });
+
+ it('reports IPv4-only when no IPv6 host answers', () => {
+ const r = classifyDualStack(
+ [probe('ipv4', 'answered'), probe('ipv6', 'no-response')],
+ 'ipv4',
+ );
+ expect(r.verdict).toBe('ipv4-only');
+ expect(r.ipv6Reachable).toBe(false);
+ });
+
+ it('reports IPv6-only when no IPv4 host answers', () => {
+ const r = classifyDualStack(
+ [probe('ipv4', 'no-response'), probe('ipv6', 'answered')],
+ 'ipv6',
+ );
+ expect(r.verdict).toBe('ipv6-only');
+ });
+
+ it('does not blame IPv6 when nothing answered at all', () => {
+ const r = classifyDualStack(
+ [probe('ipv4', 'no-response'), probe('ipv6', 'no-response')],
+ null,
+ );
+ expect(r.verdict).toBe('neither-family-answered');
+ expect(r.explanation).toMatch(/connection as a whole/);
+ });
+
+ it('returns null reachability for a family that was never probed', () => {
+ // A check that did not run has not failed. This is the distinction the
+ // whole tri-state exists for.
+ const r = classifyDualStack([probe('ipv4', 'answered')], null);
+ expect(r.ipv6Reachable).toBeNull();
+ expect(r.verdict).toBeNull();
+ });
+
+ it('returns null for an entirely empty probe set', () => {
+ const r = classifyDualStack([], null);
+ expect(r.ipv4Reachable).toBeNull();
+ expect(r.ipv6Reachable).toBeNull();
+ expect(r.verdict).toBeNull();
+ });
+
+ it('omits the preference sentence when the family is unknown', () => {
+ const r = classifyDualStack([probe('ipv4', 'answered'), probe('ipv6', 'answered')], null);
+ expect(r.explanation).not.toMatch(/prefers/);
+ });
+});
+
+describe('describeReachability', () => {
+ it('distinguishes not-checked from no-response', () => {
+ expect(describeReachability(null)).toBe('not checked');
+ expect(describeReachability(false)).toBe('no response');
+ expect(describeReachability(true)).toBe('answered');
+ });
+});
+
+describe('FAMILY_ENDPOINTS', () => {
+ it('probes at least two independent providers per family', () => {
+ for (const family of ['ipv4', 'ipv6'] as const) {
+ const hosts = new Set(
+ FAMILY_ENDPOINTS.filter((e) => e.family === family).map((e) => e.host),
+ );
+ expect(hosts.size, `${family} providers`).toBeGreaterThanOrEqual(2);
+ }
+ });
+
+ it('uses https and a host name matching its declared family', () => {
+ for (const e of FAMILY_ENDPOINTS) {
+ expect(e.url.startsWith('https://')).toBe(true);
+ expect(e.url).toContain(e.host);
+ // The pinning is the whole mechanism: a v6 probe aimed at a dual-stack
+ // host would answer over IPv4 and prove nothing.
+ expect(e.host).toMatch(e.family === 'ipv4' ? /(^|\.)(ipv4|api4)/ : /(^|\.)(ipv6|api6)/);
+ }
+ });
+});
diff --git a/src/utils/dualStack.ts b/src/utils/dualStack.ts
new file mode 100644
index 0000000..81e4f84
--- /dev/null
+++ b/src/utils/dualStack.ts
@@ -0,0 +1,378 @@
+import type {
+ AddressFamily,
+ DualStackResult,
+ FamilyProbe,
+ MeasurementFailure,
+} from '../types';
+import { createId } from './network';
+
+/**
+ * Dual-stack (IPv4 / IPv6) reachability.
+ *
+ * A page cannot ask the browser which address families it has, which one a
+ * given connection used, or what the machine's own addresses are. Everything
+ * here is therefore an observation rather than an inference:
+ *
+ * 1. Connect to hostnames that publish *only* an A record, and to hostnames
+ * that publish *only* an AAAA record. A response proves that family works
+ * end to end. No response proves nothing on its own, and is reported as
+ * "no response", never as "IPv6 is disabled".
+ *
+ * 2. Ask a dual-stack host which address it saw. The family of that address
+ * is the family the browser actually chose β Happy Eyeballs preference,
+ * observed rather than assumed.
+ *
+ * Two independent providers are probed per family so that one provider having a
+ * bad day does not turn into a verdict about the user's network.
+ */
+
+/** Per-probe timeout. Long enough for a slow first connection, short enough
+ * that a fully blocked family does not stall the whole check. */
+const PROBE_TIMEOUT_MS = 6000;
+
+export interface FamilyEndpoint {
+ family: AddressFamily;
+ host: string;
+ url: string;
+}
+
+/**
+ * Family-pinned endpoints.
+ *
+ * Each host publishes records for one family only, which is the entire point:
+ * `ipv6.icanhazip.com` has no A record, so a browser with no working IPv6 path
+ * cannot reach it by any route. Both providers return the caller's address as
+ * plain text, so a successful probe also yields the address the far end saw.
+ */
+export const FAMILY_ENDPOINTS: FamilyEndpoint[] = [
+ { family: 'ipv4', host: 'ipv4.icanhazip.com', url: 'https://ipv4.icanhazip.com/' },
+ { family: 'ipv4', host: 'api4.ipify.org', url: 'https://api4.ipify.org/' },
+ { family: 'ipv6', host: 'ipv6.icanhazip.com', url: 'https://ipv6.icanhazip.com/' },
+ { family: 'ipv6', host: 'api6.ipify.org', url: 'https://api6.ipify.org/' },
+];
+
+/** Dual-stack host used to observe which family the browser prefers. Already
+ * contacted by the speed test, so this adds no new third party. */
+const PREFERENCE_URL = 'https://speed.cloudflare.com/meta';
+
+/**
+ * Which family an address literal belongs to.
+ *
+ * Deliberately strict: anything that is not clearly one or the other returns
+ * null, so a malformed or truncated response cannot be counted as evidence of
+ * either family.
+ */
+export function familyOfIp(raw: string | null | undefined): AddressFamily | null {
+ if (!raw) return null;
+ const ip = raw.trim();
+ if (ip.length === 0) return null;
+
+ // IPv4-mapped IPv6 (::ffff:1.2.3.4) is an IPv6 literal carrying a v4 address.
+ // The connection that produced it was IPv6, so it is classified as such.
+ if (ip.includes(':')) {
+ return /^[0-9a-fA-F:]+(:\d{1,3}(\.\d{1,3}){3})?$/.test(ip) ? 'ipv6' : null;
+ }
+
+ const octets = ip.split('.');
+ if (octets.length !== 4) return null;
+ const valid = octets.every((o) => /^\d{1,3}$/.test(o) && Number(o) <= 255);
+ return valid ? 'ipv4' : null;
+}
+
+/** AbortSignal that fires on timeout or when the caller aborts. */
+function timeoutSignal(ms: number, external?: AbortSignal): { signal: AbortSignal; done: () => void } {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), ms);
+ const onAbort = () => controller.abort();
+ external?.addEventListener('abort', onAbort);
+ return {
+ signal: controller.signal,
+ done: () => {
+ clearTimeout(timer);
+ external?.removeEventListener('abort', onAbort);
+ },
+ };
+}
+
+/**
+ * Probes one family-pinned endpoint.
+ *
+ * A CORS read is attempted first because the body carries the observed address.
+ * If that fails, the request is repeated in `no-cors` mode, which still proves
+ * the connection completed even though the response is opaque. Falling back
+ * matters: a missing `Access-Control-Allow-Origin` header is a property of the
+ * provider, and reporting it as "IPv6 unreachable" would be a wrong answer
+ * derived from a working connection.
+ */
+export async function probeFamilyEndpoint(
+ endpoint: FamilyEndpoint,
+ signal?: AbortSignal,
+): Promise {
+ const url = `${endpoint.url}${endpoint.url.includes('?') ? '&' : '?'}_nr=${Date.now()}`;
+ const started = performance.now();
+
+ const gate = timeoutSignal(PROBE_TIMEOUT_MS, signal);
+ try {
+ const res = await fetch(url, { cache: 'no-store', mode: 'cors', signal: gate.signal });
+ const body = (await res.text()).trim();
+ // Only accept a body that actually parses as an address. Anything else is
+ // reported as an answer with no readable address rather than being stored
+ // as though the provider had returned something meaningful.
+ const observedIp = familyOfIp(body) === endpoint.family ? body : null;
+ return {
+ family: endpoint.family,
+ host: endpoint.host,
+ url: endpoint.url,
+ outcome: 'answered',
+ roundTripMs: Math.round(performance.now() - started),
+ observedIp,
+ error: null,
+ };
+ } catch (corsError) {
+ // Second attempt: reachability only. An opaque response still tells us the
+ // connection completed, which is the thing this check is actually about.
+ const opaqueGate = timeoutSignal(PROBE_TIMEOUT_MS, signal);
+ try {
+ await fetch(url, {
+ method: 'HEAD',
+ cache: 'no-store',
+ mode: 'no-cors',
+ signal: opaqueGate.signal,
+ });
+ return {
+ family: endpoint.family,
+ host: endpoint.host,
+ url: endpoint.url,
+ outcome: 'answered',
+ roundTripMs: Math.round(performance.now() - started),
+ observedIp: null,
+ error:
+ `${endpoint.host} answered, but did not allow this page to read the response, ` +
+ 'so the address it saw is unknown.',
+ };
+ } catch {
+ return {
+ family: endpoint.family,
+ host: endpoint.host,
+ url: endpoint.url,
+ outcome: 'no-response',
+ roundTripMs: null,
+ observedIp: null,
+ error: corsError instanceof Error ? corsError.message : 'Request failed',
+ };
+ } finally {
+ opaqueGate.done();
+ }
+ } finally {
+ gate.done();
+ }
+}
+
+const label = (family: AddressFamily): string => (family === 'ipv6' ? 'IPv6' : 'IPv4');
+
+export interface DualStackClassification {
+ ipv4Reachable: boolean | null;
+ ipv6Reachable: boolean | null;
+ verdict: DualStackResult['verdict'];
+ explanation: string;
+}
+
+/**
+ * Turns probe outcomes into a verdict.
+ *
+ * Pure, so the wording of every branch is testable without a network. Note that
+ * a family with no probes at all yields null, not false: a check that did not
+ * run has not failed.
+ */
+export function classifyDualStack(
+ probes: readonly FamilyProbe[],
+ preferredFamily: AddressFamily | null,
+): DualStackClassification {
+ const of = (family: AddressFamily) => probes.filter((p) => p.family === family);
+ const reach = (family: AddressFamily): boolean | null => {
+ const list = of(family);
+ if (list.length === 0) return null;
+ return list.some((p) => p.outcome === 'answered');
+ };
+
+ const ipv4Reachable = reach('ipv4');
+ const ipv6Reachable = reach('ipv6');
+
+ if (ipv4Reachable === null || ipv6Reachable === null) {
+ return {
+ ipv4Reachable,
+ ipv6Reachable,
+ verdict: null,
+ explanation:
+ 'Not enough of the check ran to say anything about address families. ' +
+ 'At least one endpoint in each family has to be tried.',
+ };
+ }
+
+ const preference = preferredFamily
+ ? ` The browser reached a dual-stack host over ${label(preferredFamily)}, so that is the ` +
+ 'family it prefers when both are available.'
+ : '';
+
+ if (ipv4Reachable && ipv6Reachable) {
+ return {
+ ipv4Reachable,
+ ipv6Reachable,
+ verdict: 'dual-stack',
+ explanation:
+ 'Both IPv4-only and IPv6-only hosts answered, so this connection carries both address ' +
+ `families end to end.${preference}`,
+ };
+ }
+
+ if (ipv4Reachable && !ipv6Reachable) {
+ return {
+ ipv4Reachable,
+ ipv6Reachable,
+ verdict: 'ipv4-only',
+ explanation:
+ 'IPv4-only hosts answered and no IPv6-only host did. Either this network has no IPv6 ' +
+ 'path or something along it is dropping IPv6. This is common and mostly harmless β it ' +
+ 'only bites on services that publish no IPv4 address at all.',
+ };
+ }
+
+ if (!ipv4Reachable && ipv6Reachable) {
+ return {
+ ipv4Reachable,
+ ipv6Reachable,
+ verdict: 'ipv6-only',
+ explanation:
+ 'IPv6-only hosts answered and no IPv4-only host did. That is unusual; on most IPv6-only ' +
+ 'networks a translation layer still makes IPv4 destinations reachable, so IPv4 failing ' +
+ 'outright is worth investigating.',
+ };
+ }
+
+ return {
+ ipv4Reachable,
+ ipv6Reachable,
+ verdict: 'neither-family-answered',
+ explanation:
+ 'No probe host answered in either family. That points at the connection as a whole rather ' +
+ 'than at one address family β nothing here distinguishes an IPv6 problem from being ' +
+ 'offline.',
+ };
+}
+
+/** Human-readable one-liner for a family reachability tri-state. */
+export function describeReachability(reachable: boolean | null): string {
+ if (reachable === null) return 'not checked';
+ return reachable ? 'answered' : 'no response';
+}
+
+/**
+ * Runs the full dual-stack check.
+ *
+ * Probes run concurrently: a blocked family times out rather than answering, so
+ * running them in series would make the check as slow as the sum of its
+ * failures.
+ */
+export async function checkDualStack(
+ onProgress?: (stage: string) => void,
+ signal?: AbortSignal,
+): Promise {
+ const started = performance.now();
+ const failures: MeasurementFailure[] = [];
+
+ if (!navigator.onLine) {
+ return {
+ id: createId('dualstack'),
+ timestamp: Date.now(),
+ probes: [],
+ ipv4Reachable: null,
+ ipv6Reachable: null,
+ preferredFamily: null,
+ preferredFamilySource: null,
+ verdict: null,
+ explanation:
+ 'The browser reports no network connection, so no address family was tried. Nothing ' +
+ 'here says anything about IPv4 or IPv6 on this machine.',
+ totalTimeMs: 0,
+ failures: [
+ {
+ metric: 'all',
+ reason: 'network-offline',
+ detail: 'The browser reports no network connection, so nothing was measured.',
+ },
+ ],
+ };
+ }
+
+ onProgress?.('Contacting IPv4-only and IPv6-only hosts');
+ const probes = await Promise.all(
+ FAMILY_ENDPOINTS.map((endpoint) => probeFamilyEndpoint(endpoint, signal)),
+ );
+
+ onProgress?.('Asking a dual-stack host which address it sees');
+ let preferredFamily: AddressFamily | null = null;
+ let preferredFamilySource: string | null = null;
+ try {
+ const gate = timeoutSignal(PROBE_TIMEOUT_MS, signal);
+ try {
+ const res = await fetch(`${PREFERENCE_URL}?_nr=${Date.now()}`, {
+ cache: 'no-store',
+ signal: gate.signal,
+ });
+ if (!res.ok) throw new Error(`speed.cloudflare.com/meta returned ${res.status}`);
+ const meta = await res.json();
+ preferredFamily = familyOfIp(meta.clientIp);
+ if (preferredFamily === null) {
+ failures.push({
+ metric: 'preferredFamily',
+ reason: 'unsupported-api',
+ detail:
+ 'The dual-stack host did not report a readable client address, so the family the ' +
+ 'browser prefers is unknown.',
+ });
+ } else {
+ preferredFamilySource = 'speed.cloudflare.com/meta';
+ }
+ } finally {
+ gate.done();
+ }
+ } catch (e) {
+ failures.push({
+ metric: 'preferredFamily',
+ reason: 'api-unreachable',
+ detail:
+ 'Could not reach the dual-stack reference host, so which address family the browser ' +
+ `prefers was not determined. (${e instanceof Error ? e.message : 'request failed'})`,
+ });
+ }
+
+ const classification = classifyDualStack(probes, preferredFamily);
+
+ for (const family of ['ipv4', 'ipv6'] as const) {
+ const list = probes.filter((p) => p.family === family);
+ if (list.length > 0 && list.every((p) => p.outcome === 'no-response')) {
+ failures.push({
+ metric: `${family}Reachable`,
+ reason: 'api-unreachable',
+ detail:
+ `Neither ${label(family)}-only probe host answered. This is consistent with having no ` +
+ `${label(family)} path, but a browser cannot tell that apart from both providers being ` +
+ 'unreachable for other reasons.',
+ });
+ }
+ }
+
+ return {
+ id: createId('dualstack'),
+ timestamp: Date.now(),
+ probes,
+ ipv4Reachable: classification.ipv4Reachable,
+ ipv6Reachable: classification.ipv6Reachable,
+ preferredFamily,
+ preferredFamilySource,
+ verdict: classification.verdict,
+ explanation: classification.explanation,
+ totalTimeMs: Math.round(performance.now() - started),
+ failures,
+ };
+}
diff --git a/src/utils/export.test.ts b/src/utils/export.test.ts
index 40f0356..13b5561 100644
--- a/src/utils/export.test.ts
+++ b/src/utils/export.test.ts
@@ -5,6 +5,9 @@ import {
generateSpeedtestCsv,
generatePingCsv,
generateGeoIpCsv,
+ generateTriageCsv,
+ generateDualStackCsv,
+ generateCaptivePortalCsv,
TEST_TYPES,
} from './export';
import type { HistoryItem } from '../types';
@@ -176,6 +179,201 @@ describe('GeoIP export coverage', () => {
});
});
+describe('answer-layer exports', () => {
+ const triageItem: HistoryItem = {
+ id: 'triage_1',
+ type: 'triage',
+ timestamp: 1_700_000_000_000,
+ title: 'Triage',
+ summary: 'summary',
+ data: {
+ id: 'triage_1',
+ timestamp: 1_700_000_000_000,
+ attribution: 'local-network',
+ headline: 'It is your local network, not the internet.',
+ summary: 's',
+ totalTimeMs: 8123,
+ findings: [
+ {
+ ruleId: 'bufferbloat',
+ title: 'Latency collapses under load',
+ layer: 'local-network',
+ confidence: 'confirmed',
+ severity: 'degrading',
+ verdict: 'Round-trip time rose from 18 ms to 80 ms.',
+ remediation: ['Enable SQM', 'Cap upload'],
+ evidence: [{ metric: 'speed.loadedPing', observation: '80 ms under load' }],
+ },
+ ],
+ steps: [
+ { id: 'browser-online', label: 'l', question: 'q', status: 'pass', note: 'n' },
+ { id: 'dns', label: 'l', question: 'q', status: 'skipped', note: 'n' },
+ ],
+ failures: [{ metric: 'bandwidth', reason: 'not-attempted', detail: 'skipped' }],
+ snapshot: {},
+ },
+ };
+
+ it('writes one row per ranked finding with its evidence', () => {
+ const [header, row] = generateTriageCsv([triageItem]).split('\n');
+ expect(header).toContain('Rule ID');
+ expect(row).toContain('"bufferbloat"');
+ expect(row).toContain('"local-network"');
+ expect(row).toContain('"confirmed"');
+ expect(row).toContain('"Enable SQM | Cap upload"');
+ expect(row).toContain('"speed.loadedPing: 80 ms under load"');
+ // One of two steps passed.
+ expect(row).toContain('"1"');
+ });
+
+ it('still exports a run that found nothing, carrying its attribution', () => {
+ // "Checked and found nothing" and "never ran" must stay distinguishable in
+ // a spreadsheet, not collapse into an identical empty row.
+ const clean: HistoryItem = {
+ ...triageItem,
+ data: { ...triageItem.data, attribution: 'no-fault-found', findings: [], failures: [] },
+ };
+ const rows = generateTriageCsv([clean]).split('\n');
+ expect(rows).toHaveLength(2);
+ expect(rows[1]).toContain('"no-fault-found"');
+ });
+
+ it('writes one dual-stack row per probe and never says false for unchecked', () => {
+ const item: HistoryItem = {
+ id: 'ds_1',
+ type: 'dualstack',
+ timestamp: 1_700_000_000_000,
+ title: 'Dual stack',
+ summary: 's',
+ data: {
+ verdict: 'ipv4-only',
+ ipv4Reachable: true,
+ ipv6Reachable: false,
+ preferredFamily: null,
+ preferredFamilySource: null,
+ probes: [
+ {
+ family: 'ipv4',
+ host: 'ipv4.icanhazip.com',
+ url: 'https://ipv4.icanhazip.com/',
+ outcome: 'answered',
+ roundTripMs: 42,
+ observedIp: '203.0.113.9',
+ error: null,
+ },
+ {
+ family: 'ipv6',
+ host: 'ipv6.icanhazip.com',
+ url: 'https://ipv6.icanhazip.com/',
+ outcome: 'no-response',
+ roundTripMs: null,
+ observedIp: null,
+ error: 'Failed to fetch',
+ },
+ ],
+ failures: [],
+ },
+ };
+ const [, v4Row, v6Row] = generateDualStackCsv([item]).split('\n');
+ expect(v4Row).toContain('"203.0.113.9"');
+ expect(v4Row).toContain('"42"');
+ // An unmeasured round trip is blank, never 0 β a blank means not measured.
+ expect(v6Row).toContain('""');
+ expect(v6Row).not.toContain('"0"');
+ expect(v6Row).toContain('"no response"');
+ });
+
+ it('renders an unchecked family as "not checked" rather than as a failure', () => {
+ const item: HistoryItem = {
+ id: 'ds_2',
+ type: 'dualstack',
+ timestamp: 1,
+ title: 't',
+ summary: 's',
+ data: { verdict: null, ipv4Reachable: null, ipv6Reachable: null, probes: [], failures: [] },
+ };
+ const [, row] = generateDualStackCsv([item]).split('\n');
+ expect(row).toContain('"not checked"');
+ expect(row).not.toContain('"no response"');
+ });
+
+ it('writes one interception row per probe alongside the DNS verdict', () => {
+ const item: HistoryItem = {
+ id: 'cap_1',
+ type: 'captive',
+ timestamp: 1_700_000_000_000,
+ title: 'Captive',
+ summary: 's',
+ data: {
+ portal: {
+ pageProtocol: 'https:',
+ verdict: 'no-interception-detected',
+ probes: [
+ {
+ label: 'Cloudflare edge metadata',
+ url: 'https://speed.cloudflare.com/meta',
+ expectation: 'JSON naming the edge',
+ outcome: 'verified',
+ roundTripMs: 33,
+ note: null,
+ },
+ ],
+ failures: [],
+ },
+ dns: {
+ verdict: 'resolver-working',
+ hostnameReachable: true,
+ literalIpReachable: true,
+ failures: [],
+ },
+ },
+ };
+ const [header, row] = generateCaptivePortalCsv([item]).split('\n');
+ expect(header).toContain('DNS Verdict');
+ expect(row).toContain('"no-interception-detected"');
+ expect(row).toContain('"resolver-working"');
+ expect(row).toContain('"Cloudflare edge metadata"');
+ expect(row).toContain('"33"');
+ });
+
+ it('routes all three new types through the dispatcher', () => {
+ expect(getCsvForType([triageItem], 'triage')).toContain('bufferbloat');
+ for (const id of ['triage', 'dualstack', 'captive']) {
+ expect(TEST_TYPES.map((t) => t.id)).toContain(id);
+ }
+ });
+
+ it('escapes a formula-injecting probe note', () => {
+ // Probe notes carry third-party error strings straight into the export.
+ const item: HistoryItem = {
+ id: 'cap_2',
+ type: 'captive',
+ timestamp: 1,
+ title: 't',
+ summary: 's',
+ data: {
+ portal: {
+ pageProtocol: 'https:',
+ verdict: 'mixed',
+ probes: [
+ {
+ label: 'x',
+ url: 'https://x.test/',
+ expectation: 'y',
+ outcome: 'content-mismatch',
+ roundTripMs: 1,
+ note: '=cmd|\'/c calc\'!A1',
+ },
+ ],
+ failures: [],
+ },
+ dns: {},
+ },
+ };
+ expect(generateCaptivePortalCsv([item])).toContain('"\'=cmd');
+ });
+});
+
describe('every declared export type produces a CSV with a header row', () => {
it.each(TEST_TYPES.map((t) => t.id))('%s', (type) => {
const csv = getCsvForType([], type);
diff --git a/src/utils/export.ts b/src/utils/export.ts
index b25139b..fac9112 100644
--- a/src/utils/export.ts
+++ b/src/utils/export.ts
@@ -7,9 +7,16 @@ import type {
DnsQueryResult,
GeoIpResult,
EdgePathResult,
+ DualStackResult,
+ CaptivePortalResult,
+ DnsIntegrityResult,
} from '../types';
+import type { TriageVerdict } from '../analysis/types';
export const TEST_TYPES = [
+ { id: 'triage', label: 'Network Triage Verdicts', filename: 'triage_results.csv', icon: 'Stethoscope' },
+ { id: 'dualstack', label: 'IPv4 / IPv6 Reachability', filename: 'dualstack_results.csv', icon: 'Network' },
+ { id: 'captive', label: 'Portal & DNS Hijack Checks', filename: 'captive_results.csv', icon: 'ShieldQuestion' },
{ id: 'tracert', label: 'Traceroute (TRACERT)', filename: 'tracert_results.csv', icon: 'GitCommit' },
{ id: 'speedtest', label: 'Speed Test Results', filename: 'speedtest_results.csv', icon: 'Gauge' },
{ id: 'ping', label: 'Ping & Latency Tests', filename: 'ping_results.csv', icon: 'Radio' },
@@ -497,6 +504,239 @@ export function generateEdgePathCsv(items: HistoryItem[]): string {
return [headers.join(','), ...rows.map((r) => r.join(','))].join('\n');
}
+/**
+ * Triage CSV β one row per ranked finding.
+ *
+ * A run that found nothing still exports one row carrying the verdict, because
+ * "checked and found nothing" and "never ran" have to stay distinguishable in a
+ * spreadsheet too. The Attribution column says which of those it was:
+ * `no-fault-found` versus `indeterminate`.
+ */
+export function generateTriageCsv(items: HistoryItem[]): string {
+ const headers = [
+ 'Test ID',
+ 'Timestamp',
+ 'Date',
+ 'Attribution',
+ 'Headline',
+ 'Checks Passed',
+ 'Checks Total',
+ 'Run Time (ms)',
+ 'Finding Rank',
+ 'Rule ID',
+ 'Finding',
+ 'Layer',
+ 'Confidence',
+ 'Severity',
+ 'Verdict',
+ 'Remediation',
+ 'Evidence',
+ 'Not Measured',
+ ];
+
+ const rows: string[][] = [];
+
+ items
+ .filter((i) => i.type === 'triage')
+ .forEach((item) => {
+ const d = (item.data ?? {}) as Partial;
+ const steps = d.steps ?? [];
+ const notMeasured = (d.failures ?? []).map((f) => `${f.metric}: ${f.detail}`).join(' | ');
+ const shared = [
+ escapeCsv(item.id),
+ escapeCsv(item.timestamp),
+ escapeCsv(new Date(item.timestamp).toLocaleString()),
+ escapeCsv(d.attribution ?? ''),
+ escapeCsv(d.headline ?? item.title),
+ escapeCsv(steps.filter((s) => s.status === 'pass').length),
+ escapeCsv(steps.length),
+ escapeCsv(d.totalTimeMs ?? ''),
+ ];
+
+ const findings = d.findings ?? [];
+ if (findings.length === 0) {
+ rows.push([
+ ...shared,
+ ...Array(8).fill(escapeCsv('')),
+ escapeCsv(notMeasured),
+ ]);
+ return;
+ }
+
+ findings.forEach((f, index) => {
+ rows.push([
+ ...shared,
+ escapeCsv(index + 1),
+ escapeCsv(f.ruleId),
+ escapeCsv(f.title),
+ escapeCsv(f.layer),
+ escapeCsv(f.confidence),
+ escapeCsv(f.severity),
+ escapeCsv(f.verdict),
+ escapeCsv(f.remediation.join(' | ')),
+ escapeCsv(f.evidence.map((e) => `${e.metric}: ${e.observation}`).join(' | ')),
+ escapeCsv(notMeasured),
+ ]);
+ });
+ });
+
+ return [headers.join(','), ...rows.map((r) => r.join(','))].join('\n');
+}
+
+/**
+ * Dual-stack CSV β one row per family-pinned probe.
+ *
+ * The reachability columns carry `answered` / `no response` / `not checked`
+ * rather than TRUE/FALSE, because a family that was never probed must not read
+ * as a family that failed.
+ */
+export function generateDualStackCsv(items: HistoryItem[]): string {
+ const headers = [
+ 'Test ID',
+ 'Timestamp',
+ 'Date',
+ 'Verdict',
+ 'IPv4',
+ 'IPv6',
+ 'Preferred Family',
+ 'Preference Source',
+ 'Probe Host',
+ 'Probe Family',
+ 'Probe Outcome',
+ 'Round Trip (ms)',
+ 'Address Seen',
+ 'Probe Note',
+ 'Not Measured',
+ ];
+
+ const word = (v: boolean | null | undefined): string => {
+ if (v === null || v === undefined) return 'not checked';
+ return v ? 'answered' : 'no response';
+ };
+
+ const rows: string[][] = [];
+
+ items
+ .filter((i) => i.type === 'dualstack')
+ .forEach((item) => {
+ const d = (item.data ?? {}) as Partial;
+ const shared = [
+ escapeCsv(item.id),
+ escapeCsv(item.timestamp),
+ escapeCsv(new Date(item.timestamp).toLocaleString()),
+ escapeCsv(d.verdict ?? ''),
+ escapeCsv(word(d.ipv4Reachable)),
+ escapeCsv(word(d.ipv6Reachable)),
+ escapeCsv(d.preferredFamily ?? ''),
+ escapeCsv(d.preferredFamilySource ?? ''),
+ ];
+ const notMeasured = escapeCsv(
+ (d.failures ?? []).map((f) => `${f.metric}: ${f.detail}`).join(' | '),
+ );
+
+ const probes = d.probes ?? [];
+ if (probes.length === 0) {
+ rows.push([...shared, ...Array(6).fill(escapeCsv('')), notMeasured]);
+ return;
+ }
+
+ probes.forEach((p) => {
+ rows.push([
+ ...shared,
+ escapeCsv(p.host),
+ escapeCsv(p.family),
+ escapeCsv(p.outcome),
+ escapeCsv(p.roundTripMs ?? ''),
+ escapeCsv(p.observedIp ?? ''),
+ escapeCsv(p.error ?? ''),
+ notMeasured,
+ ]);
+ });
+ });
+
+ return [headers.join(','), ...rows.map((r) => r.join(','))].join('\n');
+}
+
+/**
+ * Captive-portal / DNS-hijack CSV β one row per integrity probe.
+ *
+ * The stored record holds both halves of the check, so the DNS verdict is
+ * repeated on every row alongside the probe that row describes.
+ */
+export function generateCaptivePortalCsv(items: HistoryItem[]): string {
+ const headers = [
+ 'Test ID',
+ 'Timestamp',
+ 'Date',
+ 'Page Protocol',
+ 'Interception Verdict',
+ 'DNS Verdict',
+ 'Reached By Name',
+ 'Reached By Literal IP',
+ 'Probe',
+ 'Probe URL',
+ 'Expected',
+ 'Outcome',
+ 'Round Trip (ms)',
+ 'Probe Note',
+ 'Not Measured',
+ ];
+
+ const word = (v: boolean | null | undefined): string => {
+ if (v === null || v === undefined) return 'not checked';
+ return v ? 'answered' : 'no response';
+ };
+
+ const rows: string[][] = [];
+
+ items
+ .filter((i) => i.type === 'captive')
+ .forEach((item) => {
+ const d = (item.data ?? {}) as {
+ portal?: Partial;
+ dns?: Partial;
+ };
+ const portal = d.portal ?? {};
+ const dns = d.dns ?? {};
+ const shared = [
+ escapeCsv(item.id),
+ escapeCsv(item.timestamp),
+ escapeCsv(new Date(item.timestamp).toLocaleString()),
+ escapeCsv(portal.pageProtocol ?? ''),
+ escapeCsv(portal.verdict ?? ''),
+ escapeCsv(dns.verdict ?? ''),
+ escapeCsv(word(dns.hostnameReachable)),
+ escapeCsv(word(dns.literalIpReachable)),
+ ];
+ const notMeasured = escapeCsv(
+ [...(portal.failures ?? []), ...(dns.failures ?? [])]
+ .map((f) => `${f.metric}: ${f.detail}`)
+ .join(' | '),
+ );
+
+ const probes = portal.probes ?? [];
+ if (probes.length === 0) {
+ rows.push([...shared, ...Array(6).fill(escapeCsv('')), notMeasured]);
+ return;
+ }
+
+ probes.forEach((p) => {
+ rows.push([
+ ...shared,
+ escapeCsv(p.label),
+ escapeCsv(p.url),
+ escapeCsv(p.expectation),
+ escapeCsv(p.outcome),
+ escapeCsv(p.roundTripMs ?? ''),
+ escapeCsv(p.note ?? ''),
+ notMeasured,
+ ]);
+ });
+ });
+
+ return [headers.join(','), ...rows.map((r) => r.join(','))].join('\n');
+}
+
// Generate Generic CSV for other test types
export function generateGenericCsv(items: HistoryItem[], type: string): string {
const filtered = items.filter((i) => i.type === type);
@@ -532,6 +772,12 @@ export function getCsvForType(items: HistoryItem[], type: string): string {
return generateGeoIpCsv(items);
case 'edgepath':
return generateEdgePathCsv(items);
+ case 'triage':
+ return generateTriageCsv(items);
+ case 'dualstack':
+ return generateDualStackCsv(items);
+ case 'captive':
+ return generateCaptivePortalCsv(items);
default:
return generateGenericCsv(items, type);
}