From c11c89384556196c11b797fb27667960c8a4dc7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nils=20O=2E=20Sel=C3=A5sdal?= Date: Mon, 5 Oct 2026 20:16:01 +0200 Subject: [PATCH] Add a pcap-split tool --- README.md | 20 +++- internal/pcap/reader.go | 91 +++++++++++++++ internal/pcap/split.go | 73 ++++++++++++ internal/pcap/split_test.go | 181 ++++++++++++++++++++++++++++++ internal/web/filename.go | 28 +++++ internal/web/hex2pcap.go | 15 --- internal/web/hex2pcap_test.go | 5 +- internal/web/server.go | 15 ++- internal/web/split.go | 178 +++++++++++++++++++++++++++++ internal/web/split_test.go | 128 +++++++++++++++++++++ internal/web/static/style.css | 7 ++ internal/web/templates/split.html | 47 ++++++++ main.go | 3 +- 13 files changed, 769 insertions(+), 22 deletions(-) create mode 100644 internal/pcap/reader.go create mode 100644 internal/pcap/split.go create mode 100644 internal/pcap/split_test.go create mode 100644 internal/web/filename.go create mode 100644 internal/web/split.go create mode 100644 internal/web/split_test.go create mode 100644 internal/web/templates/split.html diff --git a/README.md b/README.md index 109cf8e..765fc1a 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,12 @@ make run # or: go run . -addr localhost:8080 Then open http://localhost:8080. +Flags: +- `-addr`: listen address (default `localhost:8080`). +- `-max-upload-mb`: largest accepted file upload in MB (default 1024). + +Uploads larger than 1 MB are spooled to temporary files under `$TMPDIR` (default `/tmp`) rather than held in memory. The temporary files are removed after each request. + ## Tools ### Hex → pcap (`/hex2pcap`) @@ -36,12 +42,24 @@ Builds a single-packet pcap file from pasted hex. - **Link type**: *Auto* picks Raw IP when layer 2 is empty and layer 3 is set, and Ethernet otherwise. You can also pick a link type from the list or enter any numeric LINKTYPE. - **Preview** shows the fixups that were applied and a hex dump coloured by layer, with changed bytes underlined. **Download pcap** returns the file. +### Split pcap (`/split`) + +Splits an uploaded pcap file into parts with at most N packets or at most N KB/MB/GB each. Sizes are decimal (1 MB = 1,000,000 bytes) and count the 24-byte file header and the 16-byte per-packet record headers. + +- The parts are returned as one zip of `_001.pcap`, `_002.pcap`, ... +- Each part starts with the original file header, and packet records are copied byte for byte, so byte order and µs/ns timestamp precision are preserved. +- A packet larger than the size limit goes into a part by itself. +- If the capture ends with a truncated packet, that packet is dropped, and the zip comment says so. +- A split may produce at most 10,000 parts. +- The upload is checked completely before the zip is sent, so errors show on the page instead of as a broken download. +- Only classic pcap is supported. Convert pcapng first with `editcap -F pcap in.pcapng out.pcap`. + ## Layout ``` main.go entry point, HTTP server internal/web/ handlers, tool registry, embedded templates and CSS -internal/pcap/ pcap file writer +internal/pcap/ pcap file reader, writer and splitter internal/inet/ IPv4/IPv6 length and checksum fixups, IP header locator internal/hexutil/ lenient hex decoding ``` diff --git a/internal/pcap/reader.go b/internal/pcap/reader.go new file mode 100644 index 0000000..9a05cb5 --- /dev/null +++ b/internal/pcap/reader.go @@ -0,0 +1,91 @@ +package pcap + +import ( + "bufio" + "encoding/binary" + "errors" + "fmt" + "io" + "slices" +) + +// MaxRecordLen is the largest captured packet length Reader accepts; larger +// values are taken as a sign of a corrupt file. +const MaxRecordLen = 64 << 20 + +// ErrTruncated is returned by Reader.Next when the file ends in the middle of +// a packet record. +var ErrTruncated = errors.New("pcap: file ends with a truncated packet record") + +// Reader reads a classic pcap file, in either byte order and with microsecond +// or nanosecond timestamps. It returns records in their raw on-disk form so +// they can be copied unchanged to another file with the same header. +type Reader struct { + r *bufio.Reader + order binary.ByteOrder + header [24]byte + linkType uint32 + rec []byte + n int // records read +} + +// NewReader reads and validates the pcap file header. +func NewReader(r io.Reader) (*Reader, error) { + pr := &Reader{r: bufio.NewReaderSize(r, 1<<20)} + if _, err := io.ReadFull(pr.r, pr.header[:]); err != nil { + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { + return nil, errors.New("not a pcap file: shorter than the 24 byte file header") + } + return nil, err + } + switch magic := binary.LittleEndian.Uint32(pr.header[0:4]); magic { + case 0xa1b2c3d4, 0xa1b23c4d: // microsecond, nanosecond + pr.order = binary.LittleEndian + case 0xd4c3b2a1, 0x4d3cb2a1: + pr.order = binary.BigEndian + case 0x0a0d0d0a: + return nil, errors.New("this is a pcapng file, which is not supported; convert it with: editcap -F pcap in.pcapng out.pcap") + case 0xa1b2cd34, 0x34cdb2a1: + return nil, errors.New("modified (Kuznetzov) pcap files are not supported; convert with: editcap -F pcap in out.pcap") + default: + return nil, fmt.Errorf("not a pcap file: unknown magic number 0x%08x", magic) + } + pr.linkType = pr.order.Uint32(pr.header[20:24]) + return pr, nil +} + +// Header returns the raw 24 byte file header. +func (r *Reader) Header() []byte { return r.header[:] } + +// LinkType returns the link-layer header type from the file header. +func (r *Reader) LinkType() uint32 { return r.linkType } + +// Next returns the next raw packet record: the 16 byte record header followed +// by the captured data. The slice is only valid until the next call. At the +// end of the file Next returns io.EOF, or ErrTruncated if the last record is +// incomplete. +func (r *Reader) Next() ([]byte, error) { + if cap(r.rec) < 16 { + r.rec = make([]byte, 16, 64*1024) + } + r.rec = r.rec[:16] + if _, err := io.ReadFull(r.r, r.rec); err != nil { + if errors.Is(err, io.ErrUnexpectedEOF) { + return nil, ErrTruncated + } + return nil, err // io.EOF at a record boundary + } + inclLen := r.order.Uint32(r.rec[8:12]) + if inclLen > MaxRecordLen { + return nil, fmt.Errorf("corrupt pcap file: packet %d has captured length %d", r.n+1, inclLen) + } + r.rec = slices.Grow(r.rec, int(inclLen))[:16+inclLen] + if _, err := io.ReadFull(r.r, r.rec[16:]); err != nil { + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { + return nil, ErrTruncated + } + return nil, err + } + r.n++ + return r.rec, nil +} diff --git a/internal/pcap/split.go b/internal/pcap/split.go new file mode 100644 index 0000000..d1713cd --- /dev/null +++ b/internal/pcap/split.go @@ -0,0 +1,73 @@ +package pcap + +import ( + "errors" + "io" +) + +// SplitLimit bounds each output file of Split. Zero means no limit. +type SplitLimit struct { + Packets int // maximum packets per file + Bytes int64 // maximum file size, including the file and record headers +} + +// SplitStats summarizes a Split. +type SplitStats struct { + Packets int // packets copied + Parts int // files created + Truncated bool // the input ended with an incomplete packet record, which was dropped +} + +// Split copies the pcap file read from r into consecutive parts, each starting +// with the input's file header and holding whole packet records, so that no +// part exceeds the limit. A packet that is bigger than the size limit on its +// own goes into a part by itself. create is called to open part 1, 2, ... +func Split(r io.Reader, limit SplitLimit, create func(part int) (io.Writer, error)) (SplitStats, error) { + var st SplitStats + pr, err := NewReader(r) + if err != nil { + return st, err + } + + var ( + w io.Writer + nPackets int + nBytes int64 + ) + for { + rec, err := pr.Next() + if err == io.EOF { + break + } + if errors.Is(err, ErrTruncated) { + st.Truncated = true + break + } + if err != nil { + return st, err + } + + full := limit.Packets > 0 && nPackets >= limit.Packets || + limit.Bytes > 0 && nPackets > 0 && nBytes+int64(len(rec)) > limit.Bytes + if w == nil || full { + st.Parts++ + if w, err = create(st.Parts); err != nil { + return st, err + } + if _, err := w.Write(pr.Header()); err != nil { + return st, err + } + nPackets, nBytes = 0, int64(len(pr.Header())) + } + if _, err := w.Write(rec); err != nil { + return st, err + } + nPackets++ + nBytes += int64(len(rec)) + st.Packets++ + } + if st.Packets == 0 { + return st, errors.New("the pcap file contains no packets") + } + return st, nil +} diff --git a/internal/pcap/split_test.go b/internal/pcap/split_test.go new file mode 100644 index 0000000..fd967a8 --- /dev/null +++ b/internal/pcap/split_test.go @@ -0,0 +1,181 @@ +package pcap + +import ( + "bytes" + "encoding/binary" + "errors" + "io" + "slices" + "strings" + "testing" + "time" +) + +// testFile builds a pcap file with one packet per entry of sizes. +func testFile(t *testing.T, sizes ...int) []byte { + t.Helper() + var buf bytes.Buffer + w, err := NewWriter(&buf, LinkTypeEthernet) + if err != nil { + t.Fatal(err) + } + for i, n := range sizes { + if err := w.WritePacket(time.Unix(int64(i), 0), bytes.Repeat([]byte{byte(i)}, n)); err != nil { + t.Fatal(err) + } + } + return buf.Bytes() +} + +// splitParts runs Split and returns the parts' contents. +func splitParts(t *testing.T, in []byte, limit SplitLimit) ([][]byte, SplitStats, error) { + t.Helper() + var parts []*bytes.Buffer + st, err := Split(bytes.NewReader(in), limit, func(part int) (io.Writer, error) { + if part != len(parts)+1 { + t.Errorf("create(%d), want %d", part, len(parts)+1) + } + parts = append(parts, &bytes.Buffer{}) + return parts[len(parts)-1], nil + }) + out := make([][]byte, len(parts)) + for i, p := range parts { + out[i] = p.Bytes() + } + return out, st, err +} + +// packetSizes returns the captured lengths of the packets in a pcap file. +func packetSizes(t *testing.T, file []byte) []int { + t.Helper() + r, err := NewReader(bytes.NewReader(file)) + if err != nil { + t.Fatal(err) + } + var sizes []int + for { + rec, err := r.Next() + if err == io.EOF { + return sizes + } + if err != nil { + t.Fatal(err) + } + sizes = append(sizes, len(rec)-16) + } +} + +func TestSplit(t *testing.T) { + tests := []struct { + name string + sizes []int + limit SplitLimit + want [][]int // packet sizes per part + }{ + {"by packets", []int{10, 20, 30, 40, 50}, SplitLimit{Packets: 2}, [][]int{{10, 20}, {30, 40}, {50}}}, + {"by packets exact", []int{10, 20, 30, 40}, SplitLimit{Packets: 2}, [][]int{{10, 20}, {30, 40}}}, + {"one part", []int{10, 20}, SplitLimit{Packets: 5}, [][]int{{10, 20}}}, + // 24 byte header + 2*(16+20) = 96 fits exactly; a third packet does not. + {"by bytes", []int{20, 20, 20, 20, 20}, SplitLimit{Bytes: 96}, [][]int{{20, 20}, {20, 20}, {20}}}, + {"oversized packet gets its own part", []int{10, 500, 10}, SplitLimit{Bytes: 100}, [][]int{{10}, {500}, {10}}}, + {"both limits", []int{1, 1, 1, 100, 1}, SplitLimit{Packets: 2, Bytes: 100}, [][]int{{1, 1}, {1}, {100}, {1}}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + in := testFile(t, tt.sizes...) + parts, st, err := splitParts(t, in, tt.limit) + if err != nil { + t.Fatal(err) + } + if st.Parts != len(tt.want) || st.Packets != len(tt.sizes) || st.Truncated { + t.Errorf("stats %+v", st) + } + if len(parts) != len(tt.want) { + t.Fatalf("got %d parts, want %d", len(parts), len(tt.want)) + } + var joined []byte + for i, p := range parts { + if !bytes.Equal(p[:24], in[:24]) { + t.Errorf("part %d: file header %x, want %x", i+1, p[:24], in[:24]) + } + if got := packetSizes(t, p); !slices.Equal(got, tt.want[i]) { + t.Errorf("part %d: packet sizes %v, want %v", i+1, got, tt.want[i]) + } + if tt.limit.Bytes > 0 && len(tt.want[i]) > 1 && int64(len(p)) > tt.limit.Bytes { + t.Errorf("part %d: %d bytes exceeds limit %d", i+1, len(p), tt.limit.Bytes) + } + joined = append(joined, p[24:]...) + } + if !bytes.Equal(joined, in[24:]) { + t.Error("parts do not add up to the input records") + } + }) + } +} + +func TestSplitTruncated(t *testing.T) { + in := testFile(t, 10, 20, 30) + parts, st, err := splitParts(t, in[:len(in)-5], SplitLimit{Packets: 10}) + if err != nil { + t.Fatal(err) + } + if !st.Truncated || st.Packets != 2 || len(parts) != 1 { + t.Errorf("stats %+v", st) + } +} + +func TestSplitErrors(t *testing.T) { + pcapng := []byte{0x0a, 0x0d, 0x0d, 0x0a, 0x1c, 0, 0, 0, 0x4d, 0x3c, 0x2b, 0x1a, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} + corrupt := testFile(t, 10) + binary.LittleEndian.PutUint32(corrupt[24+8:], MaxRecordLen+1) + for name, tc := range map[string]struct { + in []byte + want string + }{ + "empty input": {nil, "shorter than"}, + "no packets": {testFile(t), "no packets"}, + "pcapng": {pcapng, "pcapng"}, + "garbage": {bytes.Repeat([]byte("x"), 40), "unknown magic"}, + "corrupt": {corrupt, "corrupt"}, + } { + _, _, err := splitParts(t, tc.in, SplitLimit{Packets: 1}) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Errorf("%s: error %v, want %q", name, err, tc.want) + } + } +} + +func TestSplitCreateError(t *testing.T) { + boom := errors.New("boom") + _, err := Split(bytes.NewReader(testFile(t, 1, 1)), SplitLimit{Packets: 1}, func(part int) (io.Writer, error) { + if part == 2 { + return nil, boom + } + return io.Discard, nil + }) + if !errors.Is(err, boom) { + t.Errorf("err %v, want %v", err, boom) + } +} + +func TestReaderByteOrderAndNanoseconds(t *testing.T) { + // Big endian, nanosecond resolution, link type 101, one 2 byte packet. + in := []byte{ + 0xa1, 0xb2, 0x3c, 0x4d, 0, 2, 0, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 0, 0, 0, 101, + 0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0, 2, 0, 0, 0, 2, 0xaa, 0xbb, + } + r, err := NewReader(bytes.NewReader(in)) + if err != nil { + t.Fatal(err) + } + if r.LinkType() != LinkTypeRaw { + t.Errorf("link type %d", r.LinkType()) + } + rec, err := r.Next() + if err != nil || !bytes.Equal(rec, in[24:]) { + t.Errorf("record %x, %v", rec, err) + } + if _, err := r.Next(); err != io.EOF { + t.Errorf("err %v, want EOF", err) + } +} diff --git a/internal/web/filename.go b/internal/web/filename.go new file mode 100644 index 0000000..740b2b6 --- /dev/null +++ b/internal/web/filename.go @@ -0,0 +1,28 @@ +package web + +import ( + "path" + "strings" +) + +// safeName reduces a user supplied file name to a safe base name: directory +// components are dropped and unusual characters replaced by '_'. fallback is +// returned if nothing usable is left. +func safeName(name, fallback string) string { + name = path.Base(strings.ReplaceAll(strings.TrimSpace(name), `\`, "/")) + name = strings.Map(func(r rune) rune { + if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.' { + return r + } + return '_' + }, name) + if strings.Trim(name, "._") == "" { + return fallback + } + return name +} + +// pcapFilename sanitizes a user supplied download name and gives it a .pcap extension. +func pcapFilename(name string) string { + return strings.TrimSuffix(safeName(name, "packet.pcap"), ".pcap") + ".pcap" +} diff --git a/internal/web/hex2pcap.go b/internal/web/hex2pcap.go index ffb52eb..b0284a9 100644 --- a/internal/web/hex2pcap.go +++ b/internal/web/hex2pcap.go @@ -202,21 +202,6 @@ func resolveLinkType(f hex2pcapForm, haveL2, haveL3 bool) (uint32, error) { return uint32(v), nil } -// pcapFilename sanitizes a user supplied download name. -func pcapFilename(name string) string { - name = strings.TrimSuffix(strings.TrimSpace(name), ".pcap") - name = strings.Map(func(r rune) rune { - if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.' { - return r - } - return '_' - }, name) - if strings.Trim(name, "._") == "" { - name = "packet" - } - return name + ".pcap" -} - type dumpByte struct { Hex string Class string // l2, l3, l7 or empty for padding diff --git a/internal/web/hex2pcap_test.go b/internal/web/hex2pcap_test.go index d46e6fc..26efff2 100644 --- a/internal/web/hex2pcap_test.go +++ b/internal/web/hex2pcap_test.go @@ -102,7 +102,8 @@ func TestPcapFilename(t *testing.T) { " ": "packet.pcap", "dns query": "dns_query.pcap", "x.pcap": "x.pcap", - "../etc/passwd": ".._etc_passwd.pcap", + "../etc/passwd": "passwd.pcap", + `C:\\x\\y.pcap`: "y.pcap", `a"b`: "a_b.pcap", } { if got := pcapFilename(in); got != want { @@ -112,7 +113,7 @@ func TestPcapFilename(t *testing.T) { } func TestHandlers(t *testing.T) { - srv, err := New() + srv, err := New(Config{MaxUploadBytes: 1 << 20}) if err != nil { t.Fatal(err) } diff --git a/internal/web/server.go b/internal/web/server.go index 1abacba..cf83a21 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -22,21 +22,28 @@ type Tool struct { var tools = []Tool{ {Name: "Hex → pcap", Path: "/hex2pcap", Description: "Build a pcap file from hex-encoded layer 2, layer 3 and payload bytes, optionally fixing IP lengths and checksums."}, + {Name: "Split pcap", Path: "/split", Description: "Split a pcap file into several files by packet count or file size, downloaded as a zip."}, +} + +// Config holds server settings. +type Config struct { + MaxUploadBytes int64 // largest accepted file upload } // Server is the HTTP handler for the whole application. type Server struct { + cfg Config mux *http.ServeMux pages map[string]*template.Template } // New parses the templates and sets up the routes. -func New() (*Server, error) { - s := &Server{mux: http.NewServeMux(), pages: map[string]*template.Template{}} +func New(cfg Config) (*Server, error) { + s := &Server{cfg: cfg, mux: http.NewServeMux(), pages: map[string]*template.Template{}} // Each page is parsed together with the shared layout so pages can define // their own "title" and "content" blocks. - for _, page := range []string{"index.html", "hex2pcap.html"} { + for _, page := range []string{"index.html", "hex2pcap.html", "split.html"} { t, err := template.New(page).ParseFS(assets, "templates/layout.html", "templates/"+page) if err != nil { return nil, err @@ -52,6 +59,8 @@ func New() (*Server, error) { s.mux.HandleFunc("GET /{$}", s.handleIndex) s.mux.HandleFunc("GET /hex2pcap", s.handleHex2PcapForm) s.mux.HandleFunc("POST /hex2pcap", s.handleHex2Pcap) + s.mux.HandleFunc("GET /split", s.handleSplitForm) + s.mux.HandleFunc("POST /split", s.handleSplit) return s, nil } diff --git a/internal/web/split.go b/internal/web/split.go new file mode 100644 index 0000000..e2d787c --- /dev/null +++ b/internal/web/split.go @@ -0,0 +1,178 @@ +package web + +import ( + "archive/zip" + "compress/flate" + "errors" + "fmt" + "io" + "log" + "math" + "net/http" + "path" + "strconv" + "strings" + "time" + + "pcap-web-tools/internal/pcap" +) + +// maxSplitParts caps the number of files one split may produce. +var maxSplitParts = 10000 + +var sizeUnits = []struct { + Name string + Bytes float64 +}{ + {"KB", 1e3}, {"MB", 1e6}, {"GB", 1e9}, +} + +type splitForm struct { + Mode string // "packets" or "size" + Packets string + Size string + Unit string +} + +type splitPage struct { + Form splitForm + Units []string + MaxUpload string + MaxParts int + Error string +} + +func (s *Server) splitPage(f splitForm) splitPage { + units := make([]string, len(sizeUnits)) + for i, u := range sizeUnits { + units[i] = u.Name + } + return splitPage{ + Form: f, + Units: units, + MaxUpload: fmt.Sprintf("%d MB", s.cfg.MaxUploadBytes/1e6), + MaxParts: maxSplitParts, + } +} + +func (s *Server) handleSplitForm(w http.ResponseWriter, r *http.Request) { + page := s.splitPage(splitForm{Mode: "size", Packets: "10000", Size: "10", Unit: "MB"}) + s.render(w, http.StatusOK, "split.html", "/split", page) +} + +func (s *Server) handleSplit(w http.ResponseWriter, r *http.Request) { + page := s.splitPage(splitForm{Mode: "size", Packets: "10000", Size: "10", Unit: "MB"}) + fail := func(status int, msg string) { + page.Error = msg + s.render(w, status, "split.html", "/split", page) + } + + // 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) + // Uploads bigger than 1 MB are spooled to temporary files rather than kept in memory. + if err := r.ParseMultipartForm(1 << 20); err != nil { + var tooBig *http.MaxBytesError + if errors.As(err, &tooBig) { + fail(http.StatusRequestEntityTooLarge, "file too large; the maximum upload is "+page.MaxUpload) + } else { + fail(http.StatusBadRequest, "bad upload: "+err.Error()) + } + return + } + defer r.MultipartForm.RemoveAll() + + page.Form = splitForm{ + Mode: r.FormValue("mode"), + Packets: r.FormValue("packets"), + Size: r.FormValue("size"), + Unit: r.FormValue("unit"), + } + limit, err := parseSplitLimit(page.Form) + if err != nil { + fail(http.StatusBadRequest, err.Error()) + return + } + file, hdr, err := r.FormFile("file") + if err != nil { + fail(http.StatusBadRequest, "choose a pcap file to split") + return + } + defer file.Close() + + // First pass: read the whole file and count the parts, so problems are + // reported on the page before any of the zip is sent. + st, err := pcap.Split(file, limit, func(part int) (io.Writer, error) { + if part > maxSplitParts { + return nil, fmt.Errorf("this would create more than %d files; use a larger limit", maxSplitParts) + } + return io.Discard, nil + }) + if err != nil { + fail(http.StatusBadRequest, hdr.Filename+": "+err.Error()) + return + } + if _, err := file.Seek(0, io.SeekStart); err != nil { + fail(http.StatusInternalServerError, err.Error()) + return + } + + name := safeName(hdr.Filename, "capture.pcap") + base := strings.TrimSuffix(name, path.Ext(name)) + digits := max(3, len(strconv.Itoa(st.Parts))) + + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", base+"_split.zip")) + zw := zip.NewWriter(w) + zw.RegisterCompressor(zip.Deflate, func(out io.Writer) (io.WriteCloser, error) { + return flate.NewWriter(out, flate.BestSpeed) + }) + if st.Truncated { + zw.SetComment("The input file ended with a truncated packet record, which was dropped.") + } + now := time.Now() + _, err = pcap.Split(file, limit, func(part int) (io.Writer, error) { + return zw.CreateHeader(&zip.FileHeader{ + Name: fmt.Sprintf("%s_%0*d.pcap", base, digits, part), + Method: zip.Deflate, + Modified: now, + }) + }) + if err == nil { + err = zw.Close() + } + if err != nil { + // The response has already started, so the error cannot be shown on the page. + log.Printf("split %q: %v", hdr.Filename, err) + } +} + +func parseSplitLimit(f splitForm) (pcap.SplitLimit, error) { + switch f.Mode { + case "packets": + n, err := strconv.Atoi(strings.TrimSpace(f.Packets)) + if err != nil || n < 1 { + return pcap.SplitLimit{}, errors.New("packets per file must be a whole number of at least 1") + } + return pcap.SplitLimit{Packets: n}, nil + + case "size": + v, err := strconv.ParseFloat(strings.TrimSpace(f.Size), 64) + if err != nil || math.IsNaN(v) || math.IsInf(v, 0) || v <= 0 { + return pcap.SplitLimit{}, errors.New("size per file must be a positive number") + } + for _, u := range sizeUnits { + if u.Name == f.Unit { + b := v * u.Bytes + if b < 1000 { + return pcap.SplitLimit{}, errors.New("size per file must be at least 1 KB") + } + if b > math.MaxInt64/2 { + return pcap.SplitLimit{}, errors.New("size per file is too large") + } + return pcap.SplitLimit{Bytes: int64(b)}, nil + } + } + return pcap.SplitLimit{}, fmt.Errorf("unknown size unit %q", f.Unit) + } + return pcap.SplitLimit{}, errors.New("choose whether to split by packet count or by file size") +} diff --git a/internal/web/split_test.go b/internal/web/split_test.go new file mode 100644 index 0000000..c85cf5c --- /dev/null +++ b/internal/web/split_test.go @@ -0,0 +1,128 @@ +package web + +import ( + "archive/zip" + "bytes" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "pcap-web-tools/internal/pcap" +) + +func testPcap(t *testing.T, packets int) []byte { + t.Helper() + var buf bytes.Buffer + w, err := pcap.NewWriter(&buf, pcap.LinkTypeEthernet) + if err != nil { + t.Fatal(err) + } + for i := range packets { + if err := w.WritePacket(time.Unix(int64(i), 0), make([]byte, 100)); err != nil { + t.Fatal(err) + } + } + return buf.Bytes() +} + +func postSplit(t *testing.T, srv *Server, filename string, file []byte, fields map[string]string) *httptest.ResponseRecorder { + t.Helper() + var body bytes.Buffer + mw := multipart.NewWriter(&body) + for k, v := range fields { + mw.WriteField(k, v) + } + if file != nil { + fw, err := mw.CreateFormFile("file", filename) + if err != nil { + t.Fatal(err) + } + fw.Write(file) + } + mw.Close() + req := httptest.NewRequest("POST", "/split", &body) + req.Header.Set("Content-Type", mw.FormDataContentType()) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + return rec +} + +func TestSplitHandler(t *testing.T) { + srv, err := New(Config{MaxUploadBytes: 1 << 20}) + if err != nil { + t.Fatal(err) + } + in := testPcap(t, 25) + + tests := []struct { + name string + fields map[string]string + want []int // packets per part + }{ + {"by packets", map[string]string{"mode": "packets", "packets": "10"}, []int{10, 10, 5}}, + // Each record is 116 bytes: 24 + 8*116 = 952 <= 1000, a ninth does not fit. + {"by size", map[string]string{"mode": "size", "size": "1", "unit": "KB"}, []int{8, 8, 8, 1}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rec := postSplit(t, srv, `C:\caps\my capture.pcap`, in, tt.fields) + if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "application/zip" { + t.Fatalf("status %d %s: %s", rec.Code, rec.Header().Get("Content-Type"), rec.Body) + } + if cd := rec.Header().Get("Content-Disposition"); cd != `attachment; filename="my_capture_split.zip"` { + t.Errorf("Content-Disposition %q", cd) + } + zr, err := zip.NewReader(bytes.NewReader(rec.Body.Bytes()), int64(rec.Body.Len())) + if err != nil { + t.Fatal(err) + } + if len(zr.File) != len(tt.want) { + t.Fatalf("got %d files, want %d", len(zr.File), len(tt.want)) + } + for i, f := range zr.File { + if want := "my_capture_00" + string(rune('1'+i)) + ".pcap"; f.Name != want { + t.Errorf("file %d named %q, want %q", i, f.Name, want) + } + if want := int64(24 + tt.want[i]*116); int64(f.UncompressedSize64) != want { + t.Errorf("%s: %d bytes, want %d", f.Name, f.UncompressedSize64, want) + } + } + }) + } +} + +func TestSplitHandlerErrors(t *testing.T) { + srv, err := New(Config{MaxUploadBytes: 4000}) + if err != nil { + t.Fatal(err) + } + defer func(n int) { maxSplitParts = n }(maxSplitParts) + maxSplitParts = 5 + + ok := map[string]string{"mode": "packets", "packets": "1"} + tests := []struct { + name string + file []byte + fields map[string]string + status int + want string + }{ + {"no file", nil, ok, http.StatusBadRequest, "choose a pcap file"}, + {"not pcap", []byte(strings.Repeat("x", 100)), ok, http.StatusBadRequest, "not a pcap file"}, + {"too large", testPcap(t, 700), ok, http.StatusRequestEntityTooLarge, "file too large"}, + {"too many parts", testPcap(t, 6), ok, http.StatusBadRequest, "more than 5 files"}, + {"bad packets", testPcap(t, 1), map[string]string{"mode": "packets", "packets": "0"}, http.StatusBadRequest, "at least 1"}, + {"bad size", testPcap(t, 1), map[string]string{"mode": "size", "size": "abc", "unit": "MB"}, http.StatusBadRequest, "positive number"}, + {"bad unit", testPcap(t, 1), map[string]string{"mode": "size", "size": "1", "unit": "XB"}, http.StatusBadRequest, "unknown size unit"}, + {"no mode", testPcap(t, 1), map[string]string{}, http.StatusBadRequest, "choose whether"}, + } + for _, tt := range tests { + rec := postSplit(t, srv, "x.pcap", tt.file, tt.fields) + if rec.Code != tt.status || !strings.Contains(rec.Body.String(), tt.want) { + t.Errorf("%s: status %d, body %.300s", tt.name, rec.Code, rec.Body) + } + } +} diff --git a/internal/web/static/style.css b/internal/web/static/style.css index e1bf5a4..1b8d3b1 100644 --- a/internal/web/static/style.css +++ b/internal/web/static/style.css @@ -118,3 +118,10 @@ tr.changed td:last-child { font-weight: 600; } .l3 { color: var(--l3); } .l7 { color: var(--l7); } .chg { text-decoration: underline; text-underline-offset: 3px; 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; } diff --git a/internal/web/templates/split.html b/internal/web/templates/split.html new file mode 100644 index 0000000..54d77a6 --- /dev/null +++ b/internal/web/templates/split.html @@ -0,0 +1,47 @@ +{{define "title"}}Split pcap{{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 by + + +
+ +

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

+ +
+ +
+
+{{end}} +{{end}} diff --git a/main.go b/main.go index 26e67d4..8ee3f01 100644 --- a/main.go +++ b/main.go @@ -12,9 +12,10 @@ import ( func main() { addr := flag.String("addr", "localhost:8080", "listen address") + maxUploadMB := flag.Int64("max-upload-mb", 1024, "largest accepted file upload in MB") flag.Parse() - srv, err := web.New() + srv, err := web.New(web.Config{MaxUploadBytes: *maxUploadMB * 1_000_000}) if err != nil { log.Fatal(err) }