Core packages: config validation, network renderers, root helper, auth, SQLite store, capture engine
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.
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user