Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,6 @@ go.work.sum

._htmlcache/
.macgarden/

.captures/
captures/
25 changes: 25 additions & 0 deletions capture/capture.go
Original file line number Diff line number Diff line change
@@ -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)
}
33 changes: 33 additions & 0 deletions capture/config.go
Original file line number Diff line number Diff line change
@@ -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 != "" }
97 changes: 97 additions & 0 deletions capture/pcap.go
Original file line number Diff line number Diff line change
@@ -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
}
58 changes: 58 additions & 0 deletions capture/pcap_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
65 changes: 65 additions & 0 deletions cmd/classicstack/capture.go
Original file line number Diff line number Diff line change
@@ -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
}
11 changes: 11 additions & 0 deletions cmd/classicstack/config_flags.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"github.com/ObsoleteMadness/ClassicStack/capture"
"github.com/ObsoleteMadness/ClassicStack/port/ethertalk"
"github.com/ObsoleteMadness/ClassicStack/port/localtalk"
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
6 changes: 6 additions & 0 deletions cmd/classicstack/config_ini.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -26,6 +27,7 @@ type appConfig struct {
LToUDP localtalk.LToUDPConfig
TashTalk localtalk.TashTalkConfig
EtherTalk ethertalk.Config
Capture capture.Config

MacIPEnabled bool
MacIPNAT bool
Expand All @@ -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",
}
Expand Down Expand Up @@ -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 = ""
Expand Down
17 changes: 17 additions & 0 deletions cmd/classicstack/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -164,6 +168,9 @@ func main() {
MacIPNAT: *macipNAT,
MacIPDHCPRelay: *macipDHCP,
MacIPLeaseFile: *macipStateFile,
CaptureLocalTalk: *captureLocalTalk,
CaptureEtherTalk: *captureEtherTalk,
CaptureSnaplen: *captureSnaplen,
})
}

Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading