Skip to content

Commit 6d21b5a

Browse files
authored
[tctl] introduce tctl recordings download (#62431) (#63726)
* [tctl] introduce `tctl recordings download` This PR introduces a new commnd to download session recordings directly from auth server using stream. This command aims to solve problems where a user needs to download a specific session recording but doesn't have access to the underlying storage - i.e. teleport saas cloud. * use os.Getpwd * add docs
1 parent edcae8f commit 6d21b5a

4 files changed

Lines changed: 127 additions & 5 deletions

File tree

docs/pages/reference/architecture/session-recording.mdx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ informally referred to as "chunks."
4949

5050
### Database sessions
5151

52-
Teleport captures database queries and a stream of audit events
52+
Teleport captures database queries and a stream of audit events
5353
related to the database being accessed.
5454

5555
## Session recording configurations
@@ -140,7 +140,7 @@ The Proxy Service then establishes its own connection to the destination server
140140
- For [OpenSSH servers](../../enroll-resources/server-access/openssh/openssh-agentless.mdx), the Proxy Service requests a certificate signed by the Teleport OpenSSH CA, which will be trusted by the OpenSSH server.
141141

142142
With both sides of the connections decrypted, the Proxy Service acts as a pipe between the client
143-
and the destination server, performing RBAC checks, recording the session, and emitting audit events
143+
and the destination server, performing RBAC checks, recording the session, and emitting audit events
144144
in the process as shown below:
145145

146146
![recording-proxy](../../../img/recording-proxy.svg)
@@ -256,6 +256,18 @@ This is sometimes surprising to Teleport users, because even though the recordin
256256
and audit log are stored in separate backends, both need to be operational in
257257
order to play recordings.
258258

259+
## Download
260+
261+
Session recordings can be downloaded from the cluster storage using the
262+
[`tctl recordings download`](../cli/tctl.mdx#tctl-recordings-download) command.
263+
264+
```code
265+
$ tctl recordings download [--output-dir <output-dir>] <session_id>
266+
```
267+
268+
All downloaded recordings are decrypted during the streaming process if session
269+
recording encryption is enabled.
270+
259271
## Upload completer
260272

261273
Every Teleport process runs a service called the *upload completer* which periodically
@@ -338,3 +350,4 @@ defined when the key is first provisioned.
338350
- [SSH recording modes](../deployment/monitoring/audit.mdx)
339351
- [Session recording for desktops](../../enroll-resources/desktop-access/reference/sessions.mdx)
340352
- [Encrypted Session Recordings](../../enroll-resources/server-access/guides/encrypted-session-recordings/encrypted-session-recordings.mdx)
353+
- [Session recording summaries](../../identity-security/session-summaries.mdx)

docs/pages/reference/cli/tctl.mdx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1536,6 +1536,19 @@ Flags:
15361536
|---|---|---|
15371537
|`--format`|`yaml`|Output format: 'yaml', 'json' or 'text'|
15381538

1539+
## tctl recordings download
1540+
1541+
Download session recordings from the cluster's recording storage to a local file.
1542+
If you omit an output directory, recordings are saved to the current working directory.
1543+
1544+
If session recording encryption is enabled in the cluster, recordings are decrypted during download.
1545+
1546+
### Example
1547+
1548+
```code
1549+
$ tctl recordings download [--output-dir <output-dir>] <session_id>
1550+
```
1551+
15391552
## tctl recordings encryption complete-rotation
15401553

15411554
Completes an in-progress encryption key rotation.

lib/client/api.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1637,7 +1637,7 @@ func (tc *TeleportClient) GetTargetNode(ctx context.Context, clt authclient.Clie
16371637
Labels: tc.Labels,
16381638
})
16391639
switch {
1640-
//TODO(tross): DELETE IN v20.0.0
1640+
// TODO(tross): DELETE IN v20.0.0
16411641
case trace.IsNotImplemented(err):
16421642
resources, err := client.GetAllUnifiedResources(ctx, clt, &proto.ListUnifiedResourcesRequest{
16431643
Kinds: []string{types.KindNode},
@@ -2570,7 +2570,7 @@ func playSession(ctx context.Context, sessionID string, speed float64, streamer
25702570
}
25712571
}
25722572

2573-
if err := player.Err(); err != nil {
2573+
if err := player.Err(); err != nil && !errors.Is(err, io.EOF) {
25742574
return trace.Wrap(err)
25752575
}
25762576

tool/tctl/common/recordings_command.go

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"fmt"
2424
"io"
2525
"os"
26+
"path/filepath"
2627

2728
"github.com/alecthomas/kingpin/v2"
2829
"github.com/gravitational/trace"
@@ -34,7 +35,10 @@ import (
3435
"github.com/gravitational/teleport/lib/auth/authclient"
3536
"github.com/gravitational/teleport/lib/client"
3637
"github.com/gravitational/teleport/lib/defaults"
38+
"github.com/gravitational/teleport/lib/events"
39+
"github.com/gravitational/teleport/lib/events/filesessions"
3740
"github.com/gravitational/teleport/lib/service/servicecfg"
41+
"github.com/gravitational/teleport/lib/session"
3842
"github.com/gravitational/teleport/tool/common"
3943
commonclient "github.com/gravitational/teleport/tool/tctl/common/client"
4044
tctlcfg "github.com/gravitational/teleport/tool/tctl/common/config"
@@ -48,6 +52,8 @@ type RecordingsCommand struct {
4852
format string
4953
// recordingsList implements the "tctl recordings ls" subcommand.
5054
recordingsList *kingpin.CmdClause
55+
// recordingsDownload implements the "tctl recordings download" subcommand.
56+
recordingsDownload *kingpin.CmdClause
5157
// recordingsEncryption implements the "tctl recordings encryption" subcommand.
5258
recordingsEncryption recordingsEncryptionCommand
5359
// fromUTC is the start time to use for the range of recordings listed by the recorded session listing command
@@ -59,12 +65,17 @@ type RecordingsCommand struct {
5965
// recordingsSince is a duration which sets the time into the past in which to list session recordings
6066
recordingsSince string
6167

68+
// recordingsDownloadSessionID is the session ID to download recordings for
69+
recordingsDownloadSessionID string
70+
// recordingsDownloadOutputDir is the output directory to download session recordings to
71+
recordingsDownloadOutputDir string
72+
6273
// stdout allows to switch standard output source for resource command. Used in tests.
6374
stdout io.Writer
6475
}
6576

6677
// Initialize allows RecordingsCommand to plug itself into the CLI parser
67-
func (c *RecordingsCommand) Initialize(app *kingpin.Application, _ *tctlcfg.GlobalCLIFlags, config *servicecfg.Config) {
78+
func (c *RecordingsCommand) Initialize(app *kingpin.Application, t *tctlcfg.GlobalCLIFlags, config *servicecfg.Config) {
6879
if c.stdout == nil {
6980
c.stdout = os.Stdout
7081
}
@@ -79,6 +90,15 @@ func (c *RecordingsCommand) Initialize(app *kingpin.Application, _ *tctlcfg.Glob
7990
c.recordingsList.Flag("last", "Duration into the past from which session recordings should be listed. Format 5h30m40s").StringVar(&c.recordingsSince)
8091
c.recordingsEncryption.Initialize(recordings, c.stdout)
8192

93+
download := recordings.Command("download", "Download session recordings.")
94+
download.Arg("session-id", "ID of the session to download recordings for.").Required().StringVar(&c.recordingsDownloadSessionID)
95+
pwd, err := os.Getwd()
96+
if err != nil {
97+
pwd = "."
98+
}
99+
download.Flag("output-dir", "Directory to download session recordings to.").Short('o').Default(pwd).StringVar(&c.recordingsDownloadOutputDir)
100+
c.recordingsDownload = download
101+
82102
if c.recordingsEncryption.stdout == nil {
83103
c.recordingsEncryption.stdout = c.stdout
84104
}
@@ -90,6 +110,8 @@ func (c *RecordingsCommand) TryRun(ctx context.Context, cmd string, clientFunc c
90110
switch cmd {
91111
case c.recordingsList.FullCommand():
92112
commandFunc = c.ListRecordings
113+
case c.recordingsDownload.FullCommand():
114+
commandFunc = c.DownloadRecordings
93115
default:
94116
return c.recordingsEncryption.TryRun(ctx, cmd, clientFunc)
95117
}
@@ -120,3 +142,77 @@ func (c *RecordingsCommand) ListRecordings(ctx context.Context, tc *authclient.C
120142
}
121143
return trace.Wrap(common.ShowSessions(recordings, c.format, c.stdout))
122144
}
145+
146+
func (c *RecordingsCommand) DownloadRecordings(ctx context.Context, tc *authclient.Client) (err error) {
147+
sessionID, err := session.ParseID(c.recordingsDownloadSessionID)
148+
if err != nil {
149+
return trace.BadParameter("invalid session id")
150+
}
151+
152+
e, err := createFileWriter(ctx, *sessionID, c.recordingsDownloadOutputDir)
153+
if err != nil {
154+
return trace.Wrap(err, "creating file downloader")
155+
}
156+
defer func() {
157+
completeErr := e.Complete(ctx)
158+
if err == nil && completeErr == nil {
159+
return
160+
}
161+
localRemErr := os.Remove(filepath.Join(c.recordingsDownloadOutputDir, string(*sessionID)+".tar"))
162+
// ignore file not found errors
163+
if os.IsNotExist(localRemErr) {
164+
localRemErr = nil
165+
}
166+
err = trace.NewAggregate(err, completeErr, localRemErr)
167+
}()
168+
169+
recC, errC := tc.StreamSessionEvents(ctx, *sessionID, 0)
170+
loop:
171+
for {
172+
select {
173+
case rec, ok := <-recC:
174+
if !ok {
175+
break loop
176+
}
177+
prepared, err := e.PrepareSessionEvent(rec)
178+
if err != nil {
179+
return trace.Wrap(err, "preparing recording event")
180+
}
181+
err = e.RecordEvent(ctx, prepared)
182+
if err != nil {
183+
return trace.Wrap(err, "recording session event")
184+
}
185+
case err := <-errC:
186+
if err != nil && !trace.IsEOF(err) {
187+
return trace.Wrap(err, "downloading session recordings")
188+
}
189+
return nil
190+
}
191+
}
192+
return nil
193+
}
194+
195+
// createFileWriter creates a file-based session event writer that outputs to a file.
196+
func createFileWriter(ctx context.Context, sessionID session.ID, outputDir string) (*events.SessionWriter, error) {
197+
fileStreamer, err := filesessions.NewStreamer(
198+
filesessions.StreamerConfig{
199+
Dir: outputDir,
200+
},
201+
)
202+
if err != nil {
203+
return nil, trace.Wrap(err, "failed to create fileStreamer")
204+
}
205+
206+
e, err := events.NewSessionWriter(
207+
events.SessionWriterConfig{
208+
SessionID: sessionID,
209+
Component: "downloader",
210+
// Use NoOpPreparer as events are already prepared by the server.
211+
Preparer: &events.NoOpPreparer{},
212+
Context: ctx,
213+
Clock: clockwork.NewRealClock(),
214+
Streamer: fileStreamer,
215+
},
216+
)
217+
return e, trace.Wrap(err, "creating session writer")
218+
}

0 commit comments

Comments
 (0)