mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-22 07:32:32 +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:
@@ -12,6 +12,11 @@ const (
|
||||
|
||||
// HeaderSize is the fixed size of the file header in bytes.
|
||||
HeaderSize = 32
|
||||
|
||||
// MinMetadataSize is the minimum metadata block size in bytes.
|
||||
// Metadata is padded to at least this size to allow in-place patching
|
||||
// (e.g., adding CharID/UserID after login).
|
||||
MinMetadataSize = 512
|
||||
)
|
||||
|
||||
// Direction indicates whether a packet was sent or received.
|
||||
|
||||
48
network/pcap/patch.go
Normal file
48
network/pcap/patch.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package pcap
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// PatchMetadata rewrites the metadata block in a .mhfr capture file.
|
||||
// The file must have been written with padded metadata (MinMetadataSize).
|
||||
// The new JSON must fit within the existing MetadataLen allocation.
|
||||
func PatchMetadata(f *os.File, meta SessionMetadata) error {
|
||||
newJSON, err := json.Marshal(&meta)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pcap: marshal metadata: %w", err)
|
||||
}
|
||||
|
||||
// Read MetadataLen from header (offset 20: after magic(4)+version(2)+servertype(1)+clientmode(1)+startnanos(8)+reserved(4)).
|
||||
var metaLen uint32
|
||||
if _, err := f.Seek(20, 0); err != nil {
|
||||
return fmt.Errorf("pcap: seek to metadata len: %w", err)
|
||||
}
|
||||
if err := binary.Read(f, binary.BigEndian, &metaLen); err != nil {
|
||||
return fmt.Errorf("pcap: read metadata len: %w", err)
|
||||
}
|
||||
|
||||
if uint32(len(newJSON)) > metaLen {
|
||||
return fmt.Errorf("pcap: new metadata (%d bytes) exceeds allocated space (%d bytes)", len(newJSON), metaLen)
|
||||
}
|
||||
|
||||
// Pad with spaces to fill the allocated block.
|
||||
padded := make([]byte, metaLen)
|
||||
copy(padded, newJSON)
|
||||
for i := len(newJSON); i < len(padded); i++ {
|
||||
padded[i] = ' '
|
||||
}
|
||||
|
||||
// Write at offset HeaderSize (32).
|
||||
if _, err := f.Seek(HeaderSize, 0); err != nil {
|
||||
return fmt.Errorf("pcap: seek to metadata: %w", err)
|
||||
}
|
||||
if _, err := f.Write(padded); err != nil {
|
||||
return fmt.Errorf("pcap: write metadata: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package pcap
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -252,6 +253,100 @@ func TestDirectionString(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataPadding(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
|
||||
hdr := FileHeader{
|
||||
Version: FormatVersion,
|
||||
ServerType: ServerTypeChannel,
|
||||
ClientMode: 40,
|
||||
SessionStartNs: 1000,
|
||||
}
|
||||
meta := SessionMetadata{Host: "127.0.0.1"}
|
||||
|
||||
_, err := NewWriter(&buf, hdr, meta)
|
||||
if err != nil {
|
||||
t.Fatalf("NewWriter: %v", err)
|
||||
}
|
||||
|
||||
// The metadata block should be at least MinMetadataSize.
|
||||
data := buf.Bytes()
|
||||
if len(data) < HeaderSize+MinMetadataSize {
|
||||
t.Errorf("file size %d < HeaderSize+MinMetadataSize (%d)", len(data), HeaderSize+MinMetadataSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchMetadata(t *testing.T) {
|
||||
// Create a capture file with initial metadata.
|
||||
f, err := os.CreateTemp(t.TempDir(), "test-patch-*.mhfr")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTemp: %v", err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
hdr := FileHeader{
|
||||
Version: FormatVersion,
|
||||
ServerType: ServerTypeChannel,
|
||||
ClientMode: 40,
|
||||
SessionStartNs: 1000,
|
||||
}
|
||||
meta := SessionMetadata{Host: "127.0.0.1", Port: 54001}
|
||||
|
||||
w, err := NewWriter(f, hdr, meta)
|
||||
if err != nil {
|
||||
t.Fatalf("NewWriter: %v", err)
|
||||
}
|
||||
// Write a packet so we can verify it survives patching.
|
||||
if err := w.WritePacket(PacketRecord{
|
||||
TimestampNs: 2000, Direction: DirClientToServer, Opcode: 0x0013, Payload: []byte{0x00, 0x13},
|
||||
}); err != nil {
|
||||
t.Fatalf("WritePacket: %v", err)
|
||||
}
|
||||
if err := w.Flush(); err != nil {
|
||||
t.Fatalf("Flush: %v", err)
|
||||
}
|
||||
|
||||
// Patch metadata with CharID/UserID.
|
||||
patched := SessionMetadata{
|
||||
Host: "127.0.0.1",
|
||||
Port: 54001,
|
||||
CharID: 42,
|
||||
UserID: 7,
|
||||
}
|
||||
if err := PatchMetadata(f, patched); err != nil {
|
||||
t.Fatalf("PatchMetadata: %v", err)
|
||||
}
|
||||
|
||||
// Re-read from the beginning.
|
||||
if _, err := f.Seek(0, 0); err != nil {
|
||||
t.Fatalf("Seek: %v", err)
|
||||
}
|
||||
r, err := NewReader(f)
|
||||
if err != nil {
|
||||
t.Fatalf("NewReader: %v", err)
|
||||
}
|
||||
|
||||
// Verify patched metadata.
|
||||
if r.Meta.CharID != 42 {
|
||||
t.Errorf("CharID = %d, want 42", r.Meta.CharID)
|
||||
}
|
||||
if r.Meta.UserID != 7 {
|
||||
t.Errorf("UserID = %d, want 7", r.Meta.UserID)
|
||||
}
|
||||
if r.Meta.Host != "127.0.0.1" {
|
||||
t.Errorf("Host = %q, want %q", r.Meta.Host, "127.0.0.1")
|
||||
}
|
||||
|
||||
// Verify packet survived.
|
||||
rec, err := r.ReadPacket()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadPacket: %v", err)
|
||||
}
|
||||
if rec.Opcode != 0x0013 {
|
||||
t.Errorf("Opcode = 0x%04X, want 0x0013", rec.Opcode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerTypeString(t *testing.T) {
|
||||
if ServerTypeSign.String() != "sign" {
|
||||
t.Errorf("ServerTypeSign.String() = %q", ServerTypeSign.String())
|
||||
|
||||
@@ -2,28 +2,68 @@ package pcap
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"erupe-ce/network"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"erupe-ce/network"
|
||||
)
|
||||
|
||||
// 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
|
||||
inner network.Conn
|
||||
writer *Writer
|
||||
startNs int64
|
||||
excludeOpcodes map[uint16]struct{}
|
||||
metaFile *os.File // capture file handle for metadata patching
|
||||
meta *SessionMetadata // current metadata (mutated by SetSessionInfo)
|
||||
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,
|
||||
// excludeOpcodes is an optional list of opcodes to skip when recording.
|
||||
func NewRecordingConn(inner network.Conn, w *Writer, startNs int64, excludeOpcodes []uint16) *RecordingConn {
|
||||
var excl map[uint16]struct{}
|
||||
if len(excludeOpcodes) > 0 {
|
||||
excl = make(map[uint16]struct{}, len(excludeOpcodes))
|
||||
for _, op := range excludeOpcodes {
|
||||
excl[op] = struct{}{}
|
||||
}
|
||||
}
|
||||
return &RecordingConn{
|
||||
inner: inner,
|
||||
writer: w,
|
||||
startNs: startNs,
|
||||
excludeOpcodes: excl,
|
||||
}
|
||||
}
|
||||
|
||||
// SetCaptureFile sets the file handle and metadata pointer for in-place metadata patching.
|
||||
// Must be called before SetSessionInfo. Not required if metadata patching is not needed.
|
||||
func (rc *RecordingConn) SetCaptureFile(f *os.File, meta *SessionMetadata) {
|
||||
rc.mu.Lock()
|
||||
rc.metaFile = f
|
||||
rc.meta = meta
|
||||
rc.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetSessionInfo updates the CharID and UserID in the capture file metadata.
|
||||
// This is called after login when the session identity is known.
|
||||
func (rc *RecordingConn) SetSessionInfo(charID, userID uint32) {
|
||||
rc.mu.Lock()
|
||||
defer rc.mu.Unlock()
|
||||
|
||||
if rc.meta == nil || rc.metaFile == nil {
|
||||
return
|
||||
}
|
||||
|
||||
rc.meta.CharID = charID
|
||||
rc.meta.UserID = userID
|
||||
|
||||
// Best-effort patch — log errors are handled by the caller.
|
||||
_ = PatchMetadata(rc.metaFile, *rc.meta)
|
||||
}
|
||||
|
||||
// ReadPacket reads from the inner connection and records the packet as client-to-server.
|
||||
@@ -52,6 +92,12 @@ func (rc *RecordingConn) record(dir Direction, data []byte) {
|
||||
opcode = binary.BigEndian.Uint16(data[:2])
|
||||
}
|
||||
|
||||
if rc.excludeOpcodes != nil {
|
||||
if _, excluded := rc.excludeOpcodes[opcode]; excluded {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
rec := PacketRecord{
|
||||
TimestampNs: time.Now().UnixNano(),
|
||||
Direction: dir,
|
||||
|
||||
@@ -54,7 +54,7 @@ func TestRecordingConnBasic(t *testing.T) {
|
||||
t.Fatalf("NewWriter: %v", err)
|
||||
}
|
||||
|
||||
rc := NewRecordingConn(mock, w, 1000)
|
||||
rc := NewRecordingConn(mock, w, 1000, nil)
|
||||
|
||||
// Read a packet (C→S).
|
||||
data, err := rc.ReadPacket()
|
||||
@@ -134,7 +134,7 @@ func TestRecordingConnConcurrent(t *testing.T) {
|
||||
t.Fatalf("NewWriter: %v", err)
|
||||
}
|
||||
|
||||
rc := NewRecordingConn(mock, w, 1000)
|
||||
rc := NewRecordingConn(mock, w, 1000, nil)
|
||||
|
||||
// Concurrent reads and sends.
|
||||
var wg sync.WaitGroup
|
||||
@@ -181,3 +181,84 @@ func TestRecordingConnConcurrent(t *testing.T) {
|
||||
t.Errorf("got %d records, want %d", count, 2*numPackets)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordingConnExcludeOpcodes(t *testing.T) {
|
||||
// Packets with opcodes 0x0010 (excluded), 0x0013, 0x0011 (excluded), 0x0061.
|
||||
mock := &mockConn{
|
||||
readData: [][]byte{
|
||||
{0x00, 0x10, 0xAA}, // opcode 0x0010 — excluded
|
||||
{0x00, 0x13, 0xBB}, // opcode 0x0013 — kept
|
||||
{0x00, 0x11, 0xCC}, // opcode 0x0011 — excluded
|
||||
{0x00, 0x61, 0xDD, 0xEE}, // opcode 0x0061 — kept
|
||||
},
|
||||
}
|
||||
|
||||
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, []uint16{0x0010, 0x0011})
|
||||
|
||||
// Read all packets (they should all pass through to the caller).
|
||||
for i := 0; i < 4; i++ {
|
||||
data, err := rc.ReadPacket()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadPacket[%d]: %v", i, err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
t.Fatalf("ReadPacket[%d]: empty data", i)
|
||||
}
|
||||
}
|
||||
|
||||
// Also send a packet with excluded opcode — it should be sent but not recorded.
|
||||
if err := rc.SendPacket([]byte{0x00, 0x10, 0xFF}); err != nil {
|
||||
t.Fatalf("SendPacket excluded: %v", err)
|
||||
}
|
||||
// Send a packet with non-excluded opcode.
|
||||
if err := rc.SendPacket([]byte{0x00, 0x12, 0xFF}); err != nil {
|
||||
t.Fatalf("SendPacket kept: %v", err)
|
||||
}
|
||||
|
||||
if err := w.Flush(); err != nil {
|
||||
t.Fatalf("Flush: %v", err)
|
||||
}
|
||||
|
||||
// Read back: should only have 3 recorded packets (0x0013 C→S, 0x0061 C→S, 0x0012 S→C).
|
||||
r, err := NewReader(bytes.NewReader(buf.Bytes()))
|
||||
if err != nil {
|
||||
t.Fatalf("NewReader: %v", err)
|
||||
}
|
||||
|
||||
var records []PacketRecord
|
||||
for {
|
||||
rec, err := r.ReadPacket()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("ReadPacket: %v", err)
|
||||
}
|
||||
records = append(records, rec)
|
||||
}
|
||||
|
||||
if len(records) != 3 {
|
||||
t.Fatalf("got %d records, want 3; opcodes:", len(records))
|
||||
}
|
||||
if records[0].Opcode != 0x0013 {
|
||||
t.Errorf("records[0].Opcode = 0x%04X, want 0x0013", records[0].Opcode)
|
||||
}
|
||||
if records[1].Opcode != 0x0061 {
|
||||
t.Errorf("records[1].Opcode = 0x%04X, want 0x0061", records[1].Opcode)
|
||||
}
|
||||
if records[2].Opcode != 0x0012 {
|
||||
t.Errorf("records[2].Opcode = 0x%04X, want 0x0012", records[2].Opcode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,15 @@ func NewWriter(w io.Writer, header FileHeader, meta SessionMetadata) (*Writer, e
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pcap: marshal metadata: %w", err)
|
||||
}
|
||||
// Pad metadata to MinMetadataSize so PatchMetadata can update it in-place.
|
||||
if len(metaBytes) < MinMetadataSize {
|
||||
padded := make([]byte, MinMetadataSize)
|
||||
copy(padded, metaBytes)
|
||||
for i := len(metaBytes); i < MinMetadataSize; i++ {
|
||||
padded[i] = ' '
|
||||
}
|
||||
metaBytes = padded
|
||||
}
|
||||
header.MetadataLen = uint32(len(metaBytes))
|
||||
|
||||
bw := bufio.NewWriter(w)
|
||||
|
||||
Reference in New Issue
Block a user