docs/refactoring-guide.md
This guide documents the strategy, patterns, and key considerations for refactoring probe modules in the eCapture project, based on the successful refactoring of the gotls probe using bash probe as a reference template.
The refactoring effort aims to standardize probe implementations to follow a consistent architecture using:
// domain.Probe - Main probe interface
type Probe interface {
Initialize(ctx context.Context, config Configuration, dispatcher EventDispatcher) error
Start(ctx context.Context) error
Stop(ctx context.Context) error
Close() error
Name() string
IsRunning() bool
Events() []*ebpf.Map
}
// domain.EventDecoder - For decoding eBPF events
type EventDecoder interface {
Decode(em *ebpf.Map, data []byte) (Event, error)
GetDecoder(em *ebpf.Map) (Event, bool)
}
// domain.Event - Event structure interface
type Event interface {
DecodeFromBytes(data []byte) error
String() string
StringHex() string
Clone() Event
Type() EventType
UUID() string
Validate() error
}
type Probe struct {
*base.BaseProbe // Embed BaseProbe for common functionality
config *Config // Probe-specific configuration
bpfManager *manager.Manager // eBPF program manager
eventMaps []*ebpf.Map // eBPF event maps
// Probe-specific fields (e.g., file handles, state)
}
type Config struct {
*config.BaseConfig // Embed BaseConfig for common settings
// Probe-specific configuration fields
SpecificField1 string `json:"specific_field1"`
SpecificField2 bool `json:"specific_field2"`
}
func (c *Config) Validate() error {
// Validate BaseConfig first
if err := c.BaseConfig.Validate(); err != nil {
return err
}
// Validate probe-specific fields
return nil
}
Reference: internal/probe/bash/config.go
Extend config.BaseConfig:
type Config struct {
*config.BaseConfig
// ... probe-specific fields
}
Update NewConfig():
func NewConfig() *Config {
return &Config{
BaseConfig: config.NewBaseConfig(),
// ... initialize probe-specific defaults
}
}
Update Validate() method:
func (c *Config) Validate() error {
if err := c.BaseConfig.Validate(); err != nil {
return errors.NewConfigurationError("validation failed", err)
}
// ... probe-specific validation
return nil
}
Remove redundant methods that are now inherited from BaseConfig:
GetPid(), GetUid(), GetDebug(), GetHex(), GetBTF(), etc.Keep probe-specific methods and ensure Bytes() delegates to BaseConfig if no special serialization needed.
Reference: internal/probe/bash/bash_probe.go
Initialize BaseProbe:
func NewProbe() (*Probe, error) {
return &Probe{
BaseProbe: base.NewBaseProbe("probename"),
}, nil
}
Implement Initialize():
func (p *Probe) Initialize(ctx context.Context, cfg domain.Configuration,
dispatcher domain.EventDispatcher) error {
// Call BaseProbe.Initialize first
if err := p.BaseProbe.Initialize(ctx, cfg, dispatcher); err != nil {
return err
}
// Type assert to probe-specific config
probeConfig, ok := cfg.(*Config)
if !ok {
return errors.NewConfigurationError("invalid config type", nil)
}
p.config = probeConfig
// Log probe initialization with relevant fields
p.Logger().Info().
Str("field1", probeConfig.Field1).
Msg("Probe initialized")
// Probe-specific initialization (open files, etc.)
return nil
}
Implement Start():
func (p *Probe) Start(ctx context.Context) error {
// Call BaseProbe.Start
if err := p.BaseProbe.Start(ctx); err != nil {
return err
}
// Load eBPF bytecode
bpfFileName := p.BaseProbe.GetBPFName("bytecode/probe_kern.o")
byteBuf, err := assets.Asset(bpfFileName)
if err != nil {
return errors.NewEBPFLoadError(bpfFileName, err)
}
// Setup and initialize eBPF manager
if err := p.setupManager(); err != nil {
return err
}
if err := p.bpfManager.InitWithOptions(bytes.NewReader(byteBuf),
p.getManagerOptions()); err != nil {
return errors.NewEBPFLoadError("manager init", err)
}
// Start eBPF manager
if err := p.bpfManager.Start(); err != nil {
return errors.NewEBPFAttachError("manager start", err)
}
// Get event maps
eventsMap, found, err := p.bpfManager.GetMap("events")
if err != nil || !found {
return errors.NewResourceNotFoundError("eBPF map: events")
}
p.eventMaps = []*ebpf.Map{eventsMap}
// Start event readers
if err := p.StartPerfEventReader(eventsMap, p); err != nil {
return err
}
return nil
}
Implement setupManager():
func (p *Probe) setupManager() error {
p.bpfManager = &manager.Manager{
Probes: []*manager.Probe{
{
Section: "uprobe/function_name",
EbpfFuncName: "uprobe_function",
AttachToFuncName: "target_function",
BinaryPath: p.config.BinaryPath,
},
// ... more probes
},
Maps: []*manager.Map{
{Name: "events"},
// ... more maps
},
}
return nil
}
Implement getManagerOptions():
func (p *Probe) getManagerOptions() manager.Options {
opts := manager.Options{
DefaultKProbeMaxActive: 512,
VerifierOptions: ebpf.CollectionOptions{
Programs: ebpf.ProgramOptions{
LogSizeStart: 2097152,
},
},
RLimit: &unix.Rlimit{
Cur: math.MaxUint64,
Max: math.MaxUint64,
},
}
// Add constant editors if kernel supports global variables
if p.config.EnableGlobalVar() {
opts.ConstantEditors = []manager.ConstantEditor{
{Name: "target_pid", Value: p.config.GetPid()},
{Name: "target_uid", Value: p.config.GetUid()},
}
}
return opts
}
Update Close():
func (p *Probe) Close() error {
// Stop eBPF manager
if p.bpfManager != nil {
if err := p.bpfManager.Stop(manager.CleanAll); err != nil {
p.Logger().Warn().Err(err).Msg("Failed to stop eBPF manager")
}
}
// Clean up probe-specific resources
// Call BaseProbe.Close
return p.BaseProbe.Close()
}
Implement Events():
func (p *Probe) Events() []*ebpf.Map {
return p.eventMaps
}
Reference: internal/probe/bash/event.go
Import required packages:
import (
"github.com/gojue/ecapture/internal/domain"
"github.com/gojue/ecapture/internal/errors"
)
Rename Decode() to DecodeFromBytes():
func (e *Event) DecodeFromBytes(data []byte) error {
buf := bytes.NewBuffer(data)
if err := binary.Read(buf, binary.LittleEndian, &e.Field1); err != nil {
return errors.NewEventDecodeError("Event.Field1", err)
}
// ... decode other fields
return nil
}
Implement domain.Event interface methods:
// String returns a human-readable representation
func (e *Event) String() string {
return fmt.Sprintf("Field1:%v, Field2:%v", e.Field1, e.Field2)
}
// StringHex returns a hexadecimal representation
func (e *Event) StringHex() string {
hexData := fmt.Sprintf("%x", e.Data)
return fmt.Sprintf("Field1:%v, Data(hex):%s", e.Field1, hexData)
}
// Clone creates a new instance
func (e *Event) Clone() domain.Event {
return &Event{}
}
// Type returns the event type
func (e *Event) Type() domain.EventType {
return domain.EventTypeOutput
}
// UUID returns a unique identifier
func (e *Event) UUID() string {
return fmt.Sprintf("%d_%d", e.Pid, e.Timestamp)
}
// Validate checks if the event data is valid
func (e *Event) Validate() error {
// Validation logic
return nil
}
Remove Encode() method if present (not needed for domain.Event interface)
Add these methods to the Probe:
// Decode implements EventDecoder interface
func (p *Probe) Decode(em *ebpf.Map, data []byte) (domain.Event, error) {
for _, m := range p.eventMaps {
if m == em {
event := &Event{}
if err := event.DecodeFromBytes(data); err != nil {
return nil, err
}
return event, nil
}
}
return nil, fmt.Errorf("unknown eBPF map: %s", em.String())
}
// GetDecoder implements EventDecoder interface
func (p *Probe) GetDecoder(em *ebpf.Map) (domain.Event, bool) {
for _, m := range p.eventMaps {
if m == em {
return &Event{}, true
}
}
return nil, false
}
For probes with multiple event types (like gotls with TLSDataEvent and MasterSecretEvent):
func (p *Probe) Decode(em *ebpf.Map, data []byte) (domain.Event, error) {
for i, m := range p.eventMaps {
if m == em {
if i == 0 { // First map is data events
event := &DataEvent{}
if err := event.DecodeFromBytes(data); err != nil {
return nil, err
}
return event, nil
}
if i == 1 { // Second map is metadata events
event := &MetadataEvent{}
if err := event.DecodeFromBytes(data); err != nil {
return nil, err
}
return event, nil
}
}
}
return nil, fmt.Errorf("unknown eBPF map: %s", em.String())
}
Reference: internal/probe/bash/bash_probe_test.go or updated internal/probe/gotls/gotls_probe_test.go
Create a mock dispatcher:
type mockDispatcher struct{}
func (m *mockDispatcher) Register(handler domain.EventHandler) error { return nil }
func (m *mockDispatcher) Unregister(handlerName string) error { return nil }
func (m *mockDispatcher) Dispatch(event domain.Event) error { return nil }
func (m *mockDispatcher) Close() error { return nil }
Update test initialization:
func TestProbe_Initialize(t *testing.T) {
probe, err := NewProbe()
if err != nil {
t.Fatalf("NewProbe() failed: %v", err)
}
cfg := NewConfig()
// Set config fields
ctx := context.Background()
dispatcher := &mockDispatcher{}
if err := probe.Initialize(ctx, cfg, dispatcher); err != nil {
t.Errorf("Initialize() failed: %v", err)
}
// Assertions
if probe.config == nil {
t.Error("expected config to be set")
}
probe.Close()
}
Test with race detector:
go test -race -v ./internal/probe/yourprobe/...
Problem: Event decoding fails due to field alignment or size mismatches between Go struct and eBPF C struct.
Solution:
pahole to check C struct layoutProblem: Missing initialization, improper cleanup, or no logging context.
Solution:
p.BaseProbe.Initialize() first in Initializep.BaseProbe.Start() first in Startp.BaseProbe.Close() last in Closep.Logger() instead of creating new loggersProblem: eBPF map names in code don't match names in kernel code.
Solution:
kern/*.c filessetupManager(): {Name: "events"}bpftool map list if debugging liveProblem: Probe has multiple eBPF maps but only decodes one event type.
Solution:
p.eventMapsDecode() methodProblem: Tests fail after refactoring because they use old API.
Solution:
-race flagProblem: Using generic errors instead of domain-specific error types.
Solution:
errors.NewConfigurationError() for config issueserrors.NewEBPFLoadError() for eBPF loading issueserrors.NewEBPFAttachError() for attachment issueserrors.NewEventDecodeError() for event decoding issuesNewConfig() and default valuesConfig.Validate() with valid and invalid inputsNewProbe() creationInitialize() with different configurationsClose() cleanup# Run unit tests
go test -v ./internal/probe/yourprobe/...
# Run with race detector
go test -race -v ./internal/probe/yourprobe/...
# Run with coverage
go test -cover -v ./internal/probe/yourprobe/...
# Run specific test
go test -v ./internal/probe/yourprobe/... -run TestProbe_Initialize
Use this checklist when refactoring a probe:
func (e *MyEvent) DecodeFromBytes(data []byte) error {
if len(data) < 24 {
return errors.NewEventDecodeError("MyEvent",
fmt.Errorf("data too short: %d bytes", len(data)))
}
buf := bytes.NewBuffer(data)
if err := binary.Read(buf, binary.LittleEndian, &e.Timestamp); err != nil {
return errors.NewEventDecodeError("MyEvent.Timestamp", err)
}
if err := binary.Read(buf, binary.LittleEndian, &e.Pid); err != nil {
return errors.NewEventDecodeError("MyEvent.Pid", err)
}
// ... more fields
return nil
}
func (p *Probe) setupManager() error {
binaryPath := p.config.BinaryPath
if binaryPath == "" {
return errors.NewConfigurationError("binary_path required", nil)
}
p.Logger().Info().
Str("binary_path", binaryPath).
Msg("Setting up eBPF probes")
p.bpfManager = &manager.Manager{
Probes: []*manager.Probe{
{
Section: "uprobe/my_function",
EbpfFuncName: "uprobe_my_function",
AttachToFuncName: "target_function",
BinaryPath: binaryPath,
},
},
Maps: []*manager.Map{
{Name: "events"},
},
}
return nil
}
Following this guide ensures:
When in doubt, reference the bash probe implementation as the canonical example of this pattern.