mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-22 07:32:32 +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:
@@ -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