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")
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,14 @@
|
||||
"RawEnabled": false,
|
||||
"OutputDir": "save-backups"
|
||||
},
|
||||
"Capture": {
|
||||
"Enabled": false,
|
||||
"OutputDir": "captures",
|
||||
"ExcludeOpcodes": [],
|
||||
"CaptureSign": true,
|
||||
"CaptureEntrance": true,
|
||||
"CaptureChannel": true
|
||||
},
|
||||
"DebugOptions": {
|
||||
"CleanDB": false,
|
||||
"MaxLauncherHR": false,
|
||||
|
||||
@@ -85,6 +85,7 @@ type Config struct {
|
||||
EarthMonsters []int32
|
||||
SaveDumps SaveDumpOptions
|
||||
Screenshots ScreenshotsOptions
|
||||
Capture CaptureOptions
|
||||
|
||||
DebugOptions DebugOptions
|
||||
GameplayOptions GameplayOptions
|
||||
@@ -112,6 +113,16 @@ type ScreenshotsOptions struct {
|
||||
UploadQuality int //Determines the upload quality to the server
|
||||
}
|
||||
|
||||
// CaptureOptions controls protocol packet capture recording.
|
||||
type CaptureOptions struct {
|
||||
Enabled bool // Enable packet capture
|
||||
OutputDir string // Directory for .mhfr capture files
|
||||
ExcludeOpcodes []uint16 // Opcodes to exclude from capture (e.g., ping, nop, position)
|
||||
CaptureSign bool // Capture sign server sessions
|
||||
CaptureEntrance bool // Capture entrance server sessions
|
||||
CaptureChannel bool // Capture channel server sessions
|
||||
}
|
||||
|
||||
// DebugOptions holds various debug/temporary options for use while developing Erupe.
|
||||
type DebugOptions struct {
|
||||
CleanDB bool // Automatically wipes the DB on server reset.
|
||||
@@ -328,6 +339,12 @@ func LoadConfig() (*Config, error) {
|
||||
Enabled: true,
|
||||
OutputDir: "save-backups",
|
||||
})
|
||||
viper.SetDefault("Capture", CaptureOptions{
|
||||
OutputDir: "captures",
|
||||
CaptureSign: true,
|
||||
CaptureEntrance: true,
|
||||
CaptureChannel: true,
|
||||
})
|
||||
viper.SetDefault("LoopDelay", 50)
|
||||
|
||||
err := viper.ReadInConfig()
|
||||
|
||||
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()
|
||||
}
|
||||
@@ -283,6 +283,11 @@ func logoutPlayer(s *Session) {
|
||||
}
|
||||
}
|
||||
|
||||
// Flush and close capture file before closing the connection.
|
||||
if s.captureCleanup != nil {
|
||||
s.captureCleanup()
|
||||
}
|
||||
|
||||
// NOW do cleanup (after save is complete)
|
||||
s.server.Lock()
|
||||
delete(s.server.sessions, s.rawConn)
|
||||
|
||||
109
server/channelserver/sys_capture.go
Normal file
109
server/channelserver/sys_capture.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package channelserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"erupe-ce/network"
|
||||
"erupe-ce/network/pcap"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// startCapture wraps a network.Conn with a RecordingConn if capture is enabled.
|
||||
// Returns the (possibly wrapped) conn and a cleanup function that must be called on session close.
|
||||
func startCapture(server *Server, conn network.Conn, remoteAddr net.Addr, serverType pcap.ServerType) (network.Conn, func()) {
|
||||
capCfg := server.erupeConfig.Capture
|
||||
if !capCfg.Enabled {
|
||||
return conn, func() {}
|
||||
}
|
||||
|
||||
switch serverType {
|
||||
case pcap.ServerTypeSign:
|
||||
if !capCfg.CaptureSign {
|
||||
return conn, func() {}
|
||||
}
|
||||
case pcap.ServerTypeEntrance:
|
||||
if !capCfg.CaptureEntrance {
|
||||
return conn, func() {}
|
||||
}
|
||||
case pcap.ServerTypeChannel:
|
||||
if !capCfg.CaptureChannel {
|
||||
return conn, func() {}
|
||||
}
|
||||
}
|
||||
|
||||
outputDir := capCfg.OutputDir
|
||||
if outputDir == "" {
|
||||
outputDir = "captures"
|
||||
}
|
||||
if err := os.MkdirAll(outputDir, 0o755); err != nil {
|
||||
server.logger.Warn("Failed to create capture directory", zap.Error(err))
|
||||
return conn, func() {}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
filename := fmt.Sprintf("%s_%s_%s.mhfr",
|
||||
serverType.String(),
|
||||
now.Format("20060102_150405"),
|
||||
sanitizeAddr(remoteAddr.String()),
|
||||
)
|
||||
path := filepath.Join(outputDir, filename)
|
||||
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
server.logger.Warn("Failed to create capture file", zap.Error(err), zap.String("path", path))
|
||||
return conn, func() {}
|
||||
}
|
||||
|
||||
startNs := now.UnixNano()
|
||||
hdr := pcap.FileHeader{
|
||||
Version: pcap.FormatVersion,
|
||||
ServerType: serverType,
|
||||
ClientMode: byte(server.erupeConfig.RealClientMode),
|
||||
SessionStartNs: startNs,
|
||||
}
|
||||
meta := pcap.SessionMetadata{
|
||||
Host: server.erupeConfig.Host,
|
||||
RemoteAddr: remoteAddr.String(),
|
||||
}
|
||||
|
||||
w, err := pcap.NewWriter(f, hdr, meta)
|
||||
if err != nil {
|
||||
server.logger.Warn("Failed to initialize capture writer", zap.Error(err))
|
||||
_ = f.Close()
|
||||
return conn, func() {}
|
||||
}
|
||||
|
||||
server.logger.Info("Capture started", zap.String("file", path))
|
||||
|
||||
rc := pcap.NewRecordingConn(conn, w, startNs)
|
||||
cleanup := func() {
|
||||
if err := w.Flush(); err != nil {
|
||||
server.logger.Warn("Failed to flush capture", zap.Error(err))
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
server.logger.Warn("Failed to close capture file", zap.Error(err))
|
||||
}
|
||||
server.logger.Info("Capture saved", zap.String("file", path))
|
||||
}
|
||||
|
||||
return rc, cleanup
|
||||
}
|
||||
|
||||
// sanitizeAddr replaces characters that are problematic in filenames.
|
||||
func sanitizeAddr(addr string) string {
|
||||
out := make([]byte, 0, len(addr))
|
||||
for i := 0; i < len(addr); i++ {
|
||||
c := addr[i]
|
||||
if c == ':' {
|
||||
out = append(out, '_')
|
||||
} else {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"erupe-ce/network"
|
||||
"erupe-ce/network/clientctx"
|
||||
"erupe-ce/network/mhfpacket"
|
||||
"erupe-ce/network/pcap"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -70,18 +71,23 @@ type Session struct {
|
||||
// Contains the mail list that maps accumulated indexes to mail IDs
|
||||
mailList []int
|
||||
|
||||
Name string
|
||||
closed atomic.Bool
|
||||
ackStart map[uint32]time.Time
|
||||
Name string
|
||||
closed atomic.Bool
|
||||
ackStart map[uint32]time.Time
|
||||
captureCleanup func() // Called on session close to flush/close capture file
|
||||
}
|
||||
|
||||
// NewSession creates a new Session type.
|
||||
func NewSession(server *Server, conn net.Conn) *Session {
|
||||
var cryptConn network.Conn = network.NewCryptConn(conn, server.erupeConfig.RealClientMode, server.logger.Named(conn.RemoteAddr().String()))
|
||||
|
||||
cryptConn, captureCleanup := startCapture(server, cryptConn, conn.RemoteAddr(), pcap.ServerTypeChannel)
|
||||
|
||||
s := &Session{
|
||||
logger: server.logger.Named(conn.RemoteAddr().String()),
|
||||
server: server,
|
||||
rawConn: conn,
|
||||
cryptConn: network.NewCryptConn(conn, server.erupeConfig.RealClientMode, server.logger.Named(conn.RemoteAddr().String())),
|
||||
cryptConn: cryptConn,
|
||||
sendPackets: make(chan packet, 20),
|
||||
clientContext: &clientctx.ClientContext{RealClientMode: server.erupeConfig.RealClientMode},
|
||||
lastPacket: time.Now(),
|
||||
@@ -90,6 +96,7 @@ func NewSession(server *Server, conn net.Conn) *Session {
|
||||
stageMoveStack: stringstack.New(),
|
||||
ackStart: make(map[uint32]time.Time),
|
||||
semaphoreID: make([]uint16, 2),
|
||||
captureCleanup: captureCleanup,
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -108,7 +108,10 @@ func (s *Server) handleEntranceServerConnection(conn net.Conn) {
|
||||
}
|
||||
|
||||
// Create a new encrypted connection handler and read a packet from it.
|
||||
cc := network.NewCryptConn(conn, s.erupeConfig.RealClientMode, s.logger)
|
||||
var cc network.Conn = network.NewCryptConn(conn, s.erupeConfig.RealClientMode, s.logger)
|
||||
cc, captureCleanup := startEntranceCapture(s, cc, conn.RemoteAddr())
|
||||
defer captureCleanup()
|
||||
|
||||
pkt, err := cc.ReadPacket()
|
||||
if err != nil {
|
||||
s.logger.Warn("Error reading packet", zap.Error(err))
|
||||
|
||||
92
server/entranceserver/sys_capture.go
Normal file
92
server/entranceserver/sys_capture.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package entranceserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"erupe-ce/network"
|
||||
"erupe-ce/network/pcap"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// startEntranceCapture wraps a Conn with a RecordingConn if capture is enabled for entrance server.
|
||||
func startEntranceCapture(s *Server, conn network.Conn, remoteAddr net.Addr) (network.Conn, func()) {
|
||||
capCfg := s.erupeConfig.Capture
|
||||
if !capCfg.Enabled || !capCfg.CaptureEntrance {
|
||||
return conn, func() {}
|
||||
}
|
||||
|
||||
outputDir := capCfg.OutputDir
|
||||
if outputDir == "" {
|
||||
outputDir = "captures"
|
||||
}
|
||||
if err := os.MkdirAll(outputDir, 0o755); err != nil {
|
||||
s.logger.Warn("Failed to create capture directory", zap.Error(err))
|
||||
return conn, func() {}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
filename := fmt.Sprintf("entrance_%s_%s.mhfr",
|
||||
now.Format("20060102_150405"),
|
||||
sanitizeAddr(remoteAddr.String()),
|
||||
)
|
||||
path := filepath.Join(outputDir, filename)
|
||||
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to create capture file", zap.Error(err), zap.String("path", path))
|
||||
return conn, func() {}
|
||||
}
|
||||
|
||||
startNs := now.UnixNano()
|
||||
hdr := pcap.FileHeader{
|
||||
Version: pcap.FormatVersion,
|
||||
ServerType: pcap.ServerTypeEntrance,
|
||||
ClientMode: byte(s.erupeConfig.RealClientMode),
|
||||
SessionStartNs: startNs,
|
||||
}
|
||||
meta := pcap.SessionMetadata{
|
||||
Host: s.erupeConfig.Host,
|
||||
Port: int(s.erupeConfig.Entrance.Port),
|
||||
RemoteAddr: remoteAddr.String(),
|
||||
}
|
||||
|
||||
w, err := pcap.NewWriter(f, hdr, meta)
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to initialize capture writer", zap.Error(err))
|
||||
_ = f.Close()
|
||||
return conn, func() {}
|
||||
}
|
||||
|
||||
s.logger.Info("Capture started", zap.String("file", path))
|
||||
|
||||
rc := pcap.NewRecordingConn(conn, w, startNs)
|
||||
cleanup := func() {
|
||||
if err := w.Flush(); err != nil {
|
||||
s.logger.Warn("Failed to flush capture", zap.Error(err))
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
s.logger.Warn("Failed to close capture file", zap.Error(err))
|
||||
}
|
||||
s.logger.Info("Capture saved", zap.String("file", path))
|
||||
}
|
||||
|
||||
return rc, cleanup
|
||||
}
|
||||
|
||||
func sanitizeAddr(addr string) string {
|
||||
out := make([]byte, 0, len(addr))
|
||||
for i := 0; i < len(addr); i++ {
|
||||
c := addr[i]
|
||||
if c == ':' {
|
||||
out = append(out, '_')
|
||||
} else {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
@@ -27,12 +27,13 @@ const (
|
||||
// Session holds state for the sign server connection.
|
||||
type Session struct {
|
||||
sync.Mutex
|
||||
logger *zap.Logger
|
||||
server *Server
|
||||
rawConn net.Conn
|
||||
cryptConn *network.CryptConn
|
||||
client client
|
||||
psn string
|
||||
logger *zap.Logger
|
||||
server *Server
|
||||
rawConn net.Conn
|
||||
cryptConn network.Conn
|
||||
client client
|
||||
psn string
|
||||
captureCleanup func()
|
||||
}
|
||||
|
||||
func (s *Session) work() {
|
||||
|
||||
@@ -104,13 +104,21 @@ func (s *Server) handleConnection(conn net.Conn) {
|
||||
}
|
||||
|
||||
// Create a new session.
|
||||
var cc network.Conn = network.NewCryptConn(conn, s.erupeConfig.RealClientMode, s.logger)
|
||||
cc, captureCleanup := startSignCapture(s, cc, conn.RemoteAddr())
|
||||
|
||||
session := &Session{
|
||||
logger: s.logger,
|
||||
server: s,
|
||||
rawConn: conn,
|
||||
cryptConn: network.NewCryptConn(conn, s.erupeConfig.RealClientMode, s.logger),
|
||||
logger: s.logger,
|
||||
server: s,
|
||||
rawConn: conn,
|
||||
cryptConn: cc,
|
||||
captureCleanup: captureCleanup,
|
||||
}
|
||||
|
||||
// Do the session's work.
|
||||
session.work()
|
||||
|
||||
if session.captureCleanup != nil {
|
||||
session.captureCleanup()
|
||||
}
|
||||
}
|
||||
|
||||
92
server/signserver/sys_capture.go
Normal file
92
server/signserver/sys_capture.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package signserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"erupe-ce/network"
|
||||
"erupe-ce/network/pcap"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// startSignCapture wraps a Conn with a RecordingConn if capture is enabled for sign server.
|
||||
func startSignCapture(s *Server, conn network.Conn, remoteAddr net.Addr) (network.Conn, func()) {
|
||||
capCfg := s.erupeConfig.Capture
|
||||
if !capCfg.Enabled || !capCfg.CaptureSign {
|
||||
return conn, func() {}
|
||||
}
|
||||
|
||||
outputDir := capCfg.OutputDir
|
||||
if outputDir == "" {
|
||||
outputDir = "captures"
|
||||
}
|
||||
if err := os.MkdirAll(outputDir, 0o755); err != nil {
|
||||
s.logger.Warn("Failed to create capture directory", zap.Error(err))
|
||||
return conn, func() {}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
filename := fmt.Sprintf("sign_%s_%s.mhfr",
|
||||
now.Format("20060102_150405"),
|
||||
sanitizeAddr(remoteAddr.String()),
|
||||
)
|
||||
path := filepath.Join(outputDir, filename)
|
||||
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to create capture file", zap.Error(err), zap.String("path", path))
|
||||
return conn, func() {}
|
||||
}
|
||||
|
||||
startNs := now.UnixNano()
|
||||
hdr := pcap.FileHeader{
|
||||
Version: pcap.FormatVersion,
|
||||
ServerType: pcap.ServerTypeSign,
|
||||
ClientMode: byte(s.erupeConfig.RealClientMode),
|
||||
SessionStartNs: startNs,
|
||||
}
|
||||
meta := pcap.SessionMetadata{
|
||||
Host: s.erupeConfig.Host,
|
||||
Port: s.erupeConfig.Sign.Port,
|
||||
RemoteAddr: remoteAddr.String(),
|
||||
}
|
||||
|
||||
w, err := pcap.NewWriter(f, hdr, meta)
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to initialize capture writer", zap.Error(err))
|
||||
_ = f.Close()
|
||||
return conn, func() {}
|
||||
}
|
||||
|
||||
s.logger.Info("Capture started", zap.String("file", path))
|
||||
|
||||
rc := pcap.NewRecordingConn(conn, w, startNs)
|
||||
cleanup := func() {
|
||||
if err := w.Flush(); err != nil {
|
||||
s.logger.Warn("Failed to flush capture", zap.Error(err))
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
s.logger.Warn("Failed to close capture file", zap.Error(err))
|
||||
}
|
||||
s.logger.Info("Capture saved", zap.String("file", path))
|
||||
}
|
||||
|
||||
return rc, cleanup
|
||||
}
|
||||
|
||||
func sanitizeAddr(addr string) string {
|
||||
out := make([]byte, 0, len(addr))
|
||||
for i := 0; i < len(addr); i++ {
|
||||
c := addr[i]
|
||||
if c == ':' {
|
||||
out = append(out, '_')
|
||||
} else {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
Reference in New Issue
Block a user