mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-21 23:22:34 +01:00
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.
398 lines
10 KiB
Go
398 lines
10 KiB
Go
// 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 --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)")
|
|
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 == "" {
|
|
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)
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
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 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 {
|
|
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
|
|
}
|