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