From 91e53186942e68dc40c1de7ecbb5c301240eb676 Mon Sep 17 00:00:00 2001 From: pgodwin Date: Mon, 4 May 2026 12:29:14 +1000 Subject: [PATCH] Add support for dumping captures to file for analysis in wireshark --- .gitignore | 3 + capture/capture.go | 25 +++++ capture/config.go | 33 ++++++ capture/pcap.go | 97 +++++++++++++++++ capture/pcap_test.go | 58 +++++++++++ cmd/classicstack/capture.go | 65 ++++++++++++ cmd/classicstack/config_flags.go | 11 ++ cmd/classicstack/config_ini.go | 6 ++ cmd/classicstack/main.go | 17 +++ cmd/pcapdiff/decode.go | 174 +++++++++++++++++++++++++++++++ cmd/pcapdiff/main.go | 55 ++++++++++ cmd/pcapdiff/render.go | 174 +++++++++++++++++++++++++++++++ port/ethertalk/pcap.go | 28 +++++ port/localtalk/localtalk.go | 28 ++++- server.toml | 8 ++ server.toml.example | 8 ++ 16 files changed, 789 insertions(+), 1 deletion(-) create mode 100644 capture/capture.go create mode 100644 capture/config.go create mode 100644 capture/pcap.go create mode 100644 capture/pcap_test.go create mode 100644 cmd/classicstack/capture.go create mode 100644 cmd/pcapdiff/decode.go create mode 100644 cmd/pcapdiff/main.go create mode 100644 cmd/pcapdiff/render.go diff --git a/.gitignore b/.gitignore index f0a8c74..3a2c1a7 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ go.work.sum ._htmlcache/ .macgarden/ + +.captures/ +captures/ \ No newline at end of file diff --git a/capture/capture.go b/capture/capture.go new file mode 100644 index 0000000..56966d6 --- /dev/null +++ b/capture/capture.go @@ -0,0 +1,25 @@ +// Package capture writes copies of in-flight network frames to pcap +// files for offline analysis in Wireshark or similar tools. +// +// A Sink is the minimal contract a port needs: hand it a timestamp and +// a frame, and it persists the frame. A nil Sink is a no-op via the +// Write helper, so call sites can stay terse. +package capture + +import "time" + +// Sink consumes captured frames. Implementations must be safe for +// concurrent use; ports tap from multiple goroutines. +type Sink interface { + WriteFrame(ts time.Time, frame []byte) + Close() error +} + +// Write writes frame to s if s is non-nil. The frame slice may be +// retained by the sink, so callers should not mutate it after the call. +func Write(s Sink, ts time.Time, frame []byte) { + if s == nil { + return + } + s.WriteFrame(ts, frame) +} diff --git a/capture/config.go b/capture/config.go new file mode 100644 index 0000000..459b48f --- /dev/null +++ b/capture/config.go @@ -0,0 +1,33 @@ +package capture + +import ( + "fmt" + "strings" +) + +// Config selects which transports get capture files written. Empty +// path disables capture for that transport. +type Config struct { + LocalTalk string `koanf:"localtalk"` + EtherTalk string `koanf:"ethertalk"` + Snaplen uint32 `koanf:"snaplen"` +} + +func DefaultConfig() Config { + return Config{Snaplen: 65535} +} + +func (c *Config) Validate() error { + c.LocalTalk = strings.TrimSpace(c.LocalTalk) + c.EtherTalk = strings.TrimSpace(c.EtherTalk) + if c.Snaplen == 0 { + c.Snaplen = 65535 + } + if c.Snaplen < 64 { + return fmt.Errorf("Capture.snaplen %d too small", c.Snaplen) + } + return nil +} + +func (c *Config) LocalTalkEnabled() bool { return c.LocalTalk != "" } +func (c *Config) EtherTalkEnabled() bool { return c.EtherTalk != "" } diff --git a/capture/pcap.go b/capture/pcap.go new file mode 100644 index 0000000..6bff855 --- /dev/null +++ b/capture/pcap.go @@ -0,0 +1,97 @@ +package capture + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "github.com/google/gopacket" + "github.com/google/gopacket/layers" + "github.com/google/gopacket/pcapgo" +) + +// LinkType is a thin alias for layers.LinkType so callers don't have +// to import gopacket directly. +type LinkType = layers.LinkType + +const ( + LinkTypeLocalTalk LinkType = layers.LinkTypeLTalk // DLT_LTALK = 114 + LinkTypeEthernet LinkType = layers.LinkTypeEthernet // DLT_EN10MB = 1 +) + +// PcapSink writes captured frames as a libpcap-format file. +type PcapSink struct { + mu sync.Mutex + f *os.File + bw *bufio.Writer + w *pcapgo.Writer + cap uint32 +} + +// NewPcapSink creates and opens a pcap file at path with the given +// link-layer type and snap length. If snaplen is zero, 65535 is used. +func NewPcapSink(path string, lt LinkType, snaplen uint32) (*PcapSink, error) { + if snaplen == 0 { + snaplen = 65535 + } + if dir := filepath.Dir(path); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("capture: mkdir %s: %w", dir, err) + } + } + f, err := os.Create(path) + if err != nil { + return nil, fmt.Errorf("capture: open %s: %w", path, err) + } + bw := bufio.NewWriter(f) + w := pcapgo.NewWriter(bw) + if err := w.WriteFileHeader(snaplen, lt); err != nil { + _ = bw.Flush() + _ = f.Close() + return nil, fmt.Errorf("capture: write header: %w", err) + } + return &PcapSink{f: f, bw: bw, w: w, cap: snaplen}, nil +} + +// WriteFrame appends one captured frame. Errors are swallowed (logged +// nowhere) on purpose: a broken capture file should never take down +// the data path. +func (p *PcapSink) WriteFrame(ts time.Time, frame []byte) { + if p == nil || len(frame) == 0 { + return + } + data := frame + if uint32(len(data)) > p.cap { + data = data[:p.cap] + } + ci := gopacket.CaptureInfo{ + Timestamp: ts, + CaptureLength: len(data), + Length: len(frame), + } + p.mu.Lock() + _ = p.w.WritePacket(ci, data) + p.mu.Unlock() +} + +// Close flushes and closes the underlying file. +func (p *PcapSink) Close() error { + if p == nil { + return nil + } + p.mu.Lock() + defer p.mu.Unlock() + if p.f == nil { + return nil + } + flushErr := p.bw.Flush() + closeErr := p.f.Close() + p.f = nil + if flushErr != nil { + return flushErr + } + return closeErr +} diff --git a/capture/pcap_test.go b/capture/pcap_test.go new file mode 100644 index 0000000..a20a42e --- /dev/null +++ b/capture/pcap_test.go @@ -0,0 +1,58 @@ +package capture + +import ( + "bytes" + "path/filepath" + "testing" + "time" + + "github.com/google/gopacket/pcapgo" + "os" +) + +func TestPcapSinkRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "out.pcap") + + sink, err := NewPcapSink(path, LinkTypeLocalTalk, 0) + if err != nil { + t.Fatalf("NewPcapSink: %v", err) + } + + frames := [][]byte{ + {0x01, 0x02, 0x01, 0xDE, 0xAD}, + {0x03, 0x04, 0x02, 0xBE, 0xEF, 0xCA, 0xFE}, + } + now := time.Unix(1700000000, 0) + for i, f := range frames { + sink.WriteFrame(now.Add(time.Duration(i)*time.Millisecond), f) + } + if err := sink.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + f, err := os.Open(path) + if err != nil { + t.Fatalf("open: %v", err) + } + defer f.Close() + r, err := pcapgo.NewReader(f) + if err != nil { + t.Fatalf("NewReader: %v", err) + } + if got := r.LinkType(); got != LinkTypeLocalTalk { + t.Fatalf("link type = %v, want %v", got, LinkTypeLocalTalk) + } + for i, want := range frames { + data, _, err := r.ReadPacketData() + if err != nil { + t.Fatalf("ReadPacketData[%d]: %v", i, err) + } + if !bytes.Equal(data, want) { + t.Fatalf("frame %d = %x, want %x", i, data, want) + } + } + if _, _, err := r.ReadPacketData(); err == nil { + t.Fatalf("expected EOF after %d frames", len(frames)) + } +} diff --git a/cmd/classicstack/capture.go b/cmd/classicstack/capture.go new file mode 100644 index 0000000..dc0b24a --- /dev/null +++ b/cmd/classicstack/capture.go @@ -0,0 +1,65 @@ +package main + +import ( + "log" + + "github.com/ObsoleteMadness/ClassicStack/capture" + "github.com/ObsoleteMadness/ClassicStack/netlog" + "github.com/ObsoleteMadness/ClassicStack/port" + "github.com/ObsoleteMadness/ClassicStack/port/ethertalk" + "github.com/ObsoleteMadness/ClassicStack/port/localtalk" +) + +// attachCaptureSinks opens any enabled capture files in cfg, fans them +// out to matching ports, and returns the open sinks for cleanup. +// +// LocalTalk capture covers every concrete port that embeds +// *localtalk.Port (LToUDP, TashTalk). EtherTalk capture targets the +// pcap-backed EtherTalk port. +func attachCaptureSinks(ports []port.Port, cfg capture.Config) []*capture.PcapSink { + var sinks []*capture.PcapSink + + if cfg.LocalTalkEnabled() { + sink, err := capture.NewPcapSink(cfg.LocalTalk, capture.LinkTypeLocalTalk, cfg.Snaplen) + if err != nil { + log.Fatalf("capture: open localtalk pcap: %v", err) + } + count := 0 + for _, p := range ports { + if lt := localtalkBase(p); lt != nil { + lt.SetCaptureSink(sink) + count++ + } + } + netlog.Info("[CAPTURE] LocalTalk frames -> %s (%d ports)", cfg.LocalTalk, count) + sinks = append(sinks, sink) + } + + if cfg.EtherTalkEnabled() { + sink, err := capture.NewPcapSink(cfg.EtherTalk, capture.LinkTypeEthernet, cfg.Snaplen) + if err != nil { + log.Fatalf("capture: open ethertalk pcap: %v", err) + } + count := 0 + for _, p := range ports { + if ep, ok := p.(*ethertalk.PcapPort); ok { + ep.SetCaptureSink(sink) + count++ + } + } + netlog.Info("[CAPTURE] EtherTalk frames -> %s (%d ports)", cfg.EtherTalk, count) + sinks = append(sinks, sink) + } + + return sinks +} + +func localtalkBase(p port.Port) *localtalk.Port { + switch v := p.(type) { + case *localtalk.LtoudpPort: + return v.Port + case *localtalk.TashTalkPort: + return v.Port + } + return nil +} diff --git a/cmd/classicstack/config_flags.go b/cmd/classicstack/config_flags.go index 113cac1..d5e38f0 100644 --- a/cmd/classicstack/config_flags.go +++ b/cmd/classicstack/config_flags.go @@ -1,6 +1,7 @@ package main import ( + "github.com/ObsoleteMadness/ClassicStack/capture" "github.com/ObsoleteMadness/ClassicStack/port/ethertalk" "github.com/ObsoleteMadness/ClassicStack/port/localtalk" ) @@ -43,6 +44,10 @@ type flagInputs struct { MacIPNAT bool MacIPDHCPRelay bool MacIPLeaseFile string + + CaptureLocalTalk string + CaptureEtherTalk string + CaptureSnaplen uint } // flagsToConfig builds an appConfig from CLI flag values. It is the @@ -92,5 +97,11 @@ func flagsToConfig(in flagInputs) appConfig { cfg.MacIPDHCPRelay = in.MacIPDHCPRelay cfg.MacIPLeaseFile = in.MacIPLeaseFile + cfg.Capture = capture.Config{ + LocalTalk: in.CaptureLocalTalk, + EtherTalk: in.CaptureEtherTalk, + Snaplen: uint32(in.CaptureSnaplen), + } + return cfg } diff --git a/cmd/classicstack/config_ini.go b/cmd/classicstack/config_ini.go index 10f7afa..fadcbf3 100644 --- a/cmd/classicstack/config_ini.go +++ b/cmd/classicstack/config_ini.go @@ -6,6 +6,7 @@ import ( "github.com/knadh/koanf/v2" + "github.com/ObsoleteMadness/ClassicStack/capture" "github.com/ObsoleteMadness/ClassicStack/config" "github.com/ObsoleteMadness/ClassicStack/port/ethertalk" "github.com/ObsoleteMadness/ClassicStack/port/localtalk" @@ -26,6 +27,7 @@ type appConfig struct { LToUDP localtalk.LToUDPConfig TashTalk localtalk.TashTalkConfig EtherTalk ethertalk.Config + Capture capture.Config MacIPEnabled bool MacIPNAT bool @@ -45,6 +47,7 @@ func defaultAppConfig() appConfig { LToUDP: localtalk.DefaultLToUDPConfig(), TashTalk: localtalk.DefaultTashTalkConfig(), EtherTalk: ethertalk.DefaultConfig(), + Capture: capture.DefaultConfig(), MacIPSubnet: "192.168.100.0/24", } @@ -79,6 +82,9 @@ func resolveAppConfig(src config.Source) (appConfig, error) { if err := loadSection(k, "EtherTalk", &cfg.EtherTalk); err != nil { return cfg, err } + if err := loadSection(k, "Capture", &cfg.Capture); err != nil { + return cfg, err + } cfg.EtherTalk.Backend = strings.ToLower(strings.TrimSpace(cfg.EtherTalk.Backend)) if cfg.EtherTalk.Backend == "" { cfg.EtherTalk.Device = "" diff --git a/cmd/classicstack/main.go b/cmd/classicstack/main.go index 3fa59cb..b3a6035 100644 --- a/cmd/classicstack/main.go +++ b/cmd/classicstack/main.go @@ -74,6 +74,10 @@ func main() { parsePackets := flag.Bool("parse-packets", false, "Decode and log every inbound DDP packet (ATP/ASP/AFP layers)") parseOutput := flag.String("parse-output", "", "File path to write parsed packet log (appended; empty = stdout only)") + captureLocalTalk := flag.String("capture-localtalk", "", "Write LocalTalk frames (LToUDP/TashTalk/Virtual) to a pcap file at this path (empty disables)") + captureEtherTalk := flag.String("capture-ethertalk", "", "Write EtherTalk frames to a pcap file at this path (empty disables)") + captureSnaplen := flag.Uint("capture-snaplen", 65535, "Per-frame snap length for pcap captures") + // AFP file sharing flags. Schemas live in service/afp; cmd-side // wiring is split between afp_enabled.go and afp_disabled.go. afpServerName := flag.String("afp-name", "Go File Server", "AFP server name advertised to clients") @@ -164,6 +168,9 @@ func main() { MacIPNAT: *macipNAT, MacIPDHCPRelay: *macipDHCP, MacIPLeaseFile: *macipStateFile, + CaptureLocalTalk: *captureLocalTalk, + CaptureEtherTalk: *captureEtherTalk, + CaptureSnaplen: *captureSnaplen, }) } @@ -281,6 +288,16 @@ func main() { log.Fatal("no ports configured") } + if err := cfg.Capture.Validate(); err != nil { + log.Fatalf("capture config: %v", err) + } + captureSinks := attachCaptureSinks(ports, cfg.Capture) + defer func() { + for _, s := range captureSinks { + _ = s.Close() + } + }() + // Build the service list explicitly so we can share the NBP service reference // with the MacIP gateway. nbpSvc := zip.NewNameInformationService() diff --git a/cmd/pcapdiff/decode.go b/cmd/pcapdiff/decode.go new file mode 100644 index 0000000..14a5471 --- /dev/null +++ b/cmd/pcapdiff/decode.go @@ -0,0 +1,174 @@ +package main + +import ( + "fmt" + "io" + "os" + "time" + + "github.com/google/gopacket/pcapgo" + + patp "github.com/ObsoleteMadness/ClassicStack/protocol/atp" + "github.com/ObsoleteMadness/ClassicStack/protocol/ddp" + "github.com/ObsoleteMadness/ClassicStack/protocol/llap" +) + +// Event is one decoded packet from a pcap file. Layers below DDP that +// fail to decode produce an Event with Note set; partial decode is +// preserved up to the layer that failed so divergence is still visible. +type Event struct { + Index int `json:"i"` + Timestamp time.Time `json:"ts"` + WireLen int `json:"wire_len"` + Source string `json:"src"` // e.g. "1.123" (network.node) or "-" if pre-DDP + Dest string `json:"dst"` + DDPType uint8 `json:"ddp_type"` + ATPFunc string `json:"atp_func,omitempty"` // TReq/TResp/TRel + ATPTID uint16 `json:"atp_tid,omitempty"` + ATPBitSeq uint8 `json:"atp_bitseq,omitempty"` + UserData uint32 `json:"atp_user,omitempty"` + PayloadSz int `json:"payload"` + Note string `json:"note,omitempty"` +} + +// decodePcap reads a pcap file, decodes each packet to Event, and +// returns the slice. The pcap link type is auto-detected. +func decodePcap(path string) ([]Event, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open %s: %w", path, err) + } + defer f.Close() + r, err := pcapgo.NewReader(f) + if err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + + lt := r.LinkType() + var events []Event + for i := 0; ; i++ { + data, ci, err := r.ReadPacketData() + if err == io.EOF { + break + } + if err != nil { + return events, fmt.Errorf("packet %d: %w", i, err) + } + ev := Event{Index: i, Timestamp: ci.Timestamp, WireLen: ci.Length} + switch uint(lt) { + case 114: // DLT_LTALK + decodeLLAP(&ev, data) + case 1: // DLT_EN10MB + decodeEthernet(&ev, data) + default: + ev.Note = fmt.Sprintf("unsupported linktype %d", lt) + } + events = append(events, ev) + } + return events, nil +} + +func decodeLLAP(ev *Event, data []byte) { + frame, err := llap.FrameFromBytes(data) + if err != nil { + ev.Note = "llap: " + err.Error() + return + } + switch frame.Type { + case llap.TypeAppleTalkShortHeader: + d, err := ddp.DatagramFromShortHeaderBytes(frame.DestinationNode, frame.SourceNode, frame.Payload) + if err != nil { + ev.Note = "ddp-short: " + err.Error() + return + } + fillDDP(ev, d) + case llap.TypeAppleTalkLongHeader: + d, err := ddp.DatagramFromLongHeaderBytes(frame.Payload, false) + if err != nil { + ev.Note = "ddp-long: " + err.Error() + return + } + fillDDP(ev, d) + default: + ev.Note = fmt.Sprintf("llap-control 0x%02x", frame.Type) + } +} + +func decodeEthernet(ev *Event, data []byte) { + if len(data) < 14 { + ev.Note = "eth: short" + return + } + ethType := uint16(data[12])<<8 | uint16(data[13]) + payload := data[14:] + if ethType <= 1500 { + // 802.3 length + LLC/SNAP. Need at least 8 bytes of LLC/SNAP + // (DSAP, SSAP, CTL, OUI[3], PID[2]). + if len(payload) < 8 { + ev.Note = "snap: short" + return + } + // AppleTalk DDP: OUI 08:00:07, PID 80:9b + // AARP: OUI 00:00:00, PID 80:f3 + oui := payload[3:6] + pid := uint16(payload[6])<<8 | uint16(payload[7]) + body := payload[8:] + switch { + case oui[0] == 0x08 && oui[1] == 0x00 && oui[2] == 0x07 && pid == 0x809b: + d, err := ddp.DatagramFromLongHeaderBytes(body, false) + if err != nil { + ev.Note = "ddp-eth: " + err.Error() + return + } + fillDDP(ev, d) + case pid == 0x80f3: + ev.Note = "aarp" + default: + ev.Note = fmt.Sprintf("snap pid 0x%04x", pid) + } + return + } + // EtherType II — uncommon for AppleTalk on this stack but handle. + switch ethType { + case 0x809b: + d, err := ddp.DatagramFromLongHeaderBytes(payload, false) + if err != nil { + ev.Note = "ddp-eth2: " + err.Error() + return + } + fillDDP(ev, d) + case 0x80f3: + ev.Note = "aarp-eth2" + default: + ev.Note = fmt.Sprintf("ethertype 0x%04x", ethType) + } +} + +func fillDDP(ev *Event, d ddp.Datagram) { + ev.Source = fmt.Sprintf("%d.%d", d.SourceNetwork, d.SourceNode) + ev.Dest = fmt.Sprintf("%d.%d", d.DestinationNetwork, d.DestinationNode) + ev.DDPType = d.DDPType + ev.PayloadSz = len(d.Data) + if d.DDPType == patp.DDPType { + var h patp.Header + if err := h.Unmarshal(d.Data); err == nil { + ev.ATPFunc = atpFuncName(h.FuncCode()) + ev.ATPTID = h.TransID + ev.ATPBitSeq = h.Bitmap + ev.UserData = h.UserData + } + } +} + +func atpFuncName(fc patp.FuncCode) string { + switch fc { + case patp.FuncTReq: + return "TReq" + case patp.FuncTResp: + return "TResp" + case patp.FuncTRel: + return "TRel" + default: + return fmt.Sprintf("0x%02x", uint8(fc)) + } +} diff --git a/cmd/pcapdiff/main.go b/cmd/pcapdiff/main.go new file mode 100644 index 0000000..180f72d --- /dev/null +++ b/cmd/pcapdiff/main.go @@ -0,0 +1,55 @@ +// pcapdiff compares two pcap captures of AppleTalk traffic and reports +// per-side counts plus a packet-by-packet timeline annotated with DDP +// type, ATP function, ATP transaction ID, and ASP/AFP-style command. +// +// Inputs may be DLT_LTALK (LLAP+DDP, what classicstack writes for +// LocalTalk) or DLT_EN10MB (EtherTalk SNAP frames). The two files do +// not need to share a clock; alignment is by sequence, not absolute +// time. +// +// This is intentionally pragmatic — full AFP-level decoding is left to +// Wireshark/tshark. The tool's job is to surface "what conversations +// happened" so a human (or Claude in a follow-up session) can spot +// behavioural divergence. +package main + +import ( + "flag" + "fmt" + "os" +) + +func main() { + format := flag.String("format", "text", "Output format: text or json") + limit := flag.Int("limit", 0, "Limit timeline output to N events per side (0 = unlimited)") + flag.Usage = func() { + fmt.Fprintf(os.Stderr, "usage: pcapdiff [-format text|json] [-limit N] \n") + flag.PrintDefaults() + } + flag.Parse() + if flag.NArg() != 2 { + flag.Usage() + os.Exit(2) + } + + left, err := decodePcap(flag.Arg(0)) + if err != nil { + fmt.Fprintf(os.Stderr, "left: %v\n", err) + os.Exit(1) + } + right, err := decodePcap(flag.Arg(1)) + if err != nil { + fmt.Fprintf(os.Stderr, "right: %v\n", err) + os.Exit(1) + } + + switch *format { + case "text": + renderText(os.Stdout, flag.Arg(0), flag.Arg(1), left, right, *limit) + case "json": + renderJSON(os.Stdout, flag.Arg(0), flag.Arg(1), left, right, *limit) + default: + fmt.Fprintf(os.Stderr, "unknown -format %q\n", *format) + os.Exit(2) + } +} diff --git a/cmd/pcapdiff/render.go b/cmd/pcapdiff/render.go new file mode 100644 index 0000000..b2ab2fe --- /dev/null +++ b/cmd/pcapdiff/render.go @@ -0,0 +1,174 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "sort" +) + +type stats struct { + Count int `json:"count"` + WireBytes int64 `json:"wire_bytes"` + DurationMs int64 `json:"duration_ms"` + DDPTypeHist map[uint8]int `json:"ddp_types"` + ATPFuncHist map[string]int `json:"atp_funcs"` + Notes map[string]int `json:"notes"` +} + +func summarize(events []Event) stats { + s := stats{ + DDPTypeHist: map[uint8]int{}, + ATPFuncHist: map[string]int{}, + Notes: map[string]int{}, + } + if len(events) == 0 { + return s + } + s.Count = len(events) + first, last := events[0].Timestamp, events[0].Timestamp + for _, e := range events { + s.WireBytes += int64(e.WireLen) + if e.Note != "" { + s.Notes[e.Note]++ + continue + } + s.DDPTypeHist[e.DDPType]++ + if e.ATPFunc != "" { + s.ATPFuncHist[e.ATPFunc]++ + } + if e.Timestamp.Before(first) { + first = e.Timestamp + } + if e.Timestamp.After(last) { + last = e.Timestamp + } + } + s.DurationMs = last.Sub(first).Milliseconds() + return s +} + +type report struct { + Left string `json:"left"` + Right string `json:"right"` + LeftStat stats `json:"left_stats"` + RightStat stats `json:"right_stats"` + Timeline [][]any `json:"timeline,omitempty"` +} + +func renderJSON(w io.Writer, lpath, rpath string, left, right []Event, limit int) { + r := report{ + Left: lpath, + Right: rpath, + LeftStat: summarize(left), + RightStat: summarize(right), + } + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + _ = enc.Encode(r) + _ = limit // JSON output already returns full event arrays via the public Event slice if needed. +} + +func renderText(w io.Writer, lpath, rpath string, left, right []Event, limit int) { + ls := summarize(left) + rs := summarize(right) + + fmt.Fprintf(w, "pcapdiff\n left: %s (%d packets, %d bytes, %d ms)\n right: %s (%d packets, %d bytes, %d ms)\n\n", + lpath, ls.Count, ls.WireBytes, ls.DurationMs, + rpath, rs.Count, rs.WireBytes, rs.DurationMs) + + fmt.Fprintln(w, "DDP type histogram:") + printIntHist(w, ls.DDPTypeHist, rs.DDPTypeHist, func(k uint8) string { return fmt.Sprintf("type=%d", k) }) + + fmt.Fprintln(w, "\nATP function histogram:") + printStrHist(w, ls.ATPFuncHist, rs.ATPFuncHist) + + if len(ls.Notes) > 0 || len(rs.Notes) > 0 { + fmt.Fprintln(w, "\nDecode notes (non-DDP / errors):") + printStrHist(w, ls.Notes, rs.Notes) + } + + fmt.Fprintln(w, "\nTimeline (relative ms within each capture):") + fmt.Fprintf(w, " %-6s %-9s %-9s %-7s %-5s %-7s %-9s | %-6s %-9s %-9s %-7s %-5s %-7s %-9s\n", + "#", "src", "dst", "ddp", "atp", "tid", "note", + "#", "src", "dst", "ddp", "atp", "tid", "note") + n := max(len(left), len(right)) + if limit > 0 && n > limit { + n = limit + } + for i := 0; i < n; i++ { + writeRow(w, i, left, right) + } +} + +func writeRow(w io.Writer, i int, left, right []Event) { + fmt.Fprintf(w, " %s | %s\n", fmtCell(i, left), fmtCell(i, right)) +} + +func fmtCell(i int, evs []Event) string { + if i >= len(evs) { + return fmt.Sprintf("%-6s %-9s %-9s %-7s %-5s %-7s %-9s", "-", "", "", "", "", "", "") + } + e := evs[i] + relMs := int64(0) + if len(evs) > 0 { + relMs = e.Timestamp.Sub(evs[0].Timestamp).Milliseconds() + } + tid := "" + if e.ATPFunc != "" { + tid = fmt.Sprintf("%d", e.ATPTID) + } + ddpStr := "" + if e.Note == "" { + ddpStr = fmt.Sprintf("%d", e.DDPType) + } + note := e.Note + if len(note) > 9 { + note = note[:9] + } + return fmt.Sprintf("%-6d %-9s %-9s %-7s %-5s %-7s %-9s", + relMs, e.Source, e.Dest, ddpStr, e.ATPFunc, tid, note) +} + +func printIntHist(w io.Writer, l, r map[uint8]int, label func(uint8) string) { + keys := map[uint8]struct{}{} + for k := range l { + keys[k] = struct{}{} + } + for k := range r { + keys[k] = struct{}{} + } + ordered := make([]uint8, 0, len(keys)) + for k := range keys { + ordered = append(ordered, k) + } + sort.Slice(ordered, func(i, j int) bool { return ordered[i] < ordered[j] }) + for _, k := range ordered { + fmt.Fprintf(w, " %-12s left=%-6d right=%-6d delta=%+d\n", label(k), l[k], r[k], r[k]-l[k]) + } +} + +func printStrHist(w io.Writer, l, r map[string]int) { + keys := map[string]struct{}{} + for k := range l { + keys[k] = struct{}{} + } + for k := range r { + keys[k] = struct{}{} + } + ordered := make([]string, 0, len(keys)) + for k := range keys { + ordered = append(ordered, k) + } + sort.Strings(ordered) + for _, k := range ordered { + fmt.Fprintf(w, " %-20s left=%-6d right=%-6d delta=%+d\n", k, l[k], r[k], r[k]-l[k]) + } +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/port/ethertalk/pcap.go b/port/ethertalk/pcap.go index 63ca7ed..dc2f9a3 100644 --- a/port/ethertalk/pcap.go +++ b/port/ethertalk/pcap.go @@ -2,7 +2,10 @@ package ethertalk import ( "net" + "sync" + "time" + "github.com/ObsoleteMadness/ClassicStack/capture" "github.com/ObsoleteMadness/ClassicStack/netlog" "github.com/ObsoleteMadness/ClassicStack/port" "github.com/ObsoleteMadness/ClassicStack/port/rawlink" @@ -33,6 +36,29 @@ type PcapPort struct { writerQueue chan []byte writerStop chan struct{} writerDone chan struct{} + captureMu sync.Mutex + captureSink capture.Sink +} + +// SetCaptureSink installs (or clears, if nil) a pcap-style capture +// sink. Inbound and outbound Ethernet frames are forwarded post-adapter +// so the file is consistent across bridge/WiFi shims. +func (p *PcapPort) SetCaptureSink(s capture.Sink) { + p.captureMu.Lock() + p.captureSink = s + p.captureMu.Unlock() +} + +func (p *PcapPort) capture(frame []byte) { + p.captureMu.Lock() + s := p.captureSink + p.captureMu.Unlock() + if s == nil { + return + } + buf := make([]byte, len(frame)) + copy(buf, frame) + capture.Write(s, time.Now(), buf) } func NewPcapPort(opts Options) (*PcapPort, error) { @@ -187,6 +213,7 @@ func (p *PcapPort) readRun() { netlog.Warn("failed to normalize inbound frame on %s: %v", p.interfaceName, err) continue } + p.capture(normalized) p.InboundFrame(normalized) } } @@ -212,6 +239,7 @@ func (p *PcapPort) writeRun() { netlog.Warn("failed to prepare outbound frame on %s: %v", p.interfaceName, err) continue } + p.capture(prepared) if err := p.link.WriteFrame(prepared); err != nil { netlog.Warn("couldn't send packet: %v", err) } diff --git a/port/localtalk/localtalk.go b/port/localtalk/localtalk.go index acc5a3f..155f0a7 100644 --- a/port/localtalk/localtalk.go +++ b/port/localtalk/localtalk.go @@ -8,6 +8,7 @@ import ( "github.com/ObsoleteMadness/ClassicStack/protocol/ddp" + "github.com/ObsoleteMadness/ClassicStack/capture" "github.com/ObsoleteMadness/ClassicStack/netlog" "github.com/ObsoleteMadness/ClassicStack/port" ) @@ -51,6 +52,28 @@ type Port struct { stop chan struct{} sendFrameFunc func(frame []byte) error linkManager LinkManager + captureSink capture.Sink +} + +// SetCaptureSink installs (or clears, if nil) a pcap-style capture +// sink. Both inbound and outbound LLAP frames are forwarded to s. Safe +// to call before Start; not designed for live swapping. +func (p *Port) SetCaptureSink(s capture.Sink) { + p.mu.Lock() + p.captureSink = s + p.mu.Unlock() +} + +func (p *Port) capture(frame []byte) { + p.mu.Lock() + s := p.captureSink + p.mu.Unlock() + if s == nil { + return + } + buf := make([]byte, len(frame)) + copy(buf, frame) + capture.Write(s, time.Now(), buf) } func New(seedNetwork uint16, seedZoneName []byte, respondToEnq bool, desiredNode uint8) *Port { @@ -114,6 +137,7 @@ func (p *Port) SendRawLLAPFrame(frame LLAPFrame) error { } b := frame.Bytes() netlog.LogLocaltalkFrameOutbound(b, p) + p.capture(b) return p.sendFrameFunc(b) } @@ -296,7 +320,9 @@ func (p *Port) InboundFrame(frame []byte) { if err != nil { return } - netlog.LogLocaltalkFrameInbound(parsed.Bytes(), p) + parsedBytes := parsed.Bytes() + netlog.LogLocaltalkFrameInbound(parsedBytes, p) + p.capture(parsedBytes) if p.linkManager != nil { p.linkManager.InboundFrame(p, parsed) return diff --git a/server.toml b/server.toml index 8f0dcf1..dc2813d 100644 --- a/server.toml +++ b/server.toml @@ -68,3 +68,11 @@ fs_type = "macgarden" level = "debug" parse_packets = true log_traffic = false + +[Capture] +# Write a pcap-format capture of in-flight frames for offline analysis in +# Wireshark. Empty path disables that transport. LocalTalk captures use +# DLT_LTALK (114); EtherTalk captures use DLT_EN10MB (1). +localtalk = "./captures/afp-localtalk.pcap" # e.g. "captures/classicstack-localtalk.pcap" +ethertalk = "./captures/afp-ethertalk.pcap" # e.g. "captures/classicstack-ethertalk.pcap" +snaplen = 65535 # per-frame snap length \ No newline at end of file diff --git a/server.toml.example b/server.toml.example index 3544700..28500c9 100644 --- a/server.toml.example +++ b/server.toml.example @@ -68,3 +68,11 @@ rebuild_desktop_db = false level = "debug" parse_packets = true log_traffic = false + +[Capture] +# Write a pcap-format capture of in-flight frames for offline analysis in +# Wireshark. Empty path disables that transport. LocalTalk captures use +# DLT_LTALK (114); EtherTalk captures use DLT_EN10MB (1). +localtalk = "" # e.g. "captures/classicstack-localtalk.pcap" +ethertalk = "" # e.g. "captures/classicstack-ethertalk.pcap" +snaplen = 65535 # per-frame snap length