diff --git a/CLAUDE.md b/CLAUDE.md index 211dfe0..6a598f3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,6 +90,27 @@ thing this tool has that mainstream speed tests do not. --- +## The answer layer (`src/analysis/`) + +The rules engine turns measurements into findings. It is the one place where the project draws +conclusions rather than reporting numbers, so it has its own version of the rule above: + +> **No measurement, no finding.** A rule whose declared inputs are absent returns `null`. It is +> skipped and the gap is reported β€” it never evaluates against a substitute. + +- Keep rules **pure and total**: `(snapshot) => RuleHit | null`, no shared state, no ordering + dependency, no clock, no network. `evaluateRules` on an empty snapshot must return `[]`, and there + is a test that says so. That test is the engine's equivalent of the offline regression run. +- **Confidence is ordinal, never a percentage.** `confirmed` / `likely` / `possible` each have a + stated meaning. "83% confident" would be an invented number with no calculation behind it. +- **"Checked and found nothing" β‰  "did not check".** `no-fault-found` requires + `MIN_CHECKS_FOR_ALL_CLEAR` conclusive checks; below that the verdict is `indeterminate`, which + declines in both directions. Silence must never read as a clean bill of health. +- Every threshold lives in `THRESHOLDS` with a comment justifying it. They are judgements about + human experience, not measurements, and they change what the tool tells people. +- `attributeBottleneck` does substitute values β€” into a hypothetical re-score, never into a report. + A test asserts no reference value can reach the output. Keep it that way. + ## Conventions - Comments explain *why*, especially where the non-obvious choice is deliberate. Several diff --git a/README.md b/README.md index 69cd119..73e72e9 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,74 @@ why it was hard to spot; [`CLAUDE.md`](CLAUDE.md) holds the rules that keep it f --- +## 🧠 The answer layer + +Measurements are the easy half. The question people actually arrive with is **"is it me or the +internet?"**, and a grade does not answer it. + +### 🩺 One-button triage + +Walks a real decision tree β€” link β†’ name resolution β†’ interception β†’ address families β†’ four +unrelated content networks β†’ throughput β†’ behaviour under load β€” and returns a **verdict with +ranked probable causes and concrete fixes**, not a letter. + +The reasoning is a data-driven rules engine in [`src/analysis/`](src/analysis/). Each rule declares +the metrics it consumes, a predicate, a verdict, an ordinal confidence and a remediation, and every +finding carries the measurements that produced it. That makes it **deterministic, instant, offline, +and auditable** β€” same evidence in, same answer out, with no model involved and no network request +needed to reason. `@google/genai` was removed from this project deliberately; nothing here calls an +API to think. + +Two rules govern it, and both are tested: + +- **A rule whose inputs were not measured does not fire.** It is skipped, and the gap is reported. +- **Silence is not a clean bill of health.** Fewer than three conclusive checks yields + `indeterminate` β€” explicitly *not* "your network is fine". A run that measured nothing concludes + nothing. + +### 🎯 Bottleneck attribution + +The dashboard headline now names the *binding constraint* rather than showing four flat bars: + +> **Grade B β€” the binding constraint is bufferbloat, not bandwidth.** Latency rose from 18 ms idle +> to 80 ms under load, an increase of 62 ms. + +It is computed by sensitivity analysis over the existing scoring function: each measured input is +lifted, one at a time, to a level past which it stops limiting the result, and whichever lift moves +the score furthest is the constraint. Those comparison values live inside the calculation and are +never reported as measurements β€” a test asserts they cannot leak into the output. Severe bufferbloat +overrides the ranking, because the numeric score does not model it and the user's experience does. + +### 🌍 Dual-stack (IPv4 / IPv6) reachability + +Calls hostnames that publish **only** an A record and hostnames that publish **only** an AAAA +record, two independent providers per family, then asks a dual-stack host which address it saw β€” +which reveals the family the browser actually prefers. + +No IPv6 response is reported as *"no response"*, never as *"IPv6 is disabled"*: a browser cannot +distinguish an absent IPv6 path from two probe hosts being unreachable, and a family that was never +probed reads as `not checked` rather than as a failure β€” including in the CSV export. + +### πŸ›°οΈ Captive portal & DNS hijack detection + +The textbook `generate_204` redirect check needs a plaintext request, and a page served over HTTPS +may not make one. That limitation is reported (`mixed-content-blocked`) rather than worked around, +and the probe *does* run when NetReady is opened from a local `http` origin. + +Over HTTPS the signature is different, and that is the useful insight: **a captive portal cannot +rewrite an HTTPS response without breaking the certificate chain, so it blocks instead.** So this +checks endpoints whose exact response is known in advance and reports which returned their own +content, which returned something else (interception with a trusted certificate), and which said +nothing at all while the browser still claimed to be online (the portal signature). + +For DNS, it does the one test of the *system* resolver a web page can perform: reach one server two +ways β€” by name, which uses the resolver, and by literal IP, which does not. Literal answering while +the name does not is a broken or redirected resolver. Two DoH providers are also cross-checked, but +only on anycast names whose correct answer is identical worldwide; ordinary CDN hostnames disagree +by design and would manufacture findings out of geography. + +--- + ## πŸ› οΈ Tools ### 1. ⚑ Speed & Bandwidth @@ -133,8 +201,11 @@ without touching it, so the following go directly from your browser to third par | `cdn.jsdelivr.net`, `cdnjs.cloudflare.com`, `unpkg.com` | Your IP, as Edge Path Explorer probe targets (a few KB each) | | `cloudflare-dns.com`, `dns.google` | Every domain you resolve, over encrypted DoH | | `ipwho.is`, `ipapi.co`, `freeipapi.com` | Your public IP on opening the GeoIP tool, and every IP or domain you look up | -| `1.1.1.1`, `dns.quad9.net`, `doh.opendns.com`, `en.wikipedia.org` | Your IP, as latency probe targets | +| `1.1.1.1`, `one.one.one.one`, `dns.quad9.net`, `doh.opendns.com`, `en.wikipedia.org` | Your IP, as latency probe targets, and as the two halves of the resolver test | +| `ipv4.icanhazip.com`, `ipv6.icanhazip.com`, `api4.ipify.org`, `api6.ipify.org` | Your IP, during the dual-stack check β€” each answers on one address family only | +| `cp.cloudflare.com` | Your IP, during the captive-portal check, and only when NetReady is opened over plain `http` | | `stun.l.google.com` and other STUN servers | Your public IP, and potentially local addresses | +| `httpbin.org` | Your IP, only when you press "Trigger Network Spike" on the live traffic monitor | | `basemaps.cartocdn.com`, `openstreetmap.org` | Map areas you view, revealing an approximate target location | | Hosts you enter | Direct connections from your browser β€” that is what a probe *is* | @@ -150,8 +221,9 @@ devices and hosts you own or have explicit permission to test. - `npm run typecheck` β€” TypeScript in `strict` mode, zero errors. - `npm run lint` β€” ESLint with `react-hooks`, zero errors. - `npm run test` β€” Vitest. Coverage focuses on the pure logic where silent failures hide: CSV - generation, CIDR math, OUI decoding, and the rule that a failed measurement can never produce a - number. + generation, CIDR math, OUI decoding, the rules engine and bottleneck attribution, and the rule + that a failed measurement can never produce a number. The single most important assertion in the + suite is that an empty snapshot fires no rule at all. CI runs all three on every push and pull request; deployment is gated on them passing. diff --git a/src/App.tsx b/src/App.tsx index e88c8cc..ca13620 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,9 @@ import { getNetworkConnectionInfo } from './utils/network'; import { getHistory, getLocalStorageSizeBytes } from './utils/storage'; import { Navbar } from './components/Navbar'; import { Dashboard } from './components/Dashboard'; +import { TriagePanel } from './components/TriagePanel'; +import { DualStackCheck } from './components/DualStackCheck'; +import { CaptivePortalCheck } from './components/CaptivePortalCheck'; import { EdgePathExplorer } from './components/EdgePathExplorer'; import { TracertVisualizer } from './components/TracertVisualizer'; import { PortScanner } from './components/PortScanner'; @@ -80,6 +83,12 @@ export default function App() { /> )} + {activeTab === 'triage' && } + + {activeTab === 'dualstack' && } + + {activeTab === 'captive' && } + {activeTab === 'edgepath' && ( )} diff --git a/src/analysis/bottleneck.test.ts b/src/analysis/bottleneck.test.ts new file mode 100644 index 0000000..531a79c --- /dev/null +++ b/src/analysis/bottleneck.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect } from 'vitest'; +import { attributeBottleneck, constraintLabel } from './bottleneck'; +import { calculateNetReadyScore } from '../utils/network'; +import type { PingResult, SpeedTestResult } from '../types'; + +const speed = (over: Partial = {}): SpeedTestResult => ({ + id: 's', + timestamp: 0, + downloadSpeed: null, + uploadSpeed: null, + ping: null, + jitter: null, + loadedPing: null, + bufferbloatScore: null, + ...over, +}); + +const ping = (over: Partial = {}): PingResult => ({ + id: 'p', + timestamp: 0, + target: 't', + label: 'l', + packetsSent: 10, + packetsReceived: 10, + packetLoss: 0, + minPing: null, + maxPing: null, + avgPing: null, + jitter: null, + points: [], + ...over, +}); + +const attribute = (s: SpeedTestResult | null, p: PingResult | null) => + attributeBottleneck(s, p, calculateNetReadyScore(s, p)); + +describe('attributeBottleneck', () => { + it('says nothing when nothing has been measured', () => { + const r = attribute(null, null); + expect(r.constraint).toBeNull(); + expect(r.unavailableReason).toMatch(/Nothing has been measured/); + expect(r.sensitivities).toEqual([]); + }); + + it('names bandwidth when bandwidth is what is holding the score down', () => { + const r = attribute( + speed({ downloadSpeed: 3, uploadSpeed: 30, ping: 12, jitter: 1 }), + ping({ avgPing: 12, jitter: 1 }), + ); + expect(r.constraint).toBe('download'); + expect(r.headline).toContain('download bandwidth'); + expect(r.evidence[0].observation).toContain('3 Mbps'); + }); + + it('names latency when the link is fast but far', () => { + const r = attribute( + speed({ downloadSpeed: 500, uploadSpeed: 100, ping: 320, jitter: 2 }), + ping({ avgPing: 320, jitter: 2 }), + ); + expect(r.constraint).toBe('latency'); + }); + + it('names jitter when only jitter is bad', () => { + const r = attribute( + speed({ downloadSpeed: 500, uploadSpeed: 100, ping: 12, jitter: 60 }), + ping({ avgPing: 12, jitter: 60 }), + ); + expect(r.constraint).toBe('jitter'); + }); + + it('promotes bufferbloat over bandwidth, and says why', () => { + // The headline case from the brief: a decent-looking link whose real + // problem is queuing, which the numeric score does not model at all. + const r = attribute( + speed({ downloadSpeed: 45, uploadSpeed: 12, ping: 18, jitter: 3, loadedPing: 80 }), + ping({ avgPing: 18, jitter: 3 }), + ); + expect(r.constraint).toBe('bufferbloat'); + expect(r.headline).toMatch(/binding constraint is bufferbloat, not bandwidth/); + // The grade and the experience diverge here β€” the headline has to say so, + // or "Grade A+" and "something is constraining you" read as a contradiction. + expect(r.headline).toMatch(/on the numbers/); + expect(r.detail).toContain('62 ms'); + expect(r.evidence.map((e) => e.metric)).toEqual(['speed.ping', 'speed.loadedPing']); + }); + + it('leaves bufferbloat out of it when the increase is small', () => { + const r = attribute( + speed({ downloadSpeed: 4, uploadSpeed: 12, ping: 18, jitter: 3, loadedPing: 30 }), + ping({ avgPing: 18, jitter: 3 }), + ); + expect(r.constraint).toBe('download'); + }); + + it('will not invent a bufferbloat verdict from a single sample', () => { + const r = attribute( + speed({ downloadSpeed: 4, uploadSpeed: 12, ping: 18, jitter: 3, loadedPing: null }), + ping({ avgPing: 18, jitter: 3 }), + ); + expect(r.constraint).toBe('download'); + }); + + it('names no constraint when everything measured is already good', () => { + const r = attribute( + speed({ downloadSpeed: 900, uploadSpeed: 900, ping: 5, jitter: 1, loadedPing: 6 }), + ping({ avgPing: 5, jitter: 1 }), + ); + expect(r.constraint).toBeNull(); + expect(r.headline).toMatch(/nothing measured is holding this back/); + expect(r.unavailableReason).toBeTruthy(); + }); + + it('ranks only the inputs that were actually measured', () => { + // Download alone. Upload, latency and jitter must not appear as though + // they had been weighed. + const r = attribute(speed({ downloadSpeed: 5, ping: 20, jitter: 2 }), null); + const inputs = r.sensitivities.map((s) => s.input); + expect(inputs).toContain('download'); + expect(inputs).not.toContain('upload'); + }); + + it('treats a measured zero as measured', () => { + // `??` rather than `||`: a genuine 0 Mbps must be ranked, not discarded. + const r = attribute( + speed({ downloadSpeed: 0, uploadSpeed: 20, ping: 20, jitter: 2 }), + ping({ avgPing: 20, jitter: 2 }), + ); + expect(r.constraint).toBe('download'); + expect(r.evidence[0].observation).toContain('0 Mbps'); + }); + + it('produces sensitivities sorted strongest first', () => { + const r = attribute( + speed({ downloadSpeed: 2, uploadSpeed: 30, ping: 200, jitter: 40 }), + ping({ avgPing: 200, jitter: 40 }), + ); + const gains = r.sensitivities.map((s) => s.gain); + expect([...gains].sort((a, b) => b - a)).toEqual(gains); + }); + + it('is deterministic', () => { + const s = speed({ downloadSpeed: 20, uploadSpeed: 5, ping: 40, jitter: 8, loadedPing: 55 }); + const p = ping({ avgPing: 40, jitter: 8 }); + expect(JSON.stringify(attribute(s, p))).toBe(JSON.stringify(attribute(s, p))); + }); + + it('never leaks a reference value into the output', () => { + // The "what if" values used inside the sensitivity calculation must not + // escape as though they had been measured. + const r = attribute( + speed({ downloadSpeed: 7, uploadSpeed: 3, ping: 90, jitter: 12 }), + ping({ avgPing: 90, jitter: 12 }), + ); + const text = `${r.headline} ${r.detail} ${r.evidence.map((e) => e.observation).join(' ')}`; + expect(text).not.toContain('200 Mbps'); + expect(text).not.toContain('40 Mbps'); + expect(text).not.toContain('15 ms'); + expect(text).toContain('7 Mbps'); + }); +}); + +describe('constraintLabel', () => { + it('names every constraint in plain language', () => { + expect(constraintLabel('bufferbloat')).toBe('bufferbloat'); + expect(constraintLabel('download')).toBe('download bandwidth'); + expect(constraintLabel('latency')).toBe('latency'); + }); +}); diff --git a/src/analysis/bottleneck.ts b/src/analysis/bottleneck.ts new file mode 100644 index 0000000..159a022 --- /dev/null +++ b/src/analysis/bottleneck.ts @@ -0,0 +1,274 @@ +import type { NetReadyScore, PingResult, SpeedTestResult } from '../types'; +import { calculateNetReadyScore } from '../utils/network'; +import { THRESHOLDS } from './rules'; +import type { Evidence } from './types'; + +/** + * Bottleneck attribution. + * + * A grade on its own is not an answer. "B" tells a user nothing about what to + * change; "the binding constraint is bufferbloat, not bandwidth" tells them + * exactly what to change and what not to bother with. + * + * The method is sensitivity analysis over the existing scoring function. For + * each input that was actually measured, the score is recomputed with that one + * input raised to a reference level and everything else left exactly as + * measured. Whichever substitution lifts the score furthest is the input + * holding it down. + * + * The reference values below are hypotheticals used inside this calculation and + * nowhere else. They are never stored, never exported and never rendered as a + * measurement β€” the only thing that leaves this module is the *name* of the + * limiting input and the real measured value of it. That distinction is the + * whole reason this is safe: substituting a value to answer "what if" is + * analysis; substituting a value to fill a gap in a report is fabrication. + */ + +export type ConstraintInput = 'bufferbloat' | 'download' | 'upload' | 'latency' | 'jitter'; + +/** + * "Good enough that this input is no longer what limits the result." + * + * Not typical values, not targets β€” ceilings past which raising the input + * further stops changing the outcome. Internal to the sensitivity calculation. + */ +const REFERENCE = { + downloadMbps: 200, + uploadMbps: 40, + latencyMs: 15, + jitterMs: 2, +} as const; + +/** Presentation order, and the deterministic tie-break when two inputs would + * gain the score the same amount. */ +const INPUT_ORDER: ConstraintInput[] = ['bufferbloat', 'download', 'latency', 'jitter', 'upload']; + +const capitalise = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1); + +const INPUT_LABELS: Record = { + bufferbloat: 'bufferbloat', + download: 'download bandwidth', + upload: 'upload bandwidth', + latency: 'latency', + jitter: 'jitter', +}; + +export interface Sensitivity { + input: ConstraintInput; + /** Points the overall score would gain if this input alone stopped limiting + * it. Zero when the measured value is already past the reference. */ + gain: number; +} + +export interface BottleneckAttribution { + /** The input that most constrains the result, or null when it cannot be + * identified from what was measured. */ + constraint: ConstraintInput | null; + /** Short statement for the dashboard headline. Empty string is never + * returned; when there is no constraint this explains why. */ + headline: string; + /** One or two sentences of supporting detail. */ + detail: string; + evidence: Evidence[]; + /** Every input the analysis could weigh, strongest first. */ + sensitivities: Sensitivity[]; + /** Set when `constraint` is null. */ + unavailableReason: string | null; +} + +/** Rebuilds a speed/ping pair with one field replaced, for the "what if" score. + * Nothing built here is ever returned to a caller. */ +function scoreWith( + speed: SpeedTestResult | null | undefined, + ping: PingResult | null | undefined, + override: Partial>, +): NetReadyScore | null { + const nextSpeed: SpeedTestResult | null = speed + ? { + ...speed, + downloadSpeed: override.download ?? speed.downloadSpeed, + uploadSpeed: override.upload ?? speed.uploadSpeed, + ping: override.latency ?? speed.ping, + jitter: override.jitter ?? speed.jitter, + } + : null; + + const nextPing: PingResult | null = ping + ? { + ...ping, + avgPing: override.latency ?? ping.avgPing, + jitter: override.jitter ?? ping.jitter, + } + : null; + + return calculateNetReadyScore(nextSpeed, nextPing); +} + +/** + * Identifies the binding constraint on a measured result. + * + * Returns a null constraint β€” with a reason β€” whenever the measurements cannot + * support the claim. That includes the case where everything measured is + * already good: there is no bottleneck to name, and inventing one would be its + * own small lie. + */ +export function attributeBottleneck( + speed: SpeedTestResult | null | undefined, + ping: PingResult | null | undefined, + score: NetReadyScore | null, +): BottleneckAttribution { + const empty = (reason: string): BottleneckAttribution => ({ + constraint: null, + headline: 'No binding constraint identified', + detail: reason, + evidence: [], + sensitivities: [], + unavailableReason: reason, + }); + + if (score === null) { + return empty( + 'Nothing has been measured yet, so there is no result for anything to be constraining.', + ); + } + + const download = speed?.downloadSpeed ?? null; + const upload = speed?.uploadSpeed ?? null; + const latency = ping?.avgPing ?? speed?.ping ?? null; + const jitter = ping?.jitter ?? speed?.jitter ?? null; + const idlePing = speed?.ping ?? null; + const loadedPing = speed?.loadedPing ?? null; + + const evidence: Evidence[] = []; + const sensitivities: Sensitivity[] = []; + + const consider = ( + input: ConstraintInput, + measured: number | null, + reference: number, + better: 'higher' | 'lower', + override: Parameters[2], + ) => { + if (measured === null) return; + const alreadyGood = better === 'higher' ? measured >= reference : measured <= reference; + if (alreadyGood) { + sensitivities.push({ input, gain: 0 }); + return; + } + const lifted = scoreWith(speed, ping, override); + if (lifted === null) return; + sensitivities.push({ input, gain: Math.max(0, lifted.overallScore - score.overallScore) }); + }; + + consider('download', download, REFERENCE.downloadMbps, 'higher', { + download: REFERENCE.downloadMbps, + }); + consider('upload', upload, REFERENCE.uploadMbps, 'higher', { upload: REFERENCE.uploadMbps }); + consider('latency', latency, REFERENCE.latencyMs, 'lower', { latency: REFERENCE.latencyMs }); + consider('jitter', jitter, REFERENCE.jitterMs, 'lower', { jitter: REFERENCE.jitterMs }); + + if (sensitivities.length === 0) { + return empty( + 'The score exists, but none of the inputs it depends on were measured well enough to rank ' + + 'them against each other.', + ); + } + + sensitivities.sort((a, b) => { + if (b.gain !== a.gain) return b.gain - a.gain; + return INPUT_ORDER.indexOf(a.input) - INPUT_ORDER.indexOf(b.input); + }); + + /** + * Bufferbloat overrides the ranking when it is severe. + * + * It is deliberately not folded into `calculateNetReadyScore`, so sensitivity + * analysis over that function cannot see it at all. Yet a link that adds a + * tenth of a second of delay the moment it is used is limited by that, not by + * its throughput β€” the score simply does not model the thing the user + * actually experiences. This is the one place the ranking is overridden, the + * threshold is the same one the bufferbloat rule uses, and the reason is + * stated in the output rather than hidden. + */ + if (idlePing !== null && loadedPing !== null) { + const delta = Math.round(loadedPing - idlePing); + if (delta >= THRESHOLDS.bufferbloatDeltaMs) { + const runnerUp = sensitivities[0]; + return { + constraint: 'bufferbloat', + // "on the numbers" is doing real work in this headline. The score can + // read A+ while the connection falls apart the moment anything uses it, + // because `calculateNetReadyScore` has no bufferbloat term at all. + // Without the qualifier the two halves of the sentence contradict each + // other; with it, the divergence is the point. + headline: `Grade ${score.grade} on the numbers β€” the binding constraint is bufferbloat, not bandwidth`, + detail: + `Latency rose from ${idlePing} ms idle to ${loadedPing} ms under load, an increase of ` + + `${delta} ms. Everything real-time degrades at that point regardless of throughput, ` + + `so this outranks ${INPUT_LABELS[runnerUp.input]} even though the score itself does not ` + + 'model it.', + evidence: [ + { metric: 'speed.ping', observation: `${idlePing} ms idle` }, + { metric: 'speed.loadedPing', observation: `${loadedPing} ms under load` }, + ], + sensitivities: [{ input: 'bufferbloat', gain: 0 }, ...sensitivities], + unavailableReason: null, + }; + } + } + + const top = sensitivities[0]; + if (top.gain <= 0) { + return { + constraint: null, + headline: `Grade ${score.grade} β€” nothing measured is holding this back`, + detail: + 'Every input that was measured is already past the point where improving it would change ' + + 'the result. If something still feels slow, it is not in what this test covers.', + evidence: [], + sensitivities, + unavailableReason: + 'No measured input is limiting the score, so there is no bottleneck to name.', + }; + } + + const runnerUp = sensitivities.find((s) => s.input !== top.input && s.gain > 0); + const measuredValue: Record = { + download: download === null ? '' : `${download} Mbps`, + upload: upload === null ? '' : `${upload} Mbps`, + latency: latency === null ? '' : `${latency} ms`, + jitter: jitter === null ? '' : `${jitter} ms`, + bufferbloat: '', + }; + + evidence.push({ + metric: `speed.${top.input}`, + observation: `${INPUT_LABELS[top.input]} measured at ${measuredValue[top.input]}`, + }); + if (runnerUp) { + evidence.push({ + metric: `speed.${runnerUp.input}`, + observation: `${INPUT_LABELS[runnerUp.input]} measured at ${measuredValue[runnerUp.input]}`, + }); + } + + return { + constraint: top.input, + headline: `Grade ${score.grade} β€” the binding constraint is ${INPUT_LABELS[top.input]}`, + detail: + `${capitalise(INPUT_LABELS[top.input])} measured ${measuredValue[top.input]}. Fixing it ` + + `alone would move the overall score by about ${top.gain} point${top.gain === 1 ? '' : 's'}` + + (runnerUp + ? `, against ${runnerUp.gain} for ${INPUT_LABELS[runnerUp.input]} β€” so that is where the ` + + 'effort goes.' + : ', and nothing else measured is limiting it.'), + evidence, + sensitivities, + unavailableReason: null, + }; +} + +/** Label for a constraint, for use in UI copy. */ +export function constraintLabel(input: ConstraintInput): string { + return INPUT_LABELS[input]; +} diff --git a/src/analysis/engine.test.ts b/src/analysis/engine.test.ts new file mode 100644 index 0000000..1cbce75 --- /dev/null +++ b/src/analysis/engine.test.ts @@ -0,0 +1,296 @@ +import { describe, it, expect } from 'vitest'; +import { + buildVerdict, + countConclusiveChecks, + deriveSteps, + evaluateRules, + rankFindings, + MIN_CHECKS_FOR_ALL_CLEAR, +} from './engine'; +import { createSnapshot } from './snapshot'; +import type { Finding, Rule, TriageSnapshot } from './types'; +import type { DnsIntegrityResult, EdgePathResult, EdgeProbeResult, SpeedTestResult } from '../types'; + +const finding = (over: Partial): Finding => ({ + ruleId: 'x', + title: 'x', + layer: 'isp', + confidence: 'possible', + severity: 'informational', + verdict: 'v', + remediation: ['r'], + evidence: [], + ...over, +}); + +const rule = (id: string, hit: boolean): Rule => ({ + id, + title: id, + layer: 'isp', + consumes: ['browserOnline'], + evaluate: () => + hit + ? { + confidence: 'confirmed', + severity: 'degrading', + verdict: `${id} fired`, + remediation: ['do something'], + evidence: [], + } + : null, +}); + +const workingDns = (): DnsIntegrityResult => ({ + id: 'dns', + timestamp: 0, + hostnameReachable: true, + literalIpReachable: true, + hostnameProbed: 'https://one.one.one.one/cdn-cgi/trace', + literalProbed: 'https://1.1.1.1/cdn-cgi/trace', + comparisons: [], + verdict: 'resolver-working', + explanation: 'names resolve', + totalTimeMs: 10, + failures: [], +}); + +const edgeWith = (availabilities: EdgeProbeResult['availability'][]): EdgePathResult => ({ + id: 'edge', + timestamp: 0, + targetHost: null, + targetPop: null, + referencePop: null, + client: null, + probes: availabilities.map( + (availability, i) => + ({ + target: { + label: `p${i}`, + origin: `https://p${i}.test`, + probeUrl: `https://p${i}.test/x`, + expectsTao: true, + }, + availability, + phases: { + dnsMs: null, + tcpMs: null, + tlsMs: null, + ttfbMs: null, + transferMs: null, + totalMs: null, + }, + protocol: null, + roundTripMs: null, + maxDistanceKm: null, + }) as EdgeProbeResult, + ), + protocolEvidence: { + negotiated: [], + h3Count: 0, + h2Count: 0, + http1Count: 0, + verdict: null, + explanation: '', + }, + clientToPopKm: null, + totalTimeMs: 0, + failures: [], +}); + +const goodSpeed = (): SpeedTestResult => ({ + id: 'speed', + timestamp: 0, + downloadSpeed: 300, + uploadSpeed: 100, + ping: 12, + jitter: 1, + loadedPing: 15, + bufferbloatScore: 'A+', +}); + +describe('evaluateRules', () => { + it('runs every rule and keeps only the ones that fired', () => { + const findings = evaluateRules(createSnapshot(), [rule('a', true), rule('b', false), rule('c', true)]); + expect(findings.map((f) => f.ruleId)).toEqual(['a', 'c']); + }); + + it('carries the rule metadata onto the finding', () => { + const [f] = evaluateRules(createSnapshot(), [rule('a', true)]); + expect(f.title).toBe('a'); + expect(f.layer).toBe('isp'); + }); +}); + +describe('rankFindings', () => { + it('puts blocking above degrading regardless of confidence', () => { + // A user chasing a dead connection is not helped by a confirmed note about + // jitter sitting at the top of the list. + const ranked = rankFindings([ + finding({ ruleId: 'jitter', severity: 'degrading', confidence: 'confirmed' }), + finding({ ruleId: 'portal', severity: 'blocking', confidence: 'possible' }), + ]); + expect(ranked.map((f) => f.ruleId)).toEqual(['portal', 'jitter']); + }); + + it('breaks a severity tie on confidence', () => { + const ranked = rankFindings([ + finding({ ruleId: 'maybe', severity: 'blocking', confidence: 'possible' }), + finding({ ruleId: 'sure', severity: 'blocking', confidence: 'confirmed' }), + ]); + expect(ranked.map((f) => f.ruleId)).toEqual(['sure', 'maybe']); + }); + + it('is stable and does not mutate its input', () => { + const input = [ + finding({ ruleId: 'b', severity: 'degrading', confidence: 'likely' }), + finding({ ruleId: 'a', severity: 'degrading', confidence: 'likely' }), + ]; + const rules = [rule('a', false), rule('b', false)]; + expect(rankFindings(input, rules).map((f) => f.ruleId)).toEqual(['a', 'b']); + expect(input.map((f) => f.ruleId)).toEqual(['b', 'a']); + }); +}); + +describe('countConclusiveChecks', () => { + it('counts nothing for an unmeasured snapshot', () => { + expect(countConclusiveChecks(createSnapshot())).toBe(0); + }); + + it('counts a check only once it has a verdict', () => { + const s: TriageSnapshot = createSnapshot({ dns: { ...workingDns(), verdict: null } }); + expect(countConclusiveChecks(s)).toBe(0); + expect(countConclusiveChecks(createSnapshot({ dns: workingDns() }))).toBe(1); + }); + + it('counts a measured zero download as a result', () => { + // The distinction between "0 Mbps" and "no figure" is the entire point. + const s = createSnapshot({ speed: { ...goodSpeed(), downloadSpeed: 0 } }); + expect(countConclusiveChecks(s)).toBe(1); + }); +}); + +describe('buildVerdict', () => { + const opts = { id: 'triage_1', now: 1000, totalTimeMs: 42 }; + + it('refuses to declare a network healthy when almost nothing ran', () => { + // This is the fabrication trap specific to this feature: silence from a + // run that measured nothing must never read as a clean bill of health. + const v = buildVerdict(createSnapshot(), opts); + expect(v.attribution).toBe('indeterminate'); + expect(v.headline).toMatch(/Not enough was measured/); + // The summary must decline in both directions rather than lean either way. + expect(v.summary).toMatch(/Nothing here says the connection is fine/); + expect(v.summary).toMatch(/nothing says it is broken/); + }); + + it('declares no fault only once enough independent checks have passed', () => { + const s = createSnapshot({ + dns: workingDns(), + dualStack: { + id: 'ds', + timestamp: 0, + probes: [], + ipv4Reachable: true, + ipv6Reachable: true, + preferredFamily: 'ipv6', + preferredFamilySource: 'test', + verdict: 'dual-stack', + explanation: '', + totalTimeMs: 0, + failures: [], + }, + edge: edgeWith(['available', 'available']), + speed: goodSpeed(), + }); + expect(countConclusiveChecks(s)).toBeGreaterThanOrEqual(MIN_CHECKS_FOR_ALL_CLEAR); + const v = buildVerdict(s, opts); + expect(v.findings).toEqual([]); + expect(v.attribution).toBe('no-fault-found'); + // Even the clean verdict states its own limits. + expect(v.summary).toMatch(/does not cover anything that was skipped/); + }); + + it('attributes the verdict to the layer of the top-ranked finding', () => { + const v = buildVerdict(createSnapshot({ browserOnline: false }), opts); + expect(v.attribution).toBe('this-device'); + expect(v.headline).toBe('It is this device.'); + expect(v.findings[0].ruleId).toBe('browser-offline'); + }); + + it('is pure: identical input yields an identical verdict', () => { + const s = createSnapshot({ speed: { ...goodSpeed(), ping: 10, loadedPing: 200 } }); + expect(JSON.stringify(buildVerdict(s, opts))).toBe(JSON.stringify(buildVerdict(s, opts))); + }); + + it('carries the supplied failures through untouched', () => { + const failures = [ + { metric: 'bandwidth', reason: 'not-attempted' as const, detail: 'skipped deliberately' }, + ]; + expect(buildVerdict(createSnapshot(), { ...opts, failures }).failures).toEqual(failures); + }); +}); + +describe('deriveSteps', () => { + it('produces every step even when nothing ran', () => { + const steps = deriveSteps(createSnapshot()); + expect(steps).toHaveLength(8); + // Not one of them may be left blank β€” blank reads as a pass. + for (const s of steps) { + expect(s.note, `${s.id} note`).toBeTruthy(); + expect(s.question.length, `${s.id} question`).toBeGreaterThan(0); + } + }); + + it('marks unrun checks skipped rather than passed', () => { + const byId = Object.fromEntries(deriveSteps(createSnapshot()).map((s) => [s.id, s])); + expect(byId['dns'].status).toBe('skipped'); + expect(byId['captive-portal'].status).toBe('skipped'); + expect(byId['bandwidth'].status).toBe('skipped'); + expect(byId['bufferbloat'].status).toBe('skipped'); + }); + + it('always skips the gateway step and explains why a browser cannot do it', () => { + const step = deriveSteps(createSnapshot()).find((s) => s.id === 'lan-gateway')!; + expect(step.status).toBe('skipped'); + expect(step.note).toMatch(/mDNS|gateway address/); + }); + + it('reports an unmeasurable throughput as inconclusive, never as zero', () => { + const s = createSnapshot({ speed: { ...goodSpeed(), downloadSpeed: null } }); + const step = deriveSteps(s).find((st) => st.id === 'bandwidth')!; + expect(step.status).toBe('inconclusive'); + expect(step.note).toMatch(/not a figure of zero/); + }); + + it('shows a measured zero as a measurement', () => { + const s = createSnapshot({ speed: { ...goodSpeed(), downloadSpeed: 0, uploadSpeed: null } }); + const step = deriveSteps(s).find((st) => st.id === 'bandwidth')!; + expect(step.status).toBe('pass'); + expect(step.note).toBe('0 Mbps down.'); + }); + + it('grades the CDN step by how many providers answered', () => { + const at = (avail: EdgeProbeResult['availability'][]) => + deriveSteps(createSnapshot({ edge: edgeWith(avail) })).find((s) => s.id === 'cdn-reach')!; + expect(at(['available', 'available']).status).toBe('pass'); + expect(at(['available', 'request-failed']).status).toBe('inconclusive'); + expect(at(['request-failed', 'request-failed']).status).toBe('fail'); + }); + + it('fails the bufferbloat step at the threshold and passes below it', () => { + const at = (loaded: number) => + deriveSteps(createSnapshot({ speed: { ...goodSpeed(), ping: 20, loadedPing: loaded } })).find( + (s) => s.id === 'bufferbloat', + )!; + expect(at(70).status).toBe('fail'); + expect(at(69).status).toBe('pass'); + }); + + it('is inconclusive about bufferbloat when one sample is missing', () => { + const step = deriveSteps( + createSnapshot({ speed: { ...goodSpeed(), ping: 20, loadedPing: null } }), + ).find((s) => s.id === 'bufferbloat')!; + expect(step.status).toBe('inconclusive'); + expect(step.note).toMatch(/one of them is missing/); + }); +}); diff --git a/src/analysis/engine.ts b/src/analysis/engine.ts new file mode 100644 index 0000000..9fb00ad --- /dev/null +++ b/src/analysis/engine.ts @@ -0,0 +1,349 @@ +import type { MeasurementFailure } from '../types'; +import { RULES, THRESHOLDS } from './rules'; +import type { + Attribution, + Confidence, + Finding, + Layer, + Rule, + Severity, + TriageSnapshot, + TriageStep, + TriageStepStatus, + TriageVerdict, +} from './types'; + +/** + * The rules engine. + * + * Deterministic and offline by construction: same snapshot in, same verdict + * out, with no clock, no randomness and no network. That is what makes the + * answer auditable β€” a user can be shown the evidence, and a test can assert + * the whole conclusion from a literal object. + * + * The engine's one real responsibility beyond running the rules is knowing the + * difference between "checked and fine" and "did not check". Those must never + * collapse into the same answer; a diagnostic that says "nothing wrong" because + * it measured nothing is the same failure this codebase was rebuilt to remove. + */ + +const CONFIDENCE_RANK: Record = { + confirmed: 3, + likely: 2, + possible: 1, +}; + +const SEVERITY_RANK: Record = { + blocking: 3, + degrading: 2, + informational: 1, +}; + +/** Runs every rule. Order of the returned findings is the rule table's order; + * {@link rankFindings} imposes the presentation order. */ +export function evaluateRules(snapshot: TriageSnapshot, rules: Rule[] = RULES): Finding[] { + const findings: Finding[] = []; + for (const rule of rules) { + const hit = rule.evaluate(snapshot); + if (hit === null) continue; + findings.push({ ...hit, ruleId: rule.id, title: rule.title, layer: rule.layer }); + } + return findings; +} + +/** + * Ranks findings for display. + * + * Severity leads confidence deliberately. Something that stops the connection + * working is worth reading before something that merely slows it down, even + * when the engine is less certain about it β€” a user chasing a dead connection + * is not helped by a confirmed note about jitter sitting at the top. + * + * Ties break on the rule table's own order so the output is stable. + */ +export function rankFindings(findings: readonly Finding[], rules: Rule[] = RULES): Finding[] { + const order = new Map(rules.map((r, i) => [r.id, i])); + return [...findings].sort((a, b) => { + const sev = SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity]; + if (sev !== 0) return sev; + const conf = CONFIDENCE_RANK[b.confidence] - CONFIDENCE_RANK[a.confidence]; + if (conf !== 0) return conf; + return (order.get(a.ruleId) ?? 0) - (order.get(b.ruleId) ?? 0); + }); +} + +/** + * How many independent checks actually produced a usable answer. + * + * This is what separates "no fault found" from "we do not know". A run that + * reached one endpoint and gave up has not cleared the network. + */ +export function countConclusiveChecks(s: TriageSnapshot): number { + let n = 0; + if (s.dns?.verdict != null) n++; + if (s.portal?.verdict != null) n++; + if (s.dualStack?.verdict != null) n++; + if ((s.edge?.probes.length ?? 0) > 0) n++; + if (s.speed?.downloadSpeed != null) n++; + if (s.ping?.avgPing != null) n++; + return n; +} + +/** Below this, the engine says it does not know rather than saying it is fine. */ +export const MIN_CHECKS_FOR_ALL_CLEAR = 3; + +const HEADLINES: Record = { + 'this-device': 'It is this device.', + 'local-network': 'It is your local network, not the internet.', + isp: 'It is the connection between you and the internet.', + internet: 'It is out on the internet, past your provider.', + destination: 'It is the service you are trying to reach, not your connection.', + 'no-fault-found': 'Nothing here is broken.', + indeterminate: 'Not enough was measured to answer.', +}; + +const LAYER_NAMES: Record = { + 'this-device': 'this device', + 'local-network': 'your local network', + isp: 'your internet connection', + internet: 'the wider internet', + destination: 'the destination service', +}; + +/** Plain-language name for a layer, for use in prose. */ +export function describeLayer(layer: Layer): string { + return LAYER_NAMES[layer]; +} + +/** + * Derives the decision-tree display from the snapshot. + * + * Deriving rather than recording means the tree cannot drift out of step with + * the data behind it, and it can be tested from a literal snapshot. A step whose + * input is missing is `skipped` with a reason β€” never left blank, which reads as + * a pass. + */ +export function deriveSteps(s: TriageSnapshot): TriageStep[] { + const step = ( + id: TriageStep['id'], + label: string, + question: string, + status: TriageStepStatus, + note: string | null, + ): TriageStep => ({ id, label, question, status, note }); + + const steps: TriageStep[] = []; + + steps.push( + step( + 'browser-online', + 'Browser reports a link', + 'Does this machine have a network interface at all?', + s.browserOnline ? 'pass' : 'fail', + s.browserOnline + ? 'navigator.onLine is true. This means a link exists, not that it reaches anything.' + : 'navigator.onLine is false. No further check can mean anything until this changes.', + ), + ); + + // A browser cannot discover the default gateway. WebRTC used to leak local + // addresses; every current browser returns an mDNS `.local` candidate + // instead. And a page served over https: may not open a plaintext connection + // to a device with no certificate, which is every home router. Saying so is + // the honest result β€” the alternative is a probe that always fails and gets + // read as "the gateway is down". + steps.push( + step( + 'lan-gateway', + 'LAN gateway reachable', + 'Is the router answering?', + 'skipped', + s.pageProtocol === 'https:' + ? 'A browser cannot read this machine’s gateway address (modern browsers return an mDNS ' + + '.local candidate instead), and a page served over HTTPS may not open a plaintext ' + + 'connection to a device without a certificate. Use the Port Scanner with your gateway ' + + 'address to check it directly.' + : 'A browser cannot read this machine’s gateway address, so there is nothing to probe ' + + 'automatically. Enter it in the Port Scanner to check it directly.', + ), + ); + + steps.push( + step( + 'dns', + 'Names resolve', + 'Does this network turn hostnames into addresses?', + s.dns === null + ? 'skipped' + : s.dns.verdict === 'resolver-working' + ? 'pass' + : s.dns.verdict === null + ? 'inconclusive' + : 'fail', + s.dns === null ? 'The DNS check did not run.' : s.dns.explanation, + ), + ); + + steps.push( + step( + 'captive-portal', + 'No portal or interception', + 'Is anything standing between this browser and the endpoints it asked for?', + s.portal === null + ? 'skipped' + : s.portal.verdict === 'no-interception-detected' + ? 'pass' + : s.portal.verdict === null || s.portal.verdict === 'mixed' + ? 'inconclusive' + : 'fail', + s.portal === null ? 'The interception check did not run.' : s.portal.explanation, + ), + ); + + steps.push( + step( + 'dual-stack', + 'IPv4 and IPv6', + 'Which address families work from here?', + s.dualStack === null + ? 'skipped' + : s.dualStack.verdict === 'dual-stack' + ? 'pass' + : s.dualStack.verdict === 'neither-family-answered' + ? 'fail' + : 'inconclusive', + s.dualStack === null ? 'The dual-stack check did not run.' : s.dualStack.explanation, + ), + ); + + const probes = s.edge?.probes ?? []; + const failedProbes = probes.filter((p) => p.availability === 'request-failed').length; + steps.push( + step( + 'cdn-reach', + 'Independent providers answer', + 'Can several unrelated content networks be reached?', + probes.length === 0 + ? 'skipped' + : failedProbes === probes.length + ? 'fail' + : failedProbes > 0 + ? 'inconclusive' + : 'pass', + probes.length === 0 + ? 'No provider was probed.' + : `${probes.length - failedProbes} of ${probes.length} providers answered.`, + ), + ); + + const download = s.speed?.downloadSpeed ?? null; + steps.push( + step( + 'bandwidth', + 'Throughput measured', + 'How much did the link actually carry?', + s.speed === null ? 'skipped' : download === null ? 'inconclusive' : 'pass', + s.speed === null + ? 'The bandwidth test did not run.' + : download === null + ? 'No bytes could be timed, so there is no throughput figure β€” not a figure of zero.' + : `${download} Mbps down${ + s.speed.uploadSpeed === null ? '' : `, ${s.speed.uploadSpeed} Mbps up` + }.`, + ), + ); + + const idle = s.speed?.ping ?? null; + const loaded = s.speed?.loadedPing ?? null; + steps.push( + step( + 'bufferbloat', + 'Latency holds under load', + 'Does the connection stay responsive while it is busy?', + s.speed === null + ? 'skipped' + : idle === null || loaded === null + ? 'inconclusive' + : loaded - idle >= THRESHOLDS.bufferbloatDeltaMs + ? 'fail' + : 'pass', + s.speed === null + ? 'The bandwidth test did not run, so there was no load to measure under.' + : idle === null || loaded === null + ? 'Bufferbloat needs both an idle and an under-load latency sample; one of them is missing.' + : `${idle} ms idle, ${loaded} ms under load (${ + loaded - idle >= 0 ? '+' : '' + }${Math.round(loaded - idle)} ms).`, + ), + ); + + return steps; +} + +function summarise( + attribution: Attribution, + findings: readonly Finding[], + conclusive: number, +): string { + if (attribution === 'indeterminate') { + return ( + `Only ${conclusive} check${conclusive === 1 ? '' : 's'} produced a usable result, which is ` + + `fewer than the ${MIN_CHECKS_FOR_ALL_CLEAR} needed before this tool will call a network ` + + 'healthy. The steps below show what ran and what did not. Nothing here says the connection ' + + 'is fine, and nothing says it is broken.' + ); + } + + if (attribution === 'no-fault-found') { + return ( + `${conclusive} independent checks completed and none of the ${RULES.length} rules matched. ` + + 'That covers name resolution, interception, address families, several unrelated providers ' + + 'and the link itself. It does not cover anything that was skipped β€” see the steps below.' + ); + } + + const top = findings[0]; + const others = findings.length - 1; + return ( + `${top.verdict} ` + + (others > 0 + ? `${others} further finding${others === 1 ? '' : 's'} ${others === 1 ? 'is' : 'are'} listed below, ranked by how much ${others === 1 ? 'it' : 'they'} ${others === 1 ? 'matters' : 'matter'}.` + : 'No other rule matched.') + ); +} + +/** + * Builds the verdict from a snapshot. + * + * `now` and `id` are injected rather than generated so the whole function is + * pure and a test can assert on the complete object. + */ +export function buildVerdict( + snapshot: TriageSnapshot, + options: { id: string; now: number; totalTimeMs: number; failures?: MeasurementFailure[] }, +): TriageVerdict { + const findings = rankFindings(evaluateRules(snapshot)); + const conclusive = countConclusiveChecks(snapshot); + + let attribution: Attribution; + if (findings.length > 0) { + attribution = findings[0].layer; + } else if (conclusive >= MIN_CHECKS_FOR_ALL_CLEAR) { + attribution = 'no-fault-found'; + } else { + attribution = 'indeterminate'; + } + + return { + id: options.id, + timestamp: options.now, + attribution, + headline: HEADLINES[attribution], + summary: summarise(attribution, findings, conclusive), + findings, + steps: deriveSteps(snapshot), + failures: options.failures ?? [], + snapshot, + totalTimeMs: options.totalTimeMs, + }; +} diff --git a/src/analysis/rules.test.ts b/src/analysis/rules.test.ts new file mode 100644 index 0000000..7006ca4 --- /dev/null +++ b/src/analysis/rules.test.ts @@ -0,0 +1,478 @@ +import { describe, it, expect } from 'vitest'; +import { RULES, THRESHOLDS } from './rules'; +import { evaluateRules } from './engine'; +import { createSnapshot } from './snapshot'; +import type { + CaptivePortalResult, + DnsIntegrityResult, + DualStackResult, + EdgePathResult, + EdgeProbeResult, + PingResult, + SpeedTestResult, +} from '../types'; +import type { TriageSnapshot } from './types'; + +// --- builders --------------------------------------------------------------- +// Deliberately minimal: each returns a record with everything null except the +// fields a test sets. A builder that filled in plausible defaults would let a +// rule pass a test while reading an input the run never measured. + +const dns = (over: Partial = {}): DnsIntegrityResult => ({ + id: 'dns', + timestamp: 0, + hostnameReachable: null, + literalIpReachable: null, + hostnameProbed: 'https://one.one.one.one/cdn-cgi/trace', + literalProbed: 'https://1.1.1.1/cdn-cgi/trace', + comparisons: [], + verdict: null, + explanation: '', + totalTimeMs: 0, + failures: [], + ...over, +}); + +const portal = (over: Partial = {}): CaptivePortalResult => ({ + id: 'portal', + timestamp: 0, + pageProtocol: 'https:', + probes: [], + verdict: null, + explanation: '', + totalTimeMs: 0, + failures: [], + ...over, +}); + +const dualStack = (over: Partial = {}): DualStackResult => ({ + id: 'ds', + timestamp: 0, + probes: [], + ipv4Reachable: null, + ipv6Reachable: null, + preferredFamily: null, + preferredFamilySource: null, + verdict: null, + explanation: '', + totalTimeMs: 0, + failures: [], + ...over, +}); + +const edgeProbe = (label: string, availability: EdgeProbeResult['availability']): EdgeProbeResult => + ({ + target: { label, origin: `https://${label}.test`, probeUrl: `https://${label}.test/x`, expectsTao: true }, + availability, + phases: { dnsMs: null, tcpMs: null, tlsMs: null, ttfbMs: null, transferMs: null, totalMs: null }, + protocol: null, + roundTripMs: null, + maxDistanceKm: null, + }) as EdgeProbeResult; + +const edge = (over: Partial = {}): EdgePathResult => ({ + id: 'edge', + timestamp: 0, + targetHost: null, + targetPop: null, + referencePop: null, + client: null, + probes: [], + protocolEvidence: { + negotiated: [], + h3Count: 0, + h2Count: 0, + http1Count: 0, + verdict: null, + explanation: '', + }, + clientToPopKm: null, + totalTimeMs: 0, + failures: [], + ...over, +}); + +const speed = (over: Partial = {}): SpeedTestResult => ({ + id: 'speed', + timestamp: 0, + downloadSpeed: null, + uploadSpeed: null, + ping: null, + jitter: null, + loadedPing: null, + bufferbloatScore: null, + ...over, +}); + +const ping = (over: Partial = {}): PingResult => ({ + id: 'ping', + timestamp: 0, + target: 'https://1.1.1.1/cdn-cgi/trace', + label: 'Cloudflare', + packetsSent: 0, + packetsReceived: 0, + packetLoss: 0, + minPing: null, + maxPing: null, + avgPing: null, + jitter: null, + points: [], + ...over, +}); + +const fired = (snapshot: TriageSnapshot): string[] => + evaluateRules(snapshot).map((f) => f.ruleId); + +// --- the rule that matters most -------------------------------------------- + +describe('an empty snapshot', () => { + it('fires no rule at all', () => { + // This is the single most important assertion in the answer layer. A + // diagnostic that produces conclusions from nothing is exactly the failure + // this codebase was rebuilt to remove β€” a machine with no measurements must + // yield no findings, not a reassuring one and not an alarming one. + expect(fired(createSnapshot())).toEqual([]); + }); + + it('fires no rule when every probe ran and returned nothing measurable', () => { + const s = createSnapshot({ + dns: dns(), + portal: portal(), + dualStack: dualStack(), + edge: edge(), + speed: speed(), + ping: ping(), + }); + expect(fired(s)).toEqual([]); + }); +}); + +describe('browser-offline', () => { + it('fires when the browser reports no link', () => { + const findings = evaluateRules(createSnapshot({ browserOnline: false })); + expect(findings.map((f) => f.ruleId)).toEqual(['browser-offline']); + expect(findings[0].confidence).toBe('confirmed'); + expect(findings[0].layer).toBe('this-device'); + }); + + it('stays quiet when the browser has a link', () => { + expect(fired(createSnapshot({ browserOnline: true }))).not.toContain('browser-offline'); + }); +}); + +describe('dns rules', () => { + it('fires resolver-failing on the hostname/literal split', () => { + const s = createSnapshot({ + dns: dns({ verdict: 'resolver-failing', hostnameReachable: false, literalIpReachable: true }), + }); + expect(fired(s)).toContain('dns-resolver-failing'); + }); + + it('stays quiet when the resolver works', () => { + const s = createSnapshot({ dns: dns({ verdict: 'resolver-working', hostnameReachable: true }) }); + expect(fired(s)).toEqual([]); + }); + + it('fires divergence only when a comparison actually disagreed', () => { + const withDisagreement = createSnapshot({ + dns: dns({ + verdict: 'answers-diverge', + comparisons: [{ name: 'dns.google', cloudflare: ['8.8.8.8'], google: ['203.0.113.1'], agrees: false }], + }), + }); + expect(fired(withDisagreement)).toContain('dns-answers-diverge'); + + // A verdict with no disagreeing comparison behind it must not produce a + // finding; the evidence is the finding. + const withoutEvidence = createSnapshot({ + dns: dns({ verdict: 'answers-diverge', comparisons: [] }), + }); + expect(fired(withoutEvidence)).toEqual([]); + }); +}); + +describe('interception rules', () => { + it('fires content-substituted and cites the mismatching probe', () => { + const s = createSnapshot({ + portal: portal({ + verdict: 'content-substituted', + probes: [ + { + label: 'Cloudflare edge metadata', + url: 'https://speed.cloudflare.com/meta', + expectation: 'JSON', + outcome: 'content-mismatch', + roundTripMs: 20, + note: 'returned an HTML sign-in page', + }, + ], + }), + }); + const finding = evaluateRules(s).find((f) => f.ruleId === 'https-content-substituted'); + expect(finding).toBeDefined(); + expect(finding!.evidence[0].observation).toContain('sign-in page'); + }); + + it('fires https-blocked only while the browser claims to be online', () => { + const online = createSnapshot({ browserOnline: true, portal: portal({ verdict: 'https-blocked' }) }); + expect(fired(online)).toContain('https-blocked'); + + // Offline, the portal rule must yield to the offline rule rather than add + // a second, wrong explanation for the same silence. + const offline = createSnapshot({ browserOnline: false, portal: portal({ verdict: 'https-blocked' }) }); + expect(fired(offline)).toEqual(['browser-offline']); + }); +}); + +describe('cdn reachability rules', () => { + it('fires all-cdns-unreachable only when every provider failed', () => { + const all = createSnapshot({ + edge: edge({ + probes: [ + edgeProbe('a', 'request-failed'), + edgeProbe('b', 'request-failed'), + edgeProbe('c', 'request-failed'), + ], + }), + }); + expect(fired(all)).toContain('all-cdns-unreachable'); + expect(fired(all)).not.toContain('one-cdn-unreachable'); + }); + + it('fires one-cdn-unreachable for a partial failure and blames the destination', () => { + const s = createSnapshot({ + edge: edge({ probes: [edgeProbe('a', 'request-failed'), edgeProbe('b', 'available')] }), + }); + const finding = evaluateRules(s).find((f) => f.ruleId === 'one-cdn-unreachable'); + expect(finding).toBeDefined(); + expect(finding!.layer).toBe('destination'); + expect(fired(s)).not.toContain('all-cdns-unreachable'); + }); + + it('stays quiet when every provider answered', () => { + const s = createSnapshot({ + edge: edge({ probes: [edgeProbe('a', 'available'), edgeProbe('b', 'connection-reused')] }), + }); + expect(fired(s)).toEqual([]); + }); + + it('does not blame the network for unreachable providers while offline', () => { + const s = createSnapshot({ + browserOnline: false, + edge: edge({ probes: [edgeProbe('a', 'request-failed'), edgeProbe('b', 'request-failed')] }), + }); + expect(fired(s)).toEqual(['browser-offline']); + }); +}); + +describe('protocol rules', () => { + it('fires on HTTP/3 falling back to HTTP/2', () => { + const s = createSnapshot({ + edge: edge({ + protocolEvidence: { + negotiated: ['h2'], + h3Count: 0, + h2Count: 4, + http1Count: 0, + verdict: 'http3-absent-udp-possibly-blocked', + explanation: '', + }, + }), + }); + expect(fired(s)).toContain('udp-443-blocked'); + }); + + it('fires on a total fallback to HTTP/1.1', () => { + const s = createSnapshot({ + edge: edge({ + protocolEvidence: { + negotiated: ['http/1.1'], + h3Count: 0, + h2Count: 0, + http1Count: 3, + verdict: 'legacy-http1', + explanation: '', + }, + }), + }); + expect(fired(s)).toContain('legacy-http1'); + }); + + it('stays quiet when HTTP/3 works', () => { + const s = createSnapshot({ + edge: edge({ + protocolEvidence: { + negotiated: ['h3'], + h3Count: 3, + h2Count: 0, + http1Count: 0, + verdict: 'http3-working', + explanation: '', + }, + }), + }); + expect(fired(s)).toEqual([]); + }); +}); + +describe('bufferbloat', () => { + it('fires at the threshold and not below it', () => { + const at = createSnapshot({ + speed: speed({ ping: 20, loadedPing: 20 + THRESHOLDS.bufferbloatDeltaMs }), + }); + expect(fired(at)).toContain('bufferbloat'); + + const below = createSnapshot({ + speed: speed({ ping: 20, loadedPing: 20 + THRESHOLDS.bufferbloatDeltaMs - 1 }), + }); + expect(fired(below)).not.toContain('bufferbloat'); + }); + + it('needs both samples and will not derive one from the other', () => { + // The original codebase reported `loadedPing = ping + 14` when the loaded + // sample failed, and then graded bufferbloat from it. + expect(fired(createSnapshot({ speed: speed({ ping: 20, loadedPing: null }) }))).toEqual([]); + expect(fired(createSnapshot({ speed: speed({ ping: null, loadedPing: 200 }) }))).toEqual([]); + }); + + it('quotes both measured latencies as evidence', () => { + const s = createSnapshot({ speed: speed({ ping: 18, loadedPing: 80 }) }); + const finding = evaluateRules(s).find((f) => f.ruleId === 'bufferbloat')!; + expect(finding.verdict).toContain('18 ms'); + expect(finding.verdict).toContain('80 ms'); + expect(finding.evidence).toHaveLength(2); + }); +}); + +describe('packet loss', () => { + it('fires above the threshold with enough packets', () => { + const s = createSnapshot({ + ping: ping({ packetsSent: 10, packetsReceived: 8, packetLoss: 20 }), + }); + expect(fired(s)).toContain('packet-loss'); + }); + + it('refuses to report loss from too few packets', () => { + // 1 of 2 lost is 50% and means nothing. + const s = createSnapshot({ ping: ping({ packetsSent: 2, packetsReceived: 1, packetLoss: 50 }) }); + expect(fired(s)).not.toContain('packet-loss'); + }); + + it('stays quiet at zero loss', () => { + const s = createSnapshot({ + ping: ping({ packetsSent: 10, packetsReceived: 10, packetLoss: 0, avgPing: 20, jitter: 2 }), + }); + expect(fired(s)).toEqual([]); + }); +}); + +describe('latency, jitter and bandwidth rules', () => { + it('fires high-latency from either latency source', () => { + expect(fired(createSnapshot({ ping: ping({ avgPing: THRESHOLDS.highLatencyMs }) }))).toContain( + 'high-latency', + ); + expect(fired(createSnapshot({ speed: speed({ ping: 400 }) }))).toContain('high-latency'); + }); + + it('prefers the dedicated ping run over the speed test samples', () => { + const s = createSnapshot({ + ping: ping({ avgPing: 20 }), + speed: speed({ ping: 400 }), + }); + expect(fired(s)).not.toContain('high-latency'); + }); + + it('fires high-jitter above the threshold only', () => { + expect(fired(createSnapshot({ ping: ping({ jitter: THRESHOLDS.highJitterMs }) }))).toContain( + 'high-jitter', + ); + expect( + fired(createSnapshot({ ping: ping({ jitter: THRESHOLDS.highJitterMs - 1 }) })), + ).not.toContain('high-jitter'); + }); + + it('fires low-download below the threshold', () => { + expect(fired(createSnapshot({ speed: speed({ downloadSpeed: 4 }) }))).toContain('low-download'); + expect( + fired(createSnapshot({ speed: speed({ downloadSpeed: THRESHOLDS.lowDownloadMbps }) })), + ).not.toContain('low-download'); + }); + + it('treats a measured zero as a real measurement, not as absence', () => { + // `||` in place of `??` here would rewrite a genuine 0 Mbps into "not + // measured" and suppress the finding entirely. + const s = createSnapshot({ speed: speed({ downloadSpeed: 0 }) }); + expect(fired(s)).toContain('low-download'); + }); + + it('says nothing about bandwidth that was not measured', () => { + expect(fired(createSnapshot({ speed: speed({ downloadSpeed: null }) }))).toEqual([]); + }); +}); + +describe('dual-stack rule', () => { + it('fires only on an IPv4-only verdict, and only as informational', () => { + const s = createSnapshot({ dualStack: dualStack({ verdict: 'ipv4-only' }) }); + const finding = evaluateRules(s).find((f) => f.ruleId === 'no-ipv6')!; + expect(finding.severity).toBe('informational'); + expect(finding.confidence).toBe('possible'); + }); + + it('stays quiet on dual-stack and on a total failure', () => { + expect(fired(createSnapshot({ dualStack: dualStack({ verdict: 'dual-stack' }) }))).toEqual([]); + expect( + fired(createSnapshot({ dualStack: dualStack({ verdict: 'neither-family-answered' }) })), + ).toEqual([]); + }); +}); + +describe('the rule table itself', () => { + it('has unique ids', () => { + expect(new Set(RULES.map((r) => r.id)).size).toBe(RULES.length); + }); + + it('declares what every rule consumes', () => { + for (const rule of RULES) { + expect(rule.consumes.length, `${rule.id} consumes`).toBeGreaterThan(0); + expect(rule.title.length, `${rule.id} title`).toBeGreaterThan(0); + } + }); + + it('gives every rule at least one concrete remediation when it fires', () => { + // A finding with no action attached is a complaint, not a diagnosis. + const everything = createSnapshot({ + browserOnline: false, + dns: dns({ + verdict: 'resolver-failing', + hostnameReachable: false, + literalIpReachable: true, + }), + portal: portal({ verdict: 'content-substituted', probes: [] }), + dualStack: dualStack({ verdict: 'ipv4-only' }), + speed: speed({ ping: 10, loadedPing: 300, downloadSpeed: 1 }), + ping: ping({ packetsSent: 10, packetsReceived: 5, packetLoss: 50, avgPing: 400, jitter: 90 }), + }); + const findings = evaluateRules(everything); + expect(findings.length).toBeGreaterThan(3); + for (const f of findings) { + expect(f.remediation.length, `${f.ruleId} remediation`).toBeGreaterThan(0); + expect(f.verdict.length, `${f.ruleId} verdict`).toBeGreaterThan(0); + } + }); + + it('cites evidence whose metric matches something the rule declared', () => { + const s = createSnapshot({ + speed: speed({ ping: 10, loadedPing: 300, downloadSpeed: 1 }), + ping: ping({ packetsSent: 10, packetsReceived: 5, packetLoss: 50, avgPing: 400, jitter: 90 }), + }); + for (const finding of evaluateRules(s)) { + const rule = RULES.find((r) => r.id === finding.ruleId)!; + for (const e of finding.evidence) { + expect( + rule.consumes.some((c) => c === e.metric || c.startsWith(e.metric) || e.metric.startsWith(c)), + `${finding.ruleId} cites ${e.metric}, which it does not declare`, + ).toBe(true); + } + } + }); +}); diff --git a/src/analysis/rules.ts b/src/analysis/rules.ts new file mode 100644 index 0000000..96017ed --- /dev/null +++ b/src/analysis/rules.ts @@ -0,0 +1,467 @@ +import type { Evidence, Rule, TriageSnapshot } from './types'; + +/** + * The rule table. + * + * Each rule is a small, total function from the snapshot to either a finding or + * null. There is no ordering dependency between rules and no shared state, so a + * rule can be read, reasoned about and tested entirely on its own. + * + * Two conventions hold throughout and are enforced by tests: + * + * - **A rule reads only what it declares in `consumes`.** + * - **A rule returns null when its inputs are absent.** Never a default, never + * a "typical" value, never a partial conclusion dressed up as a full one. + * An unmeasured input means the rule has nothing to say, and the missing + * measurement is surfaced separately as a `MeasurementFailure`. + */ + +/** + * Thresholds. + * + * Every number here is a judgement about human experience, not a measurement, + * so each one is named and justified. They are the only constants in the answer + * layer, and changing one changes what the tool tells people β€” which is why + * they live together rather than inline. + */ +export const THRESHOLDS = { + /** Latency added under load, in ms, at which queuing is doing real damage. + * Matches the boundary between bufferbloat grades B and C in `runSpeedTest`, + * so the two never disagree. Above this, calls break up while the raw + * bandwidth figure still looks fine. */ + bufferbloatDeltaMs: 50, + /** Round-trip latency, in ms, past which interactive use is noticeably + * degraded regardless of bandwidth. */ + highLatencyMs: 150, + /** Mean consecutive delta, in ms, at which voice and video start to stutter. + * Jitter this high on an otherwise quick link usually means Wi-Fi. */ + highJitterMs: 20, + /** Packet loss percentage that is no longer attributable to sampling. */ + packetLossPercent: 5, + /** Minimum packets before a loss percentage means anything at all. */ + minPacketsForLoss: 4, + /** Download Mbps below which modern everyday use is constrained. */ + lowDownloadMbps: 10, +} as const; + +const ev = (metric: string, observation: string): Evidence => ({ metric, observation }); + +/** Measured latency, preferring the dedicated ping run over the speed test's + * own idle samples. Null when neither was measured. */ +function measuredLatency(s: TriageSnapshot): number | null { + return s.ping?.avgPing ?? s.speed?.ping ?? null; +} + +function measuredJitter(s: TriageSnapshot): number | null { + return s.ping?.jitter ?? s.speed?.jitter ?? null; +} + +export const RULES: Rule[] = [ + { + id: 'browser-offline', + title: 'This machine has no network connection', + layer: 'this-device', + consumes: ['browserOnline'], + evaluate: (s) => { + if (s.browserOnline) return null; + return { + confidence: 'confirmed', + severity: 'blocking', + verdict: + 'The browser reports no network interface at all. Nothing beyond this machine was ' + + 'reached, so nothing beyond this machine can be blamed yet.', + remediation: [ + 'Check Wi-Fi is on and connected, or that the ethernet cable is seated.', + 'Turn off airplane mode, and disconnect any VPN that may have failed closed.', + 'Reconnect, then run the triage again β€” every other check needs a link to exist.', + ], + evidence: [ev('browserOnline', 'navigator.onLine reports false')], + }; + }, + }, + + { + id: 'dns-resolver-failing', + title: 'Name resolution is broken on this network', + layer: 'local-network', + consumes: ['dns.verdict', 'dns.hostnameReachable', 'dns.literalIpReachable'], + evaluate: (s) => { + if (s.dns?.verdict !== 'resolver-failing') return null; + return { + confidence: 'confirmed', + severity: 'blocking', + verdict: + 'One server was reached by its literal IP address but not by name. The path out works; ' + + 'the thing that turns names into addresses does not.', + remediation: [ + 'Set your DNS servers manually to 1.1.1.1 and 1.0.0.1, or 8.8.8.8 and 8.8.4.4.', + 'Restart the router β€” a resolver that has stopped answering is the most common cause.', + 'If you are on a guest or hotel network, look for a sign-in page; some hold DNS hostage until you accept the terms.', + 'Enable DNS-over-HTTPS in your browser settings to bypass the local resolver entirely.', + ], + evidence: [ + ev('dns.literalIpReachable', `${s.dns.literalProbed} answered`), + ev('dns.hostnameReachable', `${s.dns.hostnameProbed} did not answer`), + ], + }; + }, + }, + + { + id: 'dns-answers-diverge', + title: 'Two DNS providers disagree about a fixed address', + layer: 'internet', + consumes: ['dns.verdict', 'dns.comparisons'], + evaluate: (s) => { + if (s.dns?.verdict !== 'answers-diverge') return null; + const disagreeing = s.dns.comparisons.filter((c) => c.agrees === false); + if (disagreeing.length === 0) return null; + return { + confidence: 'possible', + severity: 'informational', + verdict: + 'Two independent DNS-over-HTTPS providers returned different addresses for a name that ' + + 'answers identically everywhere. That is worth a look, though a provider changing its ' + + 'own infrastructure explains it just as well as tampering does.', + remediation: [ + 'Re-run the check; a record changing mid-query produces exactly this.', + 'If it persists, compare the addresses below against the provider’s published ones.', + ], + evidence: disagreeing.map((c) => + ev( + 'dns.comparisons', + `${c.name}: Cloudflare returned ${c.cloudflare?.join(', ') ?? 'nothing'}; ` + + `Google returned ${c.google?.join(', ') ?? 'nothing'}`, + ), + ), + }; + }, + }, + + { + id: 'https-content-substituted', + title: 'Something is answering in place of known endpoints', + layer: 'local-network', + consumes: ['portal.verdict', 'portal.probes'], + evaluate: (s) => { + if (s.portal?.verdict !== 'content-substituted') return null; + const wrong = s.portal.probes.filter((p) => p.outcome === 'content-mismatch'); + return { + confidence: 'likely', + severity: 'blocking', + verdict: + 'Endpoints whose exact response is known returned something else. Over HTTPS that ' + + 'requires a certificate this machine trusts, which means traffic is being intercepted ' + + 'and read β€” by a corporate inspection proxy, a filtering appliance, or a captive portal.', + remediation: [ + 'If this is a managed device on a corporate network, this is expected and intentional β€” check with whoever manages it.', + 'If it is not, inspect the certificate on any HTTPS site: an unexpected issuer names the interceptor.', + 'Review any recently installed root certificate, browser extension, or "security" product.', + ], + evidence: wrong.map((p) => ev('portal.probes', `${p.label}: ${p.note ?? 'unexpected content'}`)), + }; + }, + }, + + { + id: 'https-blocked', + title: 'Online, but no secure connection completes', + layer: 'local-network', + consumes: ['portal.verdict', 'browserOnline'], + evaluate: (s) => { + if (s.portal?.verdict !== 'https-blocked') return null; + if (!s.browserOnline) return null; + return { + confidence: 'likely', + severity: 'blocking', + verdict: + 'The browser has a link and believes it is online, yet not one HTTPS endpoint answered. ' + + 'A captive portal cannot forge an HTTPS response, so it blocks instead β€” this is what ' + + 'that looks like from inside the browser.', + remediation: [ + 'Open any plain http:// address; a portal will redirect it to its sign-in page.', + 'On a phone or laptop, disconnecting and reconnecting to the Wi-Fi usually re-triggers the sign-in prompt.', + 'If there is no portal, a firewall is blocking outbound 443 β€” check for a proxy requirement on this network.', + ], + evidence: [ + ev('browserOnline', 'navigator.onLine reports true'), + ev('portal.probes', 'no HTTPS endpoint answered'), + ], + }; + }, + }, + + { + id: 'all-cdns-unreachable', + title: 'No content network could be reached', + layer: 'isp', + consumes: ['edge.probes', 'browserOnline'], + evaluate: (s) => { + const probes = s.edge?.probes ?? []; + if (probes.length === 0 || !s.browserOnline) return null; + const failed = probes.filter((p) => p.availability === 'request-failed'); + if (failed.length !== probes.length) return null; + return { + confidence: 'confirmed', + severity: 'blocking', + verdict: + `All ${probes.length} independent content networks failed to answer. When four ` + + 'unrelated providers go dark at once, the fault is on the path out, not with any of them.', + remediation: [ + 'Power-cycle the router and modem, then re-run this check.', + 'If another device on the same network also fails, the problem is the connection itself β€” contact the ISP.', + 'If only this device fails, suspect a VPN, proxy setting, or firewall on this machine.', + ], + evidence: probes.map((p) => + ev('edge.probes', `${p.target.label} (${p.target.origin}) did not answer`), + ), + }; + }, + }, + + { + id: 'one-cdn-unreachable', + title: 'One provider is unreachable while the rest are fine', + layer: 'destination', + consumes: ['edge.probes'], + evaluate: (s) => { + const probes = s.edge?.probes ?? []; + if (probes.length < 2) return null; + const failed = probes.filter((p) => p.availability === 'request-failed'); + if (failed.length === 0 || failed.length === probes.length) return null; + return { + confidence: 'likely', + severity: 'informational', + verdict: + `${failed.length} of ${probes.length} content networks did not answer while the others ` + + 'did. Your connection is carrying traffic; something specific to those providers is not.', + remediation: [ + 'Nothing to fix locally β€” a working connection that cannot reach one provider is that provider’s problem, or a block aimed at it.', + 'If a site you need is on that provider, check its status page before changing anything here.', + ], + evidence: [ + ...failed.map((p) => ev('edge.probes', `${p.target.label} did not answer`)), + ...probes + .filter((p) => p.availability !== 'request-failed') + .map((p) => ev('edge.probes', `${p.target.label} answered`)), + ], + }; + }, + }, + + { + id: 'udp-443-blocked', + title: 'HTTP/3 is being forced back to HTTP/2', + layer: 'local-network', + consumes: ['edge.protocolEvidence'], + evaluate: (s) => { + if (s.edge?.protocolEvidence.verdict !== 'http3-absent-udp-possibly-blocked') return null; + const e = s.edge.protocolEvidence; + return { + confidence: 'likely', + severity: 'degrading', + verdict: + 'Every origin that advertises HTTP/3 was reached over HTTP/2 instead. The browser tried ' + + 'QUIC and fell back, which points at UDP port 443 being blocked upstream. Things still ' + + 'work; they just lose the faster, loss-resilient path.', + remediation: [ + 'Check the router or firewall for a rule blocking outbound UDP 443 β€” some "QUIC blocking" toggles are on by default.', + 'On a corporate network this is often deliberate, to keep traffic inspectable.', + 'The cost is mainly on lossy links such as mobile and congested Wi-Fi.', + ], + evidence: [ + ev('edge.protocolEvidence', `${e.h2Count} origin(s) negotiated h2, none negotiated h3`), + ], + }; + }, + }, + + { + id: 'legacy-http1', + title: 'Every connection fell back to HTTP/1.1', + layer: 'local-network', + consumes: ['edge.protocolEvidence'], + evaluate: (s) => { + if (s.edge?.protocolEvidence.verdict !== 'legacy-http1') return null; + return { + confidence: 'likely', + severity: 'degrading', + verdict: + 'Nothing negotiated better than HTTP/1.1, though all of these origins support HTTP/2. ' + + 'A proxy or TLS-inspecting middlebox in the path is the usual reason, and it costs both ' + + 'throughput and latency.', + remediation: [ + 'Look for an explicit proxy configured in the OS or browser network settings.', + 'On a managed device, an inspection appliance is likely doing this deliberately.', + ], + evidence: [ + ev( + 'edge.protocolEvidence', + `negotiated: ${s.edge.protocolEvidence.negotiated.join(', ')}`, + ), + ], + }; + }, + }, + + { + id: 'bufferbloat', + title: 'Latency collapses under load', + layer: 'local-network', + consumes: ['speed.ping', 'speed.loadedPing'], + evaluate: (s) => { + const idle = s.speed?.ping ?? null; + const loaded = s.speed?.loadedPing ?? null; + if (idle === null || loaded === null) return null; + const delta = loaded - idle; + if (delta < THRESHOLDS.bufferbloatDeltaMs) return null; + return { + confidence: 'confirmed', + severity: 'degrading', + verdict: + `Round-trip time rose from ${idle} ms idle to ${loaded} ms while the link was busy, an ` + + `increase of ${Math.round(delta)} ms. That is bufferbloat: oversized queues holding ` + + 'packets rather than dropping them. It is why a call falls apart the moment something ' + + 'starts downloading, and no amount of extra bandwidth fixes it.', + remediation: [ + 'Enable Smart Queue Management (SQM / fq_codel / cake) on the router β€” this is the actual fix.', + 'If the router has no SQM, setting its bandwidth limit to about 90% of the measured rate keeps the queue in the router where it can be managed.', + 'Replace an ISP-supplied router that offers no queue management; it is the single most common cause.', + ], + evidence: [ + ev('speed.ping', `${idle} ms idle`), + ev('speed.loadedPing', `${loaded} ms under load`), + ], + }; + }, + }, + + { + id: 'packet-loss', + title: 'Packets are being dropped', + layer: 'isp', + consumes: ['ping.packetLoss', 'ping.packetsSent'], + evaluate: (s) => { + const sent = s.ping?.packetsSent ?? null; + const loss = s.ping?.packetLoss ?? null; + if (sent === null || loss === null) return null; + // A loss percentage over three packets is arithmetic, not evidence. + if (sent < THRESHOLDS.minPacketsForLoss) return null; + if (loss < THRESHOLDS.packetLossPercent) return null; + return { + confidence: 'confirmed', + severity: 'degrading', + verdict: + `${loss}% of probes went unanswered (${s.ping?.packetsReceived} of ${sent} returned). ` + + 'Loss at this level hurts calls and video far more than it hurts a download, because ' + + 'every drop costs a retransmission round trip.', + remediation: [ + 'Test again on a wired connection β€” if the loss disappears, it is Wi-Fi, not the line.', + 'If it persists while wired, record several runs and take them to the ISP; loss is the one symptom they cannot attribute to your equipment.', + 'Check for a failing cable or connector before anything else; intermittent loss is very often physical.', + ], + evidence: [ + ev('ping.packetLoss', `${loss}% loss`), + ev('ping.packetsSent', `${sent} probes sent`), + ], + }; + }, + }, + + { + id: 'high-latency', + title: 'Baseline latency is high', + layer: 'isp', + consumes: ['ping.avgPing', 'speed.ping'], + evaluate: (s) => { + const latency = measuredLatency(s); + if (latency === null || latency < THRESHOLDS.highLatencyMs) return null; + return { + confidence: 'confirmed', + severity: 'degrading', + verdict: + `Round-trip time to a nearby edge is ${latency} ms even when the link is idle. This is ` + + 'a property of the path, not of its capacity, so more bandwidth will not change it.', + remediation: [ + 'Satellite and some mobile links are inherently this slow; if that is the connection, this is the ceiling.', + 'Otherwise check for a VPN or proxy routing traffic somewhere distant before it reaches the internet.', + 'Compare with the Edge Path Explorer: if the serving edge is far away, the distance is the explanation.', + ], + evidence: [ev('ping.avgPing', `${latency} ms mean round trip while idle`)], + }; + }, + }, + + { + id: 'high-jitter', + title: 'Latency is unstable', + layer: 'local-network', + consumes: ['ping.jitter', 'speed.jitter'], + evaluate: (s) => { + const jitter = measuredJitter(s); + if (jitter === null || jitter < THRESHOLDS.highJitterMs) return null; + return { + confidence: 'confirmed', + severity: 'degrading', + verdict: + `Consecutive probes differed by ${jitter} ms on average. Variable latency is what makes ` + + 'voices break up and video freeze, even when the average looks perfectly acceptable.', + remediation: [ + 'Move closer to the access point, or switch to 5 GHz β€” congested 2.4 GHz is the usual source.', + 'Test wired. Jitter that vanishes on a cable was never a line problem.', + 'Check whether something else on the network is saturating the link while you test.', + ], + evidence: [ev('ping.jitter', `${jitter} ms mean consecutive difference`)], + }; + }, + }, + + { + id: 'low-download', + title: 'Download bandwidth is low', + layer: 'isp', + consumes: ['speed.downloadSpeed'], + evaluate: (s) => { + const dl = s.speed?.downloadSpeed ?? null; + if (dl === null || dl >= THRESHOLDS.lowDownloadMbps) return null; + return { + confidence: 'confirmed', + severity: 'degrading', + verdict: + `Measured download throughput was ${dl} Mbps. That is enough for one video stream and ` + + 'not much else at the same time.', + remediation: [ + 'Compare against the rate the plan is sold at; a large gap is a support case with evidence attached.', + 'Test wired before calling β€” Wi-Fi is the bottleneck more often than the line is.', + 'Make sure nothing else was transferring during the test; the measurement includes whatever else the link was carrying.', + ], + evidence: [ev('speed.downloadSpeed', `${dl} Mbps measured`)], + }; + }, + }, + + { + id: 'no-ipv6', + title: 'No IPv6 path', + layer: 'isp', + consumes: ['dualStack.verdict'], + evaluate: (s) => { + if (s.dualStack?.verdict !== 'ipv4-only') return null; + return { + confidence: 'possible', + severity: 'informational', + verdict: + 'IPv4-only hosts answered and IPv6-only hosts did not. Either this network has no IPv6 ' + + 'or something is dropping it. This is common and rarely a problem β€” almost everything ' + + 'still publishes an IPv4 address.', + remediation: [ + 'Nothing needs fixing unless a service you use is IPv6-only.', + 'If IPv6 is expected here, check whether the router has it enabled and whether the ISP has provisioned a prefix.', + ], + evidence: [ + ev('dualStack.verdict', 'IPv4-only hosts answered; IPv6-only hosts did not'), + ], + }; + }, + }, +]; diff --git a/src/analysis/snapshot.ts b/src/analysis/snapshot.ts new file mode 100644 index 0000000..711d267 --- /dev/null +++ b/src/analysis/snapshot.ts @@ -0,0 +1,24 @@ +import type { TriageSnapshot } from './types'; + +/** + * Builds a snapshot in which nothing has been measured. + * + * Every evidence field starts as null, and null means "not measured" all the + * way through the engine. Constructing snapshots through this function rather + * than by hand means a field added to {@link TriageSnapshot} later cannot + * silently arrive as `undefined` at a rule that expects a tri-state. + */ +export function createSnapshot(overrides: Partial = {}): TriageSnapshot { + return { + startedAt: 0, + browserOnline: true, + pageProtocol: 'https:', + dns: null, + portal: null, + dualStack: null, + edge: null, + speed: null, + ping: null, + ...overrides, + }; +} diff --git a/src/analysis/triage.ts b/src/analysis/triage.ts new file mode 100644 index 0000000..3b1bc42 --- /dev/null +++ b/src/analysis/triage.ts @@ -0,0 +1,168 @@ +import type { MeasurementFailure, PingResult, SpeedTestResult } from '../types'; +import { createId, executePingBatch, runSpeedTest } from '../utils/network'; +import { exploreEdgePath } from '../utils/edgePath'; +import { checkCaptivePortal, checkDnsIntegrity } from '../utils/captivePortal'; +import { checkDualStack } from '../utils/dualStack'; +import { buildVerdict } from './engine'; +import { createSnapshot } from './snapshot'; +import type { TriageSnapshot, TriageStepId, TriageVerdict } from './types'; + +/** + * The one-button triage run. + * + * This file gathers evidence and nothing else. It runs the decision tree in + * order, stops early when a step makes everything after it meaningless, and + * hands the result to {@link buildVerdict}, which does all the reasoning. The + * split is deliberate: measurement touches the network and cannot be unit + * tested; reasoning is pure and is tested exhaustively. + * + * Ordering matters. Cheap and decisive checks come first, so a user with no + * link at all gets an answer in milliseconds rather than after a 25 MB + * download. And when an early step shows the path out is broken, the later + * steps are *skipped and reported as skipped* rather than run to produce + * failures that would look like independent findings. + */ + +export interface TriageProgress { + stepId: TriageStepId; + label: string; + /** Live sub-step text from the underlying probe, when it has any. */ + detail: string | null; +} + +export interface TriageOptions { + /** Latency and loss target. Cloudflare's anycast trace endpoint, the same one + * the dashboard audit uses. */ + pingUrl?: string; + pingLabel?: string; + packetCount?: number; + onProgress?: (progress: TriageProgress) => void; + signal?: AbortSignal; +} + +const DEFAULT_PING_URL = 'https://1.1.1.1/cdn-cgi/trace'; +const DEFAULT_PING_LABEL = 'Cloudflare (1.1.1.1)'; +const DEFAULT_PACKET_COUNT = 8; + +/** + * Runs the tree and returns a verdict. + * + * Never throws for a network reason: a failed probe is evidence, and the + * verdict is built from whatever was gathered. It can still reject if the + * caller aborts. + */ +export async function runTriage(options: TriageOptions = {}): Promise { + const { + pingUrl = DEFAULT_PING_URL, + pingLabel = DEFAULT_PING_LABEL, + packetCount = DEFAULT_PACKET_COUNT, + onProgress, + signal, + } = options; + + const started = performance.now(); + const failures: MeasurementFailure[] = []; + const report = (stepId: TriageStepId, label: string, detail: string | null = null) => + onProgress?.({ stepId, label, detail }); + + const snapshot: TriageSnapshot = createSnapshot({ + startedAt: Date.now(), + browserOnline: navigator.onLine, + pageProtocol: typeof location === 'undefined' ? 'https:' : location.protocol, + }); + + const finish = (): TriageVerdict => + buildVerdict(snapshot, { + id: createId('triage'), + now: Date.now(), + totalTimeMs: Math.round(performance.now() - started), + failures, + }); + + report('browser-online', 'Checking for a network link'); + if (!snapshot.browserOnline) { + // Everything downstream would fail for the same single reason. Running it + // anyway would produce five failures that read like five problems. + failures.push({ + metric: 'triage', + reason: 'network-offline', + detail: + 'The browser reports no network connection, so the remaining checks were not run. They ' + + 'would all have failed for the same reason, which is not five findings β€” it is one.', + }); + return finish(); + } + + report('lan-gateway', 'Local gateway'); + + report('dns', 'Testing name resolution'); + snapshot.dns = await checkDnsIntegrity( + (detail) => report('dns', 'Testing name resolution', detail), + signal, + ); + failures.push(...snapshot.dns.failures); + + report('captive-portal', 'Looking for a captive portal or interception'); + snapshot.portal = await checkCaptivePortal( + (detail) => report('captive-portal', 'Looking for a captive portal or interception', detail), + signal, + ); + failures.push(...snapshot.portal.failures); + + report('dual-stack', 'Testing IPv4 and IPv6'); + snapshot.dualStack = await checkDualStack( + (detail) => report('dual-stack', 'Testing IPv4 and IPv6', detail), + signal, + ); + failures.push(...snapshot.dualStack.failures); + + report('cdn-reach', 'Reaching four independent content networks'); + snapshot.edge = await exploreEdgePath( + undefined, + (detail) => report('cdn-reach', 'Reaching four independent content networks', detail), + signal, + ); + failures.push(...snapshot.edge.failures); + + // Bandwidth is the expensive step β€” tens of megabytes and about ten seconds. + // It is worth that only if something out there is answering at all. When no + // provider responded, the answer is already known and running it would move + // a lot of data to re-learn it. + const anyProviderAnswered = snapshot.edge.probes.some((p) => p.availability !== 'request-failed'); + if (!anyProviderAnswered) { + failures.push({ + metric: 'bandwidth', + reason: 'not-attempted', + detail: + 'No content network answered, so the bandwidth test was skipped rather than run to ' + + 'produce a guaranteed failure. There is no throughput figure β€” not a figure of zero.', + }); + return finish(); + } + + report('bandwidth', 'Measuring latency and loss'); + const pingResult: PingResult = await executePingBatch(pingUrl, pingLabel, packetCount); + snapshot.ping = pingResult; + + report('bandwidth', 'Measuring throughput'); + const speedResult: SpeedTestResult = await runSpeedTest( + (progress) => report('bandwidth', 'Measuring throughput', progress.stage), + signal, + ); + snapshot.speed = speedResult; + failures.push(...(speedResult.failures ?? [])); + + report('bufferbloat', 'Comparing idle and loaded latency'); + + return finish(); +} + +/** One-line summary for the history log. Uses only measured values. */ +export function summariseVerdict(verdict: TriageVerdict): string { + const top = verdict.findings[0]; + const passed = verdict.steps.filter((s) => s.status === 'pass').length; + const total = verdict.steps.length; + return top + ? `${top.title} (${top.confidence}) | ${passed}/${total} checks passed` + : `${verdict.headline} | ${passed}/${total} checks passed`; +} diff --git a/src/analysis/types.ts b/src/analysis/types.ts new file mode 100644 index 0000000..99e7b44 --- /dev/null +++ b/src/analysis/types.ts @@ -0,0 +1,174 @@ +import type { + CaptivePortalResult, + DnsIntegrityResult, + DualStackResult, + EdgePathResult, + MeasurementFailure, + PingResult, + SpeedTestResult, +} from '../types'; + +/** + * The answer layer's vocabulary. + * + * Everything here is deterministic and offline. There is no model, no API call + * and no scoring heuristic hidden behind a friendly sentence: a finding exists + * because a named predicate over named measurements returned true, and it + * carries the measurements that made it true. If the inputs a rule needs were + * not measured, the rule does not fire β€” it is skipped, and the gap is reported. + */ + +/** + * Where in the path a finding places the fault. + * + * Ordered from the user outwards, which is also the order in which a person can + * actually do something about it. + */ +export type Layer = + /** The browser or this machine. */ + | 'this-device' + /** Wi-Fi, cabling, the router, anything up to the demarcation point. */ + | 'local-network' + /** The access network: the ISP link and whatever it is subscribed to. */ + | 'isp' + /** The public internet between the ISP and the destination. */ + | 'internet' + /** The specific service being reached. */ + | 'destination'; + +/** + * How firmly the evidence supports the finding. + * + * Deliberately ordinal rather than a percentage. A number like "83% confident" + * would be exactly the kind of invented figure this project exists to keep out: + * there is no calculation behind it. These three levels each have a stated + * meaning, and every rule declares which one it is claiming. + * + * - `confirmed` β€” the measurement *is* the finding. Nothing is inferred. + * - `likely` β€” the observed pattern has one dominant cause, but a browser + * cannot see the cause directly. + * - `possible` β€” consistent with the finding, and with other explanations too. + */ +export type Confidence = 'confirmed' | 'likely' | 'possible'; + +/** How much the finding matters, independent of how sure we are of it. */ +export type Severity = 'blocking' | 'degrading' | 'informational'; + +/** A measurement that supports a finding, quoted rather than summarised. */ +export interface Evidence { + /** Dotted path into the snapshot, e.g. `speed.loadedPing`. Matches the rule's + * declared `consumes`, so a claim can always be traced to its inputs. */ + metric: string; + /** The observed value in words. Only ever describes something measured. */ + observation: string; +} + +/** What a rule returns when it fires. */ +export interface RuleHit { + confidence: Confidence; + severity: Severity; + /** One sentence stating what is wrong, in plain language. */ + verdict: string; + /** Concrete actions, most useful first. */ + remediation: string[]; + evidence: Evidence[]; +} + +export interface Finding extends RuleHit { + ruleId: string; + title: string; + layer: Layer; +} + +/** + * A rule. + * + * `consumes` is not decoration. It is the declared contract of what the rule + * reads, it is rendered in the UI so a user can see why a rule did or did not + * apply, and it is checked by a test against the evidence each rule actually + * cites. + */ +export interface Rule { + id: string; + title: string; + layer: Layer; + consumes: string[]; + /** Null when the rule does not apply, or when its inputs were not measured. + * A rule must never substitute a value for an absent input. */ + evaluate: (snapshot: TriageSnapshot) => RuleHit | null; +} + +export type TriageStepId = + | 'browser-online' + | 'lan-gateway' + | 'dns' + | 'captive-portal' + | 'dual-stack' + | 'cdn-reach' + | 'bandwidth' + | 'bufferbloat'; + +export type TriageStepStatus = + | 'pending' + | 'running' + | 'pass' + | 'fail' + /** Ran, but the result does not support a conclusion either way. */ + | 'inconclusive' + /** Did not run. `note` says why β€” never left to look like a pass. */ + | 'skipped'; + +export interface TriageStep { + id: TriageStepId; + label: string; + /** What this step can actually establish, shown next to the result. */ + question: string; + status: TriageStepStatus; + note: string | null; +} + +/** + * Everything one triage run observed. + * + * Every field is nullable and null means "not measured". Rules read this + * structure and nothing else, which is what makes them testable without a + * network and auditable after the fact. + */ +export interface TriageSnapshot { + startedAt: number; + browserOnline: boolean; + /** `location.protocol`, which decides whether plaintext probes are possible. */ + pageProtocol: string; + dns: DnsIntegrityResult | null; + portal: CaptivePortalResult | null; + dualStack: DualStackResult | null; + /** Reachability and phase timings across four independent CDNs, plus the + * HTTP/3 evidence. Produced by the Edge Path Explorer, reused whole. */ + edge: EdgePathResult | null; + speed: SpeedTestResult | null; + ping: PingResult | null; +} + +export type Attribution = + | Layer + /** Checks ran and none of them found a fault. */ + | 'no-fault-found' + /** Too little was measured to attribute anything. Distinct from "fine". */ + | 'indeterminate'; + +export interface TriageVerdict { + id: string; + timestamp: number; + attribution: Attribution; + /** The one-line answer to "is it me or the internet?". */ + headline: string; + /** A short paragraph explaining the headline. */ + summary: string; + /** Ranked, most actionable first. */ + findings: Finding[]; + steps: TriageStep[]; + /** Everything the run could not determine, and why. */ + failures: MeasurementFailure[]; + snapshot: TriageSnapshot; + totalTimeMs: number; +} diff --git a/src/components/BottleneckSummary.tsx b/src/components/BottleneckSummary.tsx new file mode 100644 index 0000000..379b7cd --- /dev/null +++ b/src/components/BottleneckSummary.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import { Target, HelpCircle } from 'lucide-react'; +import type { NetReadyScore, PingResult, SpeedTestResult } from '../types'; +import { attributeBottleneck, constraintLabel } from '../analysis/bottleneck'; +import type { ConstraintInput } from '../analysis/bottleneck'; + +/** + * The dashboard headline. + * + * A grade answers "how good is this?". The question people actually have is + * "what do I change?", and four progress bars never answered it β€” they showed + * which categories scored low without saying which measured input was dragging + * them down. This names the binding constraint and, just as usefully, names + * what is *not* the problem so effort does not go there. + * + * When the constraint cannot be identified the component says so plainly. It + * has no fallback state that guesses. + */ + +interface BottleneckSummaryProps { + score: NetReadyScore | null; + speed: SpeedTestResult | null; + ping: PingResult | null; +} + +const CONSTRAINT_TONE: Record = { + bufferbloat: 'text-amber-300 border-amber-500/40 bg-amber-500/10', + download: 'text-cyan-300 border-cyan-500/40 bg-cyan-500/10', + upload: 'text-cyan-300 border-cyan-500/40 bg-cyan-500/10', + latency: 'text-indigo-300 border-indigo-500/40 bg-indigo-500/10', + jitter: 'text-purple-300 border-purple-500/40 bg-purple-500/10', +}; + +export const BottleneckSummary: React.FC = ({ score, speed, ping }) => { + const attribution = attributeBottleneck(speed, ping, score); + const ranked = attribution.sensitivities.filter((s) => s.gain > 0); + const maxGain = ranked.length > 0 ? ranked[0].gain : 0; + + return ( +
+
+
+ {attribution.constraint === null ? ( + + ) : ( + + )} +
+ +
+

+ {attribution.headline} +

+

{attribution.detail}

+
+
+ + {attribution.evidence.length > 0 && ( +
+ {attribution.evidence.map((e) => ( + + {e.observation} + + ))} +
+ )} + + {ranked.length > 0 && ( +
+
+ Score points recoverable per input +
+ {ranked.map((s) => ( +
+ + {constraintLabel(s.input)} + +
+
0 ? Math.round((s.gain / maxGain) * 100) : 0}%` }} + /> +
+ + +{s.gain} + +
+ ))} +

+ Each figure is how far the overall score would move if that one input stopped limiting + it, with every other measurement left exactly as it was recorded. The comparison values + used to work that out are internal to the calculation and are never reported as + measurements. + {attribution.constraint === 'bufferbloat' && + ' Bufferbloat has no bar here because the score has no bufferbloat term β€” that gap is' + + ' exactly why it outranks the inputs that do appear.'} +

+
+ )} +
+ ); +}; diff --git a/src/components/CaptivePortalCheck.tsx b/src/components/CaptivePortalCheck.tsx new file mode 100644 index 0000000..b88fbf6 --- /dev/null +++ b/src/components/CaptivePortalCheck.tsx @@ -0,0 +1,295 @@ +import React, { useState } from 'react'; +import { + ShieldQuestion, + Play, + Loader2, + CheckCircle2, + XCircle, + AlertTriangle, + MinusCircle, +} from 'lucide-react'; +import type { + CaptivePortalResult, + DnsIntegrityResult, + HistoryItem, + IntegrityProbe, +} from '../types'; +import { checkCaptivePortal, checkDnsIntegrity } from '../utils/captivePortal'; +import { FailureNotice, MetricValue } from './MetricValue'; +import { saveHistoryItem } from '../utils/storage'; + +/** + * Captive portal and DNS hijack detection. + * + * Worth stating plainly in the UI, because it is the part users get wrong: over + * HTTPS a captive portal cannot rewrite a response. It can only block. So the + * thing to look for is not tampered content β€” it is silence from every secure + * endpoint while the browser still insists it is online. + * + * The DNS half tests the one thing about the system resolver a web page can + * observe: whether a server reachable by literal IP is also reachable by name. + */ + +interface CaptivePortalCheckProps { + onHistoryUpdate: () => void; +} + +const PORTAL_HEADLINE: Record, string> = { + 'no-interception-detected': 'Nothing is standing in the way', + 'content-substituted': 'Something is answering in place of known endpoints', + 'https-blocked': 'Online, but no secure connection completes', + mixed: 'Mixed result', +}; + +const PORTAL_TONE: Record, string> = { + 'no-interception-detected': 'border-emerald-500/40 bg-emerald-500/10 text-emerald-200', + 'content-substituted': 'border-rose-500/40 bg-rose-500/10 text-rose-200', + 'https-blocked': 'border-rose-500/40 bg-rose-500/10 text-rose-200', + mixed: 'border-amber-500/40 bg-amber-500/10 text-amber-200', +}; + +const DNS_HEADLINE: Record, string> = { + 'resolver-working': 'Name resolution is working', + 'resolver-failing': 'Name resolution is broken', + 'answers-diverge': 'Two DNS providers disagree', +}; + +const DNS_TONE: Record, string> = { + 'resolver-working': 'border-emerald-500/40 bg-emerald-500/10 text-emerald-200', + 'resolver-failing': 'border-rose-500/40 bg-rose-500/10 text-rose-200', + 'answers-diverge': 'border-amber-500/40 bg-amber-500/10 text-amber-200', +}; + +const OUTCOME_STYLE: Record< + IntegrityProbe['outcome'], + { icon: React.FC<{ className?: string }>; tone: string; label: string } +> = { + verified: { icon: CheckCircle2, tone: 'text-emerald-400', label: 'returned its own content' }, + 'content-mismatch': { icon: AlertTriangle, tone: 'text-rose-400', label: 'wrong content' }, + 'no-response': { icon: XCircle, tone: 'text-amber-400', label: 'no response' }, + 'not-attempted': { icon: MinusCircle, tone: 'text-slate-500', label: 'not attempted' }, +}; + +const reachabilityWord = (v: boolean | null): string => + v === null ? 'not checked' : v ? 'answered' : 'no response'; + +export const CaptivePortalCheck: React.FC = ({ onHistoryUpdate }) => { + const [portal, setPortal] = useState(null); + const [dns, setDns] = useState(null); + const [isRunning, setIsRunning] = useState(false); + const [stage, setStage] = useState(''); + + const run = async () => { + setIsRunning(true); + setPortal(null); + setDns(null); + try { + const portalResult = await checkCaptivePortal(setStage); + setPortal(portalResult); + const dnsResult = await checkDnsIntegrity(setStage); + setDns(dnsResult); + + const item: HistoryItem = { + id: portalResult.id, + type: 'captive', + timestamp: portalResult.timestamp, + title: `Interception check: ${ + portalResult.verdict === null ? 'no verdict' : PORTAL_HEADLINE[portalResult.verdict] + }`, + summary: + `Interception: ${portalResult.verdict ?? 'unknown'} | DNS: ${dnsResult.verdict ?? 'unknown'}`, + data: { portal: portalResult, dns: dnsResult }, + }; + saveHistoryItem(item); + onHistoryUpdate(); + } finally { + setIsRunning(false); + setStage(''); + } + }; + + const failures = [...(portal?.failures ?? []), ...(dns?.failures ?? [])]; + + return ( +
+
+
+
+ +
+
+

+ Captive portal & DNS hijack check +

+

+ A captive portal cannot rewrite an HTTPS response without breaking the certificate + chain, so from a secure page it shows up as silence rather than as a redirect. This + calls endpoints whose exact response is known in advance and reports which answered + correctly, which answered with something else, and which said nothing at all β€” then + tests whether a server reachable by literal IP is also reachable by name. +

+
+
+ + +
+ + {portal && ( + <> +
+

+ {portal.verdict === null ? 'No verdict' : PORTAL_HEADLINE[portal.verdict]} +

+

{portal.explanation}

+
+ +
+

+ Known-content probes +

+
    + {portal.probes.map((p) => { + const style = OUTCOME_STYLE[p.outcome]; + const Icon = style.icon; + return ( +
  • + +
    +
    + {p.label} + + {style.label} + +
    +

    {p.url}

    +

    Expected: {p.expectation}

    + {p.note !== null && ( +

    {p.note}

    + )} +
    + + + +
  • + ); + })} +
+

+ Page served over {portal.pageProtocol}. The + plaintext 204 probe is only possible from an http origin β€” a secure page may not open + a plaintext connection, which is a browser rule rather than a network result. +

+
+ + )} + + {dns && ( + <> +
+

+ {dns.verdict === null ? 'No DNS verdict' : DNS_HEADLINE[dns.verdict]} +

+

{dns.explanation}

+
+ +
+
+

Resolver test

+

+ The same Cloudflare server, reached two ways. Reaching it by name uses this + network’s resolver; reaching it by literal address does not. A browser cannot + read the answer the resolver gave, but it can compare the outcomes. +

+
+
+
by name
+
+ {reachabilityWord(dns.hostnameReachable)} +
+
+
+
by literal IP
+
+ {reachabilityWord(dns.literalIpReachable)} +
+
+
+
+ +
+

Provider cross-check

+

+ Names whose correct answer is identical worldwide, resolved through two independent + DNS-over-HTTPS providers. Ordinary CDN hostnames answer differently by location by + design, so they would produce disagreement constantly and mean nothing. +

+ {dns.comparisons.length === 0 ? ( +

No name was compared.

+ ) : ( +
    + {dns.comparisons.map((c) => ( +
  • +
    + {c.name} + + {c.agrees === null ? 'not comparable' : c.agrees ? 'agree' : 'differ'} + +
    +
    + cloudflare: {c.cloudflare === null ? 'β€”' : c.cloudflare.join(', ')} +
    +
    + google: {c.google === null ? 'β€”' : c.google.join(', ')} +
    +
  • + ))} +
+ )} +
+
+ + )} + + 0 ? failures : undefined} /> +
+ ); +}; diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index 00f4e97..20020ba 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -18,6 +18,9 @@ import { GitCommit, Archive, Compass, + Stethoscope, + Network, + ShieldQuestion, } from 'lucide-react'; import { ToolTab, NetworkConnectionInfo, SpeedTestResult, PingResult, HistoryItem } from '../types'; import { @@ -28,6 +31,7 @@ import { createId, } from '../utils/network'; import { displayMetric } from './MetricValue'; +import { BottleneckSummary } from './BottleneckSummary'; import { saveHistoryItem } from '../utils/storage'; import { ResponsibleNetworkingModal, isResponsibleNetworkingAccepted } from './ResponsibleNetworkingModal'; import { TrafficMonitor } from './TrafficMonitor'; @@ -228,12 +232,27 @@ export const Dashboard: React.FC = ({
+ {/* Bottleneck attribution. + This is the headline now. Four flat bars showed which categories + scored low without ever saying which measured input was dragging + them down, so the one question a user actually has β€” "what do I + change?" β€” went unanswered. The bars are still below, demoted to + supporting detail. */} +
+ +
+ {/* Application category suitability */} -
- - - - +
+
+ Category suitability +
+
+ + + + +
{auditError && ( @@ -357,6 +376,84 @@ export const Dashboard: React.FC = ({
+ {/* Triage β€” the answer layer */} +
setActiveTab('triage')} + className="group bg-gradient-to-br from-cyan-950/50 via-slate-900 to-slate-900 border border-cyan-500/40 hover:border-cyan-400 rounded-2xl p-5 cursor-pointer transition-all hover:shadow-xl hover:shadow-cyan-500/10 hover:-translate-y-0.5" + > +
+ +
+
+

+ Is it me or the internet? +

+ + New + +
+

+ One run walks the whole decision tree and returns a verdict with ranked causes and + fixes β€” deterministic rules, no model, every finding backed by its measurements. +

+
+ Run triage + +
+
+ + {/* Dual-stack */} +
setActiveTab('dualstack')} + className="group bg-slate-900 border border-slate-800 hover:border-indigo-500/50 rounded-2xl p-5 cursor-pointer transition-all hover:shadow-lg hover:-translate-y-0.5" + > +
+ +
+
+

+ IPv4 / IPv6 Reachability +

+ + New + +
+

+ Call hosts that publish only an A record and only an AAAA record, and see which + family actually carries traffic from here. +

+
+ Check both families + +
+
+ + {/* Captive portal & DNS hijack */} +
setActiveTab('captive')} + className="group bg-slate-900 border border-slate-800 hover:border-amber-500/50 rounded-2xl p-5 cursor-pointer transition-all hover:shadow-lg hover:-translate-y-0.5" + > +
+ +
+
+

+ Portal & DNS Hijack +

+ + New + +
+

+ Endpoints whose exact response is known, checked for substitution β€” plus whether a + server reachable by literal IP is also reachable by name. +

+
+ Check for interception + +
+
+ {/* Traceroute TRACERT Hop Map */}
setActiveTab('edgepath')} diff --git a/src/components/DualStackCheck.tsx b/src/components/DualStackCheck.tsx new file mode 100644 index 0000000..7fd96a2 --- /dev/null +++ b/src/components/DualStackCheck.tsx @@ -0,0 +1,195 @@ +import React, { useState } from 'react'; +import { Network, Play, Loader2, CheckCircle2, XCircle, MinusCircle } from 'lucide-react'; +import type { AddressFamily, DualStackResult, HistoryItem } from '../types'; +import { checkDualStack, describeReachability } from '../utils/dualStack'; +import { FailureNotice, MetricValue } from './MetricValue'; +import { saveHistoryItem } from '../utils/storage'; + +/** + * Dual-stack (IPv4 / IPv6) reachability. + * + * The interesting part of this check is what it refuses to say. "No IPv6 probe + * answered" is displayed as exactly that, never as "IPv6 is disabled" β€” a + * browser cannot distinguish an absent IPv6 path from two probe hosts being + * unreachable, and dressing one up as the other would be a diagnosis the + * evidence does not support. + */ + +interface DualStackCheckProps { + onHistoryUpdate: () => void; +} + +const FAMILY_LABEL: Record = { ipv4: 'IPv4', ipv6: 'IPv6' }; + +const VERDICT_TONE: Record, string> = { + 'dual-stack': 'border-emerald-500/40 bg-emerald-500/10 text-emerald-200', + 'ipv4-only': 'border-amber-500/40 bg-amber-500/10 text-amber-200', + 'ipv6-only': 'border-amber-500/40 bg-amber-500/10 text-amber-200', + 'neither-family-answered': 'border-rose-500/40 bg-rose-500/10 text-rose-200', +}; + +const VERDICT_HEADLINE: Record, string> = { + 'dual-stack': 'Both address families work', + 'ipv4-only': 'IPv4 only', + 'ipv6-only': 'IPv6 only', + 'neither-family-answered': 'Neither family answered', +}; + +const FamilyCard: React.FC<{ + family: AddressFamily; + reachable: boolean | null; + result: DualStackResult; +}> = ({ family, reachable, result }) => { + const probes = result.probes.filter((p) => p.family === family); + const Icon = reachable === null ? MinusCircle : reachable ? CheckCircle2 : XCircle; + const tone = + reachable === null ? 'text-slate-500' : reachable ? 'text-emerald-400' : 'text-rose-400'; + + return ( +
+
+

{FAMILY_LABEL[family]}

+ + + {describeReachability(reachable)} + +
+ + {probes.length === 0 ? ( +

No {FAMILY_LABEL[family]} endpoint was tried.

+ ) : ( +
    + {probes.map((p) => ( +
  • +
    + {p.host} + + + +
    + {p.observedIp !== null && ( +
    saw {p.observedIp}
    + )} + {p.error !== null &&
    {p.error}
    } +
  • + ))} +
+ )} +
+ ); +}; + +export const DualStackCheck: React.FC = ({ onHistoryUpdate }) => { + const [result, setResult] = useState(null); + const [isRunning, setIsRunning] = useState(false); + const [stage, setStage] = useState(''); + + const run = async () => { + setIsRunning(true); + setResult(null); + try { + const r = await checkDualStack(setStage); + setResult(r); + + const item: HistoryItem = { + id: r.id, + type: 'dualstack', + timestamp: r.timestamp, + title: `Dual-stack: ${r.verdict === null ? 'no verdict' : VERDICT_HEADLINE[r.verdict]}`, + summary: + `IPv4 ${describeReachability(r.ipv4Reachable)} | IPv6 ${describeReachability(r.ipv6Reachable)}` + + (r.preferredFamily === null ? '' : ` | prefers ${FAMILY_LABEL[r.preferredFamily]}`), + data: r, + }; + saveHistoryItem(item); + onHistoryUpdate(); + } finally { + setIsRunning(false); + setStage(''); + } + }; + + return ( +
+
+
+
+ +
+
+

Dual-stack reachability

+

+ A page cannot ask the browser which address families this machine has. What it can do + is call hostnames that publish only an A record, and hostnames that publish only an + AAAA record, and see which answer. Two independent providers are tried per family, so + one provider having a bad day does not become a verdict about your network. +

+
+
+ + +
+ + {result && ( + <> +
+

+ {result.verdict === null ? 'No verdict' : VERDICT_HEADLINE[result.verdict]} +

+

{result.explanation}

+
+ +
+ + +
+ +
+
+ Which family the browser chose +
+ {result.preferredFamily === null ? ( +

+ Not determined. The dual-stack reference host did not report a readable client + address, so there is nothing to read a preference from. +

+ ) : ( +

+ A dual-stack host saw this browser arrive over{' '} + + {FAMILY_LABEL[result.preferredFamily]} + + , via {result.preferredFamilySource}. That is the + family the browser picks when both are on offer. +

+ )} +
+ + 0 ? result.failures : undefined} /> + + )} +
+ ); +}; diff --git a/src/components/ExportPage.tsx b/src/components/ExportPage.tsx index c111995..ffdc055 100644 --- a/src/components/ExportPage.tsx +++ b/src/components/ExportPage.tsx @@ -23,6 +23,9 @@ import { Calculator, Search as SearchIcon, HardDrive, + Stethoscope, + Network, + ShieldQuestion, } from 'lucide-react'; import { HistoryItem } from '../types'; import { @@ -83,6 +86,12 @@ export const ExportPage: React.FC = ({ onHistoryUpdate }) => { return ; case 'mac': return ; + case 'triage': + return ; + case 'dualstack': + return ; + case 'captive': + return ; default: return ; } diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 864efb1..fabcbb6 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -22,6 +22,9 @@ import { Compass, ChevronRight, ChevronLeft, + Stethoscope, + Network, + ShieldQuestion, } from 'lucide-react'; import { ToolTab, NetworkConnectionInfo } from '../types'; import { PrivacySafetyModal } from './PrivacySafetyModal'; @@ -51,7 +54,10 @@ export const Navbar: React.FC = ({ const tabs: { id: ToolTab; label: string; icon: React.FC<{ className?: string }>; badge?: string }[] = [ { id: 'dashboard', label: 'Dashboard', icon: Activity }, - { id: 'edgepath', label: 'Edge Path', icon: Compass, badge: 'NEW' }, + { id: 'triage', label: 'Me or the Internet?', icon: Stethoscope, badge: 'NEW' }, + { id: 'dualstack', label: 'IPv4 / IPv6', icon: Network, badge: 'NEW' }, + { id: 'captive', label: 'Portal & DNS Hijack', icon: ShieldQuestion, badge: 'NEW' }, + { id: 'edgepath', label: 'Edge Path', icon: Compass }, { id: 'tracert', label: 'Route Model', icon: GitCommit, badge: 'SIM' }, { id: 'portscanner', label: 'Port Scanner', icon: Radar, badge: 'BETA' }, { id: 'geoip', label: 'GeoIP Lookup', icon: Globe }, diff --git a/src/components/PrivacySafetyModal.tsx b/src/components/PrivacySafetyModal.tsx index c99be69..63c4704 100644 --- a/src/components/PrivacySafetyModal.tsx +++ b/src/components/PrivacySafetyModal.tsx @@ -26,13 +26,34 @@ export const THIRD_PARTY_DISCLOSURES: { host: string; receives: string }[] = [ 'Your public IP when you open the GeoIP tool, and every IP or domain you look up or trace.', }, { - host: '1.1.1.1, dns.quad9.net, doh.opendns.com, en.wikipedia.org', - receives: 'Your IP, as the targets of latency probes you choose to run.', + host: '1.1.1.1, one.one.one.one, dns.quad9.net, doh.opendns.com, en.wikipedia.org', + receives: + 'Your IP, as the targets of latency probes you choose to run. The triage and DNS-hijack ' + + 'checks call 1.1.1.1 twice β€” once by name and once by literal address β€” to test ' + + 'this network’s resolver.', + }, + { + host: 'ipv4.icanhazip.com, ipv6.icanhazip.com, api4.ipify.org, api6.ipify.org', + receives: + 'Your IP, when you run the dual-stack check. Each host answers on one address family only, ' + + 'and each returns the address it saw you arrive from.', + }, + { + host: 'cp.cloudflare.com', + receives: + 'Your IP, during the captive-portal check β€” but only when NetReady is opened over plain ' + + 'http. A page served over https cannot make this request at all.', }, { host: 'stun.l.google.com (and other STUN servers)', receives: 'Your public IP, and potentially local network addresses, during WebRTC analysis.', }, + { + host: 'httpbin.org', + receives: + 'Your IP, only if you press β€œTrigger Network Spike” on the live traffic monitor, which ' + + 'makes a handful of requests so the sparklines have something real to draw.', + }, { host: 'basemaps.cartocdn.com', receives: 'The map area you view, which reveals the approximate location of a traced target.', @@ -128,7 +149,7 @@ export const PrivacySafetyModal: React.FC = ({ isOpen,
- 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 = () => {

- {currentAvgLatency} + - ms + {currentAvgLatency !== null && ( + ms + )}
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 && ( + + )} + + {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); }