mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-12 02:15:01 +00:00
Reintroduce tsh recordings export for RemoteFX (#63126)
The tsh recordings export command exports a Windows desktop session recording to a video file that can be played outside Teleport. It was built prior to our RemoteFX support, and to this day only works on legacy PNG recordings. This commit allows us to export RemoteFX recordings by leveraging the Rust RDP decoder library in tsh. This will be the first time tsh links to Rust libraries. In addition to restoring the previous functionality, this adds two new capabilities: 1. We now support recordings that change size. (The original implementation was completed prior to desktop sessions supporting resize) 2. We try to encode an MP4 video with ffmpeg (the system-installed version, not by linking ffmpeg code) if available, and fall back to the slower AVI encoder if ffmpeg is not available. (There's also a --encoder flag choosing one or the other, but most users shouldn't need it). Closes #45388 Also take the opportunity to remove the scholls/progressbar dependency and unify a few different progress bar implementations. Co-authored-by: Ryan Clark <[email protected]>
This commit is contained in:
co-authored by
Ryan Clark
parent
d0aa161a36
commit
02629106a4
@@ -425,7 +425,11 @@ function Build-Tsh {
|
||||
|
||||
$CommandDuration = Measure-Block {
|
||||
Write-Host "::group::Building tsh..."
|
||||
cargo build -p rdp-decoder --release --locked
|
||||
|
||||
# The --target must be set explicitly so the staticlib is emitted to
|
||||
# target/x86_64-pc-windows-gnu/release, which is where the cgo LDFLAGS
|
||||
# look for it.
|
||||
cargo build -p rdp-decoder --release --locked --target x86_64-pc-windows-gnu
|
||||
$UnsignedBinaryPath = "$BuildDirectory\unsigned-$BinaryName"
|
||||
go build -tags "piv rust_rdp_decoder" -trimpath -ldflags "-s -w $BuildTypeLDFlags" -o "$UnsignedBinaryPath" "$TeleportSourceDirectory\tool\tsh"
|
||||
if ($LastExitCode -ne 0) {
|
||||
|
||||
@@ -75,13 +75,30 @@ desktop icon in the first column to distinguish them from SSH recordings.
|
||||
|
||||
Click the play button to open the player in a new tab.
|
||||
|
||||
### Export to video
|
||||
|
||||
Starting in Teleport 18.11.0, desktop session recordings can be exported to video.
|
||||
|
||||
```
|
||||
$ tsh recordings export ccd2cfd0-f32c-467d-af49-cac52edd0ddb
|
||||
```
|
||||
|
||||
For the most performant encoding and smallest file sizes, ensure that
|
||||
[`ffmpeg`](https://www.ffmpeg.org/) is installed on your system. If `ffmpeg`
|
||||
is installed, Teleport will use it to encode an MP4 video file containing
|
||||
the session recording.
|
||||
|
||||
If `ffmpeg` is not available, Teleport will fall back to encoding an AVI video
|
||||
file. These recordings take significantly longer to encode and often result in
|
||||
much larger file sizes.
|
||||
|
||||
## Storage
|
||||
|
||||
Be aware, desktop session recordings save PNGs of changing sections of the
|
||||
screen, which means they take up significantly more disk space than SSH or
|
||||
Kubernetes session recordings. When using async recording modes, ensure that the
|
||||
host running Teleport's Desktop Service has sufficient disk space to store
|
||||
recordings that are in progress.
|
||||
Desktop session recordings contain images of changing sections of the screen,
|
||||
which means they take up significantly more disk space than SSH or Kubernetes
|
||||
session recordings. When using async recording modes, ensure that the host
|
||||
running Teleport's Desktop Service has sufficient disk space to store recordings
|
||||
that are in progress.
|
||||
|
||||
As a point of reference, when a full 1080p screen is redrawn (for example when
|
||||
opening a new full-sized application window), you can expect about 250kb to be
|
||||
|
||||
@@ -1570,7 +1570,9 @@ Flags:
|
||||
|
||||
|Flag|Default|Description|
|
||||
|---|---|---|
|
||||
|`--encoder`|`auto` (one of: `ffmpeg`, `avi`, `auto`)|The video encoder to use.|
|
||||
|`--out`|*no default*|Override output file name.|
|
||||
|`-q`, `--[no-]quiet`|`false`|Quiet mode.|
|
||||
|
||||
Arguments:
|
||||
|
||||
|
||||
@@ -213,7 +213,6 @@ require (
|
||||
github.com/redis/go-redis/v9 v9.21.0
|
||||
github.com/russellhaering/gosaml2 v0.11.0
|
||||
github.com/russellhaering/goxmldsig v1.6.0
|
||||
github.com/schollz/progressbar/v3 v3.18.0
|
||||
github.com/scim2/filter-parser/v2 v2.2.0
|
||||
github.com/shirou/gopsutil/v4 v4.26.3
|
||||
github.com/sigstore/cosign/v3 v3.0.6
|
||||
@@ -493,7 +492,6 @@ require (
|
||||
github.com/mdlayher/socket v0.5.1 // indirect
|
||||
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 // indirect
|
||||
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 // indirect
|
||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
|
||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
|
||||
|
||||
@@ -398,8 +398,6 @@ github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8
|
||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
|
||||
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
|
||||
github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM=
|
||||
github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
@@ -1030,8 +1028,6 @@ github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||
github.com/minio/minio-go/v7 v7.0.92 h1:jpBFWyRS3p8P/9tsRc+NuvqoFi7qAmTCFPoRFmobbVw=
|
||||
github.com/minio/minio-go/v7 v7.0.92/go.mod h1:vTIc8DNcnAZIhyFsk8EB90AbPjj3j68aWIEQCiPj7d0=
|
||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
|
||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=
|
||||
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
|
||||
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
|
||||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||
@@ -1236,8 +1232,6 @@ github.com/sassoftware/relic v7.2.1+incompatible h1:Pwyh1F3I0r4clFJXkSI8bOyJINGq
|
||||
github.com/sassoftware/relic v7.2.1+incompatible/go.mod h1:CWfAxv73/iLZ17rbyhIEq3K9hs5w6FpNMdUT//qR+zk=
|
||||
github.com/sassoftware/relic/v7 v7.6.2 h1:rS44Lbv9G9eXsukknS4mSjIAuuX+lMq/FnStgmZlUv4=
|
||||
github.com/sassoftware/relic/v7 v7.6.2/go.mod h1:kjmP0IBVkJZ6gXeAu35/KCEfca//+PKM6vTAsyDPY+k=
|
||||
github.com/schollz/progressbar/v3 v3.18.0 h1:uXdoHABRFmNIjUfte/Ex7WtuyVslrw2wVPQmCN62HpA=
|
||||
github.com/schollz/progressbar/v3 v3.18.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec=
|
||||
github.com/scim2/filter-parser/v2 v2.2.0 h1:QGadEcsmypxg8gYChRSM2j1edLyE/2j72j+hdmI4BJM=
|
||||
github.com/scim2/filter-parser/v2 v2.2.0/go.mod h1:jWnkDToqX/Y0ugz0P5VvpVEUKcWcyHHj+X+je9ce5JA=
|
||||
github.com/secure-systems-lab/go-securesystemslib v0.11.0 h1:iuCR9kcMFD4QurdKrGvPLoKZLv9YvwPYVr0473BdtFs=
|
||||
|
||||
@@ -200,7 +200,7 @@ func TestParallelUpdate(t *testing.T) {
|
||||
var progressCount int
|
||||
for i := range cap(outputs) {
|
||||
matchVersion(t, outputs[i].String(), testVersions[1])
|
||||
if strings.Contains(outputs[i].String(), "Update progress:") {
|
||||
if strings.Contains(outputs[i].String(), "Update progress") {
|
||||
progressCount++
|
||||
}
|
||||
}
|
||||
@@ -261,7 +261,7 @@ func TestUpdateInterruptSignal(t *testing.T) {
|
||||
case err := <-errChan:
|
||||
require.NoError(t, err)
|
||||
}
|
||||
assert.Contains(t, output.String(), "Update progress:")
|
||||
assert.Contains(t, output.String(), "Update progress")
|
||||
|
||||
matches := pattern.FindStringSubmatch(output.String())
|
||||
require.Len(t, matches, 2)
|
||||
|
||||
@@ -684,8 +684,6 @@ github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55h
|
||||
github.com/miekg/pkcs11 v1.0.3-0.20190429190417-a667d056470f/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs=
|
||||
github.com/miekg/pkcs11 v1.1.2 h1:/VxmeAX5qU6Q3EwafypogwWbYryHFmF2RpkJmw3m4MQ=
|
||||
github.com/miekg/pkcs11 v1.1.2/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs=
|
||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
|
||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=
|
||||
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
|
||||
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
|
||||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||
@@ -816,8 +814,6 @@ github.com/sassoftware/relic v7.2.1+incompatible h1:Pwyh1F3I0r4clFJXkSI8bOyJINGq
|
||||
github.com/sassoftware/relic v7.2.1+incompatible/go.mod h1:CWfAxv73/iLZ17rbyhIEq3K9hs5w6FpNMdUT//qR+zk=
|
||||
github.com/sassoftware/relic/v7 v7.6.2 h1:rS44Lbv9G9eXsukknS4mSjIAuuX+lMq/FnStgmZlUv4=
|
||||
github.com/sassoftware/relic/v7 v7.6.2/go.mod h1:kjmP0IBVkJZ6gXeAu35/KCEfca//+PKM6vTAsyDPY+k=
|
||||
github.com/schollz/progressbar/v3 v3.18.0 h1:uXdoHABRFmNIjUfte/Ex7WtuyVslrw2wVPQmCN62HpA=
|
||||
github.com/schollz/progressbar/v3 v3.18.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec=
|
||||
github.com/scim2/filter-parser/v2 v2.2.0 h1:QGadEcsmypxg8gYChRSM2j1edLyE/2j72j+hdmI4BJM=
|
||||
github.com/scim2/filter-parser/v2 v2.2.0/go.mod h1:jWnkDToqX/Y0ugz0P5VvpVEUKcWcyHHj+X+je9ce5JA=
|
||||
github.com/secure-systems-lab/go-securesystemslib v0.11.0 h1:iuCR9kcMFD4QurdKrGvPLoKZLv9YvwPYVr0473BdtFs=
|
||||
|
||||
@@ -332,7 +332,6 @@ require (
|
||||
github.com/mdlayher/socket v0.5.1 // indirect
|
||||
github.com/microsoft/go-mssqldb v1.9.3 // indirect
|
||||
github.com/miekg/pkcs11 v1.1.2 // indirect
|
||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
|
||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||
github.com/mitchellh/go-testing-interface v1.14.1 // indirect
|
||||
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
|
||||
@@ -393,7 +392,6 @@ require (
|
||||
github.com/russellhaering/goxmldsig v1.6.0 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||
github.com/schollz/progressbar/v3 v3.18.0 // indirect
|
||||
github.com/scim2/filter-parser/v2 v2.2.0 // indirect
|
||||
github.com/secure-systems-lab/go-securesystemslib v0.11.0 // indirect
|
||||
github.com/segmentio/asm v1.2.1 // indirect
|
||||
|
||||
@@ -355,8 +355,6 @@ github.com/chai2010/gettext-go v1.0.2 h1:1Lwwip6Q2QGsAdl/ZKPCwTe9fe0CjlUbqj5bFNS
|
||||
github.com/chai2010/gettext-go v1.0.2/go.mod h1:y+wnP2cHYaVj19NZhYKAwEMH2CI1gNHeQQ+5AjwawxA=
|
||||
github.com/charlievieth/strcase v0.0.6 h1:bOjyq1IDIa677BYv8Maqs5/hFMgvO8aM5HX/lHpwTRo=
|
||||
github.com/charlievieth/strcase v0.0.6/go.mod h1:BfLERwCrfmGJfELajGNncQWv5MCR0CCjgB8hXKhCtns=
|
||||
github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM=
|
||||
github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY=
|
||||
github.com/cilium/ebpf v0.20.0 h1:atwWj9d3NffHyPZzVlx3hmw1on5CLe9eljR8VuHTwhM=
|
||||
github.com/cilium/ebpf v0.20.0/go.mod h1:pzLjFymM+uZPLk/IXZUL63xdx5VXEo+enTzxkZXdycw=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
@@ -987,8 +985,6 @@ github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpsp
|
||||
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY=
|
||||
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI=
|
||||
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE=
|
||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
|
||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=
|
||||
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
|
||||
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
|
||||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||
@@ -1166,8 +1162,6 @@ github.com/sassoftware/relic v7.2.1+incompatible h1:Pwyh1F3I0r4clFJXkSI8bOyJINGq
|
||||
github.com/sassoftware/relic v7.2.1+incompatible/go.mod h1:CWfAxv73/iLZ17rbyhIEq3K9hs5w6FpNMdUT//qR+zk=
|
||||
github.com/sassoftware/relic/v7 v7.6.2 h1:rS44Lbv9G9eXsukknS4mSjIAuuX+lMq/FnStgmZlUv4=
|
||||
github.com/sassoftware/relic/v7 v7.6.2/go.mod h1:kjmP0IBVkJZ6gXeAu35/KCEfca//+PKM6vTAsyDPY+k=
|
||||
github.com/schollz/progressbar/v3 v3.18.0 h1:uXdoHABRFmNIjUfte/Ex7WtuyVslrw2wVPQmCN62HpA=
|
||||
github.com/schollz/progressbar/v3 v3.18.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec=
|
||||
github.com/scim2/filter-parser/v2 v2.2.0 h1:QGadEcsmypxg8gYChRSM2j1edLyE/2j72j+hdmI4BJM=
|
||||
github.com/scim2/filter-parser/v2 v2.2.0/go.mod h1:jWnkDToqX/Y0ugz0P5VvpVEUKcWcyHHj+X+je9ce5JA=
|
||||
github.com/secure-systems-lab/go-securesystemslib v0.11.0 h1:iuCR9kcMFD4QurdKrGvPLoKZLv9YvwPYVr0473BdtFs=
|
||||
|
||||
@@ -334,7 +334,6 @@ require (
|
||||
github.com/mdlayher/socket v0.5.1 // indirect
|
||||
github.com/microsoft/go-mssqldb v1.9.3 // indirect
|
||||
github.com/miekg/pkcs11 v1.1.2 // indirect
|
||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
|
||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||
github.com/mitchellh/go-testing-interface v1.14.1 // indirect
|
||||
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
|
||||
@@ -395,7 +394,6 @@ require (
|
||||
github.com/russellhaering/goxmldsig v1.6.0 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||
github.com/schollz/progressbar/v3 v3.18.0 // indirect
|
||||
github.com/scim2/filter-parser/v2 v2.2.0 // indirect
|
||||
github.com/secure-systems-lab/go-securesystemslib v0.11.0 // indirect
|
||||
github.com/segmentio/asm v1.2.1 // indirect
|
||||
|
||||
@@ -408,8 +408,6 @@ github.com/chai2010/gettext-go v1.0.2/go.mod h1:y+wnP2cHYaVj19NZhYKAwEMH2CI1gNHe
|
||||
github.com/charlievieth/strcase v0.0.6 h1:bOjyq1IDIa677BYv8Maqs5/hFMgvO8aM5HX/lHpwTRo=
|
||||
github.com/charlievieth/strcase v0.0.6/go.mod h1:BfLERwCrfmGJfELajGNncQWv5MCR0CCjgB8hXKhCtns=
|
||||
github.com/cheggaaa/pb v1.0.27/go.mod h1:pQciLPpbU0oxA0h+VJYYLxO+XeDQb5pZijXscXHm81s=
|
||||
github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM=
|
||||
github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
@@ -1132,8 +1130,6 @@ github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcs
|
||||
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI=
|
||||
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE=
|
||||
github.com/mitchellh/cli v1.1.2/go.mod h1:6iaV0fGdElS6dPBx0EApTxHrcWvmJphyh2n8YBLPPZ4=
|
||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
|
||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=
|
||||
github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw=
|
||||
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
|
||||
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
|
||||
@@ -1323,8 +1319,6 @@ github.com/sassoftware/relic v7.2.1+incompatible h1:Pwyh1F3I0r4clFJXkSI8bOyJINGq
|
||||
github.com/sassoftware/relic v7.2.1+incompatible/go.mod h1:CWfAxv73/iLZ17rbyhIEq3K9hs5w6FpNMdUT//qR+zk=
|
||||
github.com/sassoftware/relic/v7 v7.6.2 h1:rS44Lbv9G9eXsukknS4mSjIAuuX+lMq/FnStgmZlUv4=
|
||||
github.com/sassoftware/relic/v7 v7.6.2/go.mod h1:kjmP0IBVkJZ6gXeAu35/KCEfca//+PKM6vTAsyDPY+k=
|
||||
github.com/schollz/progressbar/v3 v3.18.0 h1:uXdoHABRFmNIjUfte/Ex7WtuyVslrw2wVPQmCN62HpA=
|
||||
github.com/schollz/progressbar/v3 v3.18.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec=
|
||||
github.com/scim2/filter-parser/v2 v2.2.0 h1:QGadEcsmypxg8gYChRSM2j1edLyE/2j72j+hdmI4BJM=
|
||||
github.com/scim2/filter-parser/v2 v2.2.0/go.mod h1:jWnkDToqX/Y0ugz0P5VvpVEUKcWcyHHj+X+je9ce5JA=
|
||||
github.com/sebdah/goldie v1.0.0/go.mod h1:jXP4hmWywNEwZzhMuv2ccnqTSFpuq8iyQhtQdkkZBH4=
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
/*
|
||||
* 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package tools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/gravitational/trace"
|
||||
)
|
||||
|
||||
type progressWriter struct {
|
||||
n int64
|
||||
limit int64
|
||||
size int
|
||||
progress int
|
||||
}
|
||||
|
||||
// newProgressWriter creates progress writer instance and prints empty
|
||||
// progress bar right after initialisation.
|
||||
func newProgressWriter(size int) (*progressWriter, func()) {
|
||||
pw := &progressWriter{size: size}
|
||||
pw.Print(0)
|
||||
return pw, func() {
|
||||
fmt.Print("\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Print prints the update progress bar with `n` bricks.
|
||||
func (w *progressWriter) Print(n int) {
|
||||
bricks := strings.Repeat("▒", n) + strings.Repeat(" ", w.size-n)
|
||||
fmt.Print("\rUpdate progress: [" + bricks + "] (Ctrl-C to cancel update)")
|
||||
}
|
||||
|
||||
func (w *progressWriter) Write(p []byte) (int, error) {
|
||||
if w.limit == 0 || w.size == 0 {
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
w.n += int64(len(p))
|
||||
bricks := int((w.n*100)/w.limit) / w.size
|
||||
if w.progress != bricks {
|
||||
w.Print(bricks)
|
||||
w.progress = bricks
|
||||
}
|
||||
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// CopyLimit sets the limit of writing bytes to the progress writer and initiate copying process.
|
||||
func (w *progressWriter) CopyLimit(dst io.Writer, src io.Reader, limit int64) (written int64, err error) {
|
||||
if limit < 0 {
|
||||
n, err := io.Copy(dst, io.TeeReader(src, w))
|
||||
w.Print(w.size)
|
||||
return n, trace.Wrap(err)
|
||||
}
|
||||
|
||||
w.limit = limit
|
||||
n, err := io.CopyN(dst, io.TeeReader(src, w), limit)
|
||||
return n, trace.Wrap(err)
|
||||
}
|
||||
@@ -46,6 +46,7 @@ import (
|
||||
"github.com/gravitational/teleport/lib/autoupdate"
|
||||
"github.com/gravitational/teleport/lib/utils"
|
||||
"github.com/gravitational/teleport/lib/utils/packaging"
|
||||
"github.com/gravitational/teleport/lib/utils/progressbar"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -552,21 +553,31 @@ func (u *Updater) downloadArchive(ctx context.Context, url string, f io.Writer)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, trace.BadParameter("bad status when downloading archive: %v", resp.StatusCode)
|
||||
}
|
||||
pw, finish := newProgressWriter(10)
|
||||
defer finish()
|
||||
|
||||
if resp.ContentLength != -1 {
|
||||
if err := checkFreeSpace(u.toolsDir, uint64(resp.ContentLength)); err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
}
|
||||
|
||||
bar := progressbar.New(resp.ContentLength, "Update progress (Ctrl-C to cancel update)", os.Stdout)
|
||||
|
||||
h := sha256.New()
|
||||
// It is a little inefficient to download the file to disk and then re-load
|
||||
// it into memory to unarchive later, but this is safer as it allows client
|
||||
// tools to validate the hash before trying to operate on the archive.
|
||||
if _, err := pw.CopyLimit(f, io.TeeReader(resp.Body, h), resp.ContentLength); err != nil {
|
||||
dst := io.MultiWriter(f, h, bar)
|
||||
if resp.ContentLength < 0 {
|
||||
_, err = io.Copy(dst, resp.Body)
|
||||
} else {
|
||||
_, err = io.CopyN(dst, resp.Body, resp.ContentLength)
|
||||
}
|
||||
if err != nil {
|
||||
// Finish on a partial line so later output starts fresh, but don't
|
||||
// fill the bar: the download was interrupted, not completed.
|
||||
fmt.Fprintln(os.Stdout)
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
|
||||
bar.Finish()
|
||||
return h.Sum(nil), nil
|
||||
}
|
||||
|
||||
@@ -9,8 +9,11 @@ publish.workspace = true
|
||||
crate-type = ["staticlib", "lib"]
|
||||
|
||||
[dependencies]
|
||||
fast_image_resize = "6.0.0"
|
||||
fast_image_resize = { version = "6.0.0", optional = true }
|
||||
ironrdp-core.workspace = true
|
||||
ironrdp-graphics.workspace = true
|
||||
ironrdp-pdu.workspace = true
|
||||
ironrdp-session.workspace = true
|
||||
|
||||
[features]
|
||||
image-resize = ["dep:fast_image_resize"]
|
||||
|
||||
@@ -23,13 +23,20 @@ package decoder
|
||||
#cgo linux,amd64 LDFLAGS: -L${SRCDIR}/../../../../../target/x86_64-unknown-linux-gnu/release
|
||||
#cgo linux,arm LDFLAGS: -L${SRCDIR}/../../../../../target/arm-unknown-linux-gnueabihf/release
|
||||
#cgo linux,arm64 LDFLAGS: -L${SRCDIR}/../../../../../target/aarch64-unknown-linux-gnu/release
|
||||
#cgo linux LDFLAGS: -lrdp_decoder
|
||||
// The system libraries below are based on:
|
||||
// cargo rustc --release --target x86_64-unknown-linux-gnu -p rdp-decoder \
|
||||
// --crate-type staticlib -- --print native-static-libs
|
||||
#cgo linux LDFLAGS: -lrdp_decoder -static-libgcc -lutil -lrt -lpthread -lm -ldl -lc
|
||||
|
||||
#cgo darwin,amd64 LDFLAGS: -L${SRCDIR}/../../../../../target/x86_64-apple-darwin/release
|
||||
#cgo darwin,arm64 LDFLAGS: -L${SRCDIR}/../../../../../target/aarch64-apple-darwin/release
|
||||
#cgo darwin LDFLAGS: -lrdp_decoder
|
||||
|
||||
#cgo windows,amd64 LDFLAGS: -L${SRCDIR}/../../../../../target/x86_64-pc-windows-gnu/release
|
||||
#cgo windows LDFLAGS: -lrdp_decoder
|
||||
// The Windows system libraries below are required by the Rust std library that
|
||||
// gets bundled into librdp_decoder.a. The list comes from:
|
||||
// cargo rustc --release --target x86_64-pc-windows-gnu -p rdp-decoder \
|
||||
// --crate-type staticlib -- --print native-static-libs
|
||||
#cgo windows LDFLAGS: -lrdp_decoder -lkernel32 -lntdll -luserenv -lws2_32 -ldbghelp
|
||||
*/
|
||||
import "C"
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
// Without the image-resize feature (the standalone tsh decoder), the cursor bitmap is still tracked
|
||||
// from the RDP stream but read only by the resize/crop compositor, so its accessors and the
|
||||
// synthetic default cursor are unused in that build. Keep them compiled rather than threading cfgs
|
||||
// through the decode path.
|
||||
#![cfg_attr(not(feature = "image-resize"), allow(dead_code))]
|
||||
|
||||
use ironrdp_graphics::pointer::DecodedPointer;
|
||||
use std::sync::Arc;
|
||||
|
||||
|
||||
@@ -19,11 +19,13 @@
|
||||
//! Go (via cgo) can create a decoder and call its methods.
|
||||
|
||||
mod cursor;
|
||||
#[cfg(feature = "image-resize")]
|
||||
mod image;
|
||||
mod regions;
|
||||
|
||||
use crate::cursor::CursorState;
|
||||
use crate::regions::UpdatedRegions;
|
||||
#[cfg(feature = "image-resize")]
|
||||
use fast_image_resize::Resizer;
|
||||
use ironrdp_core::WriteBuf;
|
||||
use ironrdp_graphics::image_processing::PixelFormat;
|
||||
@@ -40,7 +42,9 @@ pub struct RdpDecoder {
|
||||
fast_path_processor: Processor,
|
||||
cursor_state: CursorState,
|
||||
updated_regions: UpdatedRegions,
|
||||
#[cfg(feature = "image-resize")]
|
||||
resizer: Resizer,
|
||||
#[cfg(feature = "image-resize")]
|
||||
composite_scratch: Vec<u8>,
|
||||
}
|
||||
|
||||
@@ -69,7 +73,9 @@ impl RdpDecoder {
|
||||
.build(),
|
||||
cursor_state: Default::default(),
|
||||
updated_regions: Default::default(),
|
||||
#[cfg(feature = "image-resize")]
|
||||
resizer: Resizer::new(),
|
||||
#[cfg(feature = "image-resize")]
|
||||
composite_scratch: Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -335,28 +341,41 @@ pub unsafe extern "C" fn rdp_decoder_resize_crop(
|
||||
return false;
|
||||
}
|
||||
|
||||
let with_cursor = with_cursor != 0;
|
||||
// Resize/crop scaling is only compiled into the full RDP client (image-resize feature), which
|
||||
// the desktop thumbnail and session summary generation needs. This means we can avoid including
|
||||
// it in tsh.
|
||||
#[cfg(not(feature = "image-resize"))]
|
||||
{
|
||||
let _ = (crop_x, crop_y, out_buf_len, with_cursor);
|
||||
return false;
|
||||
}
|
||||
|
||||
catch_unwind(AssertUnwindSafe(move || unsafe {
|
||||
let decoder = &mut *ptr;
|
||||
let needed =
|
||||
(out_width as usize) * (out_height as usize) * usize::from(decoder.bytes_per_pixel());
|
||||
if out_buf_len < needed {
|
||||
return false;
|
||||
}
|
||||
#[cfg(feature = "image-resize")]
|
||||
{
|
||||
let with_cursor = with_cursor != 0;
|
||||
|
||||
let dst = std::slice::from_raw_parts_mut(out_buf, needed);
|
||||
decoder
|
||||
.resize_crop_into(
|
||||
(crop_x, crop_y),
|
||||
(crop_w, crop_h),
|
||||
(out_width, out_height),
|
||||
dst,
|
||||
with_cursor,
|
||||
)
|
||||
.is_ok()
|
||||
}))
|
||||
.unwrap_or(false)
|
||||
catch_unwind(AssertUnwindSafe(move || unsafe {
|
||||
let decoder = &mut *ptr;
|
||||
let needed = (out_width as usize)
|
||||
* (out_height as usize)
|
||||
* usize::from(decoder.bytes_per_pixel());
|
||||
if out_buf_len < needed {
|
||||
return false;
|
||||
}
|
||||
|
||||
let dst = std::slice::from_raw_parts_mut(out_buf, needed);
|
||||
decoder
|
||||
.resize_crop_into(
|
||||
(crop_x, crop_y),
|
||||
(crop_w, crop_h),
|
||||
(out_width, out_height),
|
||||
dst,
|
||||
with_cursor,
|
||||
)
|
||||
.is_ok()
|
||||
}))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Override the decoder's tracked cursor position. Useful for cursor updates
|
||||
@@ -549,12 +568,14 @@ pub unsafe extern "C" fn rdp_decoder_updated_regions_count(ptr: *mut RdpDecoder)
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(feature = "image-resize")]
|
||||
fn make_decoder(width: u16, height: u16) -> *mut RdpDecoder {
|
||||
let ptr = rdp_decoder_new(width, height, 1003, 1007);
|
||||
assert!(!ptr.is_null());
|
||||
ptr
|
||||
}
|
||||
|
||||
#[cfg(feature = "image-resize")]
|
||||
#[test]
|
||||
fn resize_crop_rejects_crop_extending_past_width() {
|
||||
let ptr = make_decoder(100, 100);
|
||||
@@ -571,6 +592,7 @@ mod tests {
|
||||
unsafe { rdp_decoder_free(ptr) };
|
||||
}
|
||||
|
||||
#[cfg(feature = "image-resize")]
|
||||
#[test]
|
||||
fn resize_crop_rejects_crop_extending_past_height() {
|
||||
let ptr = make_decoder(100, 100);
|
||||
@@ -586,6 +608,7 @@ mod tests {
|
||||
unsafe { rdp_decoder_free(ptr) };
|
||||
}
|
||||
|
||||
#[cfg(feature = "image-resize")]
|
||||
#[test]
|
||||
fn resize_crop_rejects_zero_crop_dimensions() {
|
||||
let ptr = make_decoder(100, 100);
|
||||
@@ -608,6 +631,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "image-resize")]
|
||||
#[test]
|
||||
fn resize_crop_accepts_in_bounds_crop() {
|
||||
let ptr = make_decoder(100, 100);
|
||||
@@ -677,6 +701,7 @@ mod tests {
|
||||
assert_ne!(sample_hash(&a, 1, 1, 4, 64), sample_hash(&b, 1, 1, 4, 64));
|
||||
}
|
||||
|
||||
#[cfg(feature = "image-resize")]
|
||||
#[test]
|
||||
fn resize_crop_with_cursor_accepts_in_bounds_crop() {
|
||||
let ptr = make_decoder(100, 100);
|
||||
@@ -700,6 +725,7 @@ mod tests {
|
||||
unsafe { rdp_decoder_free(ptr) };
|
||||
}
|
||||
|
||||
#[cfg(feature = "image-resize")]
|
||||
#[test]
|
||||
fn resize_crop_with_cursor_rejects_out_of_bounds_crop() {
|
||||
let ptr = make_decoder(100, 100);
|
||||
|
||||
@@ -30,7 +30,7 @@ iso7816 = "0.2.0"
|
||||
iso7816-tlv = "0.4.4"
|
||||
log = "0.4.30"
|
||||
rand = { version = "0.9.4", features = ["os_rng"] }
|
||||
rdp-decoder = { path = "../decoder" }
|
||||
rdp-decoder = { path = "../decoder", features = ["image-resize"] }
|
||||
rsa = "0.9.10"
|
||||
sspi = { version = "^0.21.0", features = ["network_client"] }
|
||||
tokio = { version = "1.52", features = ["full"] }
|
||||
|
||||
@@ -22,7 +22,6 @@ package sftp
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
@@ -34,12 +33,12 @@ import (
|
||||
|
||||
"github.com/gravitational/trace"
|
||||
"github.com/pkg/sftp"
|
||||
"github.com/schollz/progressbar/v3"
|
||||
|
||||
"github.com/gravitational/teleport"
|
||||
"github.com/gravitational/teleport/api/observability/tracing/ssh"
|
||||
"github.com/gravitational/teleport/lib/defaults"
|
||||
"github.com/gravitational/teleport/lib/sshutils/scp"
|
||||
"github.com/gravitational/teleport/lib/utils/progressbar"
|
||||
"github.com/gravitational/teleport/session/sftputils"
|
||||
)
|
||||
|
||||
@@ -102,7 +101,7 @@ func (req *FileTransferRequest) checkAndSetDefaults() error {
|
||||
)
|
||||
if req.ProgressWriter != nil && req.ProgressStream == nil {
|
||||
req.ProgressStream = func(fileInfo os.FileInfo) io.ReadWriter {
|
||||
return newProgressBar(fileInfo.Size(), fileInfo.Name(), req.ProgressWriter)
|
||||
return progressbar.New(fileInfo.Size(), fileInfo.Name(), req.ProgressWriter)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -546,48 +545,3 @@ func getAtime(fi os.FileInfo) time.Time {
|
||||
|
||||
return scp.GetAtime(fi)
|
||||
}
|
||||
|
||||
// unboundedProgressBar is a wrapper for a progress bar that increases its max
|
||||
// value when its internal count gets too big, instead of failing.
|
||||
type unboundedProgressBar struct {
|
||||
pb *progressbar.ProgressBar
|
||||
}
|
||||
|
||||
func (u *unboundedProgressBar) checkMax(n int) {
|
||||
state := u.pb.State()
|
||||
newNum := state.CurrentNum + int64(n)
|
||||
if newNum > state.Max {
|
||||
u.pb.ChangeMax64(newNum)
|
||||
}
|
||||
}
|
||||
|
||||
func (u *unboundedProgressBar) Read(p []byte) (int, error) {
|
||||
u.checkMax(len(p))
|
||||
return u.pb.Read(p)
|
||||
}
|
||||
|
||||
func (u *unboundedProgressBar) Write(p []byte) (int, error) {
|
||||
u.checkMax(len(p))
|
||||
return u.pb.Write(p)
|
||||
}
|
||||
|
||||
// newProgressBar returns a new progress bar that writes to writer.
|
||||
func newProgressBar(size int64, desc string, writer io.Writer) *unboundedProgressBar {
|
||||
// this is necessary because progressbar.DefaultBytes doesn't allow
|
||||
// the caller to specify a writer
|
||||
return &unboundedProgressBar{pb: progressbar.NewOptions64(
|
||||
size,
|
||||
progressbar.OptionSetDescription(desc),
|
||||
progressbar.OptionSetWriter(writer),
|
||||
progressbar.OptionShowBytes(true),
|
||||
progressbar.OptionSetWidth(10),
|
||||
progressbar.OptionThrottle(100*time.Millisecond),
|
||||
progressbar.OptionShowCount(),
|
||||
progressbar.OptionOnCompletion(func() {
|
||||
fmt.Fprint(writer, "\n")
|
||||
}),
|
||||
progressbar.OptionSpinnerType(14),
|
||||
progressbar.OptionFullWidth(),
|
||||
progressbar.OptionSetRenderBlankState(true),
|
||||
)}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* Teleport
|
||||
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// Package progressbar provides a minimal terminal progress bar.
|
||||
package progressbar
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// width is the number of cells used to draw the bar.
|
||||
const width = 10
|
||||
|
||||
// Bar is a minimal progress bar that redraws in place using a carriage return.
|
||||
//
|
||||
// A Bar can be advanced either by discrete steps (Add) or as a byte counter: it
|
||||
// implements io.ReadWriter, where Read and Write simply count len(p) and pass
|
||||
// no data.
|
||||
//
|
||||
// All methods are safe to call on a nil *Bar, which disables progress reporting
|
||||
// so callers can leave the bar unset.
|
||||
type Bar struct {
|
||||
w io.Writer
|
||||
desc string
|
||||
total int64
|
||||
current int64
|
||||
// lastPercent is the most recently drawn percentage, used to avoid unnecessary redraws
|
||||
lastPercent int
|
||||
finished bool
|
||||
}
|
||||
|
||||
// New returns a progress bar that advances toward total and draws to w, labeled
|
||||
// with desc. total is the number of steps or bytes expected; pass a value <= 0
|
||||
// when it is unknown, in which case the bar stays empty until Finish.
|
||||
func New(total int64, desc string, w io.Writer) *Bar {
|
||||
b := &Bar{w: w, desc: desc, total: total, lastPercent: -1}
|
||||
b.render()
|
||||
return b
|
||||
}
|
||||
|
||||
// Add advances the bar by n steps.
|
||||
func (b *Bar) Add(n int) {
|
||||
if b == nil {
|
||||
return
|
||||
}
|
||||
b.advance(int64(n))
|
||||
}
|
||||
|
||||
// Write implements io.Writer, advancing the bar by len(p) bytes. It counts only;
|
||||
// the buffer is expected to already hold the transferred data.
|
||||
func (b *Bar) Write(p []byte) (int, error) {
|
||||
if b == nil {
|
||||
return len(p), nil
|
||||
}
|
||||
b.advance(int64(len(p)))
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// Read implements io.Reader, advancing the bar by len(p) bytes. Like Write, it
|
||||
// counts only and does not populate p.
|
||||
func (b *Bar) Read(p []byte) (int, error) {
|
||||
if b == nil {
|
||||
return len(p), nil
|
||||
}
|
||||
b.advance(int64(len(p)))
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// Finish fills the bar to 100% and moves to the next line.
|
||||
func (b *Bar) Finish() {
|
||||
if b == nil {
|
||||
return
|
||||
}
|
||||
b.finished = true
|
||||
b.render()
|
||||
fmt.Fprintln(b.w)
|
||||
}
|
||||
|
||||
func (b *Bar) advance(n int64) {
|
||||
b.current += n
|
||||
|
||||
if b.total > 0 && b.current > b.total {
|
||||
b.total = b.current
|
||||
}
|
||||
|
||||
b.render()
|
||||
}
|
||||
|
||||
func (b *Bar) percent() int {
|
||||
switch {
|
||||
case b.finished:
|
||||
return 100
|
||||
case b.total <= 0:
|
||||
return 0
|
||||
default:
|
||||
return min(int(b.current*100/b.total), 100)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bar) render() {
|
||||
percent := b.percent()
|
||||
if percent == b.lastPercent {
|
||||
return
|
||||
}
|
||||
b.lastPercent = percent
|
||||
|
||||
filled := width * percent / 100
|
||||
bar := strings.Repeat("█", filled) + strings.Repeat(" ", width-filled)
|
||||
fmt.Fprintf(b.w, "\r%s [%s] %3d%%", b.desc, bar, percent)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* Teleport
|
||||
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package progressbar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// lastLine returns the final in-place frame rendered to the bar (the text after
|
||||
// the last carriage return), trimming any trailing newline.
|
||||
func lastLine(s string) string {
|
||||
frames := strings.Split(s, "\r")
|
||||
return strings.TrimSuffix(frames[len(frames)-1], "\n")
|
||||
}
|
||||
|
||||
func TestAddAndFinish(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
bar := New(4, "test", &buf)
|
||||
for range 4 {
|
||||
bar.Add(1)
|
||||
}
|
||||
bar.Finish()
|
||||
|
||||
last := lastLine(buf.String())
|
||||
require.Contains(t, last, "100%")
|
||||
require.Equal(t, width, strings.Count(last, "█"))
|
||||
require.True(t, strings.HasSuffix(buf.String(), "\n"))
|
||||
}
|
||||
|
||||
func TestUnboundedGrowth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
bar := New(2, "test", &buf)
|
||||
for range 10 {
|
||||
bar.Add(1)
|
||||
}
|
||||
bar.Finish()
|
||||
|
||||
require.Contains(t, lastLine(buf.String()), "100%")
|
||||
}
|
||||
|
||||
func TestByteCounter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const data = "hello world"
|
||||
|
||||
var buf bytes.Buffer
|
||||
bar := New(int64(len(data)), "file", &buf)
|
||||
var _ io.ReadWriter = bar
|
||||
|
||||
n, err := io.Copy(bar, bytes.NewBufferString(data))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(len(data)), n)
|
||||
bar.Finish()
|
||||
|
||||
require.Contains(t, lastLine(buf.String()), "100%")
|
||||
}
|
||||
|
||||
func TestUnknownTotal(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
bar := New(-1, "download", &buf)
|
||||
bar.Add(100)
|
||||
require.NotContains(t, buf.String(), "100%")
|
||||
|
||||
bar.Finish()
|
||||
require.Contains(t, lastLine(buf.String()), "100%")
|
||||
}
|
||||
|
||||
func TestNilBar(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var bar *Bar
|
||||
require.NotPanics(t, func() {
|
||||
bar.Add(1)
|
||||
bar.Finish()
|
||||
n, err := bar.Write([]byte("data"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 4, n)
|
||||
n, err = bar.Read(make([]byte, 4))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 4, n)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* Teleport
|
||||
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package recordingexport
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
|
||||
"github.com/gravitational/trace"
|
||||
|
||||
apievents "github.com/gravitational/teleport/api/types/events"
|
||||
"github.com/gravitational/teleport/lib/srv/desktop/tdp"
|
||||
"github.com/gravitational/teleport/lib/srv/desktop/tdp/protocol/legacy"
|
||||
)
|
||||
|
||||
// PNGDecoder decodes screen fragments from legacy PNG desktop recordings.
|
||||
type PNGDecoder struct {
|
||||
screen *image.NRGBA
|
||||
}
|
||||
|
||||
func NewPNGDecoder(maxWidth, maxHeight int) *PNGDecoder {
|
||||
return &PNGDecoder{
|
||||
screen: image.NewNRGBA(image.Rectangle{
|
||||
Min: image.Pt(0, 0),
|
||||
Max: image.Pt(maxWidth, maxHeight),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateScreen decodes one legacy recording event and applies it to the screen, reporting whether it
|
||||
// produced new image content. PNG recordings predate TDPB, so only the legacy TDP wire format is
|
||||
// handled here.
|
||||
func (p *PNGDecoder) UpdateScreen(evt *apievents.DesktopRecording) (bool, error) {
|
||||
if len(evt.Message) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
msg, err := legacy.Decode(bytes.NewReader(evt.Message))
|
||||
if err != nil {
|
||||
return false, trace.Wrap(err)
|
||||
}
|
||||
|
||||
switch msg.(type) {
|
||||
case legacy.ClientScreenSpec:
|
||||
draw.Draw(p.screen, p.screen.Bounds(), image.NewUniform(color.Black), image.Point{}, draw.Src)
|
||||
case legacy.PNGFrame, legacy.PNG2Frame:
|
||||
fragment, err := imgFromPNGMessage(msg)
|
||||
if err != nil {
|
||||
return false, trace.Wrap(err)
|
||||
}
|
||||
draw.Draw(
|
||||
p.screen,
|
||||
rectFromPNGMessage(msg),
|
||||
fragment,
|
||||
fragment.Bounds().Min,
|
||||
draw.Src,
|
||||
)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (p *PNGDecoder) Image() image.Image {
|
||||
return p.screen
|
||||
}
|
||||
|
||||
func (p *PNGDecoder) Close() error { return nil }
|
||||
|
||||
func imgFromPNGMessage(msg tdp.Message) (image.Image, error) {
|
||||
switch msg := msg.(type) {
|
||||
case legacy.PNG2Frame:
|
||||
return png.Decode(bytes.NewReader(msg.Data()))
|
||||
case legacy.PNGFrame:
|
||||
return msg.Img, nil
|
||||
default:
|
||||
// this should never happen based on what we pass at the call site
|
||||
return nil, trace.BadParameter("unsupported TDP message %T", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func rectFromPNGMessage(msg tdp.Message) image.Rectangle {
|
||||
switch msg := msg.(type) {
|
||||
case legacy.PNG2Frame:
|
||||
return image.Rect(
|
||||
// add one to bottom and right dimension, as RDP bounds are inclusive
|
||||
int(msg.Left()), int(msg.Top()),
|
||||
int(msg.Right()+1), int(msg.Bottom()+1),
|
||||
)
|
||||
case legacy.PNGFrame:
|
||||
return msg.Img.Bounds()
|
||||
default:
|
||||
// this should never happen based on what we pass at the call site
|
||||
return image.Rectangle{}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Teleport
|
||||
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package recordingexport
|
||||
|
||||
import (
|
||||
"image"
|
||||
|
||||
"github.com/gravitational/trace"
|
||||
|
||||
apievents "github.com/gravitational/teleport/api/types/events"
|
||||
"github.com/gravitational/teleport/lib/srv/desktop/rdpstate"
|
||||
)
|
||||
|
||||
// RemoteFXDecoder decodes screen fragments from RemoteFX desktop recordings by driving the shared rdpstate screen
|
||||
// reconstruction, which handles both legacy TDP and modern TDPB recordings and owns the underlying RDP decoder.
|
||||
type RemoteFXDecoder struct {
|
||||
state *rdpstate.RDPState
|
||||
}
|
||||
|
||||
// NewRemoteFXDecoder creates a decoder for RemoteFX recordings.
|
||||
//
|
||||
//nolint:staticcheck // SA4023. False positive, depends on build tags.
|
||||
func NewRemoteFXDecoder() *RemoteFXDecoder {
|
||||
return &RemoteFXDecoder{state: rdpstate.New()}
|
||||
}
|
||||
|
||||
func (r *RemoteFXDecoder) UpdateScreen(evt *apievents.DesktopRecording) (bool, error) {
|
||||
r.state.ResetUpdatedRegions()
|
||||
if err := r.state.HandleMessage(evt); err != nil {
|
||||
return false, trace.Wrap(err)
|
||||
}
|
||||
|
||||
return len(r.state.UpdatedRegions()) > 0, nil
|
||||
}
|
||||
|
||||
func (r *RemoteFXDecoder) Image() image.Image {
|
||||
if img := r.state.Image(); img != nil {
|
||||
return img
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RemoteFXDecoder) Close() error {
|
||||
r.state.Release()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* Teleport
|
||||
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package recordingexport
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
|
||||
"github.com/gravitational/trace"
|
||||
"github.com/icza/mjpeg"
|
||||
)
|
||||
|
||||
// AVIEncoder encodes an AVI video file using the motion JPEG codec.
|
||||
// It is slow, produces large files, and often needs to split a single
|
||||
// recording into multiple files, but is implemented in pure Go
|
||||
// and available on all systems.
|
||||
type AVIEncoder struct {
|
||||
currentWriter mjpeg.AviWriter
|
||||
buf *bytes.Buffer
|
||||
|
||||
maxWidth, maxHeight int32
|
||||
framesPerSecond int32
|
||||
|
||||
currentFileCount int
|
||||
outputPrefix string
|
||||
// files holds the paths of the AVI files written so far, in order.
|
||||
files []string
|
||||
}
|
||||
|
||||
// NewAVIEncoder produces AVI files with names based on the specified output prefix.
|
||||
func NewAVIEncoder(prefix string, maxWidth, maxHeight, fps int32) *AVIEncoder {
|
||||
return &AVIEncoder{
|
||||
outputPrefix: prefix,
|
||||
buf: new(bytes.Buffer),
|
||||
maxWidth: maxWidth,
|
||||
maxHeight: maxHeight,
|
||||
framesPerSecond: fps,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *AVIEncoder) EmitFrames(img image.Image, count int) error {
|
||||
a.buf.Reset()
|
||||
if err := jpeg.Encode(a.buf, img, nil /* options */); err != nil {
|
||||
return trace.Wrap(err)
|
||||
}
|
||||
|
||||
if a.currentWriter == nil {
|
||||
if err := a.openNextFile(); err != nil {
|
||||
return trace.Wrap(err)
|
||||
}
|
||||
}
|
||||
|
||||
for range count {
|
||||
err := a.currentWriter.AddFrame(a.buf.Bytes())
|
||||
if errors.Is(err, mjpeg.ErrTooLarge) {
|
||||
// file too large - open a new one
|
||||
if err := a.currentWriter.Close(); err != nil {
|
||||
return trace.WrapWithMessage(err, "failed to write partial recording")
|
||||
}
|
||||
a.currentFileCount++
|
||||
if err := a.openNextFile(); err != nil {
|
||||
return trace.Wrap(err)
|
||||
}
|
||||
|
||||
// re-emit the frame to the new file
|
||||
if err := a.currentWriter.AddFrame(a.buf.Bytes()); err != nil {
|
||||
return trace.Wrap(err)
|
||||
}
|
||||
} else if err != nil {
|
||||
return trace.Wrap(err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// openNextFile creates the AVI writer for the current file count and records
|
||||
// its path in the list of output files.
|
||||
func (a *AVIEncoder) openNextFile() error {
|
||||
filename := makeAVIFileName(a.outputPrefix, a.currentFileCount)
|
||||
writer, err := mjpeg.New(filename, a.maxWidth, a.maxHeight, a.framesPerSecond)
|
||||
if err != nil {
|
||||
return trace.Wrap(err)
|
||||
}
|
||||
a.currentWriter = writer
|
||||
a.files = append(a.files, filename)
|
||||
return nil
|
||||
}
|
||||
|
||||
// OutputFiles returns the paths of the AVI files written, in order.
|
||||
func (a *AVIEncoder) OutputFiles() []string {
|
||||
return a.files
|
||||
}
|
||||
|
||||
func (a *AVIEncoder) Close() error {
|
||||
if a.currentWriter == nil {
|
||||
return nil
|
||||
}
|
||||
if err := a.currentWriter.Close(); err != nil {
|
||||
return trace.Wrap(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func makeAVIFileName(prefix string, currentFile int) string {
|
||||
if currentFile == 0 {
|
||||
return prefix + ".avi"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%v-%d.avi", prefix, currentFile)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Teleport
|
||||
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package recordingexport
|
||||
|
||||
import (
|
||||
"image"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAVIEncoderOutputFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
prefix := filepath.Join(t.TempDir(), "session")
|
||||
enc := NewAVIEncoder(prefix, 64, 64, 30)
|
||||
|
||||
// No frames written yet, so no files exist.
|
||||
require.Empty(t, enc.OutputFiles())
|
||||
|
||||
img := image.NewRGBA(image.Rect(0, 0, 64, 64))
|
||||
require.NoError(t, enc.EmitFrames(img, 1))
|
||||
require.NoError(t, enc.Close())
|
||||
|
||||
// A single file should be reported, and it should actually exist on disk.
|
||||
files := enc.OutputFiles()
|
||||
require.Equal(t, []string{prefix + ".avi"}, files)
|
||||
require.FileExists(t, files[0])
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* Teleport
|
||||
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package recordingexport
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"io"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
|
||||
"github.com/gravitational/trace"
|
||||
"golang.org/x/image/bmp"
|
||||
)
|
||||
|
||||
// NewFFMPEGEncoder encodes videos using the system-installed ffmpeg binary.
|
||||
func NewFFMPEGEncoder(outputPrefix string, fps int) (*FFMPEGEncoder, error) {
|
||||
outputFile := outputPrefix + ".mp4"
|
||||
framesPerSecond := strconv.Itoa(fps)
|
||||
cmd := exec.Command("ffmpeg",
|
||||
"-y", // overwrite output file without asking
|
||||
|
||||
// Input args:
|
||||
"-f", "image2pipe", // format: image data from a pipe (not files on disk)
|
||||
"-framerate", framesPerSecond, // input isn't a video so we must specify the framerate
|
||||
"-vcodec", "bmp", // codec: each image from the pipe is a bitmap image
|
||||
"-i", "-", // read from stdin
|
||||
|
||||
// Output args:
|
||||
"-c:v", "libx264", // codec: encode video w/ libx264
|
||||
"-r", framesPerSecond,
|
||||
"-preset", "medium",
|
||||
"-tune", "stillimage",
|
||||
"-crf", "20", // screen content compresses well, so a lower CRF is crisp for little size
|
||||
"-pix_fmt", "yuv420p", // use a commonly-supported pixel format
|
||||
"-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2", // H264 requires even dimensions, pad the output if necessary
|
||||
"-movflags", "+faststart",
|
||||
outputFile, // output file
|
||||
)
|
||||
buf := new(bytes.Buffer)
|
||||
cmd.Stderr = buf
|
||||
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, trace.BadParameter("Could not start ffmpeg: confirm ffmpeg installation or use --encoder=avi to use the built-in AVI encoder")
|
||||
}
|
||||
|
||||
return &FFMPEGEncoder{
|
||||
cmd: cmd,
|
||||
stdin: stdin,
|
||||
stderr: buf,
|
||||
outputFile: outputFile,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// FFMPEGEncoder encodes video with ffmpeg. ffmpeg must be installed
|
||||
// on the system.
|
||||
type FFMPEGEncoder struct {
|
||||
cmd *exec.Cmd
|
||||
stdin io.WriteCloser
|
||||
|
||||
stderr *bytes.Buffer
|
||||
outputFile string
|
||||
|
||||
// frameBuf holds the most recently encoded frame. It is reused across EmitFrames calls to avoid reallocating a
|
||||
// full-frame buffer for every event.
|
||||
frameBuf bytes.Buffer
|
||||
}
|
||||
|
||||
// OutputFiles returns the path of the single MP4 file written by ffmpeg.
|
||||
func (f *FFMPEGEncoder) OutputFiles() []string {
|
||||
return []string{f.outputFile}
|
||||
}
|
||||
|
||||
func (f *FFMPEGEncoder) EmitFrames(img image.Image, count int) error {
|
||||
if img == nil {
|
||||
return trace.BadParameter("no frame to export")
|
||||
}
|
||||
|
||||
// A single event after an idle stretch can emit hundreds of identical frames (RDP sends nothing while the screen is
|
||||
// static).
|
||||
// Encode the bitmap once and write the same bytes for each frame rather than re-encoding the image every time.
|
||||
f.frameBuf.Reset()
|
||||
|
||||
// We use bitmaps instead of something like PNG because it's essentially free to encode.
|
||||
// The bitmap encoding here just puts a header in front of the existing pixel data.
|
||||
if err := bmp.Encode(&f.frameBuf, img); err != nil {
|
||||
// Close the input pipe. ffmpeg will finish any in-progress encoding and terminate.
|
||||
f.Close()
|
||||
return trace.Wrap(err, "encoding bitmap frame")
|
||||
}
|
||||
|
||||
frame := f.frameBuf.Bytes()
|
||||
for range count {
|
||||
if _, err := f.stdin.Write(frame); err != nil {
|
||||
f.Close()
|
||||
return trace.Wrap(err, "writing bitmap frame")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *FFMPEGEncoder) Close() error {
|
||||
if f.stdin != nil {
|
||||
f.stdin.Close()
|
||||
}
|
||||
var err error
|
||||
if f.cmd != nil {
|
||||
err = f.cmd.Wait()
|
||||
}
|
||||
|
||||
f.stdin = nil
|
||||
f.cmd = nil
|
||||
|
||||
if err != nil {
|
||||
return trace.Errorf("ffmpeg failed: %s", f.stderr.String())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
+251
-162
@@ -21,24 +21,26 @@ package common
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/draw"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/gravitational/trace"
|
||||
"github.com/icza/mjpeg"
|
||||
|
||||
tdpbv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/desktop/v1"
|
||||
apievents "github.com/gravitational/teleport/api/types/events"
|
||||
"github.com/gravitational/teleport/lib/events"
|
||||
"github.com/gravitational/teleport/lib/session"
|
||||
"github.com/gravitational/teleport/lib/srv/desktop/tdp"
|
||||
"github.com/gravitational/teleport/lib/srv/desktop/tdp/protocol/legacy"
|
||||
logutils "github.com/gravitational/teleport/lib/utils/log"
|
||||
"github.com/gravitational/teleport/lib/srv/desktop/tdp/protocol/tdpb"
|
||||
"github.com/gravitational/teleport/lib/utils"
|
||||
"github.com/gravitational/teleport/lib/utils/progressbar"
|
||||
"github.com/gravitational/teleport/tool/tsh/common/internal/recordingexport"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -61,139 +63,188 @@ func onExportRecording(cf *CLIConf) error {
|
||||
filenamePrefix := cf.SessionID
|
||||
if cf.OutFile != "" {
|
||||
// trim the extension if it was provided (we'll add it later on)
|
||||
filenamePrefix = strings.TrimSuffix(
|
||||
strings.TrimSuffix(cf.OutFile, ".avi"), ".AVI")
|
||||
filenamePrefix = strings.TrimSuffix(strings.TrimSuffix(cf.OutFile, ".avi"), ".AVI")
|
||||
filenamePrefix = strings.TrimSuffix(strings.TrimSuffix(filenamePrefix, ".mp4"), ".MP4")
|
||||
}
|
||||
|
||||
_, err = writeMovie(cf.Context, clusterClient.AuthClient, session.ID(cf.SessionID), filenamePrefix, fmt.Printf, tc.Config.WebProxyAddr)
|
||||
return trace.Wrap(err)
|
||||
}
|
||||
|
||||
func makeAVIFileName(prefix string, currentFile int) string {
|
||||
if currentFile == 0 {
|
||||
return prefix + ".avi"
|
||||
exporter := &desktopRecordingExporter{
|
||||
ss: clusterClient.AuthClient,
|
||||
sid: session.ID(cf.SessionID),
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%v-%d.avi", prefix, currentFile)
|
||||
meta, err := exporter.getSessionMetadata(cf.Context)
|
||||
if err != nil {
|
||||
return trace.Wrap(err)
|
||||
}
|
||||
|
||||
// Set up the encoder which writes the output video.
|
||||
// Use ffmpeg if it's installed on the system, otherwise fall back to a Go-based AVI encoder.
|
||||
var encoder videoEncoder
|
||||
|
||||
if cf.Format == "auto" {
|
||||
// check for ffmpeg
|
||||
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "WARNING: ffmpeg is not installed, falling back to legacy AVI encoder.")
|
||||
cf.Format = "avi"
|
||||
} else {
|
||||
cf.Format = "ffmpeg"
|
||||
}
|
||||
}
|
||||
switch cf.Format {
|
||||
case "ffmpeg":
|
||||
encoder, err = recordingexport.NewFFMPEGEncoder(filenamePrefix, framesPerSecond)
|
||||
if err != nil {
|
||||
return trace.Wrap(err, "creating ffmpeg encoder")
|
||||
}
|
||||
case "avi":
|
||||
encoder = recordingexport.NewAVIEncoder(filenamePrefix, int32(meta.maxWidth), int32(meta.maxHeight), framesPerSecond)
|
||||
}
|
||||
defer func() {
|
||||
if err := encoder.Close(); err != nil {
|
||||
logger.WarnContext(cf.Context, "failed to close encoder", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Set up the decoder to decode session data. It's most likely a RemoteFX session recording,
|
||||
// but very old recordings might require a PNG decoder.
|
||||
var decoder imageDecoder
|
||||
if meta.isRemoteFX {
|
||||
decoder = recordingexport.NewRemoteFXDecoder()
|
||||
} else {
|
||||
decoder = recordingexport.NewPNGDecoder(int(meta.maxWidth), int(meta.maxHeight))
|
||||
}
|
||||
|
||||
if !cf.Quiet && utils.IsTerminal(os.Stderr) {
|
||||
exporter.progress = progressbar.New(int64(meta.totalEvents), "Exporting recording", os.Stderr)
|
||||
}
|
||||
|
||||
// Encode the video.
|
||||
if _, err := exporter.run(cf.Context, meta, decoder, encoder); err != nil {
|
||||
return trace.Wrap(err)
|
||||
}
|
||||
|
||||
reportOutputFiles(cf.Stdout(), encoder.OutputFiles())
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeMovie writes the events for the specified session into one or more movie files
|
||||
// beginning with the specified prefix. It returns the number of frames that were written and an error.
|
||||
func writeMovie(ctx context.Context, ss events.SessionStreamer, sid session.ID, prefix string, write func(format string, args ...any) (int, error), webProxyAddr string) (frames int, err error) {
|
||||
// reportOutputFiles tells the user which files the recording was written to.
|
||||
// A single recording may be split across multiple files when the AVI encoder
|
||||
// is used, so all of them are listed.
|
||||
func reportOutputFiles(w io.Writer, files []string) {
|
||||
switch len(files) {
|
||||
case 0:
|
||||
fmt.Fprintln(w, "No video was written (the recording contained no screen data).")
|
||||
case 1:
|
||||
fmt.Fprintf(w, "Wrote recording to %s\n", files[0])
|
||||
default:
|
||||
fmt.Fprintf(w, "Wrote recording to %d files:\n", len(files))
|
||||
for _, f := range files {
|
||||
fmt.Fprintf(w, " %s\n", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type desktopRecordingExporter struct {
|
||||
ss events.SessionStreamer
|
||||
sid session.ID
|
||||
|
||||
// progress, if set, renders a progress bar as events are processed.
|
||||
// It is nil when the output is not a terminal.
|
||||
progress *progressbar.Bar
|
||||
}
|
||||
|
||||
type recordingMetadata struct {
|
||||
totalEvents uint32
|
||||
maxWidth uint32
|
||||
maxHeight uint32
|
||||
isRemoteFX bool
|
||||
}
|
||||
|
||||
// videoEncoder in an abstraction over the code that produces a video file
|
||||
// based on the contents of a desktop session recording.
|
||||
type videoEncoder interface {
|
||||
EmitFrames(img image.Image, count int) error
|
||||
// OutputFiles returns the paths of the video files written by the encoder,
|
||||
// in the order they were written. A single recording may be split across
|
||||
// multiple files. It should be called after Close so that all files are accounted for.
|
||||
OutputFiles() []string
|
||||
// Close releases any resources held by the encoder.
|
||||
// Must be idempotent.
|
||||
Close() error
|
||||
}
|
||||
|
||||
// imageDecoder decodes image data from a desktop session recording
|
||||
type imageDecoder interface {
|
||||
UpdateScreen(evt *apievents.DesktopRecording) (screenData bool, err error)
|
||||
Image() image.Image
|
||||
Close() error
|
||||
}
|
||||
|
||||
func (d *desktopRecordingExporter) run(
|
||||
ctx context.Context,
|
||||
meta *recordingMetadata,
|
||||
decoder imageDecoder,
|
||||
encoder videoEncoder,
|
||||
) (frames int, err error) {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
var screen *image.NRGBA
|
||||
var movie mjpeg.AviWriter
|
||||
|
||||
var frameCount int
|
||||
var gotScreenData bool
|
||||
lastEmitted := int64(-1)
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
var frameCount, fileCount int
|
||||
var width, height int32
|
||||
currentFilename := makeAVIFileName(prefix, fileCount)
|
||||
// canvas holds a max-size frame that smaller frames are composited onto, so the encoder always
|
||||
// receives a constant frame size even when the recording changes resolution. Created lazily.
|
||||
var canvas *image.RGBA
|
||||
|
||||
evts, errs := ss.StreamSessionEvents(ctx, sid, 0)
|
||||
fastPathReceived := false
|
||||
evts, errs := d.ss.StreamSessionEvents(ctx, d.sid, 0)
|
||||
loop:
|
||||
for {
|
||||
select {
|
||||
case err := <-errs:
|
||||
if movie != nil {
|
||||
if err := movie.Close(); err == nil && write != nil && frames > 0 {
|
||||
write("wrote %v\n", currentFilename)
|
||||
}
|
||||
if err := encoder.Close(); err != nil {
|
||||
logger.WarnContext(ctx, "failed to close encoder", "error", err)
|
||||
}
|
||||
return frameCount, trace.Wrap(err)
|
||||
case <-ctx.Done():
|
||||
if movie != nil {
|
||||
if err := movie.Close(); err == nil && write != nil && frames > 0 {
|
||||
write("wrote %v\n", currentFilename)
|
||||
}
|
||||
if err := encoder.Close(); err != nil {
|
||||
logger.WarnContext(ctx, "failed to close encoder", "error", err)
|
||||
}
|
||||
return frameCount, ctx.Err()
|
||||
return frameCount, trace.Wrap(ctx.Err())
|
||||
case evt, more := <-evts:
|
||||
if !more {
|
||||
logger.WarnContext(ctx, "reached end of stream before seeing session end event")
|
||||
break loop
|
||||
}
|
||||
|
||||
d.progress.Add(1)
|
||||
|
||||
switch evt := evt.(type) {
|
||||
case *apievents.WindowsDesktopSessionStart:
|
||||
case *apievents.WindowsDesktopSessionEnd:
|
||||
if !fastPathReceived {
|
||||
break loop
|
||||
}
|
||||
if movie != nil {
|
||||
movie.Close()
|
||||
os.Remove(currentFilename)
|
||||
}
|
||||
url := fmt.Sprintf("https://%s/web/cluster/%s/session/%s?recordingType=desktop&durationMs=%d",
|
||||
webProxyAddr,
|
||||
evt.ClusterName,
|
||||
evt.SessionID,
|
||||
evt.EndTime.Sub(evt.StartTime).Milliseconds())
|
||||
return frameCount, trace.BadParameter("this session can't be exported, please visit %s to view it", url)
|
||||
case *apievents.SessionStart:
|
||||
return frameCount, trace.BadParameter("only desktop recordings can be exported")
|
||||
break loop
|
||||
|
||||
case *apievents.DesktopRecording:
|
||||
msg, err := legacy.Decode(bytes.NewBuffer(evt.Message))
|
||||
screenData, err := decoder.UpdateScreen(evt)
|
||||
if err != nil {
|
||||
logger.WarnContext(ctx, "failed to decode desktop recording message", "error", err)
|
||||
break loop
|
||||
return frameCount, trace.Wrap(err)
|
||||
}
|
||||
if screenData {
|
||||
gotScreenData = true
|
||||
}
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case legacy.RDPFastPathPDU:
|
||||
fastPathReceived = true
|
||||
case legacy.ClientScreenSpec:
|
||||
if screen != nil {
|
||||
return frameCount, trace.BadParameter("invalid recording: received multiple screen specs")
|
||||
}
|
||||
// Use the dimensions in the ClientScreenSpec to allocate
|
||||
// our virtual canvas and video file.
|
||||
// Note: this works because we don't currently support resizing
|
||||
// the window during a session. If this changes, we'd have to
|
||||
// find the maximum window size first.
|
||||
logger.DebugContext(ctx, "allocating screen size", "width", msg.Width, "height", msg.Height)
|
||||
width, height = int32(msg.Width), int32(msg.Height)
|
||||
screen = image.NewNRGBA(image.Rectangle{
|
||||
Min: image.Pt(0, 0),
|
||||
Max: image.Pt(int(msg.Width), int(msg.Height)),
|
||||
})
|
||||
|
||||
movie, err = mjpeg.New(currentFilename, width, height, framesPerSecond)
|
||||
if err != nil {
|
||||
return frameCount, trace.Wrap(err)
|
||||
}
|
||||
|
||||
case legacy.PNGFrame, legacy.PNG2Frame:
|
||||
if screen == nil {
|
||||
return frameCount, trace.BadParameter("this session is missing required start metadata")
|
||||
}
|
||||
|
||||
fragment, err := imgFromPNGMessage(msg)
|
||||
if err != nil {
|
||||
return frameCount, trace.WrapWithMessage(err, "couldn't decode PNG")
|
||||
}
|
||||
|
||||
// draw the fragment from this message on the screen
|
||||
draw.Draw(
|
||||
screen,
|
||||
rectFromPNGMessage(msg),
|
||||
fragment,
|
||||
fragment.Bounds().Min,
|
||||
draw.Src,
|
||||
)
|
||||
}
|
||||
|
||||
// if it's the very first bitmap, mark the time and continue
|
||||
// if it's the very first screen fragment, mark the time and continue
|
||||
// (no need to emit a frame yet)
|
||||
if lastEmitted == -1 {
|
||||
lastEmitted = evt.DelayMilliseconds
|
||||
continue loop
|
||||
}
|
||||
|
||||
// If we haven't received any image data yet there's nothing to emit.
|
||||
if !gotScreenData {
|
||||
continue loop
|
||||
}
|
||||
|
||||
// emit a frame if there's been enough of a time lapse between last event
|
||||
delta := evt.DelayMilliseconds - lastEmitted
|
||||
framesToEmit := int64(float64(delta) / frameDelayMillis)
|
||||
@@ -202,84 +253,122 @@ loop:
|
||||
"last_event_ms", delta,
|
||||
"frames_to_emit", framesToEmit,
|
||||
)
|
||||
buf.Reset()
|
||||
if err := jpeg.Encode(buf, screen, nil); err != nil {
|
||||
var frame image.Image
|
||||
frame, canvas = fitFrame(decoder.Image(), int(meta.maxWidth), int(meta.maxHeight), canvas)
|
||||
if err := encoder.EmitFrames(frame, int(framesToEmit)); err != nil {
|
||||
return frameCount, trace.Wrap(err)
|
||||
}
|
||||
for range int(framesToEmit) {
|
||||
err := movie.AddFrame(buf.Bytes())
|
||||
if errors.Is(err, mjpeg.ErrTooLarge) {
|
||||
// this file can't get any larger - time to open a new file
|
||||
if err := movie.Close(); err != nil {
|
||||
return frameCount, trace.WrapWithMessage(err, "failed to write partial recording")
|
||||
}
|
||||
if write != nil {
|
||||
write("wrote %v\n", currentFilename)
|
||||
}
|
||||
fileCount++
|
||||
currentFilename = makeAVIFileName(prefix, fileCount)
|
||||
movie, err = mjpeg.New(currentFilename, width, height, framesPerSecond)
|
||||
if err != nil {
|
||||
return frameCount, trace.Wrap(err)
|
||||
}
|
||||
|
||||
// write the frame to the new file
|
||||
if err := movie.AddFrame(buf.Bytes()); err != nil {
|
||||
return frameCount, trace.Wrap(err)
|
||||
}
|
||||
} else if err != nil {
|
||||
return frameCount, trace.Wrap(err)
|
||||
}
|
||||
frameCount++
|
||||
}
|
||||
lastEmitted = evt.DelayMilliseconds
|
||||
frameCount += int(framesToEmit)
|
||||
}
|
||||
|
||||
default:
|
||||
logger.DebugContext(ctx, "got unexpected audit event", "event", logutils.TypeAttr(evt))
|
||||
lastEmitted = evt.DelayMilliseconds
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// if we received a session start event but the context is canceled
|
||||
// before we received the screen dimensions, then there's no movie to close
|
||||
if movie == nil {
|
||||
return 0, ctx.Err()
|
||||
if err := encoder.Close(); err != nil {
|
||||
return frameCount, trace.Wrap(err, "closing encoder")
|
||||
}
|
||||
|
||||
err = movie.Close()
|
||||
if err == nil && write != nil {
|
||||
write("wrote %v\n", currentFilename)
|
||||
}
|
||||
d.progress.Finish()
|
||||
|
||||
return frameCount, trace.Wrap(err)
|
||||
return frameCount, nil
|
||||
}
|
||||
|
||||
func imgFromPNGMessage(msg tdp.Message) (image.Image, error) {
|
||||
switch msg := msg.(type) {
|
||||
case legacy.PNG2Frame:
|
||||
return png.Decode(bytes.NewReader(msg.Data()))
|
||||
case legacy.PNGFrame:
|
||||
return msg.Img, nil
|
||||
// getSessionMetadata pre-processes the session recording in order to identify
|
||||
// metadata necessary for the export.
|
||||
func (d *desktopRecordingExporter) getSessionMetadata(ctx context.Context) (*recordingMetadata, error) {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
var remoteFX bool
|
||||
total, width, height := uint32(0), uint32(0), uint32(0)
|
||||
|
||||
evts, errs := d.ss.StreamSessionEvents(ctx, d.sid, 0)
|
||||
loop:
|
||||
for {
|
||||
select {
|
||||
case err := <-errs:
|
||||
return nil, trace.Wrap(err, "failed to process session")
|
||||
case <-ctx.Done():
|
||||
return nil, trace.Wrap(ctx.Err())
|
||||
case evt, ok := <-evts:
|
||||
if !ok {
|
||||
break loop
|
||||
}
|
||||
|
||||
switch evt.(type) {
|
||||
case *apievents.SessionStart,
|
||||
*apievents.AppSessionStart,
|
||||
*apievents.MCPSessionStart,
|
||||
*apievents.DatabaseSessionStart:
|
||||
return nil, trace.BadParameter("only desktop recordings can be exported")
|
||||
}
|
||||
|
||||
total++
|
||||
dr, ok := evt.(*apievents.DesktopRecording)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
msg, err := decodeRecordingMessage(dr)
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err, "recording includes invalid data")
|
||||
}
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case legacy.ClientScreenSpec:
|
||||
width = max(width, msg.Width)
|
||||
height = max(height, msg.Height)
|
||||
case legacy.ConnectionActivated:
|
||||
width = max(width, uint32(msg.ScreenWidth))
|
||||
height = max(height, uint32(msg.ScreenHeight))
|
||||
remoteFX = true
|
||||
case legacy.RDPFastPathPDU:
|
||||
remoteFX = true
|
||||
case *tdpb.ServerHello:
|
||||
if spec := (*tdpbv1.ServerHello)(msg).GetActivationSpec(); spec != nil {
|
||||
width = max(width, spec.GetScreenWidth())
|
||||
height = max(height, spec.GetScreenHeight())
|
||||
}
|
||||
remoteFX = true
|
||||
case *tdpb.FastPathPDU:
|
||||
remoteFX = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return &recordingMetadata{totalEvents: total, maxWidth: width, maxHeight: height, isRemoteFX: remoteFX}, nil
|
||||
}
|
||||
|
||||
func decodeRecordingMessage(dr *apievents.DesktopRecording) (tdp.Message, error) {
|
||||
switch {
|
||||
case len(dr.TDPBMessage) > 0:
|
||||
return tdpb.DecodeWithTDPDiscard(dr.TDPBMessage)
|
||||
case len(dr.Message) > 0:
|
||||
return legacy.Decode(bytes.NewReader(dr.Message))
|
||||
default:
|
||||
// this should never happen based on what we pass at the call site
|
||||
return nil, trace.BadParameter("unsupported TDP message %T", msg)
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func rectFromPNGMessage(msg tdp.Message) image.Rectangle {
|
||||
switch msg := msg.(type) {
|
||||
case legacy.PNG2Frame:
|
||||
return image.Rect(
|
||||
// add one to bottom and right dimension, as RDP
|
||||
// bounds are inclusive
|
||||
int(msg.Left()), int(msg.Top()),
|
||||
int(msg.Right()+1), int(msg.Bottom()+1),
|
||||
)
|
||||
case legacy.PNGFrame:
|
||||
return msg.Img.Bounds()
|
||||
default:
|
||||
// this should never happen based on what we pass at the call site
|
||||
return image.Rectangle{}
|
||||
// fitFrame returns img unchanged when it already matches the recording's max dimensions.
|
||||
// Otherwise, it composites img at the top-left of a reused max-size canvas (clearing the rest to black) and
|
||||
// returns the canvas, so the encoder always receives a constant frame size.
|
||||
func fitFrame(img image.Image, maxWidth, maxHeight int, canvas *image.RGBA) (image.Image, *image.RGBA) {
|
||||
if img == nil || maxWidth == 0 || maxHeight == 0 {
|
||||
return img, canvas
|
||||
}
|
||||
|
||||
b := img.Bounds()
|
||||
if b.Dx() == maxWidth && b.Dy() == maxHeight {
|
||||
return img, canvas
|
||||
}
|
||||
|
||||
if canvas == nil {
|
||||
canvas = image.NewRGBA(image.Rect(0, 0, maxWidth, maxHeight))
|
||||
} else {
|
||||
clear(canvas.Pix)
|
||||
}
|
||||
|
||||
draw.Draw(canvas, b, img, b.Min, draw.Src)
|
||||
|
||||
return canvas, canvas
|
||||
}
|
||||
|
||||
@@ -19,23 +19,26 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"fmt"
|
||||
"image"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gravitational/trace"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
apievents "github.com/gravitational/teleport/api/types/events"
|
||||
"github.com/gravitational/teleport/lib/events"
|
||||
"github.com/gravitational/teleport/lib/events/eventstest"
|
||||
"github.com/gravitational/teleport/lib/session"
|
||||
"github.com/gravitational/teleport/lib/srv/desktop/rdpstate/rdpstatetest"
|
||||
"github.com/gravitational/teleport/lib/srv/desktop/tdp"
|
||||
"github.com/gravitational/teleport/lib/srv/desktop/tdp/protocol/legacy"
|
||||
"github.com/gravitational/teleport/tool/tsh/common/internal/recordingexport"
|
||||
)
|
||||
|
||||
var pngFrame []byte
|
||||
@@ -49,55 +52,57 @@ func init() {
|
||||
binary.BigEndian.PutUint32(pngFrame[17:], 64) // bottom
|
||||
}
|
||||
|
||||
func TestWriteMovieCanBeCanceled(t *testing.T) {
|
||||
func TestUnsupportedRecordings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, startEvent := range []apievents.AuditEvent{
|
||||
new(apievents.SessionStart),
|
||||
new(apievents.AppSessionStart),
|
||||
new(apievents.MCPSessionStart),
|
||||
new(apievents.DatabaseSessionStart),
|
||||
} {
|
||||
t.Run(fmt.Sprintf("%T", startEvent), func(t *testing.T) {
|
||||
events := []apievents.AuditEvent{startEvent}
|
||||
exporter := &desktopRecordingExporter{
|
||||
ss: eventstest.NewFakeStreamer(events, 0),
|
||||
sid: session.NewID(),
|
||||
}
|
||||
_, err := exporter.getSessionMetadata(t.Context())
|
||||
require.Error(t, err)
|
||||
require.True(t, trace.IsBadParameter(err), "expected bad paramater error, got %v", err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSessionMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
events := []apievents.AuditEvent{
|
||||
&apievents.WindowsDesktopSessionStart{},
|
||||
}
|
||||
fs := eventstest.NewFakeStreamer(events, 3*time.Second)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
frames, _, err := writeMovieWrapper(t, ctx, fs, "test", "test", nil)
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
require.Equal(t, 0, frames)
|
||||
}
|
||||
|
||||
func TestWriteMovieDoesNotSupportSSH(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
events := []apievents.AuditEvent{
|
||||
&apievents.SessionStart{},
|
||||
}
|
||||
fs := eventstest.NewFakeStreamer(events, 0)
|
||||
|
||||
frames, _, err := writeMovieWrapper(t, context.Background(), fs, "test", "test", nil)
|
||||
require.True(t, trace.IsBadParameter(err), "expected bad paramater error, got %v", err)
|
||||
require.Equal(t, 0, frames)
|
||||
}
|
||||
|
||||
// TestWriteMovieMultipleScreenSpecs verifies that the export fails when a session recording
|
||||
// contains multiple TDP screen spec messages.
|
||||
//
|
||||
// At the time of this implementation, desktop access does not support resizing during the
|
||||
// screen during a session. This test exists to prevent regressions should that behavior change.
|
||||
func TestWriteMovieMultipleScreenSpecs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
events := []apievents.AuditEvent{
|
||||
tdpEvent(t, legacy.ClientScreenSpec{Width: 1920, Height: 1080}),
|
||||
tdpEvent(t, legacy.ClientScreenSpec{Width: 1024, Height: 768}),
|
||||
tdpEvent(t, legacy.PNG2Frame(pngFrame)),
|
||||
tdpEvent(t, legacy.PNG2Frame(pngFrame)),
|
||||
tdpEvent(t, legacy.ClientScreenSpec{Width: 1920, Height: 1080}),
|
||||
tdpEvent(t, legacy.PNG2Frame(pngFrame)),
|
||||
&apievents.WindowsDesktopSessionEnd{},
|
||||
}
|
||||
|
||||
fs := eventstest.NewFakeStreamer(events, 0)
|
||||
frames, _, err := writeMovieWrapper(t, context.Background(), fs, session.ID("test"), "test", nil)
|
||||
require.True(t, trace.IsBadParameter(err), "expected bad paramater error, got %v", err)
|
||||
require.Equal(t, 0, frames)
|
||||
exporter := &desktopRecordingExporter{
|
||||
ss: eventstest.NewFakeStreamer(events, 0),
|
||||
sid: session.NewID(),
|
||||
}
|
||||
|
||||
meta, err := exporter.getSessionMetadata(t.Context())
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, meta)
|
||||
|
||||
assert.False(t, meta.isRemoteFX)
|
||||
assert.EqualValues(t, 1920, meta.maxWidth)
|
||||
assert.EqualValues(t, 1080, meta.maxHeight)
|
||||
assert.EqualValues(t, len(events), meta.totalEvents)
|
||||
}
|
||||
|
||||
func TestWriteMovieWritesOneFrame(t *testing.T) {
|
||||
func TestWritesOneFrame(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
oneFrame := frameDelayMillis
|
||||
@@ -107,13 +112,43 @@ func TestWriteMovieWritesOneFrame(t *testing.T) {
|
||||
tdpEventMillis(t, legacy.PNG2Frame(pngFrame), 0),
|
||||
tdpEventMillis(t, legacy.PNG2Frame(pngFrame), int64(oneFrame)+1),
|
||||
}
|
||||
fs := eventstest.NewFakeStreamer(events, 0)
|
||||
frames, _, err := writeMovieWrapper(t, context.Background(), fs, session.ID("test"), "test", nil)
|
||||
|
||||
exporter := &desktopRecordingExporter{
|
||||
ss: eventstest.NewFakeStreamer(events, 0),
|
||||
sid: session.NewID(),
|
||||
}
|
||||
|
||||
frames, err := exporter.run(
|
||||
t.Context(),
|
||||
&recordingMetadata{},
|
||||
recordingexport.NewPNGDecoder(128, 128),
|
||||
nopEncoder{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, frames)
|
||||
}
|
||||
|
||||
func TestWriteMovieWritesManyFrames(t *testing.T) {
|
||||
func TestCancel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
events := []apievents.AuditEvent{&apievents.WindowsDesktopSessionStart{}}
|
||||
exporter := &desktopRecordingExporter{
|
||||
ss: eventstest.NewFakeStreamer(events, 3*time.Second),
|
||||
sid: session.NewID(),
|
||||
}
|
||||
|
||||
frames, err := exporter.run(
|
||||
ctx,
|
||||
&recordingMetadata{},
|
||||
recordingexport.NewPNGDecoder(128, 128),
|
||||
nopEncoder{})
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
require.Equal(t, 0, frames)
|
||||
}
|
||||
|
||||
func TestWritesManyFrames(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
events := []apievents.AuditEvent{
|
||||
@@ -123,22 +158,105 @@ func TestWriteMovieWritesManyFrames(t *testing.T) {
|
||||
// the final frame is just over 1s after the first frame
|
||||
tdpEventMillis(t, legacy.PNG2Frame(pngFrame), 1001),
|
||||
}
|
||||
fs := eventstest.NewFakeStreamer(events, 0)
|
||||
t.Cleanup(func() { os.RemoveAll("test.avi") })
|
||||
frames, _, err := writeMovieWrapper(t, context.Background(), fs, session.ID("test"), "test", nil)
|
||||
|
||||
exporter := &desktopRecordingExporter{
|
||||
ss: eventstest.NewFakeStreamer(events, 0),
|
||||
sid: session.NewID(),
|
||||
}
|
||||
|
||||
frames, err := exporter.run(
|
||||
t.Context(),
|
||||
&recordingMetadata{},
|
||||
recordingexport.NewPNGDecoder(128, 128),
|
||||
nopEncoder{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, framesPerSecond, frames)
|
||||
}
|
||||
|
||||
// writeMovieWrapper calls writeMovie, and tells the test state to cleanup the created files upon completion.
|
||||
// Returns the writeMovie call results, as well as the path-qualified prefix to the created file.
|
||||
func writeMovieWrapper(t *testing.T, ctx context.Context, ss events.SessionStreamer, sid session.ID, prefix string,
|
||||
write func(format string, args ...any) (int, error)) (int, string, error) {
|
||||
func TestGetSessionMetadataRemoteFX(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
prefix = filepath.Join(tempDir, prefix)
|
||||
frames, err := writeMovie(ctx, ss, sid, prefix, write, "")
|
||||
return frames, prefix, err
|
||||
legacyActivated, err := rdpstatetest.LegacyConnectionActivated(1280, 720)
|
||||
require.NoError(t, err)
|
||||
tdpbHello, err := rdpstatetest.EncodeTDPBServerHello(1600, 900)
|
||||
require.NoError(t, err)
|
||||
tdpbFrame, err := rdpstatetest.EncodeTDPBFastPathPDU([]byte{0x00})
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
events []apievents.AuditEvent
|
||||
width, height uint32
|
||||
}{
|
||||
{
|
||||
name: "legacy",
|
||||
events: []apievents.AuditEvent{
|
||||
&apievents.WindowsDesktopSessionStart{},
|
||||
legacyActivated,
|
||||
tdpEvent(t, legacy.RDPFastPathPDU([]byte{0x00})),
|
||||
&apievents.WindowsDesktopSessionEnd{},
|
||||
},
|
||||
width: 1280,
|
||||
height: 720,
|
||||
},
|
||||
{
|
||||
name: "tdpb",
|
||||
events: []apievents.AuditEvent{
|
||||
&apievents.WindowsDesktopSessionStart{},
|
||||
tdpbHello,
|
||||
tdpbFrame,
|
||||
&apievents.WindowsDesktopSessionEnd{},
|
||||
},
|
||||
width: 1600,
|
||||
height: 900,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
exporter := &desktopRecordingExporter{
|
||||
ss: eventstest.NewFakeStreamer(tc.events, 0),
|
||||
sid: session.NewID(),
|
||||
}
|
||||
|
||||
meta, err := exporter.getSessionMetadata(t.Context())
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, meta)
|
||||
|
||||
assert.True(t, meta.isRemoteFX)
|
||||
assert.EqualValues(t, tc.width, meta.maxWidth)
|
||||
assert.EqualValues(t, tc.height, meta.maxHeight)
|
||||
assert.EqualValues(t, len(tc.events), meta.totalEvents)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportEmitsConsistentFrameSize(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
events := []apievents.AuditEvent{
|
||||
tdpEventMillis(t, legacy.RDPFastPathPDU([]byte{0}), 0),
|
||||
tdpEventMillis(t, legacy.RDPFastPathPDU([]byte{0}), 50),
|
||||
tdpEventMillis(t, legacy.RDPFastPathPDU([]byte{0}), 100),
|
||||
}
|
||||
|
||||
dec := &resizingDecoder{sizes: []image.Rectangle{
|
||||
image.Rect(0, 0, 64, 64),
|
||||
image.Rect(0, 0, 64, 64),
|
||||
image.Rect(0, 0, 128, 128),
|
||||
}}
|
||||
enc := &sizeCapturingEncoder{}
|
||||
|
||||
exporter := &desktopRecordingExporter{
|
||||
ss: eventstest.NewFakeStreamer(events, 0),
|
||||
sid: session.NewID(),
|
||||
}
|
||||
|
||||
_, err := exporter.run(t.Context(), &recordingMetadata{maxWidth: 128, maxHeight: 128}, dec, enc)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, enc.sizes)
|
||||
|
||||
for _, s := range enc.sizes {
|
||||
assert.Equal(t, image.Pt(128, 128), s, "every emitted frame must be the recording's max size")
|
||||
}
|
||||
}
|
||||
|
||||
func tdpEvent(t *testing.T, msg tdp.Message) *apievents.DesktopRecording {
|
||||
@@ -157,3 +275,83 @@ func tdpEventMillis(t *testing.T, msg tdp.Message, millis int64) *apievents.Desk
|
||||
evt.DelayMilliseconds = millis
|
||||
return evt
|
||||
}
|
||||
|
||||
type nopEncoder struct{}
|
||||
|
||||
func (nopEncoder) EmitFrames(image.Image, int) error { return nil }
|
||||
func (nopEncoder) OutputFiles() []string { return nil }
|
||||
func (nopEncoder) Close() error { return nil }
|
||||
|
||||
func TestReportOutputFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
files []string
|
||||
contains []string
|
||||
}{
|
||||
{
|
||||
name: "no files",
|
||||
files: nil,
|
||||
contains: []string{"No video was written"},
|
||||
},
|
||||
{
|
||||
name: "single file",
|
||||
files: []string{"session.avi"},
|
||||
contains: []string{"Wrote recording to session.avi"},
|
||||
},
|
||||
{
|
||||
name: "multiple files",
|
||||
files: []string{"session.avi", "session-1.avi", "session-2.avi"},
|
||||
contains: []string{"3 files", "session.avi", "session-1.avi", "session-2.avi"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
reportOutputFiles(&buf, test.files)
|
||||
for _, want := range test.contains {
|
||||
require.Contains(t, buf.String(), want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type resizingDecoder struct {
|
||||
sizes []image.Rectangle
|
||||
i int
|
||||
cur image.Rectangle
|
||||
}
|
||||
|
||||
func (d *resizingDecoder) UpdateScreen(*apievents.DesktopRecording) (bool, error) {
|
||||
if d.i < len(d.sizes) {
|
||||
d.cur = d.sizes[d.i]
|
||||
d.i++
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (d *resizingDecoder) Image() image.Image {
|
||||
if d.cur.Empty() {
|
||||
return nil
|
||||
}
|
||||
return image.NewRGBA(d.cur)
|
||||
}
|
||||
|
||||
func (d *resizingDecoder) Close() error { return nil }
|
||||
|
||||
type sizeCapturingEncoder struct {
|
||||
sizes []image.Point
|
||||
}
|
||||
|
||||
func (e *sizeCapturingEncoder) EmitFrames(img image.Image, count int) error {
|
||||
b := img.Bounds()
|
||||
for range count {
|
||||
e.sizes = append(e.sizes, image.Pt(b.Dx(), b.Dy()))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *sizeCapturingEncoder) OutputFiles() []string { return nil }
|
||||
func (e *sizeCapturingEncoder) Close() error { return nil }
|
||||
|
||||
@@ -1150,7 +1150,9 @@ func Run(ctx context.Context, args []string, opts ...CliOption) error {
|
||||
lsRecordings.Flag("limit", fmt.Sprintf("Maximum number of recordings to show. Default %s.", defaults.TshTctlSessionListLimit)).Default(defaults.TshTctlSessionListLimit).IntVar(&cf.maxRecordingsToShow)
|
||||
lsRecordings.Flag("last", "Duration into the past from which session recordings should be listed. Format \"5h30m40s\".").StringVar(&cf.recordingsSince)
|
||||
exportRecordings := recordings.Command("export", "Export recorded desktop sessions to video.")
|
||||
exportRecordings.Flag("encoder", "The video encoder to use.").Default("auto").EnumVar(&cf.Format, "ffmpeg", "avi", "auto")
|
||||
exportRecordings.Flag("out", "Override output file name.").StringVar(&cf.OutFile)
|
||||
exportRecordings.Flag("quiet", quietHelp).Short('q').BoolVar(&cf.Quiet)
|
||||
exportRecordings.Arg("session-id", "ID of the session to export.").Required().StringVar(&cf.SessionID)
|
||||
|
||||
// Local TLS proxy.
|
||||
|
||||
Reference in New Issue
Block a user