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:
42
network/pcap/filter.go
Normal file
42
network/pcap/filter.go
Normal file
@@ -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
|
||||
}
|
||||
103
network/pcap/format.go
Normal file
103
network/pcap/format.go
Normal file
@@ -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
|
||||
268
network/pcap/pcap_test.go
Normal file
268
network/pcap/pcap_test.go
Normal file
@@ -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())
|
||||
}
|
||||
}
|
||||
110
network/pcap/reader.go
Normal file
110
network/pcap/reader.go
Normal file
@@ -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
|
||||
}
|
||||
65
network/pcap/recording_conn.go
Normal file
65
network/pcap/recording_conn.go
Normal file
@@ -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()
|
||||
}
|
||||
183
network/pcap/recording_conn_test.go
Normal file
183
network/pcap/recording_conn_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
89
network/pcap/writer.go
Normal file
89
network/pcap/writer.go
Normal file
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user