mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-21 23:22:34 +01:00
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
This commit is contained in:
79
cmd/replay/compare.go
Normal file
79
cmd/replay/compare.go
Normal file
@@ -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
|
||||
}
|
||||
266
cmd/replay/main.go
Normal file
266
cmd/replay/main.go
Normal file
@@ -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
|
||||
}
|
||||
154
cmd/replay/replay_test.go
Normal file
154
cmd/replay/replay_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user