Phase 3: conversations, Follow TCP Stream, DNS explorer, TLS metadata explorer, statistics

Conversations and statistics are computed from the SQLite summary index (across
segments); stream payload via tshark follow; DNS/TLS fields via tshark. TLS is
metadata-only and labelled 'encrypted pass-through' throughout.
This commit is contained in:
Andy Dixon
2026-10-09 18:39:39 +01:00
parent ab79730195
commit 6605a1d467
12 changed files with 1182 additions and 0 deletions
View File
+12
View File
@@ -9,6 +9,10 @@ import Library from "./pages/Library";
import NetworkPage from "./pages/Network";
import Devices from "./pages/Devices";
import Health from "./pages/Health";
import Conversations from "./pages/Conversations";
import DnsExplorer from "./pages/DnsExplorer";
import TlsExplorer from "./pages/TlsExplorer";
import Statistics from "./pages/Statistics";
import Settings from "./pages/Settings";
export interface Prefs { theme: "dark" | "light" | "system"; density: "compact" | "comfortable"; scale: number; motion: "system" | "reduce" }
@@ -20,6 +24,10 @@ interface Sess { user: string; csrf: string; totp_enabled: boolean; setup_comple
const NAV: [string, string, string][] = [
["/", "Overview", "◉"],
["/analyse", "Live Capture", "≋"],
["/conversations", "Conversations", "⇌"],
["/dns", "DNS Explorer", "◇"],
["/tls", "TLS Explorer", "⛨"],
["/statistics", "Statistics", "▦"],
["/library", "Capture Library", "▤"],
["/devices", "Devices", "⌬"],
["", "", ""],
@@ -85,6 +93,10 @@ export default function App() {
const page = (() => {
if (path === "/" ) return <Overview />;
if (path.startsWith("/analyse")) return <Analyser sessionId={path.split("/")[2] ?? ""} initialFilter={query.get("filter") ?? ""} focus={focus} setFocus={setFocus} />;
if (path.startsWith("/conversations")) return <Conversations sessionId={path.split("/")[2] ?? ""} />;
if (path.startsWith("/dns")) return <DnsExplorer sessionId={path.split("/")[2] ?? ""} />;
if (path.startsWith("/tls")) return <TlsExplorer sessionId={path.split("/")[2] ?? ""} />;
if (path.startsWith("/statistics")) return <Statistics sessionId={path.split("/")[2] ?? ""} />;
if (path === "/library") return <Library />;
if (path === "/devices") return <Devices />;
if (path === "/network") return <NetworkPage />;
+12
View File
@@ -84,3 +84,15 @@ export const fmtDur = (s: number) => {
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), x = Math.floor(s % 60);
return h ? `${h}h ${m}m` : m ? `${m}m ${x}s` : `${x}s`;
};
// ---- Phase 3 analysis types ----
export interface Conversation { transport: string; a_addr: string; a_port: number; b_addr: string; b_port: number; packets: number; bytes: number; bytes_a_to_b: number; bytes_b_to_a: number; start: number; end: number; protocols: string; seg: number; stream: number | null; first_num: number }
export interface Endpoint { addr: string; packets: number; bytes: number; tx_bytes: number; rx_bytes: number; start: number; end: number }
export interface NameCount { name: string; packets: number; bytes: number }
export interface TimeBucket { t: number; packets: number; bytes: number }
export interface Stats { packets: number; bytes: number; start: number; end: number; protocols: NameCount[]; size_buckets: NameCount[]; timeline: TimeBucket[]; tcp_streams: number; counters: Record<string, number> }
export interface StreamChunk { dir: string; hex: string; bytes: number }
export interface FollowResult { seg: number; stream: number; chunks: StreamChunk[]; a_to_b: number; b_to_a: number; note?: string; endpoint?: string }
export interface DNSRecord { num: number; seg: number; time: number; client: string; server: string; query: string; type: string; response: boolean; rcode: string; answers: string }
export interface TLSRecord { num: number; seg: number; time: number; client: string; server: string; server_port: number; sni: string; version: string; cipher: string; alpn: string; stream: number | null }
export interface CertRecord { num: number; seg: number; time: number; server: string; subject: string; issuer: string; sans: string; not_before: string; not_after: string }
+37
View File
@@ -0,0 +1,37 @@
// Shared chrome for the analysis pages: a session picker header, and a tiny
// decode-vs-encrypted banner used so the UI is honest about what is being shown.
import { fmtBytes, type Session } from "../api";
import { Badge, go } from "../ui";
export function SessionPicker({ sessions, id, base, children }: { sessions?: Session[]; id: string; base: string; children?: React.ReactNode }) {
return (
<div className="row" style={{ gap: ".5rem" }}>
<select value={id} onChange={(e) => go(`${base}/${e.target.value}`)} aria-label="Capture session" style={{ maxWidth: 320 }}>
{!id && <option value="">No capture sessions</option>}
{sessions?.map((s) => <option key={s.id} value={s.id}>{s.state === "running" ? "● " : ""}{s.name} ({s.packets.toLocaleString()} pkts)</option>)}
</select>
{children}
</div>
);
}
export function PacketsLink({ id, filter, children }: { id: string; filter: string; children: React.ReactNode }) {
return <a href="#" onClick={(e) => { e.preventDefault(); go(`/analyse/${id}?filter=${encodeURIComponent(filter)}`); }} title={filter}>{children}</a>;
}
export function Bar({ value, max, label, kind }: { value: number; max: number; label?: string; kind?: string }) {
const pct = max > 0 ? Math.max(1.5, (value / max) * 100) : 0;
return (
<div style={{ position: "relative", background: "var(--panel-2)", borderRadius: 4, height: 18, overflow: "hidden", minWidth: 80 }}>
<div style={{ width: pct + "%", height: "100%", background: kind || "var(--cyan)", opacity: .35 }} />
<span style={{ position: "absolute", inset: 0, padding: "0 .4rem", fontSize: ".78rem", display: "flex", alignItems: "center" }} className="mono">{label ?? fmtBytes(value)}</span>
</div>
);
}
export function EncryptedNote() {
return <div className="banner info" style={{ marginBottom: "1rem" }}><div>
<b>Encrypted traffic is shown as metadata only.</b>
In capture-only mode PiLens does not decrypt TLS/QUIC. You see endpoints, SNI, versions, ciphers, ALPN and certificates where they are sent in the clear — not application content. <Badge kind="plain">interception: encrypted pass-through</Badge>
</div></div>;
}
+139
View File
@@ -0,0 +1,139 @@
import { useEffect, useMemo, useState } from "react";
import { api, fmtBytes, fmtDur, type Conversation, type FollowResult, type Session, type StreamChunk } from "../api";
import { Badge, Modal, copyText, go, usePoll, usePersist } from "../ui";
import { SessionPicker, Bar, PacketsLink } from "../components/chrome";
export default function Conversations({ sessionId }: { sessionId: string }) {
const [sessions] = usePoll<Session[]>("/api/captures", 5000);
const id = sessionId || sessions?.[0]?.id || "";
const [transport, setTransport] = usePersist<"tcp" | "udp">("conv.transport", "tcp");
const [convs, err] = usePoll<Conversation[]>(id ? `/api/captures/${id}/conversations?transport=${transport}` : null);
const [q, setQ] = useState("");
const [sort, setSort] = useState<keyof Conversation>("bytes");
const [follow, setFollow] = useState<Conversation | null>(null);
const rows = useMemo(() => {
let r = (convs ?? []).filter((c) => !q || `${c.a_addr}:${c.a_port} ${c.b_addr}:${c.b_port} ${c.protocols}`.toLowerCase().includes(q.toLowerCase()));
r = [...r].sort((a, b) => (b[sort] as number) - (a[sort] as number) || 0);
return r;
}, [convs, q, sort, transport]);
const maxBytes = rows[0]?.bytes ?? 1;
const convFilter = (c: Conversation) => {
const ipA = c.a_addr.includes(":") ? "ipv6.addr" : "ip.addr";
const p = c.transport;
return `${ipA}==${c.a_addr} && ${ipA}==${c.b_addr} && ${p}.port==${c.a_port} && ${p}.port==${c.b_port}`;
};
return (
<div className="page">
<div className="page-head">
<div><h1>Conversations</h1><p>Traffic grouped by endpoint pair, from the packet index. Counts agree with the packet list (subject to capture drops).</p></div>
<SessionPicker sessions={sessions} id={id} base="/conversations" />
</div>
<div className="row" style={{ marginBottom: ".8rem" }}>
<div className="row" role="tablist" style={{ gap: ".25rem" }}>
<button className={transport === "tcp" ? "on" : ""} onClick={() => setTransport("tcp")}>TCP</button>
<button className={transport === "udp" ? "on" : ""} onClick={() => setTransport("udp")}>UDP</button>
</div>
<input placeholder="Filter endpoints / protocols" value={q} onChange={(e) => setQ(e.target.value)} />
<span className="hint">{rows.length.toLocaleString()} conversations</span>
</div>
{err && <div className="banner bad"><div>{err}</div></div>}
<div className="card" style={{ padding: 0 }}>
<table className="data">
<thead><tr>
<th>Endpoint A</th><th>Endpoint B</th><th>Protocols</th>
{([["packets", "Packets"], ["bytes", "Bytes"], ["bytes_a_to_b", "A→B"], ["bytes_b_to_a", "B→A"]] as [keyof Conversation, string][]).map(([k, l]) =>
<th key={k} style={{ cursor: "pointer" }} onClick={() => setSort(k)}>{l}{sort === k ? " ▾" : ""}</th>)}
<th>Duration</th><th></th>
</tr></thead>
<tbody>
{rows.map((c, i) => (
<tr key={i}>
<td className="mono">{c.a_addr}:{c.a_port}</td>
<td className="mono">{c.b_addr}:{c.b_port}</td>
<td>{c.protocols.split(",").map((p) => <Badge key={p} kind="plain">{p}</Badge>)}</td>
<td className="mono">{c.packets.toLocaleString()}</td>
<td style={{ minWidth: 130 }}><Bar value={c.bytes} max={maxBytes} /></td>
<td className="mono">{fmtBytes(c.bytes_a_to_b)}</td>
<td className="mono">{fmtBytes(c.bytes_b_to_a)}</td>
<td className="mono">{fmtDur(c.end - c.start)}</td>
<td><div className="row" style={{ flexWrap: "nowrap" }}>
{transport === "tcp" && c.stream != null && <button className="small" onClick={() => setFollow(c)}>Follow</button>}
<PacketsLink id={id} filter={convFilter(c)}><button className="small ghost" onClick={(e) => e.preventDefault()}>Packets</button></PacketsLink>
</div></td>
</tr>
))}
{rows.length === 0 && <tr><td colSpan={9} className="empty">No {transport.toUpperCase()} conversations.</td></tr>}
</tbody>
</table>
</div>
{follow && <FollowModal id={id} conv={follow} onClose={() => setFollow(null)} onPackets={(f) => go(`/analyse/${id}?filter=${encodeURIComponent(f)}`)} />}
</div>
);
}
const printable = (b: number) => (b >= 0x20 && b < 0x7f) || b === 0x0a || b === 0x09;
function hexToText(hex: string) {
let s = "";
for (let i = 0; i < hex.length; i += 2) { const b = parseInt(hex.substr(i, 2), 16); s += printable(b) ? String.fromCharCode(b) : "."; }
return s;
}
function hexDump(hex: string) {
const out: string[] = [];
for (let off = 0; off < hex.length; off += 32) {
const slice = hex.substr(off, 32);
const pairs = slice.match(/../g) ?? [];
out.push((off / 2).toString(16).padStart(6, "0") + " " + pairs.join(" ").padEnd(47) + " " + hexToText(slice));
}
return out.join("\n");
}
function FollowModal({ id, conv, onClose, onPackets }: { id: string; conv: Conversation; onClose: () => void; onPackets: (f: string) => void }) {
const [res, setRes] = useState<FollowResult | null>(null);
const [err, setErr] = useState("");
const [view, setView] = useState<"ascii" | "hex">("ascii");
const [dir, setDir] = useState<"both" | "ab" | "ba">("both");
useEffect(() => {
setRes(null); setErr("");
api<FollowResult>(`/api/captures/${id}/follow?seg=${conv.seg}&stream=${conv.stream}`)
.then(setRes).catch((e) => setErr(e.message));
}, [id, conv]);
const shown = (res?.chunks ?? []).filter((c) => dir === "both" || c.dir === dir);
const copyAll = () => copyText(shown.map((c) => view === "hex" ? c.hex : hexToText(c.hex)).join(""));
return (
<Modal open title={`Follow TCP stream ${conv.stream}`} onClose={onClose} footer={<>
<button onClick={() => onPackets(`tcp.stream==${conv.stream}`)}>Show packets</button>
<button onClick={copyAll}>Copy {dir === "both" ? "stream" : dir === "ab" ? "A→B" : "B→A"}</button>
<button className="primary" onClick={onClose}>Close</button></>}>
<div className="row spread">
<span className="hint mono">{conv.a_addr}:{conv.a_port} ↔ {conv.b_addr}:{conv.b_port}</span>
<div className="row" style={{ gap: ".25rem" }}>
<select value={dir} onChange={(e) => setDir(e.target.value as any)} className="small"><option value="both">Both</option><option value="ab">A→B (client)</option><option value="ba">B→A (server)</option></select>
<button className={"small" + (view === "ascii" ? " on" : "")} onClick={() => setView("ascii")}>ASCII</button>
<button className={"small" + (view === "hex" ? " on" : "")} onClick={() => setView("hex")}>Hex</button>
</div>
</div>
{res && <div className="hint">{fmtBytes(res.a_to_b)} client→server · {fmtBytes(res.b_to_a)} server→client{res.note ? " · " + res.note : ""}</div>}
{err && <div className="err">{err}</div>}
{!res && !err && <div className="empty">Reassembling…</div>}
{res && (
<pre style={{ maxHeight: "56vh", overflow: "auto", background: "var(--panel-2)", padding: ".6rem", borderRadius: 6, margin: 0, fontSize: ".82rem", whiteSpace: "pre-wrap", wordBreak: "break-all" }}>
{shown.map((c, i) => <ChunkView key={i} chunk={c} view={view} />)}
{shown.length === 0 && <span className="hint">No payload in this direction (handshake/empty).</span>}
</pre>
)}
<p className="hint">Reconstructed from the captured TCP payload by tshark. This is the transport stream, not decrypted application content: for TLS it is ciphertext.</p>
</Modal>
);
}
function ChunkView({ chunk, view }: { chunk: StreamChunk; view: "ascii" | "hex" }) {
const col = chunk.dir === "ab" ? "var(--cyan)" : "var(--accent)";
const text = view === "hex" ? hexDump(chunk.hex) : hexToText(chunk.hex);
return <span style={{ color: col }}>{text}{view === "ascii" ? "" : "\n"}</span>;
}
+83
View File
@@ -0,0 +1,83 @@
import { useMemo, useState } from "react";
import { fmtTime, type DNSRecord, type Session } from "../api";
import { Badge, usePoll } from "../ui";
import { SessionPicker, PacketsLink } from "../components/chrome";
export default function DnsExplorer({ sessionId }: { sessionId: string }) {
const [sessions] = usePoll<Session[]>("/api/captures", 5000);
const id = sessionId || sessions?.[0]?.id || "";
const [recs, err] = usePoll<DNSRecord[]>(id ? `/api/captures/${id}/dns?limit=5000` : null);
const [q, setQ] = useState("");
const [onlyErr, setOnlyErr] = useState(false);
const [grouped, setGrouped] = useState(true);
const rows = useMemo(() => (recs ?? []).filter((r) =>
(!q || `${r.query} ${r.client} ${r.answers}`.toLowerCase().includes(q.toLowerCase())) &&
(!onlyErr || (r.response && r.rcode && r.rcode !== "NOERROR"))), [recs, q, onlyErr]);
// Group queries with their most useful response per (client, name, type).
const names = useMemo(() => {
const m = new Map<string, { name: string; type: string; count: number; clients: Set<string>; answers: Set<string>; rcodes: Set<string>; last: number }>();
for (const r of rows) {
const k = r.query + "|" + r.type;
let g = m.get(k);
if (!g) { g = { name: r.query, type: r.type, count: 0, clients: new Set(), answers: new Set(), rcodes: new Set(), last: 0 }; m.set(k, g); }
g.count++; if (r.client) g.clients.add(r.client); g.last = Math.max(g.last, r.time);
if (r.answers) r.answers.split(/\s+/).filter(Boolean).forEach((a) => g!.answers.add(a));
if (r.response && r.rcode) g.rcodes.add(r.rcode);
}
return [...m.values()].sort((a, b) => b.count - a.count);
}, [rows]);
return (
<div className="page">
<div className="page-head">
<div><h1>DNS Explorer</h1><p>Cleartext DNS observed through the appliance. Encrypted DNS (DoH/DoT) is not shown here; it appears as TLS/HTTPS to the resolver.</p></div>
<SessionPicker sessions={sessions} id={id} base="/dns" />
</div>
<div className="row" style={{ marginBottom: ".8rem" }}>
<input placeholder="Filter name / client / answer" value={q} onChange={(e) => setQ(e.target.value)} />
<label className="inline"><input type="checkbox" checked={grouped} onChange={(e) => setGrouped(e.target.checked)} />Group by name</label>
<label className="inline"><input type="checkbox" checked={onlyErr} onChange={(e) => setOnlyErr(e.target.checked)} />Errors only</label>
<span className="hint">{rows.length.toLocaleString()} messages</span>
</div>
{err && <div className="banner bad"><div>{err}</div></div>}
<div className="card" style={{ padding: 0 }}>
{grouped ? (
<table className="data"><thead><tr><th>Name</th><th>Type</th><th>Queries</th><th>Clients</th><th>Answers</th><th>Result</th><th>Last seen</th></tr></thead>
<tbody>
{names.map((g, i) => (
<tr key={i}>
<td className="mono"><PacketsLink id={id} filter={`dns.qry.name=="${g.name}"`}>{g.name || "(root)"}</PacketsLink></td>
<td><Badge kind="plain">{g.type}</Badge></td>
<td className="mono">{g.count}</td>
<td className="mono">{[...g.clients].join(", ")}</td>
<td className="mono" style={{ fontSize: ".8rem", wordBreak: "break-all" }}>{[...g.answers].slice(0, 6).join(", ")}{g.answers.size > 6 ? ` +${g.answers.size - 6}` : ""}</td>
<td>{[...g.rcodes].map((rc) => <Badge key={rc} kind={rc === "NOERROR" ? "ok" : "bad"}>{rc}</Badge>)}</td>
<td className="hint">{fmtTime(g.last * 1000)}</td>
</tr>
))}
{names.length === 0 && <tr><td colSpan={7} className="empty">No cleartext DNS observed.</td></tr>}
</tbody>
</table>
) : (
<table className="data"><thead><tr><th>Time</th><th></th><th>Client</th><th>Resolver</th><th>Name</th><th>Type</th><th>Result / answers</th></tr></thead>
<tbody>
{rows.map((r, i) => (
<tr key={i}>
<td className="hint">{fmtTime(r.time * 1000)}</td>
<td>{r.response ? <Badge kind={r.rcode && r.rcode !== "NOERROR" ? "bad" : "ok"} >resp</Badge> : <Badge kind="plain">query</Badge>}</td>
<td className="mono">{r.client}</td><td className="mono">{r.server}</td>
<td className="mono"><PacketsLink id={id} filter={`frame.number==${r.num}`}>{r.query}</PacketsLink></td>
<td>{r.type}</td>
<td className="mono" style={{ wordBreak: "break-all" }}>{r.response ? (r.rcode && r.rcode !== "NOERROR" ? r.rcode : r.answers) : ""}</td>
</tr>
))}
{rows.length === 0 && <tr><td colSpan={7} className="empty">No cleartext DNS observed.</td></tr>}
</tbody>
</table>
)}
</div>
</div>
);
}
+113
View File
@@ -0,0 +1,113 @@
import { useMemo, useState } from "react";
import { fmtBytes, type Endpoint, type Session, type Stats, type TimeBucket } from "../api";
import { usePoll } from "../ui";
import { SessionPicker, Bar, PacketsLink } from "../components/chrome";
export default function Statistics({ sessionId }: { sessionId: string }) {
const [sessions] = usePoll<Session[]>("/api/captures", 5000);
const id = sessionId || sessions?.[0]?.id || "";
const [stats, err] = usePoll<Stats>(id ? `/api/captures/${id}/statistics?buckets=300` : null, 0);
const [eps] = usePoll<Endpoint[]>(id ? `/api/captures/${id}/endpoints?limit=15` : null);
const [metric, setMetric] = useState<"bytes" | "packets">("bytes");
const dur = stats ? stats.end - stats.start : 0;
const bucketS = stats?.counters?.bucket_seconds || 1;
const maxProto = stats?.protocols[0]?.[metric] ?? 1;
const maxSize = useMemo(() => Math.max(1, ...(stats?.size_buckets ?? []).map((s) => s[metric])), [stats, metric]);
const maxEp = eps?.[0]?.bytes ?? 1;
return (
<div className="page">
<div className="page-head">
<div><h1>Statistics</h1><p>Computed from the packet index. Every value is a count of captured packets or bytes, not an estimate.</p></div>
<SessionPicker sessions={sessions} id={id} base="/statistics">
<div className="row" style={{ gap: ".25rem" }}>
<button className={metric === "bytes" ? "on" : ""} onClick={() => setMetric("bytes")}>Bytes</button>
<button className={metric === "packets" ? "on" : ""} onClick={() => setMetric("packets")}>Packets</button>
</div>
</SessionPicker>
</div>
{err && <div className="banner bad"><div>{err}</div></div>}
{!stats ? <div className="empty">Loading…</div> : stats.packets === 0 ? <div className="empty">No packets captured yet.</div> : (
<>
<div className="grid g4" style={{ marginBottom: ".9rem" }}>
<div className="card"><h3>Packets</h3><div className="metric">{stats.packets.toLocaleString()}</div></div>
<div className="card"><h3>Bytes</h3><div className="metric" style={{ fontSize: "1.4rem" }}>{fmtBytes(stats.bytes)}</div></div>
<div className="card"><h3>Duration</h3><div className="metric" style={{ fontSize: "1.4rem" }}>{dur > 0 ? dur.toFixed(1) + " s" : "–"}</div><div className="hint">{dur > 0 ? `${Math.round(stats.packets / dur)} pkt/s avg · ${fmtBytes(stats.bytes / dur)}/s` : ""}</div></div>
<div className="card"><h3>TCP streams</h3><div className="metric">{stats.tcp_streams.toLocaleString()}</div></div>
</div>
<div className="card" style={{ marginBottom: ".9rem" }}>
<div className="row spread"><h3>Traffic over time</h3><span className="hint">{metric} per {bucketS >= 1 ? bucketS + " s" : "bucket"}</span></div>
<Timeline buckets={stats.timeline} metric={metric} t0={stats.start} />
</div>
<div className="grid g2">
<div className="card"><h3>Protocol hierarchy</h3>
<table className="data"><tbody>
{stats.protocols.slice(0, 15).map((p) => (
<tr key={p.name}><td className="mono">{p.name}</td>
<td style={{ width: "55%" }}><Bar value={p[metric]} max={maxProto} label={metric === "bytes" ? fmtBytes(p.bytes) : p.packets.toLocaleString()} /></td>
<td className="hint mono">{((p[metric] / (metric === "bytes" ? stats.bytes : stats.packets)) * 100).toFixed(1)}%</td></tr>
))}
</tbody></table>
</div>
<div className="card"><h3>Top talkers</h3>
<table className="data"><thead><tr><th>Address</th><th>Traffic</th><th>Tx / Rx</th></tr></thead><tbody>
{eps?.map((e) => (
<tr key={e.addr}><td className="mono"><PacketsLink id={id} filter={`${e.addr.includes(":") ? "ipv6" : "ip"}.addr==${e.addr}`}>{e.addr}</PacketsLink></td>
<td style={{ width: "45%" }}><Bar value={e.bytes} max={maxEp} /></td>
<td className="hint mono">{fmtBytes(e.tx_bytes)} / {fmtBytes(e.rx_bytes)}</td></tr>
))}
</tbody></table>
</div>
<div className="card"><h3>Packet size distribution</h3>
<table className="data"><tbody>
{stats.size_buckets.map((b) => (
<tr key={b.name}><td className="mono">{b.name}</td>
<td><Bar value={b[metric]} max={maxSize} label={metric === "bytes" ? fmtBytes(b.bytes) : b.packets.toLocaleString()} kind="var(--accent)" /></td></tr>
))}
</tbody></table>
</div>
<div className="card"><h3>Definitions</h3>
<dl className="kv" style={{ fontSize: ".85rem" }}>
<dt>Protocol</dt><dd>The highest-layer protocol TShark assigned to each packet (one packet counts once).</dd>
<dt>TCP streams</dt><dd>Distinct tshark stream indices, counted per segment, so a connection crossing a segment boundary counts more than once.</dd>
<dt>Tx / Rx</dt><dd>Bytes with this address as source / destination. A packet contributes to both its endpoints.</dd>
<dt>Bytes</dt><dd>On-the-wire frame length as captured.</dd>
</dl>
</div>
</div>
</>
)}
</div>
);
}
function Timeline({ buckets, metric, t0 }: { buckets: TimeBucket[]; metric: "bytes" | "packets"; t0: number }) {
const [hover, setHover] = useState<number | null>(null);
const W = 1000, H = 160, pad = 4;
if (!buckets.length) return <div className="empty">No data</div>;
const max = Math.max(1, ...buckets.map((b) => b[metric]));
const n = buckets.length;
const bw = (W - pad * 2) / n;
const bars = buckets.map((b, i) => {
const h = (b[metric] / max) * (H - 20);
return <rect key={i} x={pad + i * bw} y={H - h} width={Math.max(0.5, bw - 0.5)} height={h} fill="var(--cyan)" opacity={hover === i ? 1 : .5}
onMouseEnter={() => setHover(i)} onMouseLeave={() => setHover(null)} />;
});
const hb = hover != null ? buckets[hover] : null;
return (
<div style={{ position: "relative" }}>
<svg viewBox={`0 0 ${W} ${H}`} width="100%" height={H} preserveAspectRatio="none" style={{ display: "block" }} role="img" aria-label="Traffic over time">
{bars}
<line x1={pad} y1={H - 0.5} x2={W - pad} y2={H - 0.5} stroke="var(--line-2)" strokeWidth={0.5} />
</svg>
<div className="hint" style={{ display: "flex", justifyContent: "space-between" }}>
<span>0 s</span>
{hb && <span className="mono">+{(hb.t - t0).toFixed(1)} s: {metric === "bytes" ? fmtBytes(hb.bytes) : hb.packets + " pkts"}</span>}
<span>{(buckets[n - 1].t - t0).toFixed(0)} s</span>
</div>
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
import { useMemo, useState } from "react";
import { fmtTime, type CertRecord, type Session, type TLSRecord } from "../api";
import { Badge, usePoll } from "../ui";
import { SessionPicker, PacketsLink, EncryptedNote } from "../components/chrome";
interface TLSResp { handshakes: TLSRecord[]; certificates: CertRecord[]; note: string }
export default function TlsExplorer({ sessionId }: { sessionId: string }) {
const [sessions] = usePoll<Session[]>("/api/captures", 5000);
const id = sessionId || sessions?.[0]?.id || "";
const [data, err] = usePoll<TLSResp>(id ? `/api/captures/${id}/tls?limit=5000` : null);
const [q, setQ] = useState("");
const [tab, setTab] = useState<"handshakes" | "certs">("handshakes");
const hs = useMemo(() => (data?.handshakes ?? []).filter((r) => !q || `${r.sni} ${r.server} ${r.client} ${r.version} ${r.alpn}`.toLowerCase().includes(q.toLowerCase())), [data, q]);
const certs = useMemo(() => (data?.certificates ?? []).filter((r) => !q || `${r.subject} ${r.sans} ${r.server}`.toLowerCase().includes(q.toLowerCase())), [data, q]);
const verCount = useMemo(() => { const m: Record<string, number> = {}; for (const r of data?.handshakes ?? []) if (r.version) m[r.version] = (m[r.version] || 0) + 1; return m; }, [data]);
return (
<div className="page">
<div className="page-head">
<div><h1>TLS Explorer</h1><p>Observed TLS handshakes and any certificates sent in the clear.</p></div>
<SessionPicker sessions={sessions} id={id} base="/tls" />
</div>
<EncryptedNote />
{Object.keys(verCount).length > 0 && <div className="row" style={{ marginBottom: ".8rem" }}>{Object.entries(verCount).map(([v, n]) => <Badge key={v} kind="plain">{v}: {n}</Badge>)}</div>}
<div className="row" style={{ marginBottom: ".8rem" }}>
<div className="row" style={{ gap: ".25rem" }}>
<button className={tab === "handshakes" ? "on" : ""} onClick={() => setTab("handshakes")}>Handshakes ({data?.handshakes.length ?? 0})</button>
<button className={tab === "certs" ? "on" : ""} onClick={() => setTab("certs")}>Certificates ({data?.certificates.length ?? 0})</button>
</div>
<input placeholder="Filter SNI / server / subject" value={q} onChange={(e) => setQ(e.target.value)} />
</div>
{err && <div className="banner bad"><div>{err}</div></div>}
<div className="card" style={{ padding: 0 }}>
{tab === "handshakes" ? (
<table className="data"><thead><tr><th>Time</th><th>Client</th><th>Server</th><th>SNI</th><th>Version</th><th>Cipher</th><th>ALPN</th><th>Status</th><th></th></tr></thead>
<tbody>
{hs.map((r, i) => (
<tr key={i}>
<td className="hint">{fmtTime(r.time * 1000)}</td>
<td className="mono">{r.client}</td>
<td className="mono">{r.server}:{r.server_port}</td>
<td className="mono">{r.sni || <span className="hint">— (none / ECH)</span>}</td>
<td>{r.version ? <Badge kind="plain">{r.version}</Badge> : <span className="hint">?</span>}</td>
<td className="mono">{r.cipher}</td>
<td className="mono">{r.alpn}</td>
<td><Badge kind="plain">encrypted pass-through</Badge></td>
<td>{r.stream != null ? <PacketsLink id={id} filter={`tcp.stream==${r.stream}`}><button className="small ghost" onClick={(e) => e.preventDefault()}>Packets</button></PacketsLink> : <PacketsLink id={id} filter={`frame.number==${r.num}`}>↗</PacketsLink>}</td>
</tr>
))}
{hs.length === 0 && <tr><td colSpan={9} className="empty">No TLS handshakes observed.</td></tr>}
</tbody>
</table>
) : (
<table className="data"><thead><tr><th>Time</th><th>Server</th><th>Subject</th><th>Subject alt names</th><th>Valid from</th><th>Valid to</th><th></th></tr></thead>
<tbody>
{certs.map((r, i) => (
<tr key={i}>
<td className="hint">{fmtTime(r.time * 1000)}</td>
<td className="mono">{r.server}</td>
<td className="mono">{r.subject}</td>
<td className="mono" style={{ wordBreak: "break-all" }}>{r.sans}</td>
<td className="mono hint">{r.not_before}</td><td className="mono hint">{r.not_after}</td>
<td><PacketsLink id={id} filter={`frame.number==${r.num}`}>↗</PacketsLink></td>
</tr>
))}
{certs.length === 0 && <tr><td colSpan={7} className="empty">No certificates seen in the clear. TLS 1.3 encrypts the certificate, so this is expected for modern servers.</td></tr>}
</tbody>
</table>
)}
</div>
</div>
);
}