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..62f55d47a --- /dev/null +++ b/docs/reference/manual/hcloud_server_screenshot.md @@ -0,0 +1,43 @@ +## 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) +``` + +### 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..2886e0f48 --- /dev/null +++ b/internal/cmd/server/screenshot.go @@ -0,0 +1,103 @@ +package server + +import ( + "fmt" + "io" + "os" + "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)") + 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") + toStdout := output == "-" + + if toStdout && s.Terminal().StdoutIsTerminal() { + return fmt.Errorf("interactive tty detected. use --output or redirect stdout") + } + + if !toStdout && !overwrite { + if _, err := os.Stat(output); err == nil { + return fmt.Errorf("output file %s already exists", output) + } + } + + 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") + } + + var w io.Writer + if toStdout { + w = cmd.OutOrStdout() + } else { + f, err := os.OpenFile(output, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return err + } + defer f.Close() + w = f + } + + isDebug, err := config.OptionDebug.Get(s.Config()) + if err != nil { + return err + } + var debugOut io.Writer + if isDebug { + debugOut = cmd.ErrOrStderr() + } + + if err := util.TakeScreenshot(s, result.WSSURL, w, debugOut, timeout); err != nil { + return err + } + + if !toStdout { + cmd.Printf("Screenshot captured and saved to '%s'\n", output) + } + return nil + }, +} 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..47df5776e --- /dev/null +++ b/internal/cmd/util/screenshot.go @@ -0,0 +1,154 @@ +package util + +import ( + "bytes" + "context" + "fmt" + "image" + "image/color" + "image/png" + "io" + "log" + "log/slog" + "net/url" + "time" + + "github.com/tenthirtyam/go-vnc" + "golang.org/x/net/websocket" +) + +func TakeScreenshot(ctx context.Context, wsURL string, w io.Writer, debugOut io.Writer, timeout time.Duration) error { + if timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, timeout) + defer cancel() + } + + slog.Debug("dialing websocket") + ws, err := dialWebsocket(ctx, wsURL) + if err != nil { + return err + } + defer ws.Close() + + 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 { + return err + } + defer vncConn.Close() + + slog.Debug("requesting framebuffer update") + width, height := vncConn.GetFrameBufferSize() + if err := 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 := <-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 := 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 to file") + _, 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/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