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
+87
View File
@@ -0,0 +1,87 @@
package api
import (
"net/http"
"strconv"
)
func (s *Server) analysisRoutes(priv func(string, http.HandlerFunc)) {
priv("GET /api/captures/{id}/conversations", s.withSession(s.conversations))
priv("GET /api/captures/{id}/endpoints", s.withSession(s.endpoints))
priv("GET /api/captures/{id}/statistics", s.withSession(s.statistics))
priv("GET /api/captures/{id}/follow", s.withSession(s.follow))
priv("GET /api/captures/{id}/dns", s.withSession(s.dns))
priv("GET /api/captures/{id}/tls", s.withSession(s.tls))
}
func qint(r *http.Request, k string, def int) int {
if v, err := strconv.Atoi(r.URL.Query().Get(k)); err == nil {
return v
}
return def
}
func (s *Server) conversations(w http.ResponseWriter, r *http.Request, id string) {
t := r.URL.Query().Get("transport")
if t == "" {
t = "tcp"
}
c, err := s.Engine.Conversations(r.Context(), id, t)
if err != nil {
engineErr(w, err)
return
}
writeJSON(w, c)
}
func (s *Server) endpoints(w http.ResponseWriter, r *http.Request, id string) {
c, err := s.Engine.Endpoints(r.Context(), id, qint(r, "limit", 100))
if err != nil {
engineErr(w, err)
return
}
writeJSON(w, c)
}
func (s *Server) statistics(w http.ResponseWriter, r *http.Request, id string) {
st, err := s.Engine.Statistics(r.Context(), id, qint(r, "buckets", 300))
if err != nil {
engineErr(w, err)
return
}
writeJSON(w, st)
}
func (s *Server) follow(w http.ResponseWriter, r *http.Request, id string) {
seg := qint(r, "seg", 0)
stream := qint(r, "stream", -1)
if stream < 0 {
httpErr(w, http.StatusBadRequest, "seg and stream are required")
return
}
res, err := s.Engine.FollowTCP(r.Context(), id, seg, stream)
if err != nil {
engineErr(w, err)
return
}
writeJSON(w, res)
}
func (s *Server) dns(w http.ResponseWriter, r *http.Request, id string) {
recs, err := s.Engine.DNS(r.Context(), id, qint(r, "limit", 5000))
if err != nil {
engineErr(w, err)
return
}
writeJSON(w, recs)
}
func (s *Server) tls(w http.ResponseWriter, r *http.Request, id string) {
handshakes, certs, err := s.Engine.TLS(r.Context(), id, qint(r, "limit", 5000))
if err != nil {
engineErr(w, err)
return
}
writeJSON(w, map[string]any{"handshakes": handshakes, "certificates": certs,
"note": "Observed metadata only. In capture-only mode TLS payloads are not decrypted; interception status is 'encrypted pass-through' for every session."})
}
+1
View File
@@ -81,6 +81,7 @@ func (s *Server) Handler() http.Handler {
s.networkRoutes(priv)
s.captureRoutes(priv)
s.analysisRoutes(priv)
if s.Web != nil {
files := http.FileServerFS(s.Web)
+570
View File
@@ -0,0 +1,570 @@
package capture
import (
"bytes"
"context"
"encoding/hex"
"errors"
"fmt"
"path/filepath"
"sort"
"strconv"
"strings"
)
// This file derives higher-level views (conversations, streams, DNS, TLS,
// statistics) from data already captured. Conversations and statistics come
// from the SQLite summary index, so they work across segments. Stream payloads
// and DNS/TLS field detail come from tshark over the relevant segment files.
// ---------------------------------------------------------------- conversations
type Conversation struct {
Transport string `json:"transport"` // tcp | udp
AAddr string `json:"a_addr"`
APort int `json:"a_port"`
BAddr string `json:"b_addr"`
BPort int `json:"b_port"`
Packets int64 `json:"packets"`
Bytes int64 `json:"bytes"`
BytesAtoB int64 `json:"bytes_a_to_b"`
BytesBtoA int64 `json:"bytes_b_to_a"`
Start float64 `json:"start"`
End float64 `json:"end"`
Protos string `json:"protocols"`
Seg int `json:"seg"` // segment of the first packet (for Follow)
Stream *int `json:"stream"` // tcp.stream within that segment
FirstNum int64 `json:"first_num"`
}
// Conversations aggregates TCP or UDP conversations from the summary index.
// Endpoints are ordered canonically (A is the lexicographically smaller
// address, or the lower port when addresses match) so each conversation
// appears once regardless of who initiated it.
func (e *Engine) Conversations(ctx context.Context, id, transport string) ([]Conversation, error) {
var where string
switch transport {
case "tcp":
where = "stream IS NOT NULL"
case "udp":
where = "stream IS NULL AND sport IS NOT NULL"
default:
return nil, errors.New("transport must be tcp or udp")
}
// fwd = row is already in canonical (A->B) orientation.
const fwd = "(src<dst OR (src=dst AND sport<=dport))"
q := `
SELECT
CASE WHEN ` + fwd + ` THEN src ELSE dst END AS a_addr,
CASE WHEN ` + fwd + ` THEN sport ELSE dport END AS a_port,
CASE WHEN ` + fwd + ` THEN dst ELSE src END AS b_addr,
CASE WHEN ` + fwd + ` THEN dport ELSE sport END AS b_port,
COUNT(*), SUM(len),
SUM(CASE WHEN ` + fwd + ` THEN len ELSE 0 END),
SUM(CASE WHEN ` + fwd + ` THEN 0 ELSE len END),
MIN(ts), MAX(ts),
GROUP_CONCAT(DISTINCT proto),
MIN(num)
FROM packets
WHERE session_id=? AND ` + where + ` AND sport IS NOT NULL AND dport IS NOT NULL
GROUP BY a_addr, a_port, b_addr, b_port
ORDER BY SUM(len) DESC
LIMIT 5000`
rows, err := e.DB.QueryContext(ctx, q, id)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Conversation{}
for rows.Next() {
c := Conversation{Transport: transport}
var protos string
if err := rows.Scan(&c.AAddr, &c.APort, &c.BAddr, &c.BPort, &c.Packets, &c.Bytes, &c.BytesAtoB, &c.BytesBtoA, &c.Start, &c.End, &protos, &c.FirstNum); err != nil {
return nil, err
}
c.Protos = tidyProtos(protos)
out = append(out, c)
}
if err := rows.Err(); err != nil {
return nil, err
}
// Attach segment + stream of the first packet so the UI can Follow directly.
for i := range out {
var seg int
var stream *int
e.DB.QueryRowContext(ctx, `SELECT seg, stream FROM packets WHERE session_id=? AND num=?`, id, out[i].FirstNum).Scan(&seg, &stream)
out[i].Seg, out[i].Stream = seg, stream
}
return out, nil
}
func tidyProtos(csv string) string {
seen := map[string]bool{}
var keep []string
for _, p := range strings.Split(csv, ",") {
base := p
// collapse TLSv1.2/TLSv1.3 -> TLS, etc.
for _, pre := range []string{"TLSv", "SSLv"} {
if strings.HasPrefix(p, pre) {
base = "TLS"
}
}
if base != "" && !seen[base] {
seen[base] = true
keep = append(keep, base)
}
}
sort.Strings(keep)
return strings.Join(keep, ",")
}
// ---------------------------------------------------------------- top talkers
type Endpoint struct {
Addr string `json:"addr"`
Packets int64 `json:"packets"`
Bytes int64 `json:"bytes"`
TxBytes int64 `json:"tx_bytes"`
RxBytes int64 `json:"rx_bytes"`
Start float64 `json:"start"`
End float64 `json:"end"`
}
func (e *Engine) Endpoints(ctx context.Context, id string, limit int) ([]Endpoint, error) {
if limit <= 0 || limit > 1000 {
limit = 100
}
// Union of per-direction aggregates keyed by address.
q := `
SELECT addr, SUM(pk), SUM(by), SUM(tx), SUM(rx), MIN(st), MAX(en) FROM (
SELECT src AS addr, COUNT(*) pk, SUM(len) by, SUM(len) tx, 0 rx, MIN(ts) st, MAX(ts) en FROM packets WHERE session_id=? GROUP BY src
UNION ALL
SELECT dst AS addr, COUNT(*) pk, SUM(len) by, 0 tx, SUM(len) rx, MIN(ts) st, MAX(ts) en FROM packets WHERE session_id=? GROUP BY dst
) GROUP BY addr ORDER BY SUM(by) DESC LIMIT ?`
rows, err := e.DB.QueryContext(ctx, q, id, id, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Endpoint{}
for rows.Next() {
var p Endpoint
if err := rows.Scan(&p.Addr, &p.Packets, &p.Bytes, &p.TxBytes, &p.RxBytes, &p.Start, &p.End); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// ---------------------------------------------------------------- statistics
type Stats struct {
Packets int64 `json:"packets"`
Bytes int64 `json:"bytes"`
Start float64 `json:"start"`
End float64 `json:"end"`
Protocols []NameCount `json:"protocols"`
SizeBuckets []NameCount `json:"size_buckets"`
Timeline []TimeBucket `json:"timeline"`
TCPStreams int64 `json:"tcp_streams"`
Counters map[string]int64 `json:"counters"`
}
type NameCount struct {
Name string `json:"name"`
Packets int64 `json:"packets"`
Bytes int64 `json:"bytes"`
}
type TimeBucket struct {
T float64 `json:"t"`
Packets int64 `json:"packets"`
Bytes int64 `json:"bytes"`
}
func (e *Engine) Statistics(ctx context.Context, id string, buckets int) (Stats, error) {
if buckets <= 0 || buckets > 2000 {
buckets = 300
}
var s Stats
s.Counters = map[string]int64{}
err := e.DB.QueryRowContext(ctx, `SELECT COUNT(*), COALESCE(SUM(len),0), COALESCE(MIN(ts),0), COALESCE(MAX(ts),0) FROM packets WHERE session_id=?`, id).
Scan(&s.Packets, &s.Bytes, &s.Start, &s.End)
if err != nil || s.Packets == 0 {
return s, err
}
e.DB.QueryRowContext(ctx, `SELECT COUNT(DISTINCT seg||'/'||stream) FROM packets WHERE session_id=? AND stream IS NOT NULL`, id).Scan(&s.TCPStreams)
rows, _ := e.DB.QueryContext(ctx, `SELECT proto, COUNT(*), SUM(len) FROM packets WHERE session_id=? GROUP BY proto ORDER BY 2 DESC LIMIT 40`, id)
for rows.Next() {
var n NameCount
rows.Scan(&n.Name, &n.Packets, &n.Bytes)
s.Protocols = append(s.Protocols, n)
}
rows.Close()
// Packet-size distribution (Wireshark's standard buckets).
sizeCase := `CASE
WHEN len<=63 THEN '0-63' WHEN len<=127 THEN '64-127' WHEN len<=255 THEN '128-255'
WHEN len<=511 THEN '256-511' WHEN len<=1023 THEN '512-1023' WHEN len<=1513 THEN '1024-1513'
ELSE '1514+' END`
rows, _ = e.DB.QueryContext(ctx, `SELECT `+sizeCase+` b, COUNT(*), SUM(len) FROM packets WHERE session_id=? GROUP BY b`, id)
order := map[string]int{"0-63": 0, "64-127": 1, "128-255": 2, "256-511": 3, "512-1023": 4, "1024-1513": 5, "1514+": 6}
for rows.Next() {
var n NameCount
rows.Scan(&n.Name, &n.Packets, &n.Bytes)
s.SizeBuckets = append(s.SizeBuckets, n)
}
rows.Close()
sort.Slice(s.SizeBuckets, func(i, j int) bool { return order[s.SizeBuckets[i].Name] < order[s.SizeBuckets[j].Name] })
// Timeline: fixed number of equal time buckets.
span := s.End - s.Start
if span <= 0 {
span = 1
}
width := span / float64(buckets)
rows, _ = e.DB.QueryContext(ctx, `SELECT CAST((ts-?)/? AS INTEGER) b, COUNT(*), SUM(len) FROM packets WHERE session_id=? GROUP BY b ORDER BY b`, s.Start, width, id)
for rows.Next() {
var b int
var n TimeBucket
rows.Scan(&b, &n.Packets, &n.Bytes)
if b >= buckets {
b = buckets - 1
}
n.T = s.Start + (float64(b)+0.5)*width
s.Timeline = append(s.Timeline, n)
}
rows.Close()
s.Counters["bucket_seconds"] = int64(width)
return s, nil
}
// ---------------------------------------------------------------- follow TCP stream
type StreamChunk struct {
Dir string `json:"dir"` // "ab" client→server, "ba" server→client
Hex string `json:"hex"`
Bytes int `json:"bytes"`
}
type FollowResult struct {
Seg int `json:"seg"`
Stream int `json:"stream"`
Chunks []StreamChunk `json:"chunks"`
AtoB int `json:"a_to_b"`
BtoA int `json:"b_to_a"`
Note string `json:"note,omitempty"`
Endpoint string `json:"endpoint,omitempty"`
}
// FollowTCP reconstructs one TCP stream's payload using tshark's own reassembly
// (`-z follow,tcp,raw`). Stream numbers are per-segment, so seg selects the file.
func (e *Engine) FollowTCP(ctx context.Context, id string, seg, stream int) (FollowResult, error) {
s, err := e.segmentFor(id, seg)
if err != nil {
return FollowResult{}, err
}
e.flushLive(id)
path := filepath.Join(e.sessionDir(id), s.File)
cmd := e.Tools.Command(ctx, 300, "-n", "-q", "-r", path, "-z", "follow,tcp,raw,"+strconv.Itoa(stream))
var errb bytes.Buffer
cmd.Stderr = &errb
out, err := cmd.Output()
if err != nil && !strings.Contains(errb.String(), "cut short") {
return FollowResult{}, errors.New(strings.TrimSpace(errb.String() + " " + err.Error()))
}
r := parseFollow(string(out))
r.Seg, r.Stream = seg, stream
if seg > 1 || !s.Complete {
r.Note = "Reconstructed within segment " + strconv.Itoa(seg) + "; a stream spanning segment boundaries is shown only in part."
}
return r, nil
}
// parseFollow parses `-z follow,tcp,raw` output. Lines of hex are payload;
// server→client lines are indented with a tab.
func parseFollow(s string) FollowResult {
var r FollowResult
for _, line := range strings.Split(s, "\n") {
if line == "" {
continue
}
if strings.HasPrefix(line, "=") || strings.HasPrefix(line, "Follow:") || strings.HasPrefix(line, "Filter:") || strings.HasPrefix(line, "Node ") {
if strings.HasPrefix(line, "Node ") {
if r.Endpoint != "" {
r.Endpoint += " ↔ "
}
r.Endpoint += strings.TrimSpace(strings.TrimPrefix(line, "Node"))[2:]
}
continue
}
dir := "ab"
h := line
if strings.HasPrefix(line, "\t") {
dir = "ba"
h = strings.TrimPrefix(line, "\t")
}
h = strings.TrimSpace(h)
if _, err := hex.DecodeString(h); err != nil || h == "" {
continue
}
n := len(h) / 2
if dir == "ab" {
r.AtoB += n
} else {
r.BtoA += n
}
// merge consecutive same-direction chunks for a cleaner view
if len(r.Chunks) > 0 && r.Chunks[len(r.Chunks)-1].Dir == dir {
last := &r.Chunks[len(r.Chunks)-1]
last.Hex += h
last.Bytes += n
} else {
r.Chunks = append(r.Chunks, StreamChunk{Dir: dir, Hex: h, Bytes: n})
}
}
return r
}
// ---------------------------------------------------------------- field extraction (DNS, TLS)
// extractFields runs tshark over every live segment with a display filter and a
// set of fields, returning rows of field values keyed with the global packet
// number. Used by the DNS and TLS explorers. Results are not cached (these are
// opened on demand and the captures are bounded); callers may paginate in the UI.
func (e *Engine) extractFields(ctx context.Context, id, filter string, fields []string, limit int) ([][]string, error) {
segs, err := e.Segments(id)
if err != nil {
return nil, err
}
e.flushLive(id)
args := []string{"-n", "-r", "", "-Y", filter, "-T", "fields", "-E", "separator=/t", "-E", "occurrence=a", "-E", "aggregator=,", "-E", "quote=n", "-e", "frame.number", "-e", "frame.time_epoch"}
for _, f := range fields {
args = append(args, "-e", f)
}
var out [][]string
for _, s := range segs {
if s.Deleted {
continue
}
a := append([]string{}, args...)
a[2] = filepath.Join(e.sessionDir(id), s.File)
cmd := e.Tools.Command(ctx, 600, a...)
var errb bytes.Buffer
cmd.Stderr = &errb
data, err := cmd.Output()
if err != nil && !strings.Contains(errb.String(), "cut short") {
return nil, fmt.Errorf("%s: %s", s.File, strings.TrimSpace(errb.String()))
}
for _, line := range strings.Split(string(data), "\n") {
if line == "" {
continue
}
cells := strings.Split(line, "\t")
// global packet number = segment base + local - 1
if local, err := strconv.ParseInt(cells[0], 10, 64); err == nil {
cells[0] = strconv.FormatInt(s.Base+local-1, 10)
}
cells[0] = cells[0] + "\x00" + strconv.Itoa(s.Seq) // keep seg for linking
out = append(out, cells)
if len(out) >= limit {
return out, nil
}
}
}
return out, nil
}
// ---------------------------------------------------------------- DNS explorer
type DNSRecord struct {
Num int64 `json:"num"`
Seg int `json:"seg"`
Time float64 `json:"time"`
Client string `json:"client"`
Server string `json:"server"`
Query string `json:"query"`
Type string `json:"type"`
Response bool `json:"response"`
RCode string `json:"rcode"`
Answers string `json:"answers"`
}
func splitNumSeg(s string) (int64, int) {
id, seg, _ := strings.Cut(s, "\x00")
n, _ := strconv.ParseInt(id, 10, 64)
sq, _ := strconv.Atoi(seg)
return n, sq
}
var dnsTypes = map[string]string{"1": "A", "28": "AAAA", "5": "CNAME", "15": "MX", "16": "TXT", "33": "SRV", "12": "PTR", "65": "HTTPS", "64": "SVCB", "2": "NS", "6": "SOA", "255": "ANY", "257": "CAA", "35": "NAPTR", "43": "DS", "48": "DNSKEY"}
var dnsRcodes = map[string]string{"0": "NOERROR", "1": "FORMERR", "2": "SERVFAIL", "3": "NXDOMAIN", "4": "NOTIMP", "5": "REFUSED"}
func (e *Engine) DNS(ctx context.Context, id string, limit int) ([]DNSRecord, error) {
if limit <= 0 || limit > 20000 {
limit = 5000
}
fields := []string{"ip.src", "ipv6.src", "ip.dst", "ipv6.dst", "dns.flags.response", "dns.qry.name", "dns.qry.type", "dns.flags.rcode", "dns.a", "dns.aaaa", "dns.cname", "dns.resp.name", "dns.ptr.domain_name"}
rowsF, err := e.extractFields(ctx, id, "dns && !mdns", fields, limit)
if err != nil {
return nil, err
}
out := []DNSRecord{}
for _, c := range rowsF {
if len(c) < 2+len(fields) {
continue
}
r := DNSRecord{}
r.Num, r.Seg = splitNumSeg(c[0])
r.Time, _ = strconv.ParseFloat(c[1], 64)
r.Client = firstNonEmpty(c[2], c[3])
r.Server = firstNonEmpty(c[4], c[5])
r.Response = c[6] == "1"
r.Query = c[7]
if t, ok := dnsTypes[c[8]]; ok {
r.Type = t
} else {
r.Type = c[8]
}
if rc, ok := dnsRcodes[c[9]]; ok {
r.RCode = rc
} else if c[9] != "" {
r.RCode = "rcode " + c[9]
}
// For a query the "client" is src and "server" is dst; for a response swap for readability.
if r.Response {
r.Client, r.Server = r.Server, r.Client
r.Answers = strings.Trim(strings.Join([]string{c[10], c[11], c[12], c[16]}, " "), " ")
}
out = append(out, r)
}
return out, nil
}
// ---------------------------------------------------------------- TLS explorer (metadata only)
type TLSRecord struct {
Num int64 `json:"num"`
Seg int `json:"seg"`
Time float64 `json:"time"`
Client string `json:"client"`
Server string `json:"server"`
ServerPort int `json:"server_port"`
SNI string `json:"sni"`
Version string `json:"version"`
Cipher string `json:"cipher"`
ALPN string `json:"alpn"`
Stream *int `json:"stream"`
}
type CertRecord struct {
Num int64 `json:"num"`
Seg int `json:"seg"`
Time float64 `json:"time"`
Server string `json:"server"`
Subject string `json:"subject"`
Issuer string `json:"issuer"`
SANs string `json:"sans"`
NotBefore string `json:"not_before"`
NotAfter string `json:"not_after"`
}
var tlsVersions = map[string]string{"0x0300": "SSL 3.0", "0x0301": "TLS 1.0", "0x0302": "TLS 1.1", "0x0303": "TLS 1.2", "0x0304": "TLS 1.3"}
// TLS returns observed TLS handshakes: ClientHello (SNI/ALPN) joined with the
// server's chosen version and cipher. This is metadata only; no payload is
// decrypted. The interception status is always "encrypted pass-through" in this
// version, which the UI states plainly.
func (e *Engine) TLS(ctx context.Context, id string, limit int) ([]TLSRecord, []CertRecord, error) {
if limit <= 0 || limit > 20000 {
limit = 5000
}
// ClientHello: SNI, ALPN, offered; ServerHello: negotiated version + cipher.
chFields := []string{"ip.src", "ipv6.src", "ip.dst", "ipv6.dst", "tcp.dstport", "tcp.srcport", "tcp.stream", "tls.handshake.type", "tls.handshake.extensions_server_name", "tls.handshake.version", "tls.record.version", "tls.handshake.ciphersuite", "tls.handshake.extensions_alpn_str"}
rowsF, err := e.extractFields(ctx, id, "tls.handshake.type==1 || tls.handshake.type==2", chFields, limit)
if err != nil {
return nil, nil, err
}
// Index ServerHello (type 2) by stream to join onto the ClientHello.
type sh struct{ version, cipher string }
byStream := map[string]sh{}
for _, c := range rowsF {
if len(c) < 2+len(chFields) || c[9] != "2" {
continue
}
byStream[c[8]] = sh{version: firstNonEmpty(c[11], c[12]), cipher: c[13]}
}
recs := []TLSRecord{}
for _, c := range rowsF {
if len(c) < 2+len(chFields) || c[9] != "1" {
continue
}
r := TLSRecord{}
r.Num, r.Seg = splitNumSeg(c[0])
r.Time, _ = strconv.ParseFloat(c[1], 64)
r.Client = firstNonEmpty(c[2], c[3])
r.Server = firstNonEmpty(c[4], c[5])
r.ServerPort, _ = strconv.Atoi(c[6])
r.SNI = c[10]
r.ALPN = c[14]
if st := c[8]; st != "" {
if v, err := strconv.Atoi(st); err == nil {
r.Stream = &v
}
if s, ok := byStream[st]; ok {
r.Version = tlsVersion(s.version)
r.Cipher = cipherName(s.cipher)
}
}
if r.Version == "" {
r.Version = tlsVersion(firstNonEmpty(c[11], c[12]))
}
recs = append(recs, r)
}
// Certificates (visible in TLS 1.2 and earlier; encrypted in TLS 1.3).
certFields := []string{"ip.src", "ipv6.src", "x509if.printableString", "x509sat.uTF8String", "x509ce.dNSName", "x509af.notBefore", "x509af.notAfter"}
certRows, err := e.extractFields(ctx, id, "tls.handshake.type==11", certFields, limit)
if err != nil {
return recs, nil, nil // handshake records are still useful
}
certs := []CertRecord{}
for _, c := range certRows {
if len(c) < 2+len(certFields) {
continue
}
cr := CertRecord{}
cr.Num, cr.Seg = splitNumSeg(c[0])
cr.Time, _ = strconv.ParseFloat(c[1], 64)
cr.Server = firstNonEmpty(c[2], c[3])
subs := strings.Split(firstNonEmpty(c[4], c[5]), ",")
if len(subs) > 0 {
cr.Subject = subs[0]
}
cr.SANs = c[6]
cr.NotBefore, cr.NotAfter = c[7], c[8]
certs = append(certs, cr)
}
return recs, certs, nil
}
func tlsVersion(code string) string {
if v, ok := tlsVersions[strings.ToLower(code)]; ok {
return v
}
return code
}
// cipherName renders a cipher-suite code. tshark prints the IANA code number;
// we keep the hex for precision rather than ship a full lookup table.
func cipherName(code string) string {
if code == "" {
return ""
}
if n, err := strconv.Atoi(code); err == nil {
return fmt.Sprintf("0x%04x", n)
}
return code
}
+53
View File
@@ -3,6 +3,7 @@ package capture
import (
"bytes"
"context"
hexpkg "encoding/hex"
"fmt"
"net"
"os"
@@ -196,3 +197,55 @@ func TestLiveCaptureRotation(t *testing.T) {
t.Fatal("no live packet events")
}
}
func TestAnalysisFromFixture(t *testing.T) {
e := newEngine(t)
ctx := context.Background()
f, _ := os.Open(fixture)
defer f.Close()
s, err := e.Import(ctx, "fixture", f)
if err != nil {
t.Fatal(err)
}
for i := 0; i < 100 && func() bool { x, _ := e.Get(s.ID); return x.State == "importing" }(); i++ {
time.Sleep(50 * time.Millisecond)
}
conv, err := e.Conversations(ctx, s.ID, "tcp")
if err != nil || len(conv) == 0 {
t.Fatalf("tcp conversations: %v %d", err, len(conv))
}
c := conv[0]
if c.Packets == 0 || c.Bytes == 0 || c.Stream == nil || (c.BytesAtoB+c.BytesBtoA) != c.Bytes {
t.Fatalf("conversation sums wrong: %+v", c)
}
// Follow the first TCP stream and check both directions carry payload.
fr, err := e.FollowTCP(ctx, s.ID, c.Seg, *c.Stream)
if err != nil || fr.AtoB == 0 || fr.BtoA == 0 {
t.Fatalf("follow: %v %+v", err, fr)
}
// The HTTP request bytes must appear in the client->server direction.
var ab string
for _, ch := range fr.Chunks {
if ch.Dir == "ab" {
ab += ch.Hex
}
}
raw, _ := hexpkg.DecodeString(ab)
if !strings.Contains(string(raw), "GET /") {
t.Fatalf("stream payload missing HTTP request")
}
st, err := e.Statistics(ctx, s.ID, 50)
if err != nil || st.Packets == 0 || len(st.Protocols) == 0 || len(st.Timeline) == 0 || st.TCPStreams == 0 {
t.Fatalf("stats: %v %+v", err, st)
}
eps, _ := e.Endpoints(ctx, s.ID, 10)
if len(eps) == 0 || eps[0].Bytes == 0 {
t.Fatalf("endpoints: %+v", eps)
}
if _, err := e.DNS(ctx, s.ID, 100); err != nil { // fixture uses mDNS; real DNS checked on hardware
t.Fatalf("dns: %v", err)
}
if _, _, err := e.TLS(ctx, s.ID, 100); err != nil {
t.Fatalf("tls: %v", err)
}
}
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>
);
}