mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-21 23:22:34 +01:00
feat(pcap): complete replay system with filtering, metadata, and live replay
Wire ExcludeOpcodes config into RecordingConn so configured opcodes (e.g. ping, nop, position) are filtered at record time. Add padded metadata with in-place PatchMetadata to populate CharID/UserID after login. Implement --mode replay using protbot's encrypted connection with timing-aware packet sending, auto-ping response, concurrent S→C collection, and byte-level payload diff reporting.
This commit is contained in:
@@ -2,11 +2,22 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"erupe-ce/network"
|
||||
"erupe-ce/network/pcap"
|
||||
)
|
||||
|
||||
// maxPayloadDiffs is the maximum number of byte-level diffs to report per packet.
|
||||
const maxPayloadDiffs = 16
|
||||
|
||||
// ByteDiff describes a single byte difference between expected and actual payloads.
|
||||
type ByteDiff struct {
|
||||
Offset int
|
||||
Expected byte
|
||||
Actual byte
|
||||
}
|
||||
|
||||
// PacketDiff describes a difference between an expected and actual packet.
|
||||
type PacketDiff struct {
|
||||
Index int
|
||||
@@ -14,10 +25,15 @@ type PacketDiff struct {
|
||||
Actual *pcap.PacketRecord // nil if no response received
|
||||
OpcodeMismatch bool
|
||||
SizeDelta int
|
||||
PayloadDiffs []ByteDiff // byte-level diffs (when opcodes match and sizes match)
|
||||
}
|
||||
|
||||
func (d PacketDiff) String() string {
|
||||
if d.Actual == nil {
|
||||
if d.Expected.Opcode == 0 {
|
||||
return fmt.Sprintf("#%d: unexpected extra response 0x%04X (%s)",
|
||||
d.Index, d.Expected.Opcode, network.PacketID(d.Expected.Opcode))
|
||||
}
|
||||
return fmt.Sprintf("#%d: expected 0x%04X (%s), got no response",
|
||||
d.Index, d.Expected.Opcode, network.PacketID(d.Expected.Opcode))
|
||||
}
|
||||
@@ -27,8 +43,21 @@ func (d PacketDiff) String() string {
|
||||
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)
|
||||
if d.SizeDelta != 0 {
|
||||
return fmt.Sprintf("#%d: 0x%04X (%s) size delta %+d bytes",
|
||||
d.Index, d.Expected.Opcode, network.PacketID(d.Expected.Opcode), d.SizeDelta)
|
||||
}
|
||||
if len(d.PayloadDiffs) > 0 {
|
||||
var sb strings.Builder
|
||||
fmt.Fprintf(&sb, "#%d: 0x%04X (%s) %d byte diff(s):",
|
||||
d.Index, d.Expected.Opcode, network.PacketID(d.Expected.Opcode), len(d.PayloadDiffs))
|
||||
for _, bd := range d.PayloadDiffs {
|
||||
fmt.Fprintf(&sb, " [0x%04X: %02X→%02X]", bd.Offset, bd.Expected, bd.Actual)
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
return fmt.Sprintf("#%d: 0x%04X (%s) unknown diff",
|
||||
d.Index, d.Expected.Opcode, network.PacketID(d.Expected.Opcode))
|
||||
}
|
||||
|
||||
// ComparePackets compares expected server responses against actual responses.
|
||||
@@ -62,6 +91,17 @@ func ComparePackets(expected, actual []pcap.PacketRecord) []PacketDiff {
|
||||
Actual: &act,
|
||||
SizeDelta: len(act.Payload) - len(exp.Payload),
|
||||
})
|
||||
} else {
|
||||
// Same opcode and size — check for byte-level diffs.
|
||||
byteDiffs := comparePayloads(exp.Payload, act.Payload)
|
||||
if len(byteDiffs) > 0 {
|
||||
diffs = append(diffs, PacketDiff{
|
||||
Index: i,
|
||||
Expected: exp,
|
||||
Actual: &act,
|
||||
PayloadDiffs: byteDiffs,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,3 +117,19 @@ func ComparePackets(expected, actual []pcap.PacketRecord) []PacketDiff {
|
||||
|
||||
return diffs
|
||||
}
|
||||
|
||||
// comparePayloads returns byte-level diffs between two equal-length payloads.
|
||||
// Returns at most maxPayloadDiffs entries.
|
||||
func comparePayloads(expected, actual []byte) []ByteDiff {
|
||||
var diffs []ByteDiff
|
||||
for i := 0; i < len(expected) && len(diffs) < maxPayloadDiffs; i++ {
|
||||
if expected[i] != actual[i] {
|
||||
diffs = append(diffs, ByteDiff{
|
||||
Offset: i,
|
||||
Expected: expected[i],
|
||||
Actual: actual[i],
|
||||
})
|
||||
}
|
||||
}
|
||||
return diffs
|
||||
}
|
||||
|
||||
@@ -5,29 +5,35 @@
|
||||
// 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
|
||||
// replay --capture file.mhfr --mode replay --target 127.0.0.1:54001 --no-auth # Replay against live server
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"erupe-ce/cmd/protbot/conn"
|
||||
"erupe-ce/network"
|
||||
"erupe-ce/network/pcap"
|
||||
)
|
||||
|
||||
// MSG_SYS_PING opcode for auto-responding to server pings.
|
||||
const opcodeSysPing = 0x0017
|
||||
|
||||
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
|
||||
noAuth := flag.Bool("no-auth", false, "Skip auth token patching (requires DisableTokenCheck on server)")
|
||||
_ = noAuth // currently only no-auth mode is supported
|
||||
flag.Parse()
|
||||
|
||||
if *capturePath == "" {
|
||||
@@ -57,8 +63,10 @@ func main() {
|
||||
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)
|
||||
if err := runReplay(*capturePath, *target, *speed); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "replay failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown mode: %s\n", *mode)
|
||||
os.Exit(1)
|
||||
@@ -93,6 +101,129 @@ func readAllPackets(r *pcap.Reader) ([]pcap.PacketRecord, error) {
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func runReplay(path, target string, speed float64) error {
|
||||
r, f, err := openCapture(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
records, err := readAllPackets(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c2s := pcap.FilterByDirection(records, pcap.DirClientToServer)
|
||||
expectedS2C := pcap.FilterByDirection(records, pcap.DirServerToClient)
|
||||
|
||||
if len(c2s) == 0 {
|
||||
fmt.Println("No C→S packets in capture, nothing to replay.")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("=== Replay: %s ===\n", path)
|
||||
fmt.Printf("Server type: %s Target: %s Speed: %.1fx\n", r.Header.ServerType, target, speed)
|
||||
fmt.Printf("C→S packets to send: %d Expected S→C responses: %d\n\n", len(c2s), len(expectedS2C))
|
||||
|
||||
// Connect based on server type.
|
||||
var mhf *conn.MHFConn
|
||||
switch r.Header.ServerType {
|
||||
case pcap.ServerTypeChannel:
|
||||
mhf, err = conn.DialDirect(target)
|
||||
default:
|
||||
mhf, err = conn.DialWithInit(target)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to %s: %w", target, err)
|
||||
}
|
||||
|
||||
// Collect S→C responses concurrently.
|
||||
var actualS2C []pcap.PacketRecord
|
||||
var mu sync.Mutex
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
for {
|
||||
pkt, err := mhf.ReadPacket()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var opcode uint16
|
||||
if len(pkt) >= 2 {
|
||||
opcode = binary.BigEndian.Uint16(pkt[:2])
|
||||
}
|
||||
|
||||
// Auto-respond to ping to keep connection alive.
|
||||
if opcode == opcodeSysPing {
|
||||
pong := buildPingResponse()
|
||||
_ = mhf.SendPacket(pong)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
actualS2C = append(actualS2C, pcap.PacketRecord{
|
||||
TimestampNs: time.Now().UnixNano(),
|
||||
Direction: pcap.DirServerToClient,
|
||||
Opcode: opcode,
|
||||
Payload: pkt,
|
||||
})
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
// Send C→S packets with timing.
|
||||
var lastTs int64
|
||||
for i, pkt := range c2s {
|
||||
if i > 0 && speed > 0 {
|
||||
delta := time.Duration(float64(pkt.TimestampNs-lastTs) / speed)
|
||||
if delta > 0 {
|
||||
time.Sleep(delta)
|
||||
}
|
||||
}
|
||||
lastTs = pkt.TimestampNs
|
||||
opcodeName := network.PacketID(pkt.Opcode).String()
|
||||
fmt.Printf("[replay] #%d sending 0x%04X %-30s (%d bytes)\n", i, pkt.Opcode, opcodeName, len(pkt.Payload))
|
||||
if err := mhf.SendPacket(pkt.Payload); err != nil {
|
||||
fmt.Printf("[replay] send error: %v\n", err)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for remaining responses.
|
||||
fmt.Println("\n[replay] All packets sent, waiting for remaining responses...")
|
||||
time.Sleep(2 * time.Second)
|
||||
_ = mhf.Close()
|
||||
<-done
|
||||
|
||||
// Compare.
|
||||
mu.Lock()
|
||||
diffs := ComparePackets(expectedS2C, actualS2C)
|
||||
mu.Unlock()
|
||||
|
||||
// Report.
|
||||
fmt.Printf("\n=== Replay Results ===\n")
|
||||
fmt.Printf("Sent: %d C→S packets\n", len(c2s))
|
||||
fmt.Printf("Expected: %d S→C responses\n", len(expectedS2C))
|
||||
fmt.Printf("Received: %d S→C responses\n", len(actualS2C))
|
||||
fmt.Printf("Differences: %d\n\n", len(diffs))
|
||||
for _, d := range diffs {
|
||||
fmt.Println(d.String())
|
||||
}
|
||||
|
||||
if len(diffs) == 0 {
|
||||
fmt.Println("All responses match!")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildPingResponse builds a minimal MSG_SYS_PING response packet.
|
||||
// Format: [opcode 0x0017][0x00 0x10 terminator]
|
||||
func buildPingResponse() []byte {
|
||||
return []byte{0x00, 0x17, 0x00, 0x10}
|
||||
}
|
||||
|
||||
func runDump(path string) error {
|
||||
r, f, err := openCapture(path)
|
||||
if err != nil {
|
||||
|
||||
@@ -2,7 +2,10 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"erupe-ce/network/pcap"
|
||||
@@ -141,14 +144,169 @@ func TestComparePacketsMissingResponse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPacketDiffString(t *testing.T) {
|
||||
d := PacketDiff{
|
||||
Index: 0,
|
||||
Expected: pcap.PacketRecord{Opcode: 0x0012},
|
||||
Actual: nil,
|
||||
func TestComparePacketsPayloadDiff(t *testing.T) {
|
||||
expected := []pcap.PacketRecord{
|
||||
{Direction: pcap.DirServerToClient, Opcode: 0x0012, Payload: []byte{0x00, 0x12, 0xAA, 0xBB}},
|
||||
}
|
||||
s := d.String()
|
||||
if s == "" {
|
||||
t.Error("PacketDiff.String() returned empty")
|
||||
actual := []pcap.PacketRecord{
|
||||
{Direction: pcap.DirServerToClient, Opcode: 0x0012, Payload: []byte{0x00, 0x12, 0xCC, 0xBB}},
|
||||
}
|
||||
|
||||
diffs := ComparePackets(expected, actual)
|
||||
if len(diffs) != 1 {
|
||||
t.Fatalf("expected 1 diff, got %d", len(diffs))
|
||||
}
|
||||
if len(diffs[0].PayloadDiffs) != 1 {
|
||||
t.Fatalf("expected 1 payload diff, got %d", len(diffs[0].PayloadDiffs))
|
||||
}
|
||||
bd := diffs[0].PayloadDiffs[0]
|
||||
if bd.Offset != 2 || bd.Expected != 0xAA || bd.Actual != 0xCC {
|
||||
t.Errorf("ByteDiff = {Offset:%d, Expected:0x%02X, Actual:0x%02X}, want {2, 0xAA, 0xCC}",
|
||||
bd.Offset, bd.Expected, bd.Actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComparePacketsIdentical(t *testing.T) {
|
||||
records := []pcap.PacketRecord{
|
||||
{Direction: pcap.DirServerToClient, Opcode: 0x0012, Payload: []byte{0x00, 0x12, 0xAA}},
|
||||
}
|
||||
diffs := ComparePackets(records, records)
|
||||
if len(diffs) != 0 {
|
||||
t.Errorf("expected 0 diffs for identical packets, got %d", len(diffs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPacketDiffString(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
diff PacketDiff
|
||||
contains string
|
||||
}{
|
||||
{
|
||||
name: "missing response",
|
||||
diff: PacketDiff{
|
||||
Index: 0,
|
||||
Expected: pcap.PacketRecord{Opcode: 0x0012},
|
||||
Actual: nil,
|
||||
},
|
||||
contains: "no response",
|
||||
},
|
||||
{
|
||||
name: "opcode mismatch",
|
||||
diff: PacketDiff{
|
||||
Index: 1,
|
||||
Expected: pcap.PacketRecord{Opcode: 0x0012},
|
||||
Actual: &pcap.PacketRecord{Opcode: 0x0099},
|
||||
OpcodeMismatch: true,
|
||||
},
|
||||
contains: "opcode mismatch",
|
||||
},
|
||||
{
|
||||
name: "size delta",
|
||||
diff: PacketDiff{
|
||||
Index: 2,
|
||||
Expected: pcap.PacketRecord{Opcode: 0x0012},
|
||||
Actual: &pcap.PacketRecord{Opcode: 0x0012},
|
||||
SizeDelta: 5,
|
||||
},
|
||||
contains: "size delta",
|
||||
},
|
||||
{
|
||||
name: "payload diffs",
|
||||
diff: PacketDiff{
|
||||
Index: 3,
|
||||
Expected: pcap.PacketRecord{Opcode: 0x0012},
|
||||
Actual: &pcap.PacketRecord{Opcode: 0x0012},
|
||||
PayloadDiffs: []ByteDiff{
|
||||
{Offset: 2, Expected: 0xAA, Actual: 0xBB},
|
||||
},
|
||||
},
|
||||
contains: "byte diff",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := tc.diff.String()
|
||||
if !strings.Contains(s, tc.contains) {
|
||||
t.Errorf("String() = %q, want it to contain %q", s, tc.contains)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunReplayWithMockServer(t *testing.T) {
|
||||
// Start a mock TCP server that echoes a response for each received packet.
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("Listen: %v", err)
|
||||
}
|
||||
defer func() { _ = ln.Close() }()
|
||||
|
||||
serverDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(serverDone)
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer func() { _ = c.Close() }()
|
||||
|
||||
// This mock doesn't do Blowfish encryption — it just reads raw and echoes.
|
||||
// Since the replay uses protbot's CryptConn (Blowfish), we need a real crypto echo.
|
||||
// For a simpler test, just verify the function handles connection errors gracefully.
|
||||
// Read a bit and close.
|
||||
buf := make([]byte, 1024)
|
||||
_, _ = c.Read(buf)
|
||||
}()
|
||||
|
||||
// Create a minimal capture with one C→S packet.
|
||||
path := createTestCapture(t, []pcap.PacketRecord{
|
||||
{TimestampNs: 1000000100, Direction: pcap.DirClientToServer, Opcode: 0x0013,
|
||||
Payload: []byte{0x00, 0x13, 0xDE, 0xAD}},
|
||||
})
|
||||
|
||||
// Run replay — the connection will fail (no Blowfish on mock), but it should not panic.
|
||||
err = runReplay(path, ln.Addr().String(), 0)
|
||||
// We expect an error or graceful handling since the mock doesn't speak Blowfish.
|
||||
// The important thing is no panic.
|
||||
_ = err
|
||||
}
|
||||
|
||||
func TestComparePayloads(t *testing.T) {
|
||||
a := []byte{0x00, 0x12, 0xAA, 0xBB, 0xCC}
|
||||
b := []byte{0x00, 0x12, 0xAA, 0xDD, 0xCC}
|
||||
|
||||
diffs := comparePayloads(a, b)
|
||||
if len(diffs) != 1 {
|
||||
t.Fatalf("expected 1 diff, got %d", len(diffs))
|
||||
}
|
||||
if diffs[0].Offset != 3 {
|
||||
t.Errorf("Offset = %d, want 3", diffs[0].Offset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComparePayloadsMaxDiffs(t *testing.T) {
|
||||
// All bytes different — should cap at maxPayloadDiffs.
|
||||
a := make([]byte, 100)
|
||||
b := make([]byte, 100)
|
||||
for i := range b {
|
||||
b[i] = 0xFF
|
||||
}
|
||||
|
||||
diffs := comparePayloads(a, b)
|
||||
if len(diffs) != maxPayloadDiffs {
|
||||
t.Errorf("expected %d diffs (capped), got %d", maxPayloadDiffs, len(diffs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPingResponse(t *testing.T) {
|
||||
pong := buildPingResponse()
|
||||
if len(pong) < 2 {
|
||||
t.Fatal("ping response too short")
|
||||
}
|
||||
opcode := binary.BigEndian.Uint16(pong[:2])
|
||||
if opcode != opcodeSysPing {
|
||||
t.Errorf("opcode = 0x%04X, want 0x%04X", opcode, opcodeSysPing)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user