Add a pcap-split tool
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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}}
|
||||
Reference in New Issue
Block a user