diff --git a/lib/srv/db/common/packetcapture/capture.go b/lib/srv/db/common/packetcapture/capture.go
new file mode 100644
index 0000000000000..1027b3e979bac
--- /dev/null
+++ b/lib/srv/db/common/packetcapture/capture.go
@@ -0,0 +1,247 @@
+// Teleport
+// Copyright (C) 2024 Gravitational, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// Package packetcapture provides utilities for saving application-layer packets to file in either plain text or PCAP formats.
+// The PCAP functionality depends on the external utilities from Wireshark (text2pcap, mergecap) and is expected to be used in dev/debugging contexts only.
+package packetcapture
+
+import (
+ "bytes"
+ "encoding/hex"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "sync"
+ "time"
+
+ "github.com/gravitational/trace"
+ "github.com/jonboulle/clockwork"
+)
+
+// Direction represents the link and participants.
+type Direction int
+
+const (
+ ClientToTeleport Direction = iota
+ ServerToTeleport
+ TeleportToClient
+ TeleportToServer
+)
+
+const fakeClientAddr = "1.1.1.1"
+const fakeTeleportAddr = "2.2.2.2"
+const fakeServerAddr = "3.3.3.3"
+
+const mergecapBin = "mergecap"
+const text2pcapBin = "text2pcap"
+
+func (p Direction) String() string {
+ switch p {
+ case ClientToTeleport:
+ return "Client->Teleport"
+ case ServerToTeleport:
+ return "Server->Teleport"
+ case TeleportToClient:
+ return "Teleport->Client"
+ case TeleportToServer:
+ return "Teleport->Server"
+ default:
+ return fmt.Sprintf("Unknown(%d)", p)
+ }
+}
+
+// PacketEntry holds the details of each packet to be recorded.
+type PacketEntry struct {
+ Direction Direction
+ Payload []byte
+ Timestamp time.Time
+}
+
+// Capture struct holds all packet details and offers methods to add and save packets.
+type Capture struct {
+ packets []PacketEntry
+ clock clockwork.Clock
+
+ // runCommand runs a specific command and returns combined output.
+ runCommand func(name string, arg ...string) ([]byte, error)
+
+ mu sync.Mutex
+}
+
+// NewCapture initializes a new Capture object.
+func NewCapture(clock clockwork.Clock) *Capture {
+ return &Capture{
+ packets: make([]PacketEntry, 0),
+ clock: clock,
+ runCommand: func(name string, arg ...string) ([]byte, error) {
+ cmd := exec.Command(name, arg...)
+ out, err := cmd.CombinedOutput()
+ return out, trace.Wrap(err)
+ },
+ }
+}
+
+// AddPacket records the packet in the given direction and payload.
+func (c *Capture) AddPacket(direction Direction, payload []byte) {
+ // Record timestamp
+ timestamp := c.clock.Now()
+
+ // Create a new PacketEntry
+ packet := PacketEntry{
+ Direction: direction,
+ Payload: payload,
+ Timestamp: timestamp,
+ }
+
+ c.mu.Lock()
+ c.packets = append(c.packets, packet)
+ c.mu.Unlock()
+}
+
+type participant struct {
+ addr string
+ direction Direction
+}
+
+func (c *Capture) saveOneLinkToPCAP(filename string, port int, sender, receiver participant, packets []PacketEntry) error {
+ buffer := &bytes.Buffer{}
+ for _, packet := range packets {
+ // assign indicator or skip packet.
+ var indicator string
+ switch packet.Direction {
+ case sender.direction:
+ indicator = "I"
+ case receiver.direction:
+ indicator = "O"
+ default:
+ continue
+ }
+
+ // Write the timestamp and sender indicator
+ if _, err := fmt.Fprintf(buffer, "%s %s\n", indicator, packet.Timestamp.UTC().Format(time.RFC3339Nano)); err != nil {
+ return trace.Wrap(err)
+ }
+
+ // Write the packet data using hex.Dump and add a newline after each packet
+ hexData := hex.Dump(packet.Payload)
+ if _, err := buffer.Write([]byte(hexData + "\n")); err != nil {
+ return trace.Wrap(err)
+ }
+ }
+
+ filenameHex := filename + ".hex"
+
+ err := os.WriteFile(filenameHex, buffer.Bytes(), 0600)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ defer os.Remove(filenameHex)
+
+ // Invoke text2pcap to convert the hex dump to PCAP format
+ addrPair := fmt.Sprintf("%v,%v", sender.addr, receiver.addr)
+ out, err := c.runCommand(text2pcapBin,
+ // enable inbound/outbound markers
+ "-D",
+ // configure timestamp format
+ "-t", "%Y-%m-%dT%H:%M:%S.%fZ",
+ // ethernet capture encapsulation type
+ "-l", "1",
+ // specify IPv4 addr pair
+ "-4", addrPair,
+ // inbound/outbound ports; also used to hint at packet type; might be worth using "-P " instead.
+ "-T", fmt.Sprintf("%v,%v", port, port),
+ filenameHex,
+ filename)
+ if err != nil {
+ return trace.Wrap(err, "error running text2pcap (output: %v)", out)
+ }
+
+ return nil
+}
+
+// SaveToPCAP saves the captured packets to a single pcap file. Note: this will run `text2pcap` and `mergecap` programs.
+func (c *Capture) SaveToPCAP(baseFilename string, port int) error {
+ // Skip saving if filename is empty.
+ if baseFilename == "" {
+ return nil
+ }
+
+ c.mu.Lock() // Lock the mutex for the duration of the packet writing
+ defer c.mu.Unlock()
+
+ // we write to .pcap files:
+ // client <-> teleport
+ filename001 := baseFilename + ".001"
+ clientPart := participant{addr: fakeClientAddr, direction: ClientToTeleport}
+ teleportPart1 := participant{addr: fakeTeleportAddr, direction: TeleportToClient}
+ err := c.saveOneLinkToPCAP(filename001, port, clientPart, teleportPart1, c.packets)
+ if err != nil {
+ return trace.Wrap(err, "error saving to PCAP")
+ }
+ defer os.Remove(filename001)
+
+ // teleport <-> server
+ filename002 := baseFilename + ".002"
+ teleportPart2 := participant{addr: fakeTeleportAddr, direction: TeleportToServer}
+ serverPart := participant{addr: fakeServerAddr, direction: ServerToTeleport}
+ err = c.saveOneLinkToPCAP(filename002, port, teleportPart2, serverPart, c.packets)
+ if err != nil {
+ return trace.Wrap(err, "error saving to PCAP")
+ }
+ defer os.Remove(filename002)
+
+ // merge two files
+ out, err := c.runCommand(mergecapBin, "-w", baseFilename, filename001, filename002)
+ if err != nil {
+ return trace.Wrap(err, "error running mergecap (output: %v)", out)
+ }
+ return nil
+}
+
+func (c *Capture) WriteTo(w io.Writer) (int64, error) {
+ c.mu.Lock() // Lock the mutex for the duration of the packet writing
+ defer c.mu.Unlock()
+
+ var total int64
+ for _, packet := range c.packets {
+ hexData := hex.Dump(packet.Payload)
+
+ count, err := fmt.Fprintf(w, "Timestamp: %v\nDirection: %v\n\n%s\n\n", packet.Timestamp.UTC(), packet.Direction, hexData)
+ total += int64(count)
+ if err != nil {
+ return total, trace.Wrap(err)
+ }
+ }
+ return total, nil
+}
+
+// SaveAsText saves the capture using plain text format without any external dependencies.
+func (c *Capture) SaveAsText(file string) error {
+ // Skip saving if filename is empty.
+ if file == "" {
+ return nil
+ }
+
+ handle, err := os.Create(file)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ defer handle.Close()
+
+ _, err = c.WriteTo(handle)
+ return trace.Wrap(err)
+}
diff --git a/lib/srv/db/common/packetcapture/capture_test.go b/lib/srv/db/common/packetcapture/capture_test.go
new file mode 100644
index 0000000000000..31f3452f2d425
--- /dev/null
+++ b/lib/srv/db/common/packetcapture/capture_test.go
@@ -0,0 +1,224 @@
+// Teleport
+// Copyright (C) 2024 Gravitational, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package packetcapture
+
+import (
+ "bytes"
+ "os"
+ "path"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/gravitational/trace"
+ "github.com/jonboulle/clockwork"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func newTestCapture(runCommand func(name string, arg ...string) ([]byte, error)) *Capture {
+ clock := clockwork.NewFakeClockAt(time.Date(2024, time.December, 4, 0, 0, 0, 0, time.UTC))
+ testCapture := NewCapture(clock)
+ testCapture.runCommand = runCommand
+
+ testCapture.AddPacket(ClientToTeleport, []byte("hello server, this is client"))
+ clock.Advance(time.Second)
+
+ testCapture.AddPacket(TeleportToServer, []byte("hello server, this is Teleport"))
+ clock.Advance(time.Second)
+
+ testCapture.AddPacket(ServerToTeleport, []byte("hello Teleport, this is server"))
+ clock.Advance(time.Second)
+
+ testCapture.AddPacket(TeleportToClient, []byte("hello client, this is server (actually Teleport, wink)"))
+ clock.Advance(time.Second)
+
+ return testCapture
+}
+
+func TestCapture_SaveAsText(t *testing.T) {
+ testCapture := newTestCapture(func(name string, arg ...string) ([]byte, error) {
+ assert.FailNow(t, "runCommand shouldn't be called for SaveAsText")
+ return nil, trace.BadParameter("not expected")
+ })
+
+ tmp := t.TempDir()
+ fileOut := filepath.Join(tmp, "trace.txt")
+
+ err := testCapture.SaveAsText(fileOut)
+ require.NoError(t, err)
+
+ out, err := os.ReadFile(fileOut)
+ require.NoError(t, err)
+ want := `Timestamp: 2024-12-04 00:00:00 +0000 UTC
+Direction: Client->Teleport
+
+00000000 68 65 6c 6c 6f 20 73 65 72 76 65 72 2c 20 74 68 |hello server, th|
+00000010 69 73 20 69 73 20 63 6c 69 65 6e 74 |is is client|
+
+
+Timestamp: 2024-12-04 00:00:01 +0000 UTC
+Direction: Teleport->Server
+
+00000000 68 65 6c 6c 6f 20 73 65 72 76 65 72 2c 20 74 68 |hello server, th|
+00000010 69 73 20 69 73 20 54 65 6c 65 70 6f 72 74 |is is Teleport|
+
+
+Timestamp: 2024-12-04 00:00:02 +0000 UTC
+Direction: Server->Teleport
+
+00000000 68 65 6c 6c 6f 20 54 65 6c 65 70 6f 72 74 2c 20 |hello Teleport, |
+00000010 74 68 69 73 20 69 73 20 73 65 72 76 65 72 |this is server|
+
+
+Timestamp: 2024-12-04 00:00:03 +0000 UTC
+Direction: Teleport->Client
+
+00000000 68 65 6c 6c 6f 20 63 6c 69 65 6e 74 2c 20 74 68 |hello client, th|
+00000010 69 73 20 69 73 20 73 65 72 76 65 72 20 28 61 63 |is is server (ac|
+00000020 74 75 61 6c 6c 79 20 54 65 6c 65 70 6f 72 74 2c |tually Teleport,|
+00000030 20 77 69 6e 6b 29 | wink)|
+
+
+`
+ require.Equal(t, want, string(out))
+}
+
+func TestCapture_SaveToPCAP(t *testing.T) {
+ tmp := t.TempDir()
+ fileOut := path.Join(tmp, "trace.pcap")
+
+ runCommandCount := 0
+
+ testCapture := newTestCapture(func(progName string, args ...string) ([]byte, error) {
+ runCommandCount++
+
+ // verify command being run as well as its arguments.
+ switch runCommandCount {
+ case 1:
+ want := []string{
+ "-D", "-t", "%Y-%m-%dT%H:%M:%S.%fZ", "-l", "1", "-4", "1.1.1.1,2.2.2.2", "-T", "1111,1111",
+ fileOut + ".001.hex",
+ fileOut + ".001",
+ }
+ assert.Equal(t, want, args)
+ assert.Equal(t, text2pcapBin, progName)
+
+ hexData, err := os.ReadFile(fileOut + ".001.hex")
+ require.NoError(t, err)
+ require.Equal(t, `I 2024-12-04T00:00:00Z
+00000000 68 65 6c 6c 6f 20 73 65 72 76 65 72 2c 20 74 68 |hello server, th|
+00000010 69 73 20 69 73 20 63 6c 69 65 6e 74 |is is client|
+
+O 2024-12-04T00:00:03Z
+00000000 68 65 6c 6c 6f 20 63 6c 69 65 6e 74 2c 20 74 68 |hello client, th|
+00000010 69 73 20 69 73 20 73 65 72 76 65 72 20 28 61 63 |is is server (ac|
+00000020 74 75 61 6c 6c 79 20 54 65 6c 65 70 6f 72 74 2c |tually Teleport,|
+00000030 20 77 69 6e 6b 29 | wink)|
+
+`, string(hexData))
+
+ case 2:
+ want := []string{
+ "-D", "-t", "%Y-%m-%dT%H:%M:%S.%fZ", "-l", "1", "-4", "2.2.2.2,3.3.3.3", "-T", "1111,1111",
+ fileOut + ".002.hex",
+ fileOut + ".002",
+ }
+ assert.Equal(t, want, args)
+ assert.Equal(t, text2pcapBin, progName)
+
+ hexData, err := os.ReadFile(fileOut + ".002.hex")
+ require.NoError(t, err)
+ require.Equal(t, `I 2024-12-04T00:00:01Z
+00000000 68 65 6c 6c 6f 20 73 65 72 76 65 72 2c 20 74 68 |hello server, th|
+00000010 69 73 20 69 73 20 54 65 6c 65 70 6f 72 74 |is is Teleport|
+
+O 2024-12-04T00:00:02Z
+00000000 68 65 6c 6c 6f 20 54 65 6c 65 70 6f 72 74 2c 20 |hello Teleport, |
+00000010 74 68 69 73 20 69 73 20 73 65 72 76 65 72 |this is server|
+
+`, string(hexData))
+ case 3:
+ assert.Equal(t, mergecapBin, progName)
+ want := []string{"-w",
+ fileOut,
+ fileOut + ".001",
+ fileOut + ".002",
+ }
+ assert.Equal(t, want, args)
+ default:
+ assert.Fail(t, "unexpected number of commands")
+ }
+
+ // return no error.
+ return []byte("all is fine"), nil
+ })
+
+ err := testCapture.SaveToPCAP(fileOut, 1111)
+ require.NoError(t, err)
+
+ // verify all temp files have been deleted.
+ entries, err := os.ReadDir(tmp)
+ require.NoError(t, err)
+ require.Empty(t, entries)
+}
+
+func TestCapture_WriteTo(t *testing.T) {
+ testCapture := newTestCapture(func(name string, arg ...string) ([]byte, error) {
+ assert.FailNow(t, "runCommand shouldn't be called for SaveAsText")
+ return nil, trace.BadParameter("not expected")
+ })
+
+ var buf bytes.Buffer
+
+ count, err := testCapture.WriteTo(&buf)
+ require.NoError(t, err)
+ require.Equal(t, int64(1060), count)
+
+ want := `Timestamp: 2024-12-04 00:00:00 +0000 UTC
+Direction: Client->Teleport
+
+00000000 68 65 6c 6c 6f 20 73 65 72 76 65 72 2c 20 74 68 |hello server, th|
+00000010 69 73 20 69 73 20 63 6c 69 65 6e 74 |is is client|
+
+
+Timestamp: 2024-12-04 00:00:01 +0000 UTC
+Direction: Teleport->Server
+
+00000000 68 65 6c 6c 6f 20 73 65 72 76 65 72 2c 20 74 68 |hello server, th|
+00000010 69 73 20 69 73 20 54 65 6c 65 70 6f 72 74 |is is Teleport|
+
+
+Timestamp: 2024-12-04 00:00:02 +0000 UTC
+Direction: Server->Teleport
+
+00000000 68 65 6c 6c 6f 20 54 65 6c 65 70 6f 72 74 2c 20 |hello Teleport, |
+00000010 74 68 69 73 20 69 73 20 73 65 72 76 65 72 |this is server|
+
+
+Timestamp: 2024-12-04 00:00:03 +0000 UTC
+Direction: Teleport->Client
+
+00000000 68 65 6c 6c 6f 20 63 6c 69 65 6e 74 2c 20 74 68 |hello client, th|
+00000010 69 73 20 69 73 20 73 65 72 76 65 72 20 28 61 63 |is is server (ac|
+00000020 74 75 61 6c 6c 79 20 54 65 6c 65 70 6f 72 74 2c |tually Teleport,|
+00000030 20 77 69 6e 6b 29 | wink)|
+
+
+`
+ require.Equal(t, want, buf.String())
+}