diff --git a/README.md b/README.md index cb347fb..d1cf122 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,9 @@ Memory is roughly 16 bytes per packet for the index, plus about 16 MB of copy bu ``` main.go entry point, HTTP server -internal/web/ handlers, tool registry, embedded templates and CSS +internal/web/ handlers, tool registry, embedded templates +internal/web/static/ style.css: one stylesheet shared by all pages, following the + utel-frontend design; fonts/ and images/ copied from utel-frontend internal/pcap/ pcap file reader, writer, splitter and reorderer internal/inet/ IPv4/IPv6 length and checksum fixups, IP header locator internal/hexutil/ lenient hex decoding diff --git a/internal/pcap/reorder.go b/internal/pcap/reorder.go new file mode 100644 index 0000000..832b8ea --- /dev/null +++ b/internal/pcap/reorder.go @@ -0,0 +1,216 @@ +package pcap + +import ( + "cmp" + "errors" + "io" + "slices" + "sync" + "sync/atomic" +) + +// A reorderEntry is 16 bytes, which bounds the memory needed per packet. +type reorderEntry struct { + ts uint64 // seconds << 32 | fraction (µs or ns, consistent within a file) + pos uint64 // record offset << lenBits | record length (including the 16 byte record header) +} + +const ( + lenBits = 28 // fits MaxRecordLen + 16 + maxOffset = 1<<(64-lenBits) - 1 +) + +func (e reorderEntry) off() int64 { return int64(e.pos >> lenBits) } +func (e reorderEntry) len() int { return int(e.pos & (1< maxOffset { + return nil, errors.New("file too large to reorder") + } + ts := uint64(pr.order.Uint32(rec[0:4]))<<32 | uint64(pr.order.Uint32(rec[4:8])) + if ts < latest { + ro.outOfOrder++ + } + latest = max(latest, ts) + ro.entries = append(ro.entries, reorderEntry{ts: ts, pos: uint64(off)< 0 { + slices.SortStableFunc(ro.entries, func(a, b reorderEntry) int { return cmp.Compare(a.ts, b.ts) }) + } + return ro, nil +} + +// Packets returns the number of packets. +func (ro *Reorder) Packets() int { return len(ro.entries) } + +// OutOfOrder returns the number of packets with a timestamp earlier than a +// packet before them in the input. +func (ro *Reorder) OutOfOrder() int { return ro.outOfOrder } + +// Truncated reports whether the input ended with an incomplete packet +// record, which is left out of the output. +func (ro *Reorder) Truncated() bool { return ro.truncated } + +type batch struct { + buf []byte + err error +} + +// WriteTo writes the file header followed by the packets in timestamp order. +func (ro *Reorder) WriteTo(w io.Writer) (int64, error) { + if ro.outOfOrder == 0 { + return io.Copy(w, io.NewSectionReader(ro.r, 0, ro.end)) + } + + n, err := w.Write(ro.header) + total := int64(n) + if err != nil { + return total, err + } + + // A producer goroutine reads batches into two alternating buffers while + // this goroutine writes them out. + full := make(chan batch) + free := make(chan []byte, 2) + free <- nil + free <- nil + done := make(chan struct{}) + var wg sync.WaitGroup + defer func() { + close(done) + wg.Wait() + }() + wg.Go(func() { + defer close(full) + for i := 0; i < len(ro.entries); { + var buf []byte + select { + case buf = <-free: + case <-done: + return + } + var err error + i, buf, err = ro.readBatch(i, buf) + select { + case full <- batch{buf, err}: + case <-done: + return + } + if err != nil { + return + } + } + }) + + for b := range full { + if b.err != nil { + return total, b.err + } + n, err := w.Write(b.buf) + total += int64(n) + if err != nil { + return total, err + } + free <- b.buf + } + return total, nil +} + +// readBatch reads the records of entries[i:j] into buf, choosing j so that +// the batch stays within batchBytes and batchPackets (but holds at least one +// record). It returns j and the filled buffer. +func (ro *Reorder) readBatch(i int, buf []byte) (int, []byte, error) { + j, size := i, 0 + for j < len(ro.entries) && j-i < batchPackets && (j == i || size+ro.entries[j].len() <= batchBytes) { + size += ro.entries[j].len() + j++ + } + entries := ro.entries[i:j] + dst := make([]int, len(entries)+1) + for k, e := range entries { + dst[k+1] = dst[k] + e.len() + } + buf = slices.Grow(buf[:0], size)[:size] + + var ( + next atomic.Int64 + wg sync.WaitGroup + errs [readers]error + ) + for r := range min(readers, len(entries)) { + wg.Go(func() { + for { + k := int(next.Add(1) - 1) + if k >= len(entries) { + return + } + e := entries[k] + if _, err := ro.r.ReadAt(buf[dst[k]:dst[k+1]], e.off()); err != nil { + errs[r] = err + return + } + } + }) + } + wg.Wait() + return j, buf, errors.Join(errs[:]...) +} diff --git a/internal/pcap/reorder_test.go b/internal/pcap/reorder_test.go new file mode 100644 index 0000000..7fd4704 --- /dev/null +++ b/internal/pcap/reorder_test.go @@ -0,0 +1,195 @@ +package pcap + +import ( + "bytes" + "encoding/binary" + "errors" + "io" + "math/rand/v2" + "slices" + "strings" + "testing" + "time" +) + +// reorderTestFile builds a pcap file with one packet per timestamp; each +// packet's data is its index in the input. +func reorderTestFile(t *testing.T, ts ...time.Time) []byte { + t.Helper() + var buf bytes.Buffer + w, err := NewWriter(&buf, LinkTypeEthernet) + if err != nil { + t.Fatal(err) + } + for i, tm := range ts { + if err := w.WritePacket(tm, bytes.Repeat([]byte{byte(i)}, i+1)); err != nil { + t.Fatal(err) + } + } + return buf.Bytes() +} + +// packetIDs returns the first data byte of each packet in a pcap file. +func packetIDs(t *testing.T, file []byte) []int { + t.Helper() + r, err := NewReader(bytes.NewReader(file)) + if err != nil { + t.Fatal(err) + } + var ids []int + for { + rec, err := r.Next() + if err == io.EOF { + return ids + } + if err != nil { + t.Fatal(err) + } + ids = append(ids, int(rec[16])) + } +} + +func TestReorder(t *testing.T) { + s := func(sec int64, usec int) time.Time { return time.Unix(sec, int64(usec)*1000) } + tests := []struct { + name string + ts []time.Time + want []int + outOfOrder int + }{ + {"sorted", []time.Time{s(1, 0), s(1, 1), s(2, 0)}, []int{0, 1, 2}, 0}, + {"reversed", []time.Time{s(3, 0), s(2, 0), s(1, 0)}, []int{2, 1, 0}, 2}, + {"microseconds", []time.Time{s(5, 999999), s(5, 2), s(5, 10)}, []int{1, 2, 0}, 2}, + {"stable for equal timestamps", []time.Time{s(2, 0), s(1, 0), s(2, 0), s(1, 0)}, []int{1, 3, 0, 2}, 2}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + in := reorderTestFile(t, tt.ts...) + ro, err := NewReorder(bytes.NewReader(in), int64(len(in))) + if err != nil { + t.Fatal(err) + } + var out bytes.Buffer + n, err := ro.WriteTo(&out) + if err != nil { + t.Fatal(err) + } + if n != int64(len(in)) || out.Len() != len(in) { + t.Errorf("wrote %d (%d) bytes, want %d", n, out.Len(), len(in)) + } + if !bytes.Equal(out.Bytes()[:24], in[:24]) { + t.Error("file header changed") + } + if got := packetIDs(t, out.Bytes()); !slices.Equal(got, tt.want) { + t.Errorf("order %v, want %v", got, tt.want) + } + if ro.Packets() != len(tt.ts) || ro.OutOfOrder() != tt.outOfOrder || ro.Truncated() { + t.Errorf("packets %d outOfOrder %d truncated %v", ro.Packets(), ro.OutOfOrder(), ro.Truncated()) + } + }) + } +} + +func TestReorderTruncated(t *testing.T) { + in := reorderTestFile(t, time.Unix(2, 0), time.Unix(1, 0), time.Unix(0, 0)) + in = in[:len(in)-1] + ro, err := NewReorder(bytes.NewReader(in), int64(len(in))) + if err != nil { + t.Fatal(err) + } + var out bytes.Buffer + if _, err := ro.WriteTo(&out); err != nil { + t.Fatal(err) + } + if !ro.Truncated() || !slices.Equal(packetIDs(t, out.Bytes()), []int{1, 0}) { + t.Errorf("truncated %v, order %v", ro.Truncated(), packetIDs(t, out.Bytes())) + } +} + +func TestReorderErrors(t *testing.T) { + for name, tc := range map[string]struct { + in []byte + want string + }{ + "no packets": {reorderTestFile(t), "no packets"}, + "garbage": {bytes.Repeat([]byte("y"), 30), "unknown magic"}, + } { + _, err := NewReorder(bytes.NewReader(tc.in), int64(len(tc.in))) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Errorf("%s: error %v, want %q", name, err, tc.want) + } + } +} + +// TestReorderManyPackets covers multiple parallel batches, including packets +// that are bigger than a whole batch, against a simple reference sort. +func TestReorderManyPackets(t *testing.T) { + defer func(n int) { batchBytes = n }(batchBytes) + batchBytes = 64 << 10 + + r := rand.New(rand.NewPCG(1, 2)) + var buf bytes.Buffer + w, err := NewWriter(&buf, LinkTypeEthernet) + if err != nil { + t.Fatal(err) + } + type pkt struct { + ts time.Time + data []byte + } + var pkts []pkt + for i := range 50000 { + size := 8 + r.IntN(200) + if i%10000 == 5 { + size = batchBytes + 1000 // gets a batch by itself + } + data := make([]byte, size) + binary.LittleEndian.PutUint64(data, uint64(i)) + p := pkt{time.Unix(int64(r.IntN(1000)), int64(r.IntN(1000))*1000), data} + pkts = append(pkts, p) + if err := w.WritePacket(p.ts, p.data); err != nil { + t.Fatal(err) + } + } + slices.SortStableFunc(pkts, func(a, b pkt) int { return a.ts.Compare(b.ts) }) + + in := buf.Bytes() + ro, err := NewReorder(bytes.NewReader(in), int64(len(in))) + if err != nil { + t.Fatal(err) + } + var out bytes.Buffer + if _, err := ro.WriteTo(&out); err != nil { + t.Fatal(err) + } + var want bytes.Buffer + ww, _ := NewWriter(&want, LinkTypeEthernet) + for _, p := range pkts { + ww.WritePacket(p.ts, p.data) + } + if !bytes.Equal(out.Bytes(), want.Bytes()) { + t.Error("reordered output differs from reference sort") + } +} + +type failingWriter struct{ n int } + +func (f *failingWriter) Write(p []byte) (int, error) { + if f.n--; f.n < 0 { + return 0, errors.New("write failed") + } + return len(p), nil +} + +func TestReorderWriteError(t *testing.T) { + in := reorderTestFile(t, time.Unix(2, 0), time.Unix(1, 0)) + ro, err := NewReorder(bytes.NewReader(in), int64(len(in))) + if err != nil { + t.Fatal(err) + } + for n := range 2 { + if _, err := ro.WriteTo(&failingWriter{n: n}); err == nil { + t.Errorf("failing after %d writes: no error", n) + } + } +} diff --git a/internal/web/reorder.go b/internal/web/reorder.go new file mode 100644 index 0000000..092873b --- /dev/null +++ b/internal/web/reorder.go @@ -0,0 +1,53 @@ +package web + +import ( + "fmt" + "log" + "net/http" + "strconv" + + "pcap-web-tools/internal/pcap" +) + +type reorderPage struct { + MaxUpload string + Error string +} + +func (s *Server) handleReorderForm(w http.ResponseWriter, r *http.Request) { + s.render(w, http.StatusOK, "reorder.html", "/reorder", reorderPage{MaxUpload: s.maxUpload()}) +} + +func (s *Server) handleReorder(w http.ResponseWriter, r *http.Request) { + page := reorderPage{MaxUpload: s.maxUpload()} + fail := func(status int, msg string) { + page.Error = msg + s.render(w, status, "reorder.html", "/reorder", page) + } + + file, hdr, status, msg := s.parseUpload(w, r) + if status != 0 { + fail(status, msg) + return + } + defer file.Close() + + // Indexing reads the whole file, so problems are reported on the page + // before any of the download is sent. + ro, err := pcap.NewReorder(file, hdr.Size) + if err != nil { + fail(http.StatusBadRequest, hdr.Filename+": "+err.Error()) + return + } + + w.Header().Set("Content-Type", "application/vnd.tcpdump.pcap") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", uploadStem(hdr)+"_reordered.pcap")) + // Summary for scripted clients; browsers just save the file. + w.Header().Set("X-Pcap-Packets", strconv.Itoa(ro.Packets())) + w.Header().Set("X-Pcap-Out-Of-Order", strconv.Itoa(ro.OutOfOrder())) + w.Header().Set("X-Pcap-Truncated", strconv.FormatBool(ro.Truncated())) + if _, err := ro.WriteTo(w); err != nil { + // The response has already started, so the error cannot be shown on the page. + log.Printf("reorder %q: %v", hdr.Filename, err) + } +} diff --git a/internal/web/reorder_test.go b/internal/web/reorder_test.go new file mode 100644 index 0000000..a75395c --- /dev/null +++ b/internal/web/reorder_test.go @@ -0,0 +1,60 @@ +package web + +import ( + "bytes" + "io" + "net/http" + "strings" + "testing" + "time" + + "pcap-web-tools/internal/pcap" +) + +func TestReorderHandler(t *testing.T) { + srv, err := New(Config{MaxUploadBytes: 1 << 20}) + if err != nil { + t.Fatal(err) + } + var in bytes.Buffer + w, _ := pcap.NewWriter(&in, pcap.LinkTypeEthernet) + for _, sec := range []int64{3, 1, 2} { + w.WritePacket(time.Unix(sec, 0), []byte{byte(sec)}) + } + + rec := postUpload(t, srv, "/reorder", "trace.pcap", in.Bytes(), nil) + if rec.Code != http.StatusOK { + t.Fatalf("status %d: %s", rec.Code, rec.Body) + } + if cd := rec.Header().Get("Content-Disposition"); cd != `attachment; filename="trace_reordered.pcap"` { + t.Errorf("Content-Disposition %q", cd) + } + if got := rec.Header().Get("X-Pcap-Out-Of-Order"); got != "2" { + t.Errorf("X-Pcap-Out-Of-Order %q", got) + } + r, err := pcap.NewReader(rec.Body) + if err != nil { + t.Fatal(err) + } + var order []byte + for { + p, err := r.Next() + if err == io.EOF { + break + } + if err != nil { + t.Fatal(err) + } + order = append(order, p[16]) + } + if !bytes.Equal(order, []byte{1, 2, 3}) { + t.Errorf("order %v", order) + } + + for name, file := range map[string][]byte{"no file": nil, "not pcap": []byte("hello world, not a pcap file")} { + rec := postUpload(t, srv, "/reorder", "x.pcap", file, nil) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), `class="error"`) { + t.Errorf("%s: status %d", name, rec.Code) + } + } +} diff --git a/internal/web/static/fonts/Dazzed-Medium.otf b/internal/web/static/fonts/Dazzed-Medium.otf new file mode 100644 index 0000000..c5c1ab0 Binary files /dev/null and b/internal/web/static/fonts/Dazzed-Medium.otf differ diff --git a/internal/web/static/fonts/RobotoMono-Regular.ttf b/internal/web/static/fonts/RobotoMono-Regular.ttf new file mode 100644 index 0000000..6df2b25 Binary files /dev/null and b/internal/web/static/fonts/RobotoMono-Regular.ttf differ diff --git a/internal/web/static/fonts/inter-v12-latin-500.woff2 b/internal/web/static/fonts/inter-v12-latin-500.woff2 new file mode 100644 index 0000000..6fc94ad Binary files /dev/null and b/internal/web/static/fonts/inter-v12-latin-500.woff2 differ diff --git a/internal/web/static/fonts/inter-v12-latin-600.woff2 b/internal/web/static/fonts/inter-v12-latin-600.woff2 new file mode 100644 index 0000000..bc76d10 Binary files /dev/null and b/internal/web/static/fonts/inter-v12-latin-600.woff2 differ diff --git a/internal/web/static/fonts/inter-v12-latin-700.woff2 b/internal/web/static/fonts/inter-v12-latin-700.woff2 new file mode 100644 index 0000000..8fcc432 Binary files /dev/null and b/internal/web/static/fonts/inter-v12-latin-700.woff2 differ diff --git a/internal/web/static/fonts/inter-v12-latin-regular.woff2 b/internal/web/static/fonts/inter-v12-latin-regular.woff2 new file mode 100644 index 0000000..c659f5e Binary files /dev/null and b/internal/web/static/fonts/inter-v12-latin-regular.woff2 differ diff --git a/internal/web/static/images/favicon-16x16.png b/internal/web/static/images/favicon-16x16.png new file mode 100644 index 0000000..fc8f582 Binary files /dev/null and b/internal/web/static/images/favicon-16x16.png differ diff --git a/internal/web/static/images/favicon-32x32.png b/internal/web/static/images/favicon-32x32.png new file mode 100644 index 0000000..dd17f44 Binary files /dev/null and b/internal/web/static/images/favicon-32x32.png differ diff --git a/internal/web/static/style.css b/internal/web/static/style.css index 1b8d3b1..20c3851 100644 --- a/internal/web/static/style.css +++ b/internal/web/static/style.css @@ -1,127 +1,453 @@ +/* + * Shared stylesheet for all pcap tools pages. + * + * Follows the utel-frontend design: dark theme, utel color tokens, Inter / + * Dazzed / Roboto Mono fonts, top bar with a sidebar, "widget" cards and + * pill shaped buttons and inputs. + */ + +/* ---------- Fonts ---------- */ + +@font-face { font-family: "Inter"; font-weight: 400; font-display: swap; src: url("/static/fonts/inter-v12-latin-regular.woff2") format("woff2"); } +@font-face { font-family: "Inter"; font-weight: 500; font-display: swap; src: url("/static/fonts/inter-v12-latin-500.woff2") format("woff2"); } +@font-face { font-family: "Inter"; font-weight: 600; font-display: swap; src: url("/static/fonts/inter-v12-latin-600.woff2") format("woff2"); } +@font-face { font-family: "Inter"; font-weight: 700; font-display: swap; src: url("/static/fonts/inter-v12-latin-700.woff2") format("woff2"); } +@font-face { font-family: "Dazzed-Medium"; font-display: swap; src: url("/static/fonts/Dazzed-Medium.otf") format("opentype"); } +@font-face { font-family: "Roboto-mono"; font-display: swap; src: url("/static/fonts/RobotoMono-Regular.ttf") format("truetype"); } + +/* ---------- Tokens (from utel-frontend variables.scss) ---------- */ + :root { - --bg: #f7f7f5; - --fg: #1d1d1f; - --muted: #6b6b70; - --card: #ffffff; - --border: #d9d9de; - --accent: #2463eb; - --accent-fg: #ffffff; - --error-bg: #fdecec; - --error-fg: #9b1c1c; - --l2: #b45309; - --l3: #1d4ed8; - --l7: #047857; - --mono: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; + color-scheme: dark; + + --utel-body-background: #191c1c; + --utel-navigation-background: #1f2f2e; + --utel-navigation-background-darken: #15201f; + --utel-text-color: #fff; + --utel-scrollbar-color: #696969; + --utel-icon-label-text-color: #a7a7a7; + --utel-icon-color: #d2d2d2; + --utel-p-text-color: #d3d3d3; + --utel-title-text-color: #d8d8d8; + --utel-title-link-color: #a7a7a7; + --utel-navigation-darker-text-color: #e6e6e6; + --utel-content-background: #242828; + --utel-content-darker-background: #1c1f1f; + --utel-content-text-color: #d8d8d8; + --utel-highlight-text-color: #9ed7aa; + --utel-content-border-color: #707070ac; + --utel-modal-border-color: #303434; + --utel-solid-button-color: #6da478; + --utel-solid-button-color-darker: #5d9568; + --utel-widget-button-color: #3c4343; + --utel-widget-button-color-darker: #434b4b; + --utel-widget-title-border: #1c1f1f; + --utel-widget-border: #323737; + --utel-content-hover-color: #393d3d; + --utel-fraud-color-yellow: #e4b266; + --utel-fraud-color-red: #ea6f80; + --utel-table-color: #2d3232; + --utel-table-split-color: #242828; + --utel-table-header-text-color: #9a9a9a; + --utel-severity-color-low: #6daa9c; + --utel-input-border-color: #3b4040; + + --top-bar-height: 55px; + --sidebar-width: 220px; + --font-body: "Inter", system-ui, sans-serif; + --font-heading: "Dazzed-Medium", "Inter", sans-serif; + --font-mono: "Roboto-mono", ui-monospace, Menlo, Consolas, monospace; } -@media (prefers-color-scheme: dark) { - :root { - --bg: #161618; - --fg: #e8e8ea; - --muted: #9a9aa2; - --card: #1f1f22; - --border: #36363c; - --accent: #5b8cff; - --accent-fg: #0b0b0c; - --error-bg: #3a1717; - --error-fg: #ffb4b4; - --l2: #f5a65b; - --l3: #8fb0ff; - --l7: #5fd3a6; - } -} +/* ---------- Base ---------- */ -* { box-sizing: border-box; } +*, *::before, *::after { box-sizing: border-box; } + +html, body { margin: 0; } body { - margin: 0; - background: var(--bg); - color: var(--fg); - font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + background: var(--utel-body-background); + color: var(--utel-text-color); + font: 400 15px/1.5 var(--font-body); + scrollbar-color: var(--utel-scrollbar-color) transparent; + scrollbar-width: thin; } -header { border-bottom: 1px solid var(--border); background: var(--card); } -nav { max-width: 960px; margin: 0 auto; padding: 0.75rem 1rem; display: flex; gap: 1.25rem; flex-wrap: wrap; } -nav a { color: var(--muted); text-decoration: none; } -nav a:hover, nav a.active { color: var(--fg); } -nav a.brand { color: var(--fg); font-weight: 600; } +::-webkit-scrollbar { width: 7px; height: 7px; } +::-webkit-scrollbar-track { background: transparent; border-radius: 80px; } +::-webkit-scrollbar-thumb { background-color: var(--utel-scrollbar-color); border-radius: 80px; } -main { max-width: 960px; margin: 0 auto; padding: 1.5rem 1rem 3rem; } -h1 { margin: 0 0 0.5rem; font-size: 1.6rem; } -h2 { font-size: 1.25rem; margin: 0 0 0.75rem; } -h3 { font-size: 1rem; margin: 1.25rem 0 0.5rem; } -.lead { color: var(--muted); margin-top: 0; } -a { color: var(--accent); } -code { font-family: var(--mono); font-size: 0.9em; } -.hint { color: var(--muted); font-weight: normal; font-size: 0.85em; } +h1, h2, h3 { font-family: var(--font-heading); font-weight: normal; margin: 0; } +h1 { font-size: 20px; } +h2 { font-size: 16px; } +h3 { font-size: 15px; color: var(--utel-title-text-color); margin: 16px 0 8px; } -.tools { list-style: none; padding: 0; display: grid; gap: 0.75rem; } -.tools li { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 1rem; } -.tools a { font-weight: 600; font-size: 1.05rem; } -.tools p { margin: 0.25rem 0 0; color: var(--muted); } +p { color: var(--utel-p-text-color); } +a { color: var(--utel-highlight-text-color); } +code, pre { font-family: var(--font-mono); font-size: 13px; } -form label { display: block; font-weight: 600; margin: 1rem 0 0.3rem; } -textarea, input, select { - width: 100%; - font: inherit; - color: var(--fg); - background: var(--card); - border: 1px solid var(--border); - border-radius: 6px; - padding: 0.5rem 0.6rem; +.hint { color: var(--utel-icon-label-text-color); font-weight: 400; font-size: 13px; } +.note { color: var(--utel-icon-label-text-color); font-size: 14px; margin: 6px 0; } + +.visually-hidden { + position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; + overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } -textarea { font-family: var(--mono); font-size: 0.9rem; resize: vertical; } -textarea:focus, input:focus, select:focus { outline: 2px solid var(--accent); outline-offset: -1px; } -fieldset { border: 0; padding: 0; margin: 0; } -label.check { display: flex; gap: 0.5rem; align-items: baseline; flex-wrap: wrap; font-weight: 600; } -label.check input { width: auto; } -.row { display: grid; grid-template-columns: 2fr 1fr 1fr; gap: 1rem; } -@media (max-width: 720px) { .row { grid-template-columns: 1fr; gap: 0; } } +/* ---------- Top bar ---------- */ -.actions { margin-top: 1.25rem; display: flex; gap: 0.75rem; } -button { - font: inherit; - padding: 0.5rem 1.1rem; - border-radius: 6px; - border: 1px solid var(--border); - background: var(--card); - color: var(--fg); +.topbar { + position: sticky; + top: 0; + z-index: 20; + display: flex; + align-items: center; + height: var(--top-bar-height); + background: var(--utel-navigation-background); + border-bottom: 1px solid var(--utel-body-background); +} + +.burger { + display: grid; + place-items: center; + width: var(--top-bar-height); + height: var(--top-bar-height); + flex: none; + background: var(--utel-navigation-background-darken); cursor: pointer; } -button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-fg); } +.burger svg { width: 19px; fill: var(--utel-text-color); } +.nav-toggle:focus-visible + .topbar .burger { outline: 2px solid var(--utel-solid-button-color); outline-offset: -2px; } -.error { background: var(--error-bg); color: var(--error-fg); padding: 0.75rem 1rem; border-radius: 6px; margin: 1rem 0; } -.note { color: var(--muted); margin: 0.35rem 0; } +.app-title { + margin-left: 15px; + font: 16px/var(--top-bar-height) var(--font-heading); + color: var(--utel-text-color); + text-decoration: none; +} -.result { margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid var(--border); } -.summary { display: grid; grid-template-columns: max-content 1fr; gap: 0.25rem 1rem; margin: 0; } -.summary dt { color: var(--muted); } +.logo-symbol { + position: absolute; + left: 50%; + transform: translateX(-50%); + display: flex; +} +.logo-symbol svg { width: 40px; height: 40px; fill: var(--utel-text-color); } + +/* ---------- Sidebar ---------- */ + +.sidebar { + position: fixed; + top: var(--top-bar-height); + left: 0; + z-index: 10; + width: var(--sidebar-width); + height: calc(100% - var(--top-bar-height)); + overflow-y: auto; + background: var(--utel-navigation-background-darken); + transition: transform 0.15s ease; +} + +.nav-section { + display: flex; + align-items: center; + gap: 8px; + margin-top: 10px; + padding: 8px 15px; + color: var(--utel-icon-label-text-color); + font-size: 11px; + letter-spacing: 0.8px; + text-transform: uppercase; +} + +.nav-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 15px; + color: var(--utel-text-color); + font-weight: 500; + font-size: 15px; + text-decoration: none; +} +.nav-item:hover, .nav-item.active { background: var(--utel-navigation-background); } +.nav-item.active { color: var(--utel-highlight-text-color); } +.nav-item::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--utel-solid-button-color); + flex: none; +} + +/* + * The burger toggles the sidebar using a checkbox, so no JavaScript is needed. + * Wide screens show the sidebar by default and the burger hides it; narrow + * screens hide it by default and the burger shows it over the content. + */ +.content { margin-left: var(--sidebar-width); padding: 10px; transition: margin-left 0.15s ease; } +.nav-toggle:checked ~ .sidebar { transform: translateX(-100%); } +.nav-toggle:checked ~ .content { margin-left: 0; } + +@media (max-width: 64rem) { + .sidebar { transform: translateX(-100%); box-shadow: 4px 0 12px rgb(0 0 0 / 0.35); } + .nav-toggle:checked ~ .sidebar { transform: none; } + .content { margin-left: 0; } + .logo-symbol { display: none; } +} + +/* ---------- Page toolbar ---------- */ + +.toolbar { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 0 20px; + margin: 0 5px 10px; + font-size: 16px; +} +.breadcrumb { color: var(--utel-navigation-darker-text-color); } +.toolbar-link { color: var(--utel-title-link-color); text-decoration: none; } +.toolbar-link:hover { color: var(--utel-title-text-color); border-bottom: 1px solid var(--utel-title-text-color); } +.title-separator { margin: 0 5px 0 7px; color: var(--utel-title-link-color); } +.toolbar-title { color: var(--utel-title-text-color); } +.toolbar-info { color: var(--utel-icon-label-text-color); font-size: 14px; } + +/* ---------- Widgets ---------- */ + +.widget { + margin: 5px 5px 10px; + background: var(--utel-content-background); + border-radius: 5px; +} +.widget-title { + display: flex; + align-items: center; + gap: 10px; + min-height: 38px; + border-bottom: 1px solid var(--utel-widget-title-border); +} +.widget-title h2 { padding: 8px 0 8px 10px; color: var(--utel-title-text-color); } +.widget-title .hint { margin-left: auto; padding-right: 10px; } +.widget-content { padding: 10px; border-top: 1px solid var(--utel-widget-border); } +.widget-content > :first-child { margin-top: 0; } +.widget-content > :last-child { margin-bottom: 0; } + +.widget-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); } +.tool-card { display: flex; flex-direction: column; text-decoration: none; color: inherit; transition: background 0.1s; } +.tool-card:hover { background: var(--utel-content-hover-color); } +.tool-card .widget-content { flex: 1; color: var(--utel-p-text-color); font-size: 14px; } + +/* ---------- Buttons ---------- */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + height: 35px; + padding: 6px 16px; + border: 0; + border-radius: 80px; + font: 700 15px var(--font-body); + color: var(--utel-text-color); + background: var(--utel-widget-button-color); + cursor: pointer; + text-decoration: none; + white-space: nowrap; +} +.btn:hover { background: var(--utel-widget-button-color-darker); } +.btn:focus-visible { outline: 2px solid var(--utel-highlight-text-color); outline-offset: 2px; } +.btn.primary { background: var(--utel-solid-button-color); } +.btn.primary:hover { background: var(--utel-solid-button-color-darker); } +.btn:disabled { opacity: 0.5; cursor: not-allowed; } + +.actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 15px; } + +/* ---------- Forms ---------- */ + +label, .label { + display: block; + margin: 12px 0 4px; + color: var(--utel-content-text-color); + font-size: 14px; + font-weight: 500; +} + +input, select, textarea { + font: 400 14px var(--font-body); + color: var(--utel-content-text-color); +} + +/* Single line inputs and selects: utel pill style */ +input:not([type=checkbox], [type=radio], [type=file]), select { + width: 100%; + height: 32px; + padding: 0 15px; + background: var(--utel-content-background); + border: 1px solid var(--utel-input-border-color); + border-radius: 64px; +} +select { + appearance: none; + padding-right: 36px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' fill='none' stroke='%23a7a7a7' stroke-width='1.6'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 14px center; + background-size: 12px 8px; + text-overflow: ellipsis; +} +select option { background: var(--utel-content-background); } + +/* Multi-line hex input: same border, darker field, rounded rectangle */ +textarea { + display: block; + width: 100%; + padding: 8px 12px; + background: var(--utel-content-darker-background); + border: 1px solid var(--utel-input-border-color); + border-radius: 5px; + font-family: var(--font-mono); + font-size: 13px; + resize: vertical; +} + +::placeholder { color: var(--utel-icon-label-text-color); opacity: 1; } + +input:focus, select:focus, textarea:focus { outline: none; border-color: var(--utel-solid-button-color); } + +/* Checkbox and radio: 18px box with the utel green when checked */ +input[type=checkbox], input[type=radio] { + appearance: none; + flex: none; + width: 18px; + height: 18px; + margin: 0; + border: 1px solid var(--utel-content-border-color); + border-radius: 3px; + background: transparent; + display: grid; + place-items: center; + cursor: pointer; +} +input[type=radio] { border-radius: 50%; } +input[type=checkbox]:checked, input[type=radio]:checked { + background: var(--utel-solid-button-color); + border-color: var(--utel-solid-button-color); +} +input[type=checkbox]:checked::after { + content: ""; + width: 10px; + height: 6px; + margin-top: -3px; + border: solid var(--utel-text-color); + border-width: 0 0 2px 2px; + transform: rotate(-45deg); +} +input[type=radio]:checked::after { + content: ""; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--utel-text-color); +} +input[type=checkbox]:focus-visible, input[type=radio]:focus-visible { outline: 2px solid var(--utel-highlight-text-color); outline-offset: 2px; } + +label.check { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + font-weight: 400; + font-size: 15px; +} + +/* File input: the button looks like a secondary utel button */ +input[type=file] { + width: 100%; + padding: 4px; + border: 1px dashed var(--utel-input-border-color); + border-radius: 5px; + color: var(--utel-icon-label-text-color); +} +input[type=file]::file-selector-button { + height: 30px; + margin-right: 12px; + padding: 0 14px; + border: 0; + border-radius: 80px; + font: 700 14px var(--font-body); + color: var(--utel-text-color); + background: var(--utel-widget-button-color); + cursor: pointer; +} +input[type=file]::file-selector-button:hover { background: var(--utel-widget-button-color-darker); } + +fieldset { border: 0; padding: 0; margin: 0; min-width: 0; } +legend { padding: 0; margin: 12px 0 4px; font-size: 14px; font-weight: 500; color: var(--utel-content-text-color); } + +/* Three controls on one line: link type, custom link type, file name */ +.row { display: grid; grid-template-columns: 2fr 1fr 1fr; gap: 0 12px; align-items: start; } +.row label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.field-hint { display: block; margin: 4px 0 0 15px; } +@media (max-width: 720px) { .row { grid-template-columns: 1fr; } } + +/* Split modes: radio with inline number input and unit */ +.modes label.check { margin: 8px 0; } +.modes input.num { width: 8rem; } +.modes select.unit { width: 5.5rem; } + +/* ---------- Messages ---------- */ + +.error { + margin: 0 5px 10px; + padding: 10px 15px; + border: 1px solid var(--utel-fraud-color-red); + border-left-width: 4px; + border-radius: 5px; + background: rgb(234 111 128 / 0.1); + color: var(--utel-text-color); +} + +/* ---------- Tables (utel table-theme) ---------- */ + +table { width: 100%; border-collapse: collapse; font-size: 14px; } +th { + padding: 0 10px 5px 5px; + text-align: left; + font-weight: 400; + white-space: nowrap; + color: var(--utel-table-header-text-color); +} +td { + padding: 5px 10px 5px 5px; + font-weight: 500; + background: var(--utel-table-color); + border-top: 1px solid var(--utel-table-split-color); +} +tr.changed td:last-child { color: var(--utel-highlight-text-color); } + +/* ---------- hex2pcap result ---------- */ + +.summary { display: grid; grid-template-columns: max-content 1fr; gap: 4px 16px; margin: 0; font-size: 14px; } +.summary dt { color: var(--utel-icon-label-text-color); } .summary dd { margin: 0; } -table { border-collapse: collapse; width: 100%; background: var(--card); border: 1px solid var(--border); } -th, td { text-align: left; padding: 0.4rem 0.75rem; border-bottom: 1px solid var(--border); } -th { color: var(--muted); font-weight: 600; font-size: 0.85rem; } -tr.changed td:last-child { font-weight: 600; } - .dump { - font-family: var(--mono); - font-size: 0.85rem; - background: var(--card); - border: 1px solid var(--border); - border-radius: 6px; - padding: 0.75rem 1rem; + margin: 0; + padding: 10px 12px; overflow-x: auto; + background: var(--utel-content-darker-background); + border-radius: 5px; + color: var(--utel-p-text-color); + line-height: 1.6; } -.dump .off { color: var(--muted); } -.l2 { color: var(--l2); } -.l3 { color: var(--l3); } -.l7 { color: var(--l7); } -.chg { text-decoration: underline; text-underline-offset: 3px; font-weight: 700; } +.dump .off { color: var(--utel-table-header-text-color); } +.l2 { color: var(--utel-fraud-color-yellow); } +.l3 { color: var(--utel-severity-color-low); } +.l7 { color: var(--utel-p-text-color); } +.chg { text-decoration: underline; text-underline-offset: 3px; color: var(--utel-highlight-text-color); font-weight: 700; } -input[type=file] { padding: 0.4rem; } -.modes { margin-top: 1rem; } -.modes legend { font-weight: 600; margin-bottom: 0.3rem; } -.modes label.check { font-weight: normal; margin: 0.5rem 0; align-items: center; } -.modes input.num { width: 8rem; } -.modes select.unit { width: auto; } +/* A hint under a checkbox lines up with the checkbox's text */ +label.check + .field-hint { margin: 2px 0 0 26px; } diff --git a/internal/web/templates/hex2pcap.html b/internal/web/templates/hex2pcap.html index 6838702..6acc84b 100644 --- a/internal/web/templates/hex2pcap.html +++ b/internal/web/templates/hex2pcap.html @@ -1,35 +1,44 @@ {{define "title"}}Hex → pcap{{end}} +{{define "toolbar-info"}}Build a pcap file from hex · Load example{{end}} + {{define "content"}} {{with .Data}} -

Hex → pcap

-

- Paste hex for each layer. The fields are decoded and concatenated into one packet. - Any field may be empty, so you can also paste a whole packet into a single field. - Spaces, newlines, :, -, 0x and \x are ignored. - Load example. -

- {{if .Error}}{{end}}
- - +
+

Packet

+
+

+ Paste hex for each layer. The fields are decoded and concatenated into one packet. + Any field may be empty, so you can also paste a whole packet into a single field. + Spaces, newlines, :, -, 0x and \x are ignored. +

- - + + - - + + -
+ + +
+
+ +
+

Options

+
+

+ IPv4/IPv6 length, IPv4 header checksum, TCP/UDP/ICMP/ICMPv6/SCTP checksum, UDP length. + The end of the layer 3 field also sets the TCP data offset and the length of the SCTP chunk the payload belongs to + (paste chunk data without padding). +

@@ -39,55 +48,59 @@
- - + + + Used when “Custom…” is selected
- -
- - +
+ + +
+
{{with .Result}} -
-

Preview

-
-
Packet length
{{len .Packet}} bytes
-
Link type
{{.LinkTypeName}} ({{.LinkType}})
-
Layers
-
- layer 2: {{.L2Len}} · - layer 3: {{.L3Len}} · - payload: {{.L7Len}} -
-
+
+

Preview

+
+
+
Packet length
{{len .Packet}} bytes
+
Link type
{{.LinkTypeName}} ({{.LinkType}})
+
Layers
+
+ layer 2: {{.L2Len}} · + layer 3: {{.L3Len}} · + payload: {{.L7Len}} +
+
- {{if .Fixed}} -

Fixups IP header at offset {{.IPOffset}}

- - - - {{range .Fix.Changes}} - - {{end}} - -
FieldOldNew
{{.Field}}{{.Old}}{{.New}}
- {{range .Fix.Notes}}

{{.}}

{{end}} - {{end}} + {{if .Fixed}} +

Fixups IP header at offset {{.IPOffset}}

+ + + + {{range .Fix.Changes}} + + {{end}} + +
FieldOldNew
{{.Field}}{{.Old}}{{.New}}
+ {{range .Fix.Notes}}

{{.}}

{{end}} + {{end}} -

Hex dump changed bytes are underlined

-
+    

Hex dump changed bytes are underlined

+
 {{- range .Dump -}}
 {{.Offset}}  {{range .Bytes}}{{.Hex}}{{.Sep}}{{end}} {{.ASCII}}
 {{end -}}
 
+
{{end}} {{end}} diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html index a413945..53d7758 100644 --- a/internal/web/templates/index.html +++ b/internal/web/templates/index.html @@ -1,14 +1,14 @@ -{{define "title"}}Home{{end}} +{{define "title"}}Tools{{end}} + +{{define "toolbar-info"}}Small utilities for working with pcap packet captures{{end}} {{define "content"}} -

pcap tools

-

Small utilities for working with pcap packet captures.

- + {{end}} diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index 71b75a1..96061b7 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -3,17 +3,36 @@ + {{template "title" .}} · pcap tools + + -
- + +
+ + pcap tools + + +
-
+ +
+
+ + {{block "toolbar-info" .}}{{end}} +
{{template "content" .}}
diff --git a/internal/web/templates/reorder.html b/internal/web/templates/reorder.html new file mode 100644 index 0000000..2e7a6ab --- /dev/null +++ b/internal/web/templates/reorder.html @@ -0,0 +1,29 @@ +{{define "title"}}Reorder pcap{{end}} + +{{define "toolbar-info"}}Sort packets by timestamp, like reordercap{{end}} + +{{define "content"}} +{{with .Data}} +{{if .Error}}{{end}} + +
+
+

Reorder

+
+

+ Packets with the same timestamp keep their original order. Packet data and the file header are not changed. +

+ + + + +

If the capture ends with a truncated packet, that packet is dropped.

+ +
+ +
+
+
+
+{{end}} +{{end}} diff --git a/internal/web/templates/split.html b/internal/web/templates/split.html index 54d77a6..b6ef0c4 100644 --- a/internal/web/templates/split.html +++ b/internal/web/templates/split.html @@ -1,47 +1,51 @@ {{define "title"}}Split pcap{{end}} +{{define "toolbar-info"}}Split a pcap file by packet count or file size{{end}} + {{define "content"}} {{with .Data}} -

Split pcap

-

- Upload a pcap file and split it into several files by packet count or by file size. - The parts are downloaded together as a zip. - Packets are copied unchanged, and each part keeps the original file header. -

- {{if .Error}}{{end}}
- - +
+

Split

+
+

+ The parts are downloaded together as a zip. Packets are copied unchanged, and each part keeps the original file header. +

-
- Split by - - -
+ + -

- At most {{.MaxParts}} output files. If the capture ends with a truncated packet, that packet is dropped. -

+
+ Split by + + +
-
- +

+ 1 MB = 1,000,000 bytes. A packet bigger than the size limit gets a file to itself. + At most {{.MaxParts}} output files. If the capture ends with a truncated packet, that packet is dropped. +

+ +
+ +
+
{{end}} {{end}} diff --git a/internal/web/upload.go b/internal/web/upload.go new file mode 100644 index 0000000..a7099b0 --- /dev/null +++ b/internal/web/upload.go @@ -0,0 +1,42 @@ +package web + +import ( + "errors" + "fmt" + "mime/multipart" + "net/http" + "path" + "strings" +) + +// parseUpload parses a multipart form with a file in the "file" field. On +// failure it returns an HTTP status and a message for the page. +// +// Files bigger than 1 MB are spooled to temporary files rather than kept in +// memory; net/http removes them when the handler returns. +func (s *Server) parseUpload(w http.ResponseWriter, r *http.Request) (multipart.File, *multipart.FileHeader, int, string) { + // Allow some slack over the file size limit for the multipart framing and form fields. + r.Body = http.MaxBytesReader(w, r.Body, s.cfg.MaxUploadBytes+64<<10) + if err := r.ParseMultipartForm(1 << 20); err != nil { + var tooBig *http.MaxBytesError + if errors.As(err, &tooBig) { + return nil, nil, http.StatusRequestEntityTooLarge, "file too large; the maximum upload is " + s.maxUpload() + } + return nil, nil, http.StatusBadRequest, "bad upload: " + err.Error() + } + file, hdr, err := r.FormFile("file") + if err != nil { + return nil, nil, http.StatusBadRequest, "choose a pcap file" + } + return file, hdr, 0, "" +} + +func (s *Server) maxUpload() string { + return fmt.Sprintf("%d MB", s.cfg.MaxUploadBytes/1e6) +} + +// uploadStem returns the sanitized name of an uploaded file without its extension. +func uploadStem(hdr *multipart.FileHeader) string { + name := safeName(hdr.Filename, "capture.pcap") + return strings.TrimSuffix(name, path.Ext(name)) +}