Add a pcap-split tool

This commit is contained in:
2026-10-05 20:16:01 +02:00
parent 5b5cf211d0
commit c11c893845
13 changed files with 769 additions and 22 deletions
+28
View File
@@ -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"
}
-15
View File
@@ -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
+3 -2
View File
@@ -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)
}
+12 -3
View File
@@ -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
}
+178
View File
@@ -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")
}
+128
View File
@@ -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)
}
}
}
+7
View File
@@ -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; }
+47
View File
@@ -0,0 +1,47 @@
{{define "title"}}Split pcap{{end}}
{{define "content"}}
{{with .Data}}
<h1>Split pcap</h1>
<p class="lead">
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.
</p>
{{if .Error}}<div class="error" role="alert">{{.Error}}</div>{{end}}
<form method="post" action="/split" enctype="multipart/form-data">
<label for="file">pcap file <span class="hint">classic pcap, up to {{.MaxUpload}}. Convert pcapng with <code>editcap -F pcap</code></span></label>
<input id="file" name="file" type="file" accept=".pcap,.cap,.dmp,application/vnd.tcpdump.pcap" required>
<fieldset class="modes">
<legend>Split by</legend>
<label class="check">
<input type="radio" name="mode" value="packets"{{if eq .Form.Mode "packets"}} checked{{end}}>
At most
<input name="packets" class="num" inputmode="numeric" value="{{.Form.Packets}}" aria-label="Packets per file">
packets per file
</label>
<label class="check">
<input type="radio" name="mode" value="size"{{if eq .Form.Mode "size"}} checked{{end}}>
At most
<input name="size" class="num" inputmode="decimal" value="{{.Form.Size}}" aria-label="Size per file">
<select name="unit" class="unit" aria-label="Size unit">
{{range .Units}}<option{{if eq . $.Data.Form.Unit}} selected{{end}}>{{.}}</option>{{end}}
</select>
per file
<span class="hint">1 MB = 1,000,000 bytes. A packet bigger than this gets a file to itself.</span>
</label>
</fieldset>
<p class="note">
At most {{.MaxParts}} output files. If the capture ends with a truncated packet, that packet is dropped.
</p>
<div class="actions">
<button type="submit" class="primary">Split and download zip</button>
</div>
</form>
{{end}}
{{end}}