Capture engine streams dumpcap pcapng into verbatim, hashed segments with per-segment tshark summaries; display filters, PDML dissection with verified byte ranges, export and import. Tests run in a Debian trixie container.
274 lines
8.0 KiB
Go
274 lines
8.0 KiB
Go
package capture
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Tools locates and runs Wireshark command-line tools. All tshark children run
|
|
// through `pilens rlimit-exec` so hostile input cannot exhaust the appliance.
|
|
type Tools struct {
|
|
Self string // path to the pilens binary (for rlimit-exec)
|
|
TShark string
|
|
Dumpcap string
|
|
MemMB int // RLIMIT_AS for tshark children
|
|
|
|
cols []string // summary -e fields, version dependent
|
|
emptyNG string // path to an empty pcapng used for filter validation
|
|
}
|
|
|
|
// summaryFields; the column fields are filled in by Init (names changed in Wireshark 4.2).
|
|
var summaryTail = []string{
|
|
"frame.len", "tcp.srcport", "udp.srcport", "tcp.dstport", "udp.dstport",
|
|
"tcp.stream", "tcp.flags.str", "ip.ttl", "ipv6.hlim", "vlan.id", "frame.coloring_rule.name",
|
|
}
|
|
|
|
// EmptyPcapng is a valid pcapng with one Ethernet interface and no packets.
|
|
func EmptyPcapng() []byte {
|
|
var b bytes.Buffer
|
|
le := binary.LittleEndian
|
|
w32 := func(v uint32) { binary.Write(&b, le, v) }
|
|
// SHB
|
|
w32(0x0A0D0D0A)
|
|
w32(28)
|
|
w32(0x1A2B3C4D)
|
|
binary.Write(&b, le, uint16(1))
|
|
binary.Write(&b, le, uint16(0))
|
|
binary.Write(&b, le, int64(-1))
|
|
w32(28)
|
|
// IDB: linktype 1 (Ethernet), snaplen 262144
|
|
w32(1)
|
|
w32(20)
|
|
binary.Write(&b, le, uint16(1))
|
|
binary.Write(&b, le, uint16(0))
|
|
w32(262144)
|
|
w32(20)
|
|
return b.Bytes()
|
|
}
|
|
|
|
func (t *Tools) Init(stateDir string) error {
|
|
if t.MemMB == 0 {
|
|
t.MemMB = 1536
|
|
}
|
|
p := stateDir + "/empty.pcapng"
|
|
if err := os.WriteFile(p, EmptyPcapng(), 0o640); err != nil {
|
|
return err
|
|
}
|
|
t.emptyNG = p
|
|
newCols := []string{"_ws.col.def_src", "_ws.col.def_dst", "_ws.col.protocol", "_ws.col.info"}
|
|
oldCols := []string{"_ws.col.Source", "_ws.col.Destination", "_ws.col.Protocol", "_ws.col.Info"}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
if _, err := t.run(ctx, "-n", "-r", p, "-T", "fields", "-e", newCols[3]); err == nil {
|
|
t.cols = newCols
|
|
} else if _, err2 := t.run(ctx, "-n", "-r", p, "-T", "fields", "-e", oldCols[3]); err2 == nil {
|
|
t.cols = oldCols
|
|
} else {
|
|
return fmt.Errorf("tshark unusable: %v", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Command builds a resource-limited tshark command. cpuSec 0 = no CPU limit.
|
|
func (t *Tools) Command(ctx context.Context, cpuSec int, args ...string) *exec.Cmd {
|
|
var cmd *exec.Cmd
|
|
if t.Self == "" { // tests only
|
|
cmd = exec.CommandContext(ctx, t.TShark, args...)
|
|
} else {
|
|
a := append([]string{"rlimit-exec", strconv.Itoa(t.MemMB), strconv.Itoa(cpuSec), "--", t.TShark}, args...)
|
|
cmd = exec.CommandContext(ctx, t.Self, a...)
|
|
}
|
|
cmd.Env = []string{"PATH=/usr/bin:/bin", "HOME=/nonexistent", "LANG=C.UTF-8"}
|
|
cmd.WaitDelay = 5 * time.Second
|
|
return cmd
|
|
}
|
|
|
|
func (t *Tools) run(ctx context.Context, args ...string) ([]byte, error) {
|
|
cmd := t.Command(ctx, 300, args...)
|
|
var stderr bytes.Buffer
|
|
cmd.Stderr = &stderr
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
msg := strings.TrimSpace(stderr.String())
|
|
if msg == "" {
|
|
msg = err.Error()
|
|
}
|
|
return out, errors.New(msg)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// SummaryArgs returns tshark arguments that print one tab-separated summary row per packet.
|
|
func (t *Tools) SummaryArgs(input string) []string {
|
|
a := []string{"-n", "-l", "--color", "-r", input, "-T", "fields", "-E", "separator=/t", "-E", "occurrence=f", "-E", "quote=n",
|
|
"-e", "frame.number", "-e", "frame.time_epoch", "-e", t.cols[0], "-e", t.cols[1], "-e", t.cols[2]}
|
|
for _, f := range summaryTail {
|
|
a = append(a, "-e", f)
|
|
}
|
|
return append(a, "-e", t.cols[3]) // info last: it may contain anything
|
|
}
|
|
|
|
// Row is one packet summary.
|
|
type Row struct {
|
|
Num int64 `json:"n"`
|
|
TS float64 `json:"t"`
|
|
Src string `json:"s"`
|
|
Dst string `json:"d"`
|
|
Proto string `json:"p"`
|
|
Len int `json:"l"`
|
|
Info string `json:"i"`
|
|
SPort *int `json:"sp,omitempty"`
|
|
DPort *int `json:"dp,omitempty"`
|
|
Stream *int `json:"st,omitempty"`
|
|
TCPFlags string `json:"tf,omitempty"`
|
|
TTL *int `json:"ttl,omitempty"`
|
|
VLAN *int `json:"vl,omitempty"`
|
|
Color string `json:"c,omitempty"`
|
|
Seg int `json:"-"`
|
|
Off int64 `json:"-"`
|
|
}
|
|
|
|
func atoiPtr(s string) *int {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
v, err := strconv.Atoi(s)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return &v
|
|
}
|
|
|
|
func firstNonEmpty(a, b string) string {
|
|
if a != "" {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
// ParseSummaryLine parses one line of SummaryArgs output. Frame numbers are local to the input.
|
|
func ParseSummaryLine(line string) (Row, error) {
|
|
f := strings.SplitN(line, "\t", 17)
|
|
if len(f) != 17 {
|
|
return Row{}, fmt.Errorf("unexpected tshark summary line (%d fields)", len(f))
|
|
}
|
|
var r Row
|
|
var err error
|
|
if r.Num, err = strconv.ParseInt(f[0], 10, 64); err != nil {
|
|
return r, err
|
|
}
|
|
r.TS, _ = strconv.ParseFloat(f[1], 64)
|
|
r.Src, r.Dst, r.Proto = f[2], f[3], f[4]
|
|
r.Len, _ = strconv.Atoi(f[5])
|
|
r.SPort = atoiPtr(firstNonEmpty(f[6], f[7]))
|
|
r.DPort = atoiPtr(firstNonEmpty(f[8], f[9]))
|
|
r.Stream = atoiPtr(f[10])
|
|
r.TCPFlags = f[11]
|
|
r.TTL = atoiPtr(firstNonEmpty(f[12], f[13]))
|
|
r.VLAN = atoiPtr(f[14])
|
|
r.Color = f[15]
|
|
r.Info = strings.TrimSpace(f[16])
|
|
return r, nil
|
|
}
|
|
|
|
// ReadSummaries parses tshark summary output, calling fn per row.
|
|
func ReadSummaries(r io.Reader, fn func(Row)) error {
|
|
sc := bufio.NewScanner(r)
|
|
sc.Buffer(make([]byte, 64*1024), 4<<20)
|
|
for sc.Scan() {
|
|
row, err := ParseSummaryLine(sc.Text())
|
|
if err != nil {
|
|
continue // ponytail: skip malformed lines rather than abort a capture; count if it ever matters
|
|
}
|
|
fn(row)
|
|
}
|
|
return sc.Err()
|
|
}
|
|
|
|
// ValidateDisplayFilter asks tshark to compile the filter against an empty file.
|
|
func (t *Tools) ValidateDisplayFilter(ctx context.Context, f string) error {
|
|
if err := sanityFilter(f); err != nil {
|
|
return err
|
|
}
|
|
_, err := t.run(ctx, "-n", "-r", t.emptyNG, "-Y", f)
|
|
return err
|
|
}
|
|
|
|
// ValidateCaptureFilter asks dumpcap to compile a BPF filter for iface.
|
|
func (t *Tools) ValidateCaptureFilter(ctx context.Context, iface, f string) error {
|
|
if err := sanityFilter(f); err != nil {
|
|
return err
|
|
}
|
|
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
|
defer cancel()
|
|
out, err := exec.CommandContext(ctx, t.Dumpcap, "-d", "-i", iface, "-f", f).CombinedOutput()
|
|
if err != nil {
|
|
return errors.New(strings.TrimSpace(string(out)))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func sanityFilter(f string) error {
|
|
if len(f) > 2048 {
|
|
return errors.New("filter too long")
|
|
}
|
|
for _, r := range f {
|
|
if r < 0x20 && r != '\t' {
|
|
return errors.New("filter contains control characters")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MatchFrames returns the local frame numbers in file that match a display filter.
|
|
// Truncated trailing data (a segment still being written) is tolerated.
|
|
func (t *Tools) MatchFrames(ctx context.Context, file, filter string) ([]int64, error) {
|
|
cmd := t.Command(ctx, 600, "-n", "-r", file, "-Y", filter, "-T", "fields", "-e", "frame.number")
|
|
var stderr bytes.Buffer
|
|
cmd.Stderr = &stderr
|
|
out, err := cmd.Output()
|
|
if err != nil && !strings.Contains(stderr.String(), "cut short") {
|
|
return nil, errors.New(strings.TrimSpace(stderr.String() + " " + err.Error()))
|
|
}
|
|
var nums []int64
|
|
for _, l := range strings.Split(string(out), "\n") {
|
|
if n, err := strconv.ParseInt(strings.TrimSpace(l), 10, 64); err == nil {
|
|
nums = append(nums, n)
|
|
}
|
|
}
|
|
return nums, nil
|
|
}
|
|
|
|
// PDML returns the PDML dissection for one local frame of file.
|
|
func (t *Tools) PDML(ctx context.Context, file string, local int64) ([]byte, error) {
|
|
cmd := t.Command(ctx, 120, "-n", "-r", file, "-Y", "frame.number=="+strconv.FormatInt(local, 10), "-T", "pdml")
|
|
var stderr bytes.Buffer
|
|
cmd.Stderr = &stderr
|
|
out, err := cmd.Output()
|
|
if err != nil && !strings.Contains(stderr.String(), "cut short") {
|
|
return nil, errors.New(strings.TrimSpace(stderr.String() + " " + err.Error()))
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (t *Tools) Version() string {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
out, err := exec.CommandContext(ctx, t.TShark, "--version").Output()
|
|
if err != nil {
|
|
return "unknown"
|
|
}
|
|
l, _, _ := strings.Cut(string(out), "\n")
|
|
return l
|
|
}
|