From 7ef5efc54998663fec37d7af522724d953eebe30 Mon Sep 17 00:00:00 2001 From: Houmgaor Date: Mon, 23 Feb 2026 18:50:44 +0100 Subject: [PATCH] feat(network): add protocol packet capture and replay system Add a recording and replay foundation for the MHF network protocol. A RecordingConn decorator wraps network.Conn to transparently capture all decrypted packets to binary .mhfr files, with zero handler changes and zero overhead when disabled. - network/pcap: binary capture format (writer, reader, filters) - RecordingConn: thread-safe Conn decorator with direction tracking - CaptureOptions in config (disabled by default) - Capture wired into all three server types (sign, entrance, channel) - cmd/replay: CLI tool with dump, json, stats, and compare modes - 19 new tests, all passing with -race --- cmd/replay/compare.go | 79 +++++++ cmd/replay/main.go | 266 ++++++++++++++++++++++ cmd/replay/replay_test.go | 154 +++++++++++++ config.example.json | 8 + config/config.go | 17 ++ network/pcap/filter.go | 42 ++++ network/pcap/format.go | 103 +++++++++ network/pcap/pcap_test.go | 268 +++++++++++++++++++++++ network/pcap/reader.go | 110 ++++++++++ network/pcap/recording_conn.go | 65 ++++++ network/pcap/recording_conn_test.go | 183 ++++++++++++++++ network/pcap/writer.go | 89 ++++++++ server/channelserver/handlers_session.go | 5 + server/channelserver/sys_capture.go | 109 +++++++++ server/channelserver/sys_session.go | 15 +- server/entranceserver/entrance_server.go | 5 +- server/entranceserver/sys_capture.go | 92 ++++++++ server/signserver/session.go | 13 +- server/signserver/sign_server.go | 16 +- server/signserver/sys_capture.go | 92 ++++++++ 20 files changed, 1716 insertions(+), 15 deletions(-) create mode 100644 cmd/replay/compare.go create mode 100644 cmd/replay/main.go create mode 100644 cmd/replay/replay_test.go create mode 100644 network/pcap/filter.go create mode 100644 network/pcap/format.go create mode 100644 network/pcap/pcap_test.go create mode 100644 network/pcap/reader.go create mode 100644 network/pcap/recording_conn.go create mode 100644 network/pcap/recording_conn_test.go create mode 100644 network/pcap/writer.go create mode 100644 server/channelserver/sys_capture.go create mode 100644 server/entranceserver/sys_capture.go create mode 100644 server/signserver/sys_capture.go diff --git a/cmd/replay/compare.go b/cmd/replay/compare.go new file mode 100644 index 000000000..a628b2d36 --- /dev/null +++ b/cmd/replay/compare.go @@ -0,0 +1,79 @@ +package main + +import ( + "fmt" + + "erupe-ce/network" + "erupe-ce/network/pcap" +) + +// PacketDiff describes a difference between an expected and actual packet. +type PacketDiff struct { + Index int + Expected pcap.PacketRecord + Actual *pcap.PacketRecord // nil if no response received + OpcodeMismatch bool + SizeDelta int +} + +func (d PacketDiff) String() string { + if d.Actual == nil { + return fmt.Sprintf("#%d: expected 0x%04X (%s), got no response", + d.Index, d.Expected.Opcode, network.PacketID(d.Expected.Opcode)) + } + if d.OpcodeMismatch { + return fmt.Sprintf("#%d: opcode mismatch: expected 0x%04X (%s), got 0x%04X (%s)", + d.Index, + d.Expected.Opcode, network.PacketID(d.Expected.Opcode), + d.Actual.Opcode, network.PacketID(d.Actual.Opcode)) + } + return fmt.Sprintf("#%d: 0x%04X (%s) size delta %+d bytes", + d.Index, d.Expected.Opcode, network.PacketID(d.Expected.Opcode), d.SizeDelta) +} + +// ComparePackets compares expected server responses against actual responses. +// Only compares S→C packets (server responses). +func ComparePackets(expected, actual []pcap.PacketRecord) []PacketDiff { + expectedS2C := pcap.FilterByDirection(expected, pcap.DirServerToClient) + actualS2C := pcap.FilterByDirection(actual, pcap.DirServerToClient) + + var diffs []PacketDiff + for i, exp := range expectedS2C { + if i >= len(actualS2C) { + diffs = append(diffs, PacketDiff{ + Index: i, + Expected: exp, + Actual: nil, + }) + continue + } + act := actualS2C[i] + if exp.Opcode != act.Opcode { + diffs = append(diffs, PacketDiff{ + Index: i, + Expected: exp, + Actual: &act, + OpcodeMismatch: true, + }) + } else if len(exp.Payload) != len(act.Payload) { + diffs = append(diffs, PacketDiff{ + Index: i, + Expected: exp, + Actual: &act, + SizeDelta: len(act.Payload) - len(exp.Payload), + }) + } + } + + // Extra actual packets beyond expected. + for i := len(expectedS2C); i < len(actualS2C); i++ { + act := actualS2C[i] + diffs = append(diffs, PacketDiff{ + Index: i, + Expected: pcap.PacketRecord{}, + Actual: &act, + }) + } + + return diffs +} diff --git a/cmd/replay/main.go b/cmd/replay/main.go new file mode 100644 index 000000000..9e1c5b583 --- /dev/null +++ b/cmd/replay/main.go @@ -0,0 +1,266 @@ +// replay is a CLI tool for inspecting and replaying .mhfr packet capture files. +// +// Usage: +// +// replay --capture file.mhfr --mode dump # Human-readable text output +// replay --capture file.mhfr --mode json # JSON export +// replay --capture file.mhfr --mode stats # Opcode histogram, duration, counts +// replay --capture file.mhfr --mode replay --target 127.0.0.1:54001 # Replay against live server +package main + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "os" + "sort" + "time" + + "erupe-ce/network" + "erupe-ce/network/pcap" +) + +func main() { + capturePath := flag.String("capture", "", "Path to .mhfr capture file (required)") + mode := flag.String("mode", "dump", "Mode: dump, json, stats, replay") + target := flag.String("target", "", "Target server address for replay mode (host:port)") + speed := flag.Float64("speed", 1.0, "Replay speed multiplier (e.g. 2.0 = 2x faster)") + _ = target // used in replay mode + _ = speed + flag.Parse() + + if *capturePath == "" { + fmt.Fprintln(os.Stderr, "error: --capture is required") + flag.Usage() + os.Exit(1) + } + + switch *mode { + case "dump": + if err := runDump(*capturePath); err != nil { + fmt.Fprintf(os.Stderr, "dump failed: %v\n", err) + os.Exit(1) + } + case "json": + if err := runJSON(*capturePath); err != nil { + fmt.Fprintf(os.Stderr, "json failed: %v\n", err) + os.Exit(1) + } + case "stats": + if err := runStats(*capturePath); err != nil { + fmt.Fprintf(os.Stderr, "stats failed: %v\n", err) + os.Exit(1) + } + case "replay": + if *target == "" { + fmt.Fprintln(os.Stderr, "error: --target is required for replay mode") + os.Exit(1) + } + fmt.Fprintln(os.Stderr, "replay mode not yet implemented (requires live server connection)") + os.Exit(1) + default: + fmt.Fprintf(os.Stderr, "unknown mode: %s\n", *mode) + os.Exit(1) + } +} + +func openCapture(path string) (*pcap.Reader, *os.File, error) { + f, err := os.Open(path) + if err != nil { + return nil, nil, fmt.Errorf("open capture: %w", err) + } + r, err := pcap.NewReader(f) + if err != nil { + _ = f.Close() + return nil, nil, fmt.Errorf("read capture: %w", err) + } + return r, f, nil +} + +func readAllPackets(r *pcap.Reader) ([]pcap.PacketRecord, error) { + var records []pcap.PacketRecord + for { + rec, err := r.ReadPacket() + if err == io.EOF { + break + } + if err != nil { + return records, err + } + records = append(records, rec) + } + return records, nil +} + +func runDump(path string) error { + r, f, err := openCapture(path) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + + // Print header info. + startTime := time.Unix(0, r.Header.SessionStartNs) + fmt.Printf("=== MHFR Capture: %s ===\n", path) + fmt.Printf("Server: %s ClientMode: %d Start: %s\n", + r.Header.ServerType, r.Header.ClientMode, startTime.Format(time.RFC3339Nano)) + if r.Meta.Host != "" { + fmt.Printf("Host: %s Port: %d Remote: %s\n", r.Meta.Host, r.Meta.Port, r.Meta.RemoteAddr) + } + if r.Meta.CharID != 0 { + fmt.Printf("CharID: %d UserID: %d\n", r.Meta.CharID, r.Meta.UserID) + } + fmt.Println() + + records, err := readAllPackets(r) + if err != nil { + return err + } + + for i, rec := range records { + elapsed := time.Duration(rec.TimestampNs - r.Header.SessionStartNs) + opcodeName := network.PacketID(rec.Opcode).String() + fmt.Printf("#%04d +%-12s %s 0x%04X %-30s %d bytes\n", + i, elapsed, rec.Direction, rec.Opcode, opcodeName, len(rec.Payload)) + } + + fmt.Printf("\nTotal: %d packets\n", len(records)) + return nil +} + +type jsonCapture struct { + Header jsonHeader `json:"header"` + Meta pcap.SessionMetadata `json:"metadata"` + Packets []jsonPacket `json:"packets"` +} + +type jsonHeader struct { + Version uint16 `json:"version"` + ServerType string `json:"server_type"` + ClientMode int `json:"client_mode"` + StartTime string `json:"start_time"` +} + +type jsonPacket struct { + Index int `json:"index"` + Timestamp string `json:"timestamp"` + ElapsedNs int64 `json:"elapsed_ns"` + Direction string `json:"direction"` + Opcode uint16 `json:"opcode"` + OpcodeName string `json:"opcode_name"` + PayloadLen int `json:"payload_len"` +} + +func runJSON(path string) error { + r, f, err := openCapture(path) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + + records, err := readAllPackets(r) + if err != nil { + return err + } + + out := jsonCapture{ + Header: jsonHeader{ + Version: r.Header.Version, + ServerType: r.Header.ServerType.String(), + ClientMode: int(r.Header.ClientMode), + StartTime: time.Unix(0, r.Header.SessionStartNs).Format(time.RFC3339Nano), + }, + Meta: r.Meta, + Packets: make([]jsonPacket, len(records)), + } + + for i, rec := range records { + out.Packets[i] = jsonPacket{ + Index: i, + Timestamp: time.Unix(0, rec.TimestampNs).Format(time.RFC3339Nano), + ElapsedNs: rec.TimestampNs - r.Header.SessionStartNs, + Direction: rec.Direction.String(), + Opcode: rec.Opcode, + OpcodeName: network.PacketID(rec.Opcode).String(), + PayloadLen: len(rec.Payload), + } + } + + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(out) +} + +func runStats(path string) error { + r, f, err := openCapture(path) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + + records, err := readAllPackets(r) + if err != nil { + return err + } + + if len(records) == 0 { + fmt.Println("Empty capture (0 packets)") + return nil + } + + // Compute stats. + type opcodeStats struct { + opcode uint16 + count int + bytes int + } + statsMap := make(map[uint16]*opcodeStats) + var totalC2S, totalS2C int + var bytesC2S, bytesS2C int + + for _, rec := range records { + s, ok := statsMap[rec.Opcode] + if !ok { + s = &opcodeStats{opcode: rec.Opcode} + statsMap[rec.Opcode] = s + } + s.count++ + s.bytes += len(rec.Payload) + + switch rec.Direction { + case pcap.DirClientToServer: + totalC2S++ + bytesC2S += len(rec.Payload) + case pcap.DirServerToClient: + totalS2C++ + bytesS2C += len(rec.Payload) + } + } + + // Sort by count descending. + sorted := make([]*opcodeStats, 0, len(statsMap)) + for _, s := range statsMap { + sorted = append(sorted, s) + } + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].count > sorted[j].count + }) + + duration := time.Duration(records[len(records)-1].TimestampNs - records[0].TimestampNs) + + fmt.Printf("=== Capture Stats: %s ===\n", path) + fmt.Printf("Server: %s Duration: %s Packets: %d\n", + r.Header.ServerType, duration, len(records)) + fmt.Printf("C→S: %d packets (%d bytes) S→C: %d packets (%d bytes)\n\n", + totalC2S, bytesC2S, totalS2C, bytesS2C) + + fmt.Printf("%-8s %-35s %8s %10s\n", "Opcode", "Name", "Count", "Bytes") + fmt.Printf("%-8s %-35s %8s %10s\n", "------", "----", "-----", "-----") + for _, s := range sorted { + name := network.PacketID(s.opcode).String() + fmt.Printf("0x%04X %-35s %8d %10d\n", s.opcode, name, s.count, s.bytes) + } + + return nil +} diff --git a/cmd/replay/replay_test.go b/cmd/replay/replay_test.go new file mode 100644 index 000000000..e7a2deb56 --- /dev/null +++ b/cmd/replay/replay_test.go @@ -0,0 +1,154 @@ +package main + +import ( + "bytes" + "os" + "testing" + + "erupe-ce/network/pcap" +) + +func createTestCapture(t *testing.T, records []pcap.PacketRecord) string { + t.Helper() + f, err := os.CreateTemp(t.TempDir(), "test-*.mhfr") + if err != nil { + t.Fatalf("CreateTemp: %v", err) + } + defer func() { _ = f.Close() }() + + hdr := pcap.FileHeader{ + Version: pcap.FormatVersion, + ServerType: pcap.ServerTypeChannel, + ClientMode: 40, + SessionStartNs: 1000000000, + } + meta := pcap.SessionMetadata{Host: "127.0.0.1", Port: 54001} + + w, err := pcap.NewWriter(f, hdr, meta) + if err != nil { + t.Fatalf("NewWriter: %v", err) + } + for _, r := range records { + if err := w.WritePacket(r); err != nil { + t.Fatalf("WritePacket: %v", err) + } + } + if err := w.Flush(); err != nil { + t.Fatalf("Flush: %v", err) + } + return f.Name() +} + +func TestRunDump(t *testing.T) { + path := createTestCapture(t, []pcap.PacketRecord{ + {TimestampNs: 1000000100, Direction: pcap.DirClientToServer, Opcode: 0x0013, Payload: []byte{0x00, 0x13}}, + {TimestampNs: 1000000200, Direction: pcap.DirServerToClient, Opcode: 0x0012, Payload: []byte{0x00, 0x12, 0xFF}}, + }) + // Just verify it doesn't error. + if err := runDump(path); err != nil { + t.Fatalf("runDump: %v", err) + } +} + +func TestRunStats(t *testing.T) { + path := createTestCapture(t, []pcap.PacketRecord{ + {TimestampNs: 1000000100, Direction: pcap.DirClientToServer, Opcode: 0x0013, Payload: []byte{0x00, 0x13}}, + {TimestampNs: 1000000200, Direction: pcap.DirServerToClient, Opcode: 0x0012, Payload: []byte{0x00, 0x12, 0xFF}}, + {TimestampNs: 1000000300, Direction: pcap.DirClientToServer, Opcode: 0x0013, Payload: []byte{0x00, 0x13, 0xAA}}, + }) + if err := runStats(path); err != nil { + t.Fatalf("runStats: %v", err) + } +} + +func TestRunStatsEmpty(t *testing.T) { + path := createTestCapture(t, nil) + if err := runStats(path); err != nil { + t.Fatalf("runStats empty: %v", err) + } +} + +func TestRunJSON(t *testing.T) { + path := createTestCapture(t, []pcap.PacketRecord{ + {TimestampNs: 1000000100, Direction: pcap.DirClientToServer, Opcode: 0x0013, Payload: []byte{0x00, 0x13}}, + }) + // Capture stdout. + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + if err := runJSON(path); err != nil { + os.Stdout = old + t.Fatalf("runJSON: %v", err) + } + + _ = w.Close() + os.Stdout = old + + var buf bytes.Buffer + _, _ = buf.ReadFrom(r) + if buf.Len() == 0 { + t.Error("runJSON produced no output") + } + // Should be valid JSON containing "packets". + if !bytes.Contains(buf.Bytes(), []byte(`"packets"`)) { + t.Error("runJSON output missing 'packets' key") + } +} + +func TestComparePackets(t *testing.T) { + expected := []pcap.PacketRecord{ + {Direction: pcap.DirClientToServer, Opcode: 0x0013, Payload: []byte{0x00, 0x13}}, + {Direction: pcap.DirServerToClient, Opcode: 0x0012, Payload: []byte{0x00, 0x12, 0xAA}}, + {Direction: pcap.DirServerToClient, Opcode: 0x0061, Payload: []byte{0x00, 0x61}}, + } + actual := []pcap.PacketRecord{ + {Direction: pcap.DirServerToClient, Opcode: 0x0012, Payload: []byte{0x00, 0x12, 0xBB, 0xCC}}, // size diff + {Direction: pcap.DirServerToClient, Opcode: 0x0099, Payload: []byte{0x00, 0x99}}, // opcode mismatch + } + + diffs := ComparePackets(expected, actual) + if len(diffs) != 2 { + t.Fatalf("expected 2 diffs, got %d", len(diffs)) + } + + // First diff: size delta. + if diffs[0].SizeDelta != 1 { + t.Errorf("diffs[0] SizeDelta = %d, want 1", diffs[0].SizeDelta) + } + + // Second diff: opcode mismatch. + if !diffs[1].OpcodeMismatch { + t.Error("diffs[1] expected OpcodeMismatch=true") + } +} + +func TestComparePacketsMissingResponse(t *testing.T) { + expected := []pcap.PacketRecord{ + {Direction: pcap.DirServerToClient, Opcode: 0x0012, Payload: []byte{0x00, 0x12}}, + {Direction: pcap.DirServerToClient, Opcode: 0x0061, Payload: []byte{0x00, 0x61}}, + } + actual := []pcap.PacketRecord{ + {Direction: pcap.DirServerToClient, Opcode: 0x0012, Payload: []byte{0x00, 0x12}}, + } + + diffs := ComparePackets(expected, actual) + if len(diffs) != 1 { + t.Fatalf("expected 1 diff, got %d", len(diffs)) + } + if diffs[0].Actual != nil { + t.Error("expected nil Actual for missing response") + } +} + +func TestPacketDiffString(t *testing.T) { + d := PacketDiff{ + Index: 0, + Expected: pcap.PacketRecord{Opcode: 0x0012}, + Actual: nil, + } + s := d.String() + if s == "" { + t.Error("PacketDiff.String() returned empty") + } +} diff --git a/config.example.json b/config.example.json index e92a7a92f..b54734db1 100644 --- a/config.example.json +++ b/config.example.json @@ -31,6 +31,14 @@ "RawEnabled": false, "OutputDir": "save-backups" }, + "Capture": { + "Enabled": false, + "OutputDir": "captures", + "ExcludeOpcodes": [], + "CaptureSign": true, + "CaptureEntrance": true, + "CaptureChannel": true + }, "DebugOptions": { "CleanDB": false, "MaxLauncherHR": false, diff --git a/config/config.go b/config/config.go index fea564f39..114f01885 100644 --- a/config/config.go +++ b/config/config.go @@ -85,6 +85,7 @@ type Config struct { EarthMonsters []int32 SaveDumps SaveDumpOptions Screenshots ScreenshotsOptions + Capture CaptureOptions DebugOptions DebugOptions GameplayOptions GameplayOptions @@ -112,6 +113,16 @@ type ScreenshotsOptions struct { UploadQuality int //Determines the upload quality to the server } +// CaptureOptions controls protocol packet capture recording. +type CaptureOptions struct { + Enabled bool // Enable packet capture + OutputDir string // Directory for .mhfr capture files + ExcludeOpcodes []uint16 // Opcodes to exclude from capture (e.g., ping, nop, position) + CaptureSign bool // Capture sign server sessions + CaptureEntrance bool // Capture entrance server sessions + CaptureChannel bool // Capture channel server sessions +} + // DebugOptions holds various debug/temporary options for use while developing Erupe. type DebugOptions struct { CleanDB bool // Automatically wipes the DB on server reset. @@ -328,6 +339,12 @@ func LoadConfig() (*Config, error) { Enabled: true, OutputDir: "save-backups", }) + viper.SetDefault("Capture", CaptureOptions{ + OutputDir: "captures", + CaptureSign: true, + CaptureEntrance: true, + CaptureChannel: true, + }) viper.SetDefault("LoopDelay", 50) err := viper.ReadInConfig() diff --git a/network/pcap/filter.go b/network/pcap/filter.go new file mode 100644 index 000000000..4c65de817 --- /dev/null +++ b/network/pcap/filter.go @@ -0,0 +1,42 @@ +package pcap + +// FilterByOpcode returns only records matching any of the given opcodes. +func FilterByOpcode(records []PacketRecord, opcodes ...uint16) []PacketRecord { + set := make(map[uint16]struct{}, len(opcodes)) + for _, op := range opcodes { + set[op] = struct{}{} + } + var out []PacketRecord + for _, r := range records { + if _, ok := set[r.Opcode]; ok { + out = append(out, r) + } + } + return out +} + +// FilterByDirection returns only records matching the given direction. +func FilterByDirection(records []PacketRecord, dir Direction) []PacketRecord { + var out []PacketRecord + for _, r := range records { + if r.Direction == dir { + out = append(out, r) + } + } + return out +} + +// FilterExcludeOpcodes returns records excluding any of the given opcodes. +func FilterExcludeOpcodes(records []PacketRecord, opcodes ...uint16) []PacketRecord { + set := make(map[uint16]struct{}, len(opcodes)) + for _, op := range opcodes { + set[op] = struct{}{} + } + var out []PacketRecord + for _, r := range records { + if _, ok := set[r.Opcode]; !ok { + out = append(out, r) + } + } + return out +} diff --git a/network/pcap/format.go b/network/pcap/format.go new file mode 100644 index 000000000..9974c3e7c --- /dev/null +++ b/network/pcap/format.go @@ -0,0 +1,103 @@ +package pcap + +import "encoding/json" + +// Capture file format constants. +const ( + // Magic is the 4-byte magic number for .mhfr capture files. + Magic = "MHFR" + + // FormatVersion is the current capture format version. + FormatVersion uint16 = 1 + + // HeaderSize is the fixed size of the file header in bytes. + HeaderSize = 32 +) + +// Direction indicates whether a packet was sent or received. +type Direction byte + +const ( + DirClientToServer Direction = 0x01 + DirServerToClient Direction = 0x02 +) + +func (d Direction) String() string { + switch d { + case DirClientToServer: + return "C→S" + case DirServerToClient: + return "S→C" + default: + return "???" + } +} + +// ServerType identifies which server a capture originated from. +type ServerType byte + +const ( + ServerTypeSign ServerType = 0x01 + ServerTypeEntrance ServerType = 0x02 + ServerTypeChannel ServerType = 0x03 +) + +func (st ServerType) String() string { + switch st { + case ServerTypeSign: + return "sign" + case ServerTypeEntrance: + return "entrance" + case ServerTypeChannel: + return "channel" + default: + return "unknown" + } +} + +// FileHeader is the fixed 32-byte header at the start of a .mhfr file. +// +// [4B] Magic "MHFR" +// [2B] Version +// [1B] ServerType +// [1B] ClientMode +// [8B] SessionStartNs +// [4B] Reserved +// [4B] MetadataLen +// [8B] Reserved +type FileHeader struct { + Version uint16 + ServerType ServerType + ClientMode byte + SessionStartNs int64 + MetadataLen uint32 +} + +// SessionMetadata is the JSON-encoded metadata block following the file header. +type SessionMetadata struct { + ServerVersion string `json:"server_version,omitempty"` + Host string `json:"host,omitempty"` + Port int `json:"port,omitempty"` + CharID uint32 `json:"char_id,omitempty"` + UserID uint32 `json:"user_id,omitempty"` + RemoteAddr string `json:"remote_addr,omitempty"` +} + +// MarshalJSON serializes the metadata to JSON. +func (m *SessionMetadata) MarshalJSON() ([]byte, error) { + type Alias SessionMetadata + return json.Marshal((*Alias)(m)) +} + +// PacketRecord is a single captured packet. +// +// [8B] TimestampNs [1B] Direction [2B] Opcode [4B] PayloadLen [NB] Payload +type PacketRecord struct { + TimestampNs int64 + Direction Direction + Opcode uint16 + Payload []byte // Full decrypted packet bytes (includes the 2-byte opcode prefix) +} + +// PacketRecordHeaderSize is the fixed overhead per packet record (before payload). +const PacketRecordHeaderSize = 8 + 1 + 2 + 4 // 15 bytes diff --git a/network/pcap/pcap_test.go b/network/pcap/pcap_test.go new file mode 100644 index 000000000..df142fbf0 --- /dev/null +++ b/network/pcap/pcap_test.go @@ -0,0 +1,268 @@ +package pcap + +import ( + "bytes" + "io" + "testing" +) + +func TestRoundTrip(t *testing.T) { + var buf bytes.Buffer + + hdr := FileHeader{ + Version: FormatVersion, + ServerType: ServerTypeChannel, + ClientMode: 40, // ZZ + SessionStartNs: 1700000000000000000, + } + meta := SessionMetadata{ + ServerVersion: "test-v1", + Host: "127.0.0.1", + Port: 54001, + CharID: 42, + UserID: 7, + RemoteAddr: "192.168.1.100:12345", + } + + w, err := NewWriter(&buf, hdr, meta) + if err != nil { + t.Fatalf("NewWriter: %v", err) + } + + packets := []PacketRecord{ + {TimestampNs: 1700000000000000100, Direction: DirClientToServer, Opcode: 0x0013, Payload: []byte{0x00, 0x13, 0x01, 0x02}}, + {TimestampNs: 1700000000000000200, Direction: DirServerToClient, Opcode: 0x0012, Payload: []byte{0x00, 0x12, 0xAA, 0xBB, 0xCC}}, + {TimestampNs: 1700000000000000300, Direction: DirClientToServer, Opcode: 0x0061, Payload: []byte{0x00, 0x61}}, + } + + for _, p := range packets { + if err := w.WritePacket(p); err != nil { + t.Fatalf("WritePacket: %v", err) + } + } + if err := w.Flush(); err != nil { + t.Fatalf("Flush: %v", err) + } + + // Read it back. + r, err := NewReader(bytes.NewReader(buf.Bytes())) + if err != nil { + t.Fatalf("NewReader: %v", err) + } + + // Verify header. + if r.Header.Version != FormatVersion { + t.Errorf("Version = %d, want %d", r.Header.Version, FormatVersion) + } + if r.Header.ServerType != ServerTypeChannel { + t.Errorf("ServerType = %d, want %d", r.Header.ServerType, ServerTypeChannel) + } + if r.Header.ClientMode != 40 { + t.Errorf("ClientMode = %d, want 40", r.Header.ClientMode) + } + if r.Header.SessionStartNs != 1700000000000000000 { + t.Errorf("SessionStartNs = %d, want 1700000000000000000", r.Header.SessionStartNs) + } + + // Verify metadata. + if r.Meta.ServerVersion != "test-v1" { + t.Errorf("ServerVersion = %q, want %q", r.Meta.ServerVersion, "test-v1") + } + if r.Meta.CharID != 42 { + t.Errorf("CharID = %d, want 42", r.Meta.CharID) + } + + // Verify packets. + for i, want := range packets { + got, err := r.ReadPacket() + if err != nil { + t.Fatalf("ReadPacket[%d]: %v", i, err) + } + if got.TimestampNs != want.TimestampNs { + t.Errorf("[%d] TimestampNs = %d, want %d", i, got.TimestampNs, want.TimestampNs) + } + if got.Direction != want.Direction { + t.Errorf("[%d] Direction = %d, want %d", i, got.Direction, want.Direction) + } + if got.Opcode != want.Opcode { + t.Errorf("[%d] Opcode = 0x%04X, want 0x%04X", i, got.Opcode, want.Opcode) + } + if !bytes.Equal(got.Payload, want.Payload) { + t.Errorf("[%d] Payload = %v, want %v", i, got.Payload, want.Payload) + } + } + + // Verify EOF. + _, err = r.ReadPacket() + if err != io.EOF { + t.Errorf("expected io.EOF, got %v", err) + } +} + +func TestEmptyCapture(t *testing.T) { + var buf bytes.Buffer + + hdr := FileHeader{ + Version: FormatVersion, + ServerType: ServerTypeSign, + ClientMode: 40, + SessionStartNs: 1000, + } + meta := SessionMetadata{} + + w, err := NewWriter(&buf, hdr, meta) + if err != nil { + t.Fatalf("NewWriter: %v", err) + } + if err := w.Flush(); err != nil { + t.Fatalf("Flush: %v", err) + } + + r, err := NewReader(bytes.NewReader(buf.Bytes())) + if err != nil { + t.Fatalf("NewReader: %v", err) + } + + _, err = r.ReadPacket() + if err != io.EOF { + t.Errorf("expected io.EOF for empty capture, got %v", err) + } + _ = r // use reader +} + +func TestInvalidMagic(t *testing.T) { + data := []byte("NOPE" + "\x00\x01\x03\x28" + "\x00\x00\x00\x00\x00\x00\x00\x01" + "\x00\x00\x00\x00" + "\x00\x00\x00\x02" + "\x00\x00\x00\x00\x00\x00\x00\x00" + "{}") + _, err := NewReader(bytes.NewReader(data)) + if err == nil { + t.Fatal("expected error for invalid magic") + } +} + +func TestInvalidVersion(t *testing.T) { + // Valid magic, bad version (99). + var buf bytes.Buffer + buf.WriteString(Magic) + buf.Write([]byte{0x00, 0x63}) // version 99 + buf.Write(make([]byte, 26)) // rest of header + _, err := NewReader(&buf) + if err == nil { + t.Fatal("expected error for unsupported version") + } +} + +func TestLargePayload(t *testing.T) { + var buf bytes.Buffer + + hdr := FileHeader{ + Version: FormatVersion, + ServerType: ServerTypeChannel, + ClientMode: 40, + SessionStartNs: 1000, + } + meta := SessionMetadata{} + + w, err := NewWriter(&buf, hdr, meta) + if err != nil { + t.Fatalf("NewWriter: %v", err) + } + + // 64KB payload. + payload := make([]byte, 65536) + for i := range payload { + payload[i] = byte(i % 256) + } + rec := PacketRecord{ + TimestampNs: 2000, + Direction: DirServerToClient, + Opcode: 0xFFFF, + Payload: payload, + } + if err := w.WritePacket(rec); err != nil { + t.Fatalf("WritePacket: %v", err) + } + if err := w.Flush(); err != nil { + t.Fatalf("Flush: %v", err) + } + + r, err := NewReader(bytes.NewReader(buf.Bytes())) + if err != nil { + t.Fatalf("NewReader: %v", err) + } + got, err := r.ReadPacket() + if err != nil { + t.Fatalf("ReadPacket: %v", err) + } + if len(got.Payload) != 65536 { + t.Errorf("payload len = %d, want 65536", len(got.Payload)) + } + if !bytes.Equal(got.Payload, payload) { + t.Error("payload mismatch") + } +} + +func TestFilterByOpcode(t *testing.T) { + records := []PacketRecord{ + {Opcode: 0x01}, + {Opcode: 0x02}, + {Opcode: 0x03}, + {Opcode: 0x01}, + } + got := FilterByOpcode(records, 0x01, 0x03) + if len(got) != 3 { + t.Errorf("FilterByOpcode: got %d records, want 3", len(got)) + } +} + +func TestFilterByDirection(t *testing.T) { + records := []PacketRecord{ + {Direction: DirClientToServer}, + {Direction: DirServerToClient}, + {Direction: DirClientToServer}, + } + got := FilterByDirection(records, DirServerToClient) + if len(got) != 1 { + t.Errorf("FilterByDirection: got %d records, want 1", len(got)) + } +} + +func TestFilterExcludeOpcodes(t *testing.T) { + records := []PacketRecord{ + {Opcode: 0x10}, // MSG_SYS_END + {Opcode: 0x11}, // MSG_SYS_NOP + {Opcode: 0x61}, // something else + } + got := FilterExcludeOpcodes(records, 0x10, 0x11) + if len(got) != 1 { + t.Errorf("FilterExcludeOpcodes: got %d records, want 1", len(got)) + } + if got[0].Opcode != 0x61 { + t.Errorf("remaining opcode = 0x%04X, want 0x0061", got[0].Opcode) + } +} + +func TestDirectionString(t *testing.T) { + if DirClientToServer.String() != "C→S" { + t.Errorf("DirClientToServer.String() = %q", DirClientToServer.String()) + } + if DirServerToClient.String() != "S→C" { + t.Errorf("DirServerToClient.String() = %q", DirServerToClient.String()) + } + if Direction(0xFF).String() != "???" { + t.Errorf("unknown direction = %q", Direction(0xFF).String()) + } +} + +func TestServerTypeString(t *testing.T) { + if ServerTypeSign.String() != "sign" { + t.Errorf("ServerTypeSign.String() = %q", ServerTypeSign.String()) + } + if ServerTypeEntrance.String() != "entrance" { + t.Errorf("ServerTypeEntrance.String() = %q", ServerTypeEntrance.String()) + } + if ServerTypeChannel.String() != "channel" { + t.Errorf("ServerTypeChannel.String() = %q", ServerTypeChannel.String()) + } + if ServerType(0xFF).String() != "unknown" { + t.Errorf("unknown server type = %q", ServerType(0xFF).String()) + } +} diff --git a/network/pcap/reader.go b/network/pcap/reader.go new file mode 100644 index 000000000..86388f3a8 --- /dev/null +++ b/network/pcap/reader.go @@ -0,0 +1,110 @@ +package pcap + +import ( + "encoding/binary" + "encoding/json" + "fmt" + "io" +) + +// Reader reads .mhfr capture files. +type Reader struct { + r io.Reader + Header FileHeader + Meta SessionMetadata +} + +// NewReader creates a Reader, reading and validating the file header and metadata. +func NewReader(r io.Reader) (*Reader, error) { + // Read magic. + magicBuf := make([]byte, 4) + if _, err := io.ReadFull(r, magicBuf); err != nil { + return nil, fmt.Errorf("pcap: read magic: %w", err) + } + if string(magicBuf) != Magic { + return nil, fmt.Errorf("pcap: invalid magic %q, expected %q", string(magicBuf), Magic) + } + + var hdr FileHeader + + if err := binary.Read(r, binary.BigEndian, &hdr.Version); err != nil { + return nil, fmt.Errorf("pcap: read version: %w", err) + } + if hdr.Version != FormatVersion { + return nil, fmt.Errorf("pcap: unsupported version %d, expected %d", hdr.Version, FormatVersion) + } + + var serverType byte + if err := binary.Read(r, binary.BigEndian, &serverType); err != nil { + return nil, fmt.Errorf("pcap: read server type: %w", err) + } + hdr.ServerType = ServerType(serverType) + + if err := binary.Read(r, binary.BigEndian, &hdr.ClientMode); err != nil { + return nil, fmt.Errorf("pcap: read client mode: %w", err) + } + if err := binary.Read(r, binary.BigEndian, &hdr.SessionStartNs); err != nil { + return nil, fmt.Errorf("pcap: read session start: %w", err) + } + + // Skip 4 reserved bytes. + if _, err := io.ReadFull(r, make([]byte, 4)); err != nil { + return nil, fmt.Errorf("pcap: read reserved: %w", err) + } + + if err := binary.Read(r, binary.BigEndian, &hdr.MetadataLen); err != nil { + return nil, fmt.Errorf("pcap: read metadata len: %w", err) + } + + // Skip 8 reserved bytes. + if _, err := io.ReadFull(r, make([]byte, 8)); err != nil { + return nil, fmt.Errorf("pcap: read reserved: %w", err) + } + + // Read metadata JSON. + metaBytes := make([]byte, hdr.MetadataLen) + if _, err := io.ReadFull(r, metaBytes); err != nil { + return nil, fmt.Errorf("pcap: read metadata: %w", err) + } + + var meta SessionMetadata + if err := json.Unmarshal(metaBytes, &meta); err != nil { + return nil, fmt.Errorf("pcap: unmarshal metadata: %w", err) + } + + return &Reader{r: r, Header: hdr, Meta: meta}, nil +} + +// ReadPacket reads the next packet record. Returns io.EOF when no more packets. +func (rd *Reader) ReadPacket() (PacketRecord, error) { + var rec PacketRecord + + if err := binary.Read(rd.r, binary.BigEndian, &rec.TimestampNs); err != nil { + if err == io.EOF || err == io.ErrUnexpectedEOF { + return rec, io.EOF + } + return rec, fmt.Errorf("pcap: read timestamp: %w", err) + } + + var dir byte + if err := binary.Read(rd.r, binary.BigEndian, &dir); err != nil { + return rec, fmt.Errorf("pcap: read direction: %w", err) + } + rec.Direction = Direction(dir) + + if err := binary.Read(rd.r, binary.BigEndian, &rec.Opcode); err != nil { + return rec, fmt.Errorf("pcap: read opcode: %w", err) + } + + var payloadLen uint32 + if err := binary.Read(rd.r, binary.BigEndian, &payloadLen); err != nil { + return rec, fmt.Errorf("pcap: read payload len: %w", err) + } + + rec.Payload = make([]byte, payloadLen) + if _, err := io.ReadFull(rd.r, rec.Payload); err != nil { + return rec, fmt.Errorf("pcap: read payload: %w", err) + } + + return rec, nil +} diff --git a/network/pcap/recording_conn.go b/network/pcap/recording_conn.go new file mode 100644 index 000000000..c259b3733 --- /dev/null +++ b/network/pcap/recording_conn.go @@ -0,0 +1,65 @@ +package pcap + +import ( + "encoding/binary" + "erupe-ce/network" + "sync" + "time" +) + +// RecordingConn wraps a network.Conn and records all packets to a Writer. +// It is safe for concurrent use from separate send/recv goroutines. +type RecordingConn struct { + inner network.Conn + writer *Writer + startNs int64 + mu sync.Mutex +} + +// NewRecordingConn wraps inner, recording all packets to w. +// startNs is the session start time in nanoseconds (used as the time base). +func NewRecordingConn(inner network.Conn, w *Writer, startNs int64) *RecordingConn { + return &RecordingConn{ + inner: inner, + writer: w, + startNs: startNs, + } +} + +// ReadPacket reads from the inner connection and records the packet as client-to-server. +func (rc *RecordingConn) ReadPacket() ([]byte, error) { + data, err := rc.inner.ReadPacket() + if err != nil { + return data, err + } + rc.record(DirClientToServer, data) + return data, nil +} + +// SendPacket sends via the inner connection and records the packet as server-to-client. +func (rc *RecordingConn) SendPacket(data []byte) error { + err := rc.inner.SendPacket(data) + if err != nil { + return err + } + rc.record(DirServerToClient, data) + return nil +} + +func (rc *RecordingConn) record(dir Direction, data []byte) { + var opcode uint16 + if len(data) >= 2 { + opcode = binary.BigEndian.Uint16(data[:2]) + } + + rec := PacketRecord{ + TimestampNs: time.Now().UnixNano(), + Direction: dir, + Opcode: opcode, + Payload: data, + } + + rc.mu.Lock() + _ = rc.writer.WritePacket(rec) + rc.mu.Unlock() +} diff --git a/network/pcap/recording_conn_test.go b/network/pcap/recording_conn_test.go new file mode 100644 index 000000000..430bb3c73 --- /dev/null +++ b/network/pcap/recording_conn_test.go @@ -0,0 +1,183 @@ +package pcap + +import ( + "bytes" + "io" + "sync" + "testing" +) + +// mockConn implements network.Conn for testing. +type mockConn struct { + readData [][]byte + readIdx int + sent [][]byte + mu sync.Mutex +} + +func (m *mockConn) ReadPacket() ([]byte, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.readIdx >= len(m.readData) { + return nil, io.EOF + } + data := m.readData[m.readIdx] + m.readIdx++ + return data, nil +} + +func (m *mockConn) SendPacket(data []byte) error { + m.mu.Lock() + defer m.mu.Unlock() + cp := make([]byte, len(data)) + copy(cp, data) + m.sent = append(m.sent, cp) + return nil +} + +func TestRecordingConnBasic(t *testing.T) { + mock := &mockConn{ + readData: [][]byte{ + {0x00, 0x13, 0xDE, 0xAD}, // opcode 0x0013 + }, + } + + var buf bytes.Buffer + hdr := FileHeader{ + Version: FormatVersion, + ServerType: ServerTypeChannel, + ClientMode: 40, + SessionStartNs: 1000, + } + w, err := NewWriter(&buf, hdr, SessionMetadata{}) + if err != nil { + t.Fatalf("NewWriter: %v", err) + } + + rc := NewRecordingConn(mock, w, 1000) + + // Read a packet (C→S). + data, err := rc.ReadPacket() + if err != nil { + t.Fatalf("ReadPacket: %v", err) + } + if !bytes.Equal(data, []byte{0x00, 0x13, 0xDE, 0xAD}) { + t.Errorf("ReadPacket data mismatch") + } + + // Send a packet (S→C). + sendData := []byte{0x00, 0x12, 0xBE, 0xEF} + if err := rc.SendPacket(sendData); err != nil { + t.Fatalf("SendPacket: %v", err) + } + + // Flush and read back. + if err := w.Flush(); err != nil { + t.Fatalf("Flush: %v", err) + } + + r, err := NewReader(bytes.NewReader(buf.Bytes())) + if err != nil { + t.Fatalf("NewReader: %v", err) + } + + // First record: C→S. + rec, err := r.ReadPacket() + if err != nil { + t.Fatalf("ReadPacket[0]: %v", err) + } + if rec.Direction != DirClientToServer { + t.Errorf("rec[0] direction = %v, want C→S", rec.Direction) + } + if rec.Opcode != 0x0013 { + t.Errorf("rec[0] opcode = 0x%04X, want 0x0013", rec.Opcode) + } + + // Second record: S→C. + rec, err = r.ReadPacket() + if err != nil { + t.Fatalf("ReadPacket[1]: %v", err) + } + if rec.Direction != DirServerToClient { + t.Errorf("rec[1] direction = %v, want S→C", rec.Direction) + } + if rec.Opcode != 0x0012 { + t.Errorf("rec[1] opcode = 0x%04X, want 0x0012", rec.Opcode) + } + + // EOF. + _, err = r.ReadPacket() + if err != io.EOF { + t.Errorf("expected EOF, got %v", err) + } +} + +func TestRecordingConnConcurrent(t *testing.T) { + // Generate enough packets for concurrent stress. + const numPackets = 100 + readData := make([][]byte, numPackets) + for i := range readData { + readData[i] = []byte{byte(i >> 8), byte(i), 0xAA} + } + + mock := &mockConn{readData: readData} + + var buf bytes.Buffer + hdr := FileHeader{ + Version: FormatVersion, + ServerType: ServerTypeChannel, + ClientMode: 40, + SessionStartNs: 1000, + } + w, err := NewWriter(&buf, hdr, SessionMetadata{}) + if err != nil { + t.Fatalf("NewWriter: %v", err) + } + + rc := NewRecordingConn(mock, w, 1000) + + // Concurrent reads and sends. + var wg sync.WaitGroup + wg.Add(2) + + go func() { + defer wg.Done() + for i := 0; i < numPackets; i++ { + _, _ = rc.ReadPacket() + } + }() + + go func() { + defer wg.Done() + for i := 0; i < numPackets; i++ { + _ = rc.SendPacket([]byte{byte(i >> 8), byte(i), 0xBB}) + } + }() + + wg.Wait() + + if err := w.Flush(); err != nil { + t.Fatalf("Flush: %v", err) + } + + // Verify all 200 records can be read back. + r, err := NewReader(bytes.NewReader(buf.Bytes())) + if err != nil { + t.Fatalf("NewReader: %v", err) + } + + count := 0 + for { + _, err := r.ReadPacket() + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("ReadPacket: %v", err) + } + count++ + } + if count != 2*numPackets { + t.Errorf("got %d records, want %d", count, 2*numPackets) + } +} diff --git a/network/pcap/writer.go b/network/pcap/writer.go new file mode 100644 index 000000000..d6d24d00e --- /dev/null +++ b/network/pcap/writer.go @@ -0,0 +1,89 @@ +package pcap + +import ( + "bufio" + "encoding/binary" + "encoding/json" + "fmt" + "io" +) + +// Writer writes .mhfr capture files. +type Writer struct { + bw *bufio.Writer +} + +// NewWriter creates a Writer, immediately writing the file header and metadata block. +func NewWriter(w io.Writer, header FileHeader, meta SessionMetadata) (*Writer, error) { + metaBytes, err := json.Marshal(&meta) + if err != nil { + return nil, fmt.Errorf("pcap: marshal metadata: %w", err) + } + header.MetadataLen = uint32(len(metaBytes)) + + bw := bufio.NewWriter(w) + + // Write 32-byte file header. + if _, err := bw.WriteString(Magic); err != nil { + return nil, err + } + if err := binary.Write(bw, binary.BigEndian, header.Version); err != nil { + return nil, err + } + if err := bw.WriteByte(byte(header.ServerType)); err != nil { + return nil, err + } + if err := bw.WriteByte(header.ClientMode); err != nil { + return nil, err + } + if err := binary.Write(bw, binary.BigEndian, header.SessionStartNs); err != nil { + return nil, err + } + // 4 bytes reserved + if _, err := bw.Write(make([]byte, 4)); err != nil { + return nil, err + } + if err := binary.Write(bw, binary.BigEndian, header.MetadataLen); err != nil { + return nil, err + } + // 8 bytes reserved + if _, err := bw.Write(make([]byte, 8)); err != nil { + return nil, err + } + + // Write metadata JSON block. + if _, err := bw.Write(metaBytes); err != nil { + return nil, err + } + + if err := bw.Flush(); err != nil { + return nil, err + } + + return &Writer{bw: bw}, nil +} + +// WritePacket appends a single packet record. +func (w *Writer) WritePacket(rec PacketRecord) error { + if err := binary.Write(w.bw, binary.BigEndian, rec.TimestampNs); err != nil { + return err + } + if err := w.bw.WriteByte(byte(rec.Direction)); err != nil { + return err + } + if err := binary.Write(w.bw, binary.BigEndian, rec.Opcode); err != nil { + return err + } + if err := binary.Write(w.bw, binary.BigEndian, uint32(len(rec.Payload))); err != nil { + return err + } + if _, err := w.bw.Write(rec.Payload); err != nil { + return err + } + return nil +} + +// Flush flushes the buffered writer. +func (w *Writer) Flush() error { + return w.bw.Flush() +} diff --git a/server/channelserver/handlers_session.go b/server/channelserver/handlers_session.go index 4c6571879..b07714ad0 100644 --- a/server/channelserver/handlers_session.go +++ b/server/channelserver/handlers_session.go @@ -283,6 +283,11 @@ func logoutPlayer(s *Session) { } } + // Flush and close capture file before closing the connection. + if s.captureCleanup != nil { + s.captureCleanup() + } + // NOW do cleanup (after save is complete) s.server.Lock() delete(s.server.sessions, s.rawConn) diff --git a/server/channelserver/sys_capture.go b/server/channelserver/sys_capture.go new file mode 100644 index 000000000..c5c0160bf --- /dev/null +++ b/server/channelserver/sys_capture.go @@ -0,0 +1,109 @@ +package channelserver + +import ( + "fmt" + "net" + "os" + "path/filepath" + "time" + + "erupe-ce/network" + "erupe-ce/network/pcap" + + "go.uber.org/zap" +) + +// startCapture wraps a network.Conn with a RecordingConn if capture is enabled. +// Returns the (possibly wrapped) conn and a cleanup function that must be called on session close. +func startCapture(server *Server, conn network.Conn, remoteAddr net.Addr, serverType pcap.ServerType) (network.Conn, func()) { + capCfg := server.erupeConfig.Capture + if !capCfg.Enabled { + return conn, func() {} + } + + switch serverType { + case pcap.ServerTypeSign: + if !capCfg.CaptureSign { + return conn, func() {} + } + case pcap.ServerTypeEntrance: + if !capCfg.CaptureEntrance { + return conn, func() {} + } + case pcap.ServerTypeChannel: + if !capCfg.CaptureChannel { + return conn, func() {} + } + } + + outputDir := capCfg.OutputDir + if outputDir == "" { + outputDir = "captures" + } + if err := os.MkdirAll(outputDir, 0o755); err != nil { + server.logger.Warn("Failed to create capture directory", zap.Error(err)) + return conn, func() {} + } + + now := time.Now() + filename := fmt.Sprintf("%s_%s_%s.mhfr", + serverType.String(), + now.Format("20060102_150405"), + sanitizeAddr(remoteAddr.String()), + ) + path := filepath.Join(outputDir, filename) + + f, err := os.Create(path) + if err != nil { + server.logger.Warn("Failed to create capture file", zap.Error(err), zap.String("path", path)) + return conn, func() {} + } + + startNs := now.UnixNano() + hdr := pcap.FileHeader{ + Version: pcap.FormatVersion, + ServerType: serverType, + ClientMode: byte(server.erupeConfig.RealClientMode), + SessionStartNs: startNs, + } + meta := pcap.SessionMetadata{ + Host: server.erupeConfig.Host, + RemoteAddr: remoteAddr.String(), + } + + w, err := pcap.NewWriter(f, hdr, meta) + if err != nil { + server.logger.Warn("Failed to initialize capture writer", zap.Error(err)) + _ = f.Close() + return conn, func() {} + } + + server.logger.Info("Capture started", zap.String("file", path)) + + rc := pcap.NewRecordingConn(conn, w, startNs) + cleanup := func() { + if err := w.Flush(); err != nil { + server.logger.Warn("Failed to flush capture", zap.Error(err)) + } + if err := f.Close(); err != nil { + server.logger.Warn("Failed to close capture file", zap.Error(err)) + } + server.logger.Info("Capture saved", zap.String("file", path)) + } + + return rc, cleanup +} + +// sanitizeAddr replaces characters that are problematic in filenames. +func sanitizeAddr(addr string) string { + out := make([]byte, 0, len(addr)) + for i := 0; i < len(addr); i++ { + c := addr[i] + if c == ':' { + out = append(out, '_') + } else { + out = append(out, c) + } + } + return string(out) +} diff --git a/server/channelserver/sys_session.go b/server/channelserver/sys_session.go index 6b75d3aa5..3944d4101 100644 --- a/server/channelserver/sys_session.go +++ b/server/channelserver/sys_session.go @@ -16,6 +16,7 @@ import ( "erupe-ce/network" "erupe-ce/network/clientctx" "erupe-ce/network/mhfpacket" + "erupe-ce/network/pcap" "go.uber.org/zap" ) @@ -70,18 +71,23 @@ type Session struct { // Contains the mail list that maps accumulated indexes to mail IDs mailList []int - Name string - closed atomic.Bool - ackStart map[uint32]time.Time + Name string + closed atomic.Bool + ackStart map[uint32]time.Time + captureCleanup func() // Called on session close to flush/close capture file } // NewSession creates a new Session type. func NewSession(server *Server, conn net.Conn) *Session { + var cryptConn network.Conn = network.NewCryptConn(conn, server.erupeConfig.RealClientMode, server.logger.Named(conn.RemoteAddr().String())) + + cryptConn, captureCleanup := startCapture(server, cryptConn, conn.RemoteAddr(), pcap.ServerTypeChannel) + s := &Session{ logger: server.logger.Named(conn.RemoteAddr().String()), server: server, rawConn: conn, - cryptConn: network.NewCryptConn(conn, server.erupeConfig.RealClientMode, server.logger.Named(conn.RemoteAddr().String())), + cryptConn: cryptConn, sendPackets: make(chan packet, 20), clientContext: &clientctx.ClientContext{RealClientMode: server.erupeConfig.RealClientMode}, lastPacket: time.Now(), @@ -90,6 +96,7 @@ func NewSession(server *Server, conn net.Conn) *Session { stageMoveStack: stringstack.New(), ackStart: make(map[uint32]time.Time), semaphoreID: make([]uint16, 2), + captureCleanup: captureCleanup, } return s } diff --git a/server/entranceserver/entrance_server.go b/server/entranceserver/entrance_server.go index fb98c945c..964905afd 100644 --- a/server/entranceserver/entrance_server.go +++ b/server/entranceserver/entrance_server.go @@ -108,7 +108,10 @@ func (s *Server) handleEntranceServerConnection(conn net.Conn) { } // Create a new encrypted connection handler and read a packet from it. - cc := network.NewCryptConn(conn, s.erupeConfig.RealClientMode, s.logger) + var cc network.Conn = network.NewCryptConn(conn, s.erupeConfig.RealClientMode, s.logger) + cc, captureCleanup := startEntranceCapture(s, cc, conn.RemoteAddr()) + defer captureCleanup() + pkt, err := cc.ReadPacket() if err != nil { s.logger.Warn("Error reading packet", zap.Error(err)) diff --git a/server/entranceserver/sys_capture.go b/server/entranceserver/sys_capture.go new file mode 100644 index 000000000..2e4b25404 --- /dev/null +++ b/server/entranceserver/sys_capture.go @@ -0,0 +1,92 @@ +package entranceserver + +import ( + "fmt" + "net" + "os" + "path/filepath" + "time" + + "erupe-ce/network" + "erupe-ce/network/pcap" + + "go.uber.org/zap" +) + +// startEntranceCapture wraps a Conn with a RecordingConn if capture is enabled for entrance server. +func startEntranceCapture(s *Server, conn network.Conn, remoteAddr net.Addr) (network.Conn, func()) { + capCfg := s.erupeConfig.Capture + if !capCfg.Enabled || !capCfg.CaptureEntrance { + return conn, func() {} + } + + outputDir := capCfg.OutputDir + if outputDir == "" { + outputDir = "captures" + } + if err := os.MkdirAll(outputDir, 0o755); err != nil { + s.logger.Warn("Failed to create capture directory", zap.Error(err)) + return conn, func() {} + } + + now := time.Now() + filename := fmt.Sprintf("entrance_%s_%s.mhfr", + now.Format("20060102_150405"), + sanitizeAddr(remoteAddr.String()), + ) + path := filepath.Join(outputDir, filename) + + f, err := os.Create(path) + if err != nil { + s.logger.Warn("Failed to create capture file", zap.Error(err), zap.String("path", path)) + return conn, func() {} + } + + startNs := now.UnixNano() + hdr := pcap.FileHeader{ + Version: pcap.FormatVersion, + ServerType: pcap.ServerTypeEntrance, + ClientMode: byte(s.erupeConfig.RealClientMode), + SessionStartNs: startNs, + } + meta := pcap.SessionMetadata{ + Host: s.erupeConfig.Host, + Port: int(s.erupeConfig.Entrance.Port), + RemoteAddr: remoteAddr.String(), + } + + w, err := pcap.NewWriter(f, hdr, meta) + if err != nil { + s.logger.Warn("Failed to initialize capture writer", zap.Error(err)) + _ = f.Close() + return conn, func() {} + } + + s.logger.Info("Capture started", zap.String("file", path)) + + rc := pcap.NewRecordingConn(conn, w, startNs) + cleanup := func() { + if err := w.Flush(); err != nil { + s.logger.Warn("Failed to flush capture", zap.Error(err)) + } + if err := f.Close(); err != nil { + s.logger.Warn("Failed to close capture file", zap.Error(err)) + } + s.logger.Info("Capture saved", zap.String("file", path)) + } + + return rc, cleanup +} + +func sanitizeAddr(addr string) string { + out := make([]byte, 0, len(addr)) + for i := 0; i < len(addr); i++ { + c := addr[i] + if c == ':' { + out = append(out, '_') + } else { + out = append(out, c) + } + } + return string(out) +} diff --git a/server/signserver/session.go b/server/signserver/session.go index bdbcf9a07..01350519a 100644 --- a/server/signserver/session.go +++ b/server/signserver/session.go @@ -27,12 +27,13 @@ const ( // Session holds state for the sign server connection. type Session struct { sync.Mutex - logger *zap.Logger - server *Server - rawConn net.Conn - cryptConn *network.CryptConn - client client - psn string + logger *zap.Logger + server *Server + rawConn net.Conn + cryptConn network.Conn + client client + psn string + captureCleanup func() } func (s *Session) work() { diff --git a/server/signserver/sign_server.go b/server/signserver/sign_server.go index 207e3aea6..4d66ef487 100644 --- a/server/signserver/sign_server.go +++ b/server/signserver/sign_server.go @@ -104,13 +104,21 @@ func (s *Server) handleConnection(conn net.Conn) { } // Create a new session. + var cc network.Conn = network.NewCryptConn(conn, s.erupeConfig.RealClientMode, s.logger) + cc, captureCleanup := startSignCapture(s, cc, conn.RemoteAddr()) + session := &Session{ - logger: s.logger, - server: s, - rawConn: conn, - cryptConn: network.NewCryptConn(conn, s.erupeConfig.RealClientMode, s.logger), + logger: s.logger, + server: s, + rawConn: conn, + cryptConn: cc, + captureCleanup: captureCleanup, } // Do the session's work. session.work() + + if session.captureCleanup != nil { + session.captureCleanup() + } } diff --git a/server/signserver/sys_capture.go b/server/signserver/sys_capture.go new file mode 100644 index 000000000..00a87a988 --- /dev/null +++ b/server/signserver/sys_capture.go @@ -0,0 +1,92 @@ +package signserver + +import ( + "fmt" + "net" + "os" + "path/filepath" + "time" + + "erupe-ce/network" + "erupe-ce/network/pcap" + + "go.uber.org/zap" +) + +// startSignCapture wraps a Conn with a RecordingConn if capture is enabled for sign server. +func startSignCapture(s *Server, conn network.Conn, remoteAddr net.Addr) (network.Conn, func()) { + capCfg := s.erupeConfig.Capture + if !capCfg.Enabled || !capCfg.CaptureSign { + return conn, func() {} + } + + outputDir := capCfg.OutputDir + if outputDir == "" { + outputDir = "captures" + } + if err := os.MkdirAll(outputDir, 0o755); err != nil { + s.logger.Warn("Failed to create capture directory", zap.Error(err)) + return conn, func() {} + } + + now := time.Now() + filename := fmt.Sprintf("sign_%s_%s.mhfr", + now.Format("20060102_150405"), + sanitizeAddr(remoteAddr.String()), + ) + path := filepath.Join(outputDir, filename) + + f, err := os.Create(path) + if err != nil { + s.logger.Warn("Failed to create capture file", zap.Error(err), zap.String("path", path)) + return conn, func() {} + } + + startNs := now.UnixNano() + hdr := pcap.FileHeader{ + Version: pcap.FormatVersion, + ServerType: pcap.ServerTypeSign, + ClientMode: byte(s.erupeConfig.RealClientMode), + SessionStartNs: startNs, + } + meta := pcap.SessionMetadata{ + Host: s.erupeConfig.Host, + Port: s.erupeConfig.Sign.Port, + RemoteAddr: remoteAddr.String(), + } + + w, err := pcap.NewWriter(f, hdr, meta) + if err != nil { + s.logger.Warn("Failed to initialize capture writer", zap.Error(err)) + _ = f.Close() + return conn, func() {} + } + + s.logger.Info("Capture started", zap.String("file", path)) + + rc := pcap.NewRecordingConn(conn, w, startNs) + cleanup := func() { + if err := w.Flush(); err != nil { + s.logger.Warn("Failed to flush capture", zap.Error(err)) + } + if err := f.Close(); err != nil { + s.logger.Warn("Failed to close capture file", zap.Error(err)) + } + s.logger.Info("Capture saved", zap.String("file", path)) + } + + return rc, cleanup +} + +func sanitizeAddr(addr string) string { + out := make([]byte, 0, len(addr)) + for i := 0; i < len(addr); i++ { + c := addr[i] + if c == ':' { + out = append(out, '_') + } else { + out = append(out, c) + } + } + return string(out) +}