Skip to main content

GopacketTrafficMirror — Passive Mirror

The passive mirror captures traffic on any port without changing the existing deployment (pcap / AF_PACKET / eBPF). Packets are captured via gopacket and reassembled with tcpassembly, then forwarded / recorded through pluggable hooks.

Source: getwayServer/gopacket_mirror.go


1. Config: GopacketMirrorConfig

type GopacketMirrorConfig struct {
ObservedPort uint16
TargetAddr string
Writer io.Writer
WriteFunc WriteFunc

ForwardHook TrafficMirrorHook
RecordHook TrafficMirrorHook
DialContext func(ctx context.Context, network, address string) (net.Conn, error)
}
FieldTypeRequiredNotes
ObservedPortuint16yesPort of the observed service; drives TrafficDirection. When 0, every direction becomes unknown and is dropped
TargetAddrstringnoForward target; empty string disables forwarding (record-only)
Writerio.Writerone ofRecord writer; mutually exclusive with WriteFunc
WriteFuncWriteFuncone ofCustom writer; takes precedence over Writer when non-nil
ForwardHookTrafficMirrorHooknoForward-direction hook; defaults to DefaultForwardTrafficMirrorHook (forwards only client→observed direction)
RecordHookTrafficMirrorHooknoRecord-direction hook; defaults to DefaultRecordTrafficMirrorHook (records only client→observed direction)
DialContextdial funcnoDialer for forwarding; defaults to &net.Dialer{Timeout: 5*time.Second}.DialContext

Behavioral contracts:

  • When both Writer and WriteFunc are nil, no record write happens — but RecordHook still fires (useful for side-channel export).
  • When TargetAddr is empty, forwarding is skipped — and ForwardHook is not called.

2. Construct and Start

2.1 NewGopacketTrafficMirror

func NewGopacketTrafficMirror(config GopacketMirrorConfig) *GopacketTrafficMirror

Fills in defaults for missing ForwardHook / RecordHook / DialContext.

2.2 Start

func (m *GopacketTrafficMirror) Start(ctx context.Context, source ObservedPacketSource) error
ParamTypeNotes
ctxcontext.ContextOn cancel: flushes the assembler and returns ctx.Err()
sourceObservedPacketSourceCapture source; nil returns errors.New("packet source is nil")
ReturnNotes
errorFirst error from ctx cancel, source close, hook, or write

source.Close() is defer-ed inside Start — the caller does not need to close it manually.


3. Hook: TrafficMirrorHook

type TrafficMirrorHook interface {
Handle(ctx context.Context, chunk *ObservedTrafficChunk) (data []byte, keep bool, err error)
}

type TrafficMirrorHookFunc func(ctx context.Context, chunk *ObservedTrafficChunk) (data []byte, keep bool, err error)
func (f TrafficMirrorHookFunc) Handle(ctx context.Context, chunk *ObservedTrafficChunk) ([]byte, bool, error)
ParamTypeNotes
ctxcontext.ContextRoot ctx from Start
chunk*ObservedTrafficChunkReassembled, single-direction traffic
ReturnTypeNotes
data[]byteEither the original chunk.Data or a modified copy; length 0 skips this write/forward
keepboolfalse drops the chunk
errerrorNon-nil aborts the entire Start

ObservedTrafficChunk

type ObservedTrafficChunk struct {
Data []byte // reassembled contiguous bytes
From string // source host:port
To string // destination host:port
Direction TrafficDirection // to_observed / from_observed / unknown
Type Types.ClientType // defaults to TCPType
SeenAt time.Time // capture timestamp
}

TrafficDirection values:

  • TrafficDirectionToObserved — client → observed service
  • TrafficDirectionFromObserved — observed service → client
  • TrafficDirectionUnknown — port did not match; usually dropped

4. Default Hooks

NameBehavior
DefaultTrafficMirrorHookKeep everything; one copy
DefaultForwardTrafficMirrorHookKeep only TrafficDirectionToObserved; drop the rest
DefaultRecordTrafficMirrorHookSame as above (records only the request direction)

Want to record both directions? Swap RecordHook for DefaultTrafficMirrorHook{}.


5. Capture Source: ObservedPacketSource

type ObservedPacketSource interface {
Packets() <-chan gopacket.Packet
Close() error
}

The base abstraction is not tied to any specific capture impl — plug in:

  • pcap (see cmd/tcp-proxy/live_capture.go)
  • AF_PACKET / eBPF
  • pcap file replay
  • An in-memory channel for tests — use NewChannelObservedPacketSource(ch)

NewChannelObservedPacketSource

func NewChannelObservedPacketSource(packets <-chan gopacket.Packet) *ChannelObservedPacketSource

Close() on the returned value is a no-op; the channel lifecycle is owned by the caller.


6. End-to-End Example

recordFile, _ := os.Create("./mirror.log")
defer recordFile.Close()

mirror := getwayServer.NewGopacketTrafficMirror(getwayServer.GopacketMirrorConfig{
ObservedPort: 8090,
TargetAddr: "127.0.0.1:18090",
Writer: recordFile,

// Inject an X-Mirror header before forwarding, but only for the request direction
ForwardHook: getwayServer.TrafficMirrorHookFunc(func(ctx context.Context, chunk *getwayServer.ObservedTrafficChunk) ([]byte, bool, error) {
if chunk.Direction != getwayServer.TrafficDirectionToObserved {
return nil, false, nil
}
injected := bytes.Replace(chunk.Data, []byte("\r\n\r\n"), []byte("\r\nX-Mirror: 1\r\n\r\n"), 1)
return injected, true, nil
}),

// Strip the Authorization header before recording
RecordHook: getwayServer.TrafficMirrorHookFunc(func(ctx context.Context, chunk *getwayServer.ObservedTrafficChunk) ([]byte, bool, error) {
if chunk.Direction != getwayServer.TrafficDirectionToObserved {
return nil, false, nil
}
sanitized := redactAuthHeader(chunk.Data)
return sanitized, true, nil
}),
})

ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := mirror.Start(ctx, mySource); err != nil {
log.Fatal(err)
}

7. Common Pitfalls

  1. WriteFunc is serialized by default: the passive mirror uses a global recordMu mutex, unlike the active proxy where each connection has its own WriteQueue. If your WriteFunc is already concurrency-safe, that mutex can become a main-thread bottleneck — removing it requires a source edit.
  2. Modifying data in ForwardHook does not affect RecordHook: Reassembled makes a copy between the two hooks.
  3. ObservedPacketSource.Packets() channel must be closed for Start to return cleanly — Close() alone is not enough.
  4. ObservedPort = 0 makes every direction unknown, so all traffic is dropped — always set it explicitly.

See also: SimpleTCPServer · proto format