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:
Houmgaor
2026-02-23 18:50:44 +01:00
parent e5ffc4d52d
commit 7ef5efc549
20 changed files with 1716 additions and 15 deletions

View File

@@ -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() {

View File

@@ -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()
}
}

View 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)
}