diff --git a/docs/reference/manual/hcloud_server.md b/docs/reference/manual/hcloud_server.md index 094020627..c58ec6748 100644 --- a/docs/reference/manual/hcloud_server.md +++ b/docs/reference/manual/hcloud_server.md @@ -56,6 +56,7 @@ Manage Servers * [hcloud server request-console](hcloud_server_request-console.md) - Request a WebSocket VNC console for a Server * [hcloud server reset](hcloud_server_reset.md) - Reset a Server * [hcloud server reset-password](hcloud_server_reset-password.md) - Reset the root password of a Server +* [hcloud server screenshot](hcloud_server_screenshot.md) - [experimental] Take a screenshot of the Server VNC console * [hcloud server set-rdns](hcloud_server_set-rdns.md) - Change reverse DNS of a Server * [hcloud server shutdown](hcloud_server_shutdown.md) - Shutdown a server * [hcloud server ssh](hcloud_server_ssh.md) - Spawn an SSH connection for the Server diff --git a/docs/reference/manual/hcloud_server_screenshot.md b/docs/reference/manual/hcloud_server_screenshot.md new file mode 100644 index 000000000..50c2b0274 --- /dev/null +++ b/docs/reference/manual/hcloud_server_screenshot.md @@ -0,0 +1,44 @@ +## hcloud server screenshot + +[experimental] Take a screenshot of the Server VNC console + +### Synopsis + +Take a screenshot of the Server VNC console + +Experimental: The server screenshot command is experimental, breaking changes may occur within minor releases. + + +``` +hcloud server screenshot [options] +``` + +### Options + +``` + -h, --help help for screenshot + -o, --output string Output file for the screenshot ('-' for stdout; must be a .png file otherwise) (default "screenshot.png") + --overwrite Overwrite the output file if it exists already (true, false) + --timeout duration Timeout for taking the screenshot (0s disables the timeout) (default 10s) + --watch duration Take a screenshot at the given interval (minimum 1s) +``` + +### Options inherited from parent commands + +``` + --config string Config file path (default "~/.config/hcloud/cli.toml") + --context string Currently active context + --debug Enable debug output + --debug-file string File to write debug output to + --endpoint string Hetzner Cloud API endpoint (default "https://api.hetzner.cloud/v1") + --hetzner-endpoint string Hetzner API endpoint (default "https://api.hetzner.com/v1") + --http-timeout duration Timeout for HTTP requests (default 0 = no timeout) + --no-experimental-warnings If true, experimental warnings are not shown + --poll-interval duration Interval at which to poll information, for example action progress (default 500ms) + --quiet If true, only print error messages +``` + +### SEE ALSO + +* [hcloud server](hcloud_server.md) - Manage Servers + diff --git a/go.mod b/go.mod index 5432db9c8..2be57952c 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/hetznercloud/cli -go 1.26.0 +go 1.26.5 toolchain go1.27.2 @@ -20,6 +20,7 @@ require ( github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.12.1 github.com/swaggest/assertjson v1.10.0 + github.com/tenthirtyam/go-vnc v1.0.1 go.uber.org/mock v0.6.0 golang.org/x/crypto v0.57.0 golang.org/x/exp v0.0.0-20260718201538-764159d718ef diff --git a/go.sum b/go.sum index 04237a8b6..a21d3c535 100644 --- a/go.sum +++ b/go.sum @@ -104,6 +104,8 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/swaggest/assertjson v1.10.0 h1:OYIx29UbgqjwAekKbeIO8T0XbAMyViJ8qqrVj2GQY2M= github.com/swaggest/assertjson v1.10.0/go.mod h1:31+ufMpzrCO/daIRZ4s1UZAA1R2q9EEUfJaoczgAty8= +github.com/tenthirtyam/go-vnc v1.0.1 h1:jJBNsPkhnkNR1va5yfFY7v7rVc1C45x4pBMCr0a54oU= +github.com/tenthirtyam/go-vnc v1.0.1/go.mod h1:J009Y0mesUZAmX6vBWBjcI8f7mOBNtQmhs47mman5NU= github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= diff --git a/internal/cli/root.go b/internal/cli/root.go index 2adb7cf1e..78d301162 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -2,6 +2,7 @@ package cli import ( "io" + "log/slog" "github.com/spf13/cobra" @@ -104,6 +105,14 @@ func NewRootCommand(s state.State) *cobra.Command { out = io.Discard } cmd.SetOut(out) + + isDebug, err := config.OptionDebug.Get(s.Config()) + if err != nil { + return err + } + if isDebug { + slog.SetLogLoggerLevel(slog.LevelDebug) + } return nil } return cmd diff --git a/internal/cmd/base/experimental.go b/internal/cmd/base/experimental.go index db0fa9119..5e1d51fbc 100644 --- a/internal/cmd/base/experimental.go +++ b/internal/cmd/base/experimental.go @@ -41,11 +41,13 @@ func ExperimentalWrapper(product, maturity, url string) func(state.State, *cobra } cmd.Short = "[experimental] " + cmd.Short - cmd.Long += fmt.Sprintf(` - -Experimental: %s is %s, breaking changes may occur within minor releases. -See %s for more details. -`, product, maturity, url) + cmd.Long += fmt.Sprintf( + "\n\nExperimental: %s is %s, breaking changes may occur within minor releases.\n", + product, maturity, + ) + if url != "" { + cmd.Long += fmt.Sprintf("See %s for more details.\n", url) + } cmd.PreRunE = util.ChainRunE(cmd.PreRunE, func(cmd *cobra.Command, _ []string) error { hideWarning, err := config.OptionNoExperimentalWarning.Get(s.Config()) diff --git a/internal/cmd/server/screenshot.go b/internal/cmd/server/screenshot.go new file mode 100644 index 000000000..3515365a1 --- /dev/null +++ b/internal/cmd/server/screenshot.go @@ -0,0 +1,190 @@ +package server + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "os/signal" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/hetznercloud/cli/internal/cmd/base" + "github.com/hetznercloud/cli/internal/cmd/cmpl" + "github.com/hetznercloud/cli/internal/cmd/util" + "github.com/hetznercloud/cli/internal/hcapi2" + "github.com/hetznercloud/cli/internal/state" + "github.com/hetznercloud/cli/internal/state/config" +) + +var ScreenshotCmd = base.Cmd{ + BaseCobraCommand: func(client hcapi2.Client) *cobra.Command { + cmd := &cobra.Command{ + Use: "screenshot [options] ", + Short: "Take a screenshot of the Server VNC console", + ValidArgsFunction: cmpl.SuggestArgs(cmpl.SuggestCandidatesF(client.Server().Names)), + TraverseChildren: true, + DisableFlagsInUseLine: true, + } + cmd.Flags().StringP("output", "o", "screenshot.png", "Output file for the screenshot ('-' for stdout; must be a .png file otherwise)") + cmd.Flags().Duration("timeout", 10*time.Second, "Timeout for taking the screenshot (0s disables the timeout)") + cmd.Flags().Bool("overwrite", false, "Overwrite the output file if it exists already (true, false)") + cmd.Flags().Duration("watch", 0, "Take a screenshot at the given interval (minimum 1s)") + + cmd.MarkFlagsMutuallyExclusive("timeout", "watch") + return cmd + }, + Experimental: base.ExperimentalWrapper("The server screenshot command", "experimental", ""), + Run: func(s state.State, cmd *cobra.Command, args []string) error { + idOrName := args[0] + output, _ := cmd.Flags().GetString("output") + timeout, _ := cmd.Flags().GetDuration("timeout") + overwrite, _ := cmd.Flags().GetBool("overwrite") + watch, _ := cmd.Flags().GetDuration("watch") + isWatch := cmd.Flags().Changed("watch") + toStdout := output == "-" + + if isWatch { + if watch < time.Second { + return fmt.Errorf("--watch interval must be at least 1s") + } + if toStdout { + return fmt.Errorf("--watch cannot be used with output to stdout") + } + } + + if toStdout && s.Terminal().StdoutIsTerminal() { + return fmt.Errorf("interactive tty detected. use --output or redirect stdout") + } + + if !toStdout && !overwrite { + firstOutput := output + if isWatch { + firstOutput = util.IndexedFilePath(output, 1) + } + if _, err := os.Stat(firstOutput); err == nil { + return fmt.Errorf("output file %s already exists", firstOutput) + } + } + + server, _, err := s.Client().Server().Get(s, idOrName) + if err != nil { + return err + } + if server == nil { + return fmt.Errorf("Server not found: %s", idOrName) + } + + result, _, err := s.Client().Server().RequestConsole(s, server) + if err != nil { + return err + } + + if err := s.WaitForActions(s, cmd, result.Action); err != nil { + return err + } + + if !toStdout && !strings.HasSuffix(strings.ToLower(output), ".png") { + cmd.Printf("Warning: Only .png output file are supported\n") + } + + isDebug, err := config.OptionDebug.Get(s.Config()) + if err != nil { + return err + } + var debugOut io.Writer + if isDebug { + debugOut = cmd.ErrOrStderr() + } + + if isWatch { + return watchScreenshots(s, cmd, result.WSSURL, output, overwrite, watch, debugOut) + } + + ctx := context.Context(s) + if timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, timeout) + defer cancel() + } + + session, err := util.OpenScreenshotSession(ctx, result.WSSURL, debugOut) + if err != nil { + return err + } + defer session.Close() + + var buf bytes.Buffer + if err := session.Capture(ctx, &buf); err != nil { + return err + } + + if toStdout { + _, err := cmd.OutOrStdout().Write(buf.Bytes()) + return err + } + if err := writeScreenshotFile(output, buf.Bytes(), overwrite); err != nil { + return err + } + cmd.Printf("Screenshot captured and saved to '%s'\n", output) + return nil + }, +} + +func watchScreenshots( + ctx context.Context, cmd *cobra.Command, wsURL, output string, overwrite bool, + interval time.Duration, debugOut io.Writer, +) error { + // capture ctrl + c to stop taking screenshots + ctx, stop := signal.NotifyContext(ctx, os.Interrupt) + defer stop() + + session, err := util.OpenScreenshotSession(ctx, wsURL, debugOut) + if err != nil { + if ctx.Err() != nil { + return nil + } + return err + } + defer session.Close() + + cmd.Printf("Taking a screenshot every %s, press Ctrl+C to stop\n", interval) + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for idx := 1; ; idx++ { + var buf bytes.Buffer + if err := session.Capture(ctx, &buf); err != nil { + if ctx.Err() != nil { + // ctrl + c signal received + return nil + } + return err + } + + path := util.IndexedFilePath(output, idx) + if err := writeScreenshotFile(path, buf.Bytes(), overwrite); err != nil { + return err + } + cmd.Printf("Screenshot captured and saved to '%s'\n", path) + + select { + case <-ctx.Done(): + return nil + case <-ticker.C: + } + } +} + +func writeScreenshotFile(path string, data []byte, overwrite bool) error { + if !overwrite { + if _, err := os.Stat(path); err == nil { + return fmt.Errorf("output file %s already exists", path) + } + } + return os.WriteFile(path, data, 0600) +} diff --git a/internal/cmd/server/server.go b/internal/cmd/server/server.go index 7fdf68eb8..29a23a48c 100644 --- a/internal/cmd/server/server.go +++ b/internal/cmd/server/server.go @@ -73,6 +73,7 @@ func NewCommand(s state.State) *cobra.Command { SSHCmd.CobraCommand(s), IPCmd.CobraCommand(s), RequestConsoleCmd.CobraCommand(s), + ScreenshotCmd.CobraCommand(s), ResetPasswordCmd.CobraCommand(s), MetricsCmd.CobraCommand(s), SetRDNSCmd.CobraCommand(s), diff --git a/internal/cmd/util/screenshot.go b/internal/cmd/util/screenshot.go new file mode 100644 index 000000000..0ccdd5f78 --- /dev/null +++ b/internal/cmd/util/screenshot.go @@ -0,0 +1,162 @@ +package util + +import ( + "bytes" + "context" + "fmt" + "image" + "image/color" + "image/png" + "io" + "log" + "log/slog" + "net/url" + + "github.com/tenthirtyam/go-vnc" + "golang.org/x/net/websocket" +) + +type ScreenshotSession struct { + vncConn *vnc.ClientConn + serverMessages chan vnc.ServerMessage +} + +func OpenScreenshotSession(ctx context.Context, wsURL string, debugOut io.Writer) (*ScreenshotSession, error) { + slog.Debug("dialing websocket") + ws, err := dialWebsocket(ctx, wsURL) + if err != nil { + return nil, err + } + + var logger vnc.Logger = &vnc.NoOpLogger{} + if debugOut != nil { + logger = &vnc.StandardLogger{ + Logger: log.New(debugOut, "VNC", 0), + } + } + + slog.Debug("creating vnc client") + serverMessages := make(chan vnc.ServerMessage, 8) + vncConn, err := vnc.ClientWithOptions(ctx, ws, + // Auth is managed at the HTTP/websocket level,therefore no password is + // needed for the VNC client. + vnc.WithAuth(&vnc.ClientAuthNone{}), + vnc.WithServerMessageChannel(serverMessages), + vnc.WithLogger(logger), + ) + if err != nil { + _ = ws.Close() + return nil, err + } + + return &ScreenshotSession{ + vncConn: vncConn, + serverMessages: serverMessages, + }, nil +} + +func (s *ScreenshotSession) Close() error { + return s.vncConn.Close() +} + +func (s *ScreenshotSession) Capture(ctx context.Context, w io.Writer) error { + slog.Debug("requesting framebuffer update") + width, height := s.vncConn.GetFrameBufferSize() + if err := s.vncConn.FramebufferUpdateRequest(false, 0, 0, width, height); err != nil { + return err + } + + slog.Debug("waiting for server message") + var framebufferUpdate *vnc.FramebufferUpdateMessage + for framebufferUpdate == nil { + select { + case msg, ok := <-s.serverMessages: + if !ok { + return fmt.Errorf("vnc connection closed before receiving framebuffer update") + } + update, ok := msg.(*vnc.FramebufferUpdateMessage) + if !ok { + slog.Debug("ignoring unexpected server message", "type", msg.Type()) + continue + } + framebufferUpdate = update + case <-ctx.Done(): + return ctx.Err() + } + } + + // Sanity checks + if len(framebufferUpdate.Rectangles) != 1 || + framebufferUpdate.Rectangles[0].X != 0 || + framebufferUpdate.Rectangles[0].Y != 0 || + framebufferUpdate.Rectangles[0].Width != width || + framebufferUpdate.Rectangles[0].Height != height { + return fmt.Errorf("received invalid frame buffer update") + } + + slog.Debug("composing image from framebuffer") + img := image.NewRGBA(image.Rect(0, 0, int(width), int(height))) + { + rect := framebufferUpdate.Rectangles[0] + enc := rect.Enc.(*vnc.RawEncoding) + pf := s.vncConn.GetPixelFormat() + for index, clr := range enc.Colors { + x, y := index%int(width), index/int(width) + img.Set(int(rect.X)+x, int(rect.Y)+y, color.RGBA{ + R: scaleColor(clr.R, pf.RedMax), + G: scaleColor(clr.G, pf.GreenMax), + B: scaleColor(clr.B, pf.BlueMax), + A: 255, + }) + } + } + + slog.Debug("encoding image") + imgData := bytes.NewBuffer(nil) + { + pngEncoder := png.Encoder{CompressionLevel: png.DefaultCompression} + if err := pngEncoder.Encode(io.Writer(imgData), img); err != nil { + return err + } + } + + slog.Debug("writing image") + _, err := w.Write(imgData.Bytes()) + return err +} + +func websocketOrigin(wsURL string) (string, error) { + u, err := url.Parse(wsURL) + if err != nil { + return "", err + } + u.Scheme = "https" + return u.String(), nil +} + +func dialWebsocket(ctx context.Context, wsURL string) (*websocket.Conn, error) { + origin, err := websocketOrigin(wsURL) + if err != nil { + return nil, err + } + + cfg, err := websocket.NewConfig(wsURL, origin) + if err != nil { + return nil, err + } + + ws, err := cfg.DialContext(ctx) + if err != nil { + return nil, err + } + ws.PayloadType = websocket.BinaryFrame + + return ws, nil +} + +func scaleColor(value, maxValue uint16) uint8 { + if maxValue == 0 { + return 0 + } + return uint8(uint32(value) * 255 / uint32(maxValue)) +} diff --git a/internal/cmd/util/util.go b/internal/cmd/util/util.go index 7e46c0101..a460dc453 100644 --- a/internal/cmd/util/util.go +++ b/internal/cmd/util/util.go @@ -8,6 +8,7 @@ import ( "io" "iter" "maps" + "path/filepath" "reflect" "slices" "strconv" @@ -447,3 +448,8 @@ func DescribeLabels(out io.Writer, labels map[string]string, prefix string) { } } } + +func IndexedFilePath(path string, index int) string { + ext := filepath.Ext(path) + return fmt.Sprintf("%s-%04d%s", strings.TrimSuffix(path, ext), index, ext) +} diff --git a/internal/cmd/util/util_test.go b/internal/cmd/util/util_test.go index 91a3e1423..6250a9bb0 100644 --- a/internal/cmd/util/util_test.go +++ b/internal/cmd/util/util_test.go @@ -573,3 +573,24 @@ func TestWeekdayFromString(t *testing.T) { require.Error(t, err) require.EqualError(t, err, "invalid weekday: 8") } + +func TestIndexedFilePath(t *testing.T) { + tests := []struct { + path string + index int + expect string + }{ + {"screenshot.png", 1, "screenshot-0001.png"}, + {"screenshot.png", 42, "screenshot-0042.png"}, + {"screenshot.png", 12345, "screenshot-12345.png"}, + {"screenshot", 3, "screenshot-0003"}, + {"screenshot.PNG", 5, "screenshot-0005.PNG"}, + {"screenshots/screenshot.png", 2, "screenshots/screenshot-0002.png"}, + {"some.dir/screenshot.png", 4, "some.dir/screenshot-0004.png"}, + {"../../screenshot.png", 4, "../../screenshot-0004.png"}, + {"screenshot.tar.gz", 7, "screenshot.tar-0007.gz"}, + } + for _, tt := range tests { + assert.Equal(t, tt.expect, util.IndexedFilePath(tt.path, tt.index)) + } +} diff --git a/test/e2e/combined_test.go b/test/e2e/combined_test.go index 6b44b2fe9..2cb4deded 100644 --- a/test/e2e/combined_test.go +++ b/test/e2e/combined_test.go @@ -3,7 +3,9 @@ package e2e import ( + "bytes" "fmt" + "image/png" "os" "path" "strconv" @@ -260,6 +262,50 @@ func TestCombined(t *testing.T) { assert.Empty(t, out) }) + t.Run("screenshot", func(t *testing.T) { + t.Run("non-existing-server", func(t *testing.T) { + out, err := runCommand(t, "server", "screenshot", "non-existing-server") + require.EqualError(t, err, "Server not found: non-existing-server") + assert.Empty(t, out) + }) + + t.Run("default", func(t *testing.T) { + output := path.Join(t.TempDir(), "screenshot.png") + + out, err := runCommand(t, "server", "screenshot", strconv.FormatInt(serverID, 10), "--output", output) + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Screenshot captured and saved to '%s'\n", output), out) + + data, err := os.ReadFile(output) + require.NoError(t, err) + + img, err := png.Decode(bytes.NewReader(data)) + require.NoError(t, err) + assert.Positive(t, img.Bounds().Dx()) + assert.Positive(t, img.Bounds().Dy()) + }) + + t.Run("stdout", func(t *testing.T) { + out, err := runCommand(t, "server", "screenshot", strconv.FormatInt(serverID, 10), "--output", "-") + require.NoError(t, err) + + img, err := png.Decode(bytes.NewReader([]byte(out))) + require.NoError(t, err) + assert.Positive(t, img.Bounds().Dx()) + assert.Positive(t, img.Bounds().Dy()) + }) + + t.Run("timeout", func(t *testing.T) { + output := path.Join(t.TempDir(), "timeout.png") + + out, err := runCommand(t, "server", "screenshot", strconv.FormatInt(serverID, 10), "--output", output, "--timeout", "1ms") + require.Error(t, err) + assert.Regexp(t, `(?i)timeout|deadline exceeded`, err.Error()) + assert.Empty(t, out) + assert.NoFileExists(t, output) + }) + }) + t.Run("image", func(t *testing.T) { var id int64