From 7e639f5b41388f32b3351449fd94bbd6ffffaccb Mon Sep 17 00:00:00 2001 From: jo Date: Mon, 15 Jun 2026 15:20:42 +0200 Subject: [PATCH 01/13] feat(server): add screenshot command Adds a new `hcloud server screenshot -o screenshot.png `, that take a screenshot of the server console using a VNC client. --- docs/reference/manual/hcloud_server.md | 1 + .../manual/hcloud_server_screenshot.md | 33 +++++ go.mod | 1 + go.sum | 2 + internal/cmd/server/screenshot.go | 54 +++++++ internal/cmd/server/screenshot/screenshot.go | 139 ++++++++++++++++++ .../cmd/server/screenshot/screenshot_test.go | 37 +++++ internal/cmd/server/server.go | 1 + 8 files changed, 268 insertions(+) create mode 100644 docs/reference/manual/hcloud_server_screenshot.md create mode 100644 internal/cmd/server/screenshot.go create mode 100644 internal/cmd/server/screenshot/screenshot.go create mode 100644 internal/cmd/server/screenshot/screenshot_test.go diff --git a/docs/reference/manual/hcloud_server.md b/docs/reference/manual/hcloud_server.md index 094020627..4990b23a3 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) - 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..689154adc --- /dev/null +++ b/docs/reference/manual/hcloud_server_screenshot.md @@ -0,0 +1,33 @@ +## hcloud server screenshot + +Take a screenshot of the Server VNC console + +``` +hcloud server screenshot [options] +``` + +### Options + +``` + -h, --help help for screenshot + -o, --output string Output file for the screenshot (must be a .png file). (default "screenshot.png") +``` + +### 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") + --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..12ec25144 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ toolchain go1.27.2 require ( github.com/BurntSushi/toml v1.6.0 + github.com/alexsnet/go-vnc v0.1.0 github.com/cheggaaa/pb/v3 v3.2.1 github.com/dustin/go-humanize v1.1.0 github.com/fatih/color v1.19.0 diff --git a/go.sum b/go.sum index 04237a8b6..67430420b 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow= github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4= +github.com/alexsnet/go-vnc v0.1.0 h1:vBCwPNy79WEL8V/Z5A0ngEFCvTWBAjmS048lkR2rdmY= +github.com/alexsnet/go-vnc v0.1.0/go.mod h1:bbRsg41Sh3zvrnWsw+REKJVGZd8Of2+S0V1G0ZaBhlU= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bool64/dev v0.2.43 h1:yQ7qiZVef6WtCl2vDYU0Y+qSq+0aBrQzY8KXkklk9cQ= diff --git a/internal/cmd/server/screenshot.go b/internal/cmd/server/screenshot.go new file mode 100644 index 000000000..a54c95a0b --- /dev/null +++ b/internal/cmd/server/screenshot.go @@ -0,0 +1,54 @@ +package server + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/hetznercloud/cli/internal/cmd/base" + "github.com/hetznercloud/cli/internal/cmd/cmpl" + "github.com/hetznercloud/cli/internal/cmd/server/screenshot" + "github.com/hetznercloud/cli/internal/hcapi2" + "github.com/hetznercloud/cli/internal/state" +) + +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 (must be a .png file).") + return cmd + }, + Run: func(s state.State, cmd *cobra.Command, args []string) error { + idOrName := args[0] + 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 + } + + output, _ := cmd.Flags().GetString("output") + if !strings.HasSuffix(strings.ToLower(output), ".png") { + cmd.Printf("Warning: Only .png output file are supported\n") + } + + return screenshot.TakeScreenshot(s, result.WSSURL, output) + }, +} diff --git a/internal/cmd/server/screenshot/screenshot.go b/internal/cmd/server/screenshot/screenshot.go new file mode 100644 index 000000000..29cc23db5 --- /dev/null +++ b/internal/cmd/server/screenshot/screenshot.go @@ -0,0 +1,139 @@ +package screenshot + +import ( + "bytes" + "context" + "fmt" + "image" + "image/color" + "image/png" + "io" + "log/slog" + "net/url" + "os" + + "github.com/alexsnet/go-vnc" + "github.com/alexsnet/go-vnc/rfbflags" + "golang.org/x/net/websocket" +) + +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 TakeScreenshot(ctx context.Context, wsURL string, filename string) error { + slog.Debug("dialing websocket") + ws, err := dialWebsocket(ctx, wsURL) + if err != nil { + return err + } + defer ws.Close() + + slog.Debug("creating vnc client") + vncCfg := &vnc.ClientConfig{ + Auth: []vnc.ClientAuth{ + // Auth is managed at the HTTP/websocket level,therefore no password is + // needed for the VNC client. + &vnc.ClientAuthNone{}, + }, + ServerMessages: []vnc.ServerMessage{ + &vnc.FramebufferUpdate{}, + }, + ServerMessageCh: make(chan vnc.ServerMessage, 1), + } + + slog.Debug("connecting vnc client") + vncConn, err := vnc.Connect(ctx, ws, vncCfg) + if err != nil { + return err + } + defer vncConn.Close() + + go func() { + slog.Debug("listening for vnc server messages") + if err := vncConn.ListenAndHandle(); err != nil { + slog.Error("failed to listen and handle incoming vnc server messages", "err", err) + } + }() + + slog.Debug("requesting framebuffer update") + if err := vncConn.FramebufferUpdateRequest( + rfbflags.RFBFalse, + 0, 0, + vncConn.FramebufferWidth(), vncConn.FramebufferHeight(), + ); err != nil { + return err + } + + slog.Debug("waiting for server message") + msg := <-vncCfg.ServerMessageCh + + framebufferUpdate, ok := msg.(*vnc.FramebufferUpdate) + if !ok { + return fmt.Errorf("received unexpected server message: %T", msg) + } + + // Sanity checks + if len(framebufferUpdate.Rects) != 1 || + framebufferUpdate.Rects[0].X != 0 || + framebufferUpdate.Rects[0].Y != 0 || + framebufferUpdate.Rects[0].Width != vncConn.FramebufferWidth() || + framebufferUpdate.Rects[0].Height != vncConn.FramebufferHeight() { + return fmt.Errorf("received invalid frame buffer update") + } + + width := int(vncConn.FramebufferWidth()) + height := int(vncConn.FramebufferHeight()) + + slog.Debug("composing image from framebuffer") + img := image.NewRGBA(image.Rect(0, 0, width, height)) + { + rect := framebufferUpdate.Rects[0] + enc := rect.Enc.(*vnc.RawEncoding) + for index, clr := range enc.Colors { + x, y := index%width, index/width + img.Set(int(rect.X)+x, int(rect.Y)+y, color.RGBA{uint8(clr.R), uint8(clr.G), uint8(clr.B), 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") + if err := os.WriteFile(filename, imgData.Bytes(), 0600); err != nil { + return err + } + + return nil +} diff --git a/internal/cmd/server/screenshot/screenshot_test.go b/internal/cmd/server/screenshot/screenshot_test.go new file mode 100644 index 000000000..b2936b680 --- /dev/null +++ b/internal/cmd/server/screenshot/screenshot_test.go @@ -0,0 +1,37 @@ +package screenshot + +import ( + "context" + "log/slog" + "os" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/hetznercloud/hcloud-go/v2/hcloud" +) + +func TestTakeScreenshot(t *testing.T) { + t.Skip() + + ctx := t.Context() + + // TODO: Load token + client := hcloud.NewClient(hcloud.WithToken("TOKEN")) + + // TODO: Create or use existing server + result, _, err := client.Server.RequestConsole(ctx, &hcloud.Server{ID: 0}) + require.NoError(t, err) + + err = client.Action.WaitFor(ctx, result.Action) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))) + + err = TakeScreenshot(ctx, result.WSSURL, "screenshot.png") + require.NoError(t, err) +} 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), From 9b43ae8196d050396b25666dc68117588c5c23e4 Mon Sep 17 00:00:00 2001 From: phm07 <22707808+phm07@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:01:55 +0200 Subject: [PATCH 02/13] github.com/alexsnet/go-vnc -> github.com/tenthirtyam/go-vnc --- go.mod | 4 +- go.sum | 4 +- internal/cmd/server/screenshot/screenshot.go | 90 +++++++++++--------- 3 files changed, 52 insertions(+), 46 deletions(-) diff --git a/go.mod b/go.mod index 12ec25144..2be57952c 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,11 @@ module github.com/hetznercloud/cli -go 1.26.0 +go 1.26.5 toolchain go1.27.2 require ( github.com/BurntSushi/toml v1.6.0 - github.com/alexsnet/go-vnc v0.1.0 github.com/cheggaaa/pb/v3 v3.2.1 github.com/dustin/go-humanize v1.1.0 github.com/fatih/color v1.19.0 @@ -21,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 67430420b..a21d3c535 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,6 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow= github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4= -github.com/alexsnet/go-vnc v0.1.0 h1:vBCwPNy79WEL8V/Z5A0ngEFCvTWBAjmS048lkR2rdmY= -github.com/alexsnet/go-vnc v0.1.0/go.mod h1:bbRsg41Sh3zvrnWsw+REKJVGZd8Of2+S0V1G0ZaBhlU= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bool64/dev v0.2.43 h1:yQ7qiZVef6WtCl2vDYU0Y+qSq+0aBrQzY8KXkklk9cQ= @@ -106,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/cmd/server/screenshot/screenshot.go b/internal/cmd/server/screenshot/screenshot.go index 29cc23db5..cc72dd8aa 100644 --- a/internal/cmd/server/screenshot/screenshot.go +++ b/internal/cmd/server/screenshot/screenshot.go @@ -12,8 +12,7 @@ import ( "net/url" "os" - "github.com/alexsnet/go-vnc" - "github.com/alexsnet/go-vnc/rfbflags" + "github.com/tenthirtyam/go-vnc" "golang.org/x/net/websocket" ) @@ -55,69 +54,69 @@ func TakeScreenshot(ctx context.Context, wsURL string, filename string) error { defer ws.Close() slog.Debug("creating vnc client") - vncCfg := &vnc.ClientConfig{ - Auth: []vnc.ClientAuth{ - // Auth is managed at the HTTP/websocket level,therefore no password is - // needed for the VNC client. - &vnc.ClientAuthNone{}, - }, - ServerMessages: []vnc.ServerMessage{ - &vnc.FramebufferUpdate{}, - }, - ServerMessageCh: make(chan vnc.ServerMessage, 1), - } + serverMessages := make(chan vnc.ServerMessage, 8) slog.Debug("connecting vnc client") - vncConn, err := vnc.Connect(ctx, ws, vncCfg) + 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), + ) if err != nil { return err } defer vncConn.Close() - go func() { - slog.Debug("listening for vnc server messages") - if err := vncConn.ListenAndHandle(); err != nil { - slog.Error("failed to listen and handle incoming vnc server messages", "err", err) - } - }() + width, height := vncConn.GetFrameBufferSize() slog.Debug("requesting framebuffer update") - if err := vncConn.FramebufferUpdateRequest( - rfbflags.RFBFalse, - 0, 0, - vncConn.FramebufferWidth(), vncConn.FramebufferHeight(), - ); err != nil { + if err := vncConn.FramebufferUpdateRequest(false, 0, 0, width, height); err != nil { return err } slog.Debug("waiting for server message") - msg := <-vncCfg.ServerMessageCh - - framebufferUpdate, ok := msg.(*vnc.FramebufferUpdate) - if !ok { - return fmt.Errorf("received unexpected server message: %T", msg) + 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.Rects) != 1 || - framebufferUpdate.Rects[0].X != 0 || - framebufferUpdate.Rects[0].Y != 0 || - framebufferUpdate.Rects[0].Width != vncConn.FramebufferWidth() || - framebufferUpdate.Rects[0].Height != vncConn.FramebufferHeight() { + 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") } - width := int(vncConn.FramebufferWidth()) - height := int(vncConn.FramebufferHeight()) - slog.Debug("composing image from framebuffer") - img := image.NewRGBA(image.Rect(0, 0, width, height)) + img := image.NewRGBA(image.Rect(0, 0, int(width), int(height))) { - rect := framebufferUpdate.Rects[0] + rect := framebufferUpdate.Rectangles[0] enc := rect.Enc.(*vnc.RawEncoding) + pf := vncConn.GetPixelFormat() for index, clr := range enc.Colors { - x, y := index%width, index/width - img.Set(int(rect.X)+x, int(rect.Y)+y, color.RGBA{uint8(clr.R), uint8(clr.G), uint8(clr.B), 255}) + 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, + }) } } @@ -137,3 +136,10 @@ func TakeScreenshot(ctx context.Context, wsURL string, filename string) error { return nil } + +func scaleColor(value, maxValue uint16) uint8 { + if maxValue == 0 { + return 0 + } + return uint8(uint32(value) * 255 / uint32(maxValue)) +} From 63d4b601d18c82522b652fa72462bf57f1859e62 Mon Sep 17 00:00:00 2001 From: phm07 <22707808+phm07@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:55:44 +0200 Subject: [PATCH 03/13] add configurable timeout --- docs/reference/manual/hcloud_server_screenshot.md | 6 ++++-- internal/cmd/server/screenshot.go | 8 ++++++-- internal/cmd/server/screenshot/screenshot.go | 9 ++++++++- internal/cmd/server/screenshot/screenshot_test.go | 6 +----- 4 files changed, 19 insertions(+), 10 deletions(-) diff --git a/docs/reference/manual/hcloud_server_screenshot.md b/docs/reference/manual/hcloud_server_screenshot.md index 689154adc..5e8e1c0d9 100644 --- a/docs/reference/manual/hcloud_server_screenshot.md +++ b/docs/reference/manual/hcloud_server_screenshot.md @@ -9,8 +9,9 @@ hcloud server screenshot [options] ### Options ``` - -h, --help help for screenshot - -o, --output string Output file for the screenshot (must be a .png file). (default "screenshot.png") + -h, --help help for screenshot + -o, --output string Output file for the screenshot (must be a .png file). (default "screenshot.png") + --timeout duration Timeout for taking the screenshot (0s disables the timeout) (default 10s) ``` ### Options inherited from parent commands @@ -22,6 +23,7 @@ hcloud server screenshot [options] --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 diff --git a/internal/cmd/server/screenshot.go b/internal/cmd/server/screenshot.go index a54c95a0b..5fc173867 100644 --- a/internal/cmd/server/screenshot.go +++ b/internal/cmd/server/screenshot.go @@ -3,6 +3,7 @@ package server import ( "fmt" "strings" + "time" "github.com/spf13/cobra" @@ -23,10 +24,14 @@ var ScreenshotCmd = base.Cmd{ DisableFlagsInUseLine: true, } cmd.Flags().StringP("output", "o", "screenshot.png", "Output file for the screenshot (must be a .png file).") + cmd.Flags().Duration("timeout", 10*time.Second, "Timeout for taking the screenshot (0s disables the timeout)") return cmd }, Run: func(s state.State, cmd *cobra.Command, args []string) error { idOrName := args[0] + output, _ := cmd.Flags().GetString("output") + timeout, _ := cmd.Flags().GetDuration("timeout") + server, _, err := s.Client().Server().Get(s, idOrName) if err != nil { return err @@ -44,11 +49,10 @@ var ScreenshotCmd = base.Cmd{ return err } - output, _ := cmd.Flags().GetString("output") if !strings.HasSuffix(strings.ToLower(output), ".png") { cmd.Printf("Warning: Only .png output file are supported\n") } - return screenshot.TakeScreenshot(s, result.WSSURL, output) + return screenshot.TakeScreenshot(s, result.WSSURL, output, timeout) }, } diff --git a/internal/cmd/server/screenshot/screenshot.go b/internal/cmd/server/screenshot/screenshot.go index cc72dd8aa..868693952 100644 --- a/internal/cmd/server/screenshot/screenshot.go +++ b/internal/cmd/server/screenshot/screenshot.go @@ -11,6 +11,7 @@ import ( "log/slog" "net/url" "os" + "time" "github.com/tenthirtyam/go-vnc" "golang.org/x/net/websocket" @@ -45,7 +46,13 @@ func dialWebsocket(ctx context.Context, wsURL string) (*websocket.Conn, error) { return ws, nil } -func TakeScreenshot(ctx context.Context, wsURL string, filename string) error { +func TakeScreenshot(ctx context.Context, wsURL string, filename string, 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 { diff --git a/internal/cmd/server/screenshot/screenshot_test.go b/internal/cmd/server/screenshot/screenshot_test.go index b2936b680..a50862109 100644 --- a/internal/cmd/server/screenshot/screenshot_test.go +++ b/internal/cmd/server/screenshot/screenshot_test.go @@ -1,7 +1,6 @@ package screenshot import ( - "context" "log/slog" "os" "testing" @@ -27,11 +26,8 @@ func TestTakeScreenshot(t *testing.T) { err = client.Action.WaitFor(ctx, result.Action) require.NoError(t, err) - ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) - defer cancel() - slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))) - err = TakeScreenshot(ctx, result.WSSURL, "screenshot.png") + err = TakeScreenshot(ctx, result.WSSURL, "screenshot.png", 10*time.Second) require.NoError(t, err) } From 9395dea1c07029de4ebb19fdd1e69229065f032d Mon Sep 17 00:00:00 2001 From: phm07 <22707808+phm07@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:30:36 +0200 Subject: [PATCH 04/13] add e2e test --- test/e2e/combined_test.go | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/test/e2e/combined_test.go b/test/e2e/combined_test.go index 6b44b2fe9..bf0bc5ccb 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,40 @@ 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.Empty(t, 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("timeout", func(t *testing.T) { + output := path.Join(t.TempDir(), "screenshot.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 From 4590278c5f2818b100d17d9ce3e5e28583776c88 Mon Sep 17 00:00:00 2001 From: phm07 <22707808+phm07@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:56:30 +0200 Subject: [PATCH 05/13] refactor and remove debug prints --- internal/cmd/server/screenshot.go | 4 +- .../cmd/server/screenshot/screenshot_test.go | 33 --------- .../{server/screenshot => util}/screenshot.go | 71 ++++++++----------- 3 files changed, 32 insertions(+), 76 deletions(-) delete mode 100644 internal/cmd/server/screenshot/screenshot_test.go rename internal/cmd/{server/screenshot => util}/screenshot.go (90%) diff --git a/internal/cmd/server/screenshot.go b/internal/cmd/server/screenshot.go index 5fc173867..01ba65877 100644 --- a/internal/cmd/server/screenshot.go +++ b/internal/cmd/server/screenshot.go @@ -9,7 +9,7 @@ import ( "github.com/hetznercloud/cli/internal/cmd/base" "github.com/hetznercloud/cli/internal/cmd/cmpl" - "github.com/hetznercloud/cli/internal/cmd/server/screenshot" + "github.com/hetznercloud/cli/internal/cmd/util" "github.com/hetznercloud/cli/internal/hcapi2" "github.com/hetznercloud/cli/internal/state" ) @@ -53,6 +53,6 @@ var ScreenshotCmd = base.Cmd{ cmd.Printf("Warning: Only .png output file are supported\n") } - return screenshot.TakeScreenshot(s, result.WSSURL, output, timeout) + return util.TakeScreenshot(s, result.WSSURL, output, timeout) }, } diff --git a/internal/cmd/server/screenshot/screenshot_test.go b/internal/cmd/server/screenshot/screenshot_test.go deleted file mode 100644 index a50862109..000000000 --- a/internal/cmd/server/screenshot/screenshot_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package screenshot - -import ( - "log/slog" - "os" - "testing" - "time" - - "github.com/stretchr/testify/require" - - "github.com/hetznercloud/hcloud-go/v2/hcloud" -) - -func TestTakeScreenshot(t *testing.T) { - t.Skip() - - ctx := t.Context() - - // TODO: Load token - client := hcloud.NewClient(hcloud.WithToken("TOKEN")) - - // TODO: Create or use existing server - result, _, err := client.Server.RequestConsole(ctx, &hcloud.Server{ID: 0}) - require.NoError(t, err) - - err = client.Action.WaitFor(ctx, result.Action) - require.NoError(t, err) - - slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))) - - err = TakeScreenshot(ctx, result.WSSURL, "screenshot.png", 10*time.Second) - require.NoError(t, err) -} diff --git a/internal/cmd/server/screenshot/screenshot.go b/internal/cmd/util/screenshot.go similarity index 90% rename from internal/cmd/server/screenshot/screenshot.go rename to internal/cmd/util/screenshot.go index 868693952..8fbc0f7d0 100644 --- a/internal/cmd/server/screenshot/screenshot.go +++ b/internal/cmd/util/screenshot.go @@ -1,4 +1,4 @@ -package screenshot +package util import ( "bytes" @@ -17,35 +17,6 @@ import ( "golang.org/x/net/websocket" ) -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 TakeScreenshot(ctx context.Context, wsURL string, filename string, timeout time.Duration) error { if timeout > 0 { var cancel context.CancelFunc @@ -53,17 +24,13 @@ func TakeScreenshot(ctx context.Context, wsURL string, filename string, timeout defer cancel() } - slog.Debug("dialing websocket") ws, err := dialWebsocket(ctx, wsURL) if err != nil { return err } defer ws.Close() - slog.Debug("creating vnc client") serverMessages := make(chan vnc.ServerMessage, 8) - - slog.Debug("connecting vnc client") vncConn, err := vnc.ClientWithOptions(ctx, ws, // Auth is managed at the HTTP/websocket level,therefore no password is // needed for the VNC client. @@ -76,13 +43,10 @@ func TakeScreenshot(ctx context.Context, wsURL string, filename string, timeout defer vncConn.Close() width, height := vncConn.GetFrameBufferSize() - - slog.Debug("requesting framebuffer update") 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 { @@ -110,7 +74,6 @@ func TakeScreenshot(ctx context.Context, wsURL string, filename string, timeout 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] @@ -127,7 +90,6 @@ func TakeScreenshot(ctx context.Context, wsURL string, filename string, timeout } } - slog.Debug("encoding image") imgData := bytes.NewBuffer(nil) { pngEncoder := png.Encoder{CompressionLevel: png.DefaultCompression} @@ -136,14 +98,41 @@ func TakeScreenshot(ctx context.Context, wsURL string, filename string, timeout } } - slog.Debug("writing image to file") if err := os.WriteFile(filename, imgData.Bytes(), 0600); err != nil { return err } - return nil } +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 From 177538946c5337342040e22e602c51c8d15c82b9 Mon Sep 17 00:00:00 2001 From: phm07 <22707808+phm07@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:59:09 +0200 Subject: [PATCH 06/13] add --overwrite flag --- docs/reference/manual/hcloud_server_screenshot.md | 1 + internal/cmd/server/screenshot.go | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/docs/reference/manual/hcloud_server_screenshot.md b/docs/reference/manual/hcloud_server_screenshot.md index 5e8e1c0d9..8a59cfe60 100644 --- a/docs/reference/manual/hcloud_server_screenshot.md +++ b/docs/reference/manual/hcloud_server_screenshot.md @@ -11,6 +11,7 @@ hcloud server screenshot [options] ``` -h, --help help for screenshot -o, --output string Output file for the screenshot (must be a .png file). (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) ``` diff --git a/internal/cmd/server/screenshot.go b/internal/cmd/server/screenshot.go index 01ba65877..322d3b9b7 100644 --- a/internal/cmd/server/screenshot.go +++ b/internal/cmd/server/screenshot.go @@ -2,6 +2,7 @@ package server import ( "fmt" + "os" "strings" "time" @@ -25,12 +26,20 @@ var ScreenshotCmd = base.Cmd{ } cmd.Flags().StringP("output", "o", "screenshot.png", "Output file for the screenshot (must be a .png file).") 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 }, 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") + + if !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 { From 9da8189e1088bd944048adbaa7f27b58299d2e13 Mon Sep 17 00:00:00 2001 From: phm07 <22707808+phm07@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:01:46 +0200 Subject: [PATCH 07/13] add success message --- internal/cmd/server/screenshot.go | 7 ++++++- test/e2e/combined_test.go | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/internal/cmd/server/screenshot.go b/internal/cmd/server/screenshot.go index 322d3b9b7..d3195d802 100644 --- a/internal/cmd/server/screenshot.go +++ b/internal/cmd/server/screenshot.go @@ -62,6 +62,11 @@ var ScreenshotCmd = base.Cmd{ cmd.Printf("Warning: Only .png output file are supported\n") } - return util.TakeScreenshot(s, result.WSSURL, output, timeout) + if err := util.TakeScreenshot(s, result.WSSURL, output, timeout); err != nil { + return err + } + + cmd.Printf("Screenshot captured and saved to '%s'\n", output) + return nil }, } diff --git a/test/e2e/combined_test.go b/test/e2e/combined_test.go index bf0bc5ccb..397593403 100644 --- a/test/e2e/combined_test.go +++ b/test/e2e/combined_test.go @@ -274,7 +274,7 @@ func TestCombined(t *testing.T) { out, err := runCommand(t, "server", "screenshot", strconv.FormatInt(serverID, 10), "--output", output) require.NoError(t, err) - assert.Empty(t, out) + assert.Equal(t, fmt.Sprintf("Screenshot captured and saved to '%s'\n", output), out) data, err := os.ReadFile(output) require.NoError(t, err) From 3c7ff3d5357ae86b3660f9cd9a8c67d10ce0535f Mon Sep 17 00:00:00 2001 From: phm07 <22707808+phm07@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:09:53 +0200 Subject: [PATCH 08/13] allow output to stdout --- .../manual/hcloud_server_screenshot.md | 2 +- internal/cmd/server/screenshot.go | 30 +++++++++++++++---- internal/cmd/util/screenshot.go | 9 ++---- test/e2e/combined_test.go | 10 +++++++ 4 files changed, 39 insertions(+), 12 deletions(-) diff --git a/docs/reference/manual/hcloud_server_screenshot.md b/docs/reference/manual/hcloud_server_screenshot.md index 8a59cfe60..bc22f53ed 100644 --- a/docs/reference/manual/hcloud_server_screenshot.md +++ b/docs/reference/manual/hcloud_server_screenshot.md @@ -10,7 +10,7 @@ hcloud server screenshot [options] ``` -h, --help help for screenshot - -o, --output string Output file for the screenshot (must be a .png file). (default "screenshot.png") + -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) ``` diff --git a/internal/cmd/server/screenshot.go b/internal/cmd/server/screenshot.go index d3195d802..b5d7b6534 100644 --- a/internal/cmd/server/screenshot.go +++ b/internal/cmd/server/screenshot.go @@ -2,6 +2,7 @@ package server import ( "fmt" + "io" "os" "strings" "time" @@ -24,7 +25,7 @@ var ScreenshotCmd = base.Cmd{ TraverseChildren: true, DisableFlagsInUseLine: true, } - cmd.Flags().StringP("output", "o", "screenshot.png", "Output file for the screenshot (must be a .png file).") + 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 @@ -34,8 +35,13 @@ var ScreenshotCmd = base.Cmd{ output, _ := cmd.Flags().GetString("output") timeout, _ := cmd.Flags().GetDuration("timeout") overwrite, _ := cmd.Flags().GetBool("overwrite") + toStdout := output == "-" - if !overwrite { + 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) } @@ -58,15 +64,29 @@ var ScreenshotCmd = base.Cmd{ return err } - if !strings.HasSuffix(strings.ToLower(output), ".png") { + if !toStdout && !strings.HasSuffix(strings.ToLower(output), ".png") { cmd.Printf("Warning: Only .png output file are supported\n") } - if err := util.TakeScreenshot(s, result.WSSURL, output, timeout); err != nil { + 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 + } + + if err := util.TakeScreenshot(s, result.WSSURL, w, timeout); err != nil { return err } - cmd.Printf("Screenshot captured and saved to '%s'\n", output) + if !toStdout { + cmd.Printf("Screenshot captured and saved to '%s'\n", output) + } return nil }, } diff --git a/internal/cmd/util/screenshot.go b/internal/cmd/util/screenshot.go index 8fbc0f7d0..cee2d7248 100644 --- a/internal/cmd/util/screenshot.go +++ b/internal/cmd/util/screenshot.go @@ -10,14 +10,13 @@ import ( "io" "log/slog" "net/url" - "os" "time" "github.com/tenthirtyam/go-vnc" "golang.org/x/net/websocket" ) -func TakeScreenshot(ctx context.Context, wsURL string, filename string, timeout time.Duration) error { +func TakeScreenshot(ctx context.Context, wsURL string, w io.Writer, timeout time.Duration) error { if timeout > 0 { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, timeout) @@ -98,10 +97,8 @@ func TakeScreenshot(ctx context.Context, wsURL string, filename string, timeout } } - if err := os.WriteFile(filename, imgData.Bytes(), 0600); err != nil { - return err - } - return nil + _, err = w.Write(imgData.Bytes()) + return err } func websocketOrigin(wsURL string) (string, error) { diff --git a/test/e2e/combined_test.go b/test/e2e/combined_test.go index 397593403..2f8297fb6 100644 --- a/test/e2e/combined_test.go +++ b/test/e2e/combined_test.go @@ -285,6 +285,16 @@ func TestCombined(t *testing.T) { 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(), "screenshot.png") From a1bb3e2b72aeb177a033b1a830803696ec52579b Mon Sep 17 00:00:00 2001 From: phm07 <22707808+phm07@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:14:55 +0200 Subject: [PATCH 09/13] add experimental warning --- docs/reference/manual/hcloud_server.md | 2 +- docs/reference/manual/hcloud_server_screenshot.md | 7 +++++++ internal/cmd/base/experimental.go | 12 +++++++----- internal/cmd/server/screenshot.go | 1 + 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/docs/reference/manual/hcloud_server.md b/docs/reference/manual/hcloud_server.md index 4990b23a3..c58ec6748 100644 --- a/docs/reference/manual/hcloud_server.md +++ b/docs/reference/manual/hcloud_server.md @@ -56,7 +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) - Take a screenshot of the Server VNC console +* [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 index bc22f53ed..62f55d47a 100644 --- a/docs/reference/manual/hcloud_server_screenshot.md +++ b/docs/reference/manual/hcloud_server_screenshot.md @@ -1,7 +1,14 @@ ## 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] ``` 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 index b5d7b6534..97dcd5cdd 100644 --- a/internal/cmd/server/screenshot.go +++ b/internal/cmd/server/screenshot.go @@ -30,6 +30,7 @@ var ScreenshotCmd = base.Cmd{ 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") From 5cff1652f423325030c8711c01e37bc56f3b9873 Mon Sep 17 00:00:00 2001 From: phm07 <22707808+phm07@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:23:43 +0200 Subject: [PATCH 10/13] fix e2e test --- test/e2e/combined_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/e2e/combined_test.go b/test/e2e/combined_test.go index 2f8297fb6..2cb4deded 100644 --- a/test/e2e/combined_test.go +++ b/test/e2e/combined_test.go @@ -296,7 +296,7 @@ func TestCombined(t *testing.T) { }) t.Run("timeout", func(t *testing.T) { - output := path.Join(t.TempDir(), "screenshot.png") + 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) From 00208645d1a20f02b56c0d36b07c671bb92271f2 Mon Sep 17 00:00:00 2001 From: phm07 <22707808+phm07@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:23:48 +0200 Subject: [PATCH 11/13] log library debug messages --- internal/cmd/server/screenshot.go | 12 +++++++++++- internal/cmd/util/screenshot.go | 11 ++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/internal/cmd/server/screenshot.go b/internal/cmd/server/screenshot.go index 97dcd5cdd..2886e0f48 100644 --- a/internal/cmd/server/screenshot.go +++ b/internal/cmd/server/screenshot.go @@ -14,6 +14,7 @@ import ( "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{ @@ -81,7 +82,16 @@ var ScreenshotCmd = base.Cmd{ w = f } - if err := util.TakeScreenshot(s, result.WSSURL, w, timeout); err != nil { + 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 } diff --git a/internal/cmd/util/screenshot.go b/internal/cmd/util/screenshot.go index cee2d7248..c8f16ce31 100644 --- a/internal/cmd/util/screenshot.go +++ b/internal/cmd/util/screenshot.go @@ -8,6 +8,7 @@ import ( "image/color" "image/png" "io" + "log" "log/slog" "net/url" "time" @@ -16,7 +17,7 @@ import ( "golang.org/x/net/websocket" ) -func TakeScreenshot(ctx context.Context, wsURL string, w io.Writer, timeout time.Duration) error { +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) @@ -29,12 +30,20 @@ func TakeScreenshot(ctx context.Context, wsURL string, w io.Writer, timeout time } defer ws.Close() + var logger vnc.Logger = &vnc.NoOpLogger{} + if debugOut != nil { + logger = &vnc.StandardLogger{ + Logger: log.New(debugOut, "VNC", 0), + } + } + 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 From 0d52746df1942d6b2db2481bada31912dba5a42b Mon Sep 17 00:00:00 2001 From: phm07 <22707808+phm07@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:32:19 +0200 Subject: [PATCH 12/13] add back debug messages --- internal/cli/root.go | 9 +++++++++ internal/cmd/util/screenshot.go | 7 +++++++ 2 files changed, 16 insertions(+) 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/util/screenshot.go b/internal/cmd/util/screenshot.go index c8f16ce31..47df5776e 100644 --- a/internal/cmd/util/screenshot.go +++ b/internal/cmd/util/screenshot.go @@ -24,6 +24,7 @@ func TakeScreenshot(ctx context.Context, wsURL string, w io.Writer, debugOut io. defer cancel() } + slog.Debug("dialing websocket") ws, err := dialWebsocket(ctx, wsURL) if err != nil { return err @@ -37,6 +38,7 @@ func TakeScreenshot(ctx context.Context, wsURL string, w io.Writer, debugOut io. } } + 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 @@ -50,11 +52,13 @@ func TakeScreenshot(ctx context.Context, wsURL string, w io.Writer, debugOut io. } 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 { @@ -82,6 +86,7 @@ func TakeScreenshot(ctx context.Context, wsURL string, w io.Writer, debugOut io. 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] @@ -98,6 +103,7 @@ func TakeScreenshot(ctx context.Context, wsURL string, w io.Writer, debugOut io. } } + slog.Debug("encoding image") imgData := bytes.NewBuffer(nil) { pngEncoder := png.Encoder{CompressionLevel: png.DefaultCompression} @@ -106,6 +112,7 @@ func TakeScreenshot(ctx context.Context, wsURL string, w io.Writer, debugOut io. } } + slog.Debug("writing image to file") _, err = w.Write(imgData.Bytes()) return err } From 68c998625d15470c5d13f980b3f2192520976320 Mon Sep 17 00:00:00 2001 From: phm07 <22707808+phm07@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:44:42 +0200 Subject: [PATCH 13/13] add --watch flag for screenshot interval --- .../manual/hcloud_server_screenshot.md | 1 + internal/cmd/server/screenshot.go | 121 +++++++++++++++--- internal/cmd/util/screenshot.go | 42 +++--- internal/cmd/util/util.go | 6 + internal/cmd/util/util_test.go | 21 +++ 5 files changed, 157 insertions(+), 34 deletions(-) diff --git a/docs/reference/manual/hcloud_server_screenshot.md b/docs/reference/manual/hcloud_server_screenshot.md index 62f55d47a..50c2b0274 100644 --- a/docs/reference/manual/hcloud_server_screenshot.md +++ b/docs/reference/manual/hcloud_server_screenshot.md @@ -20,6 +20,7 @@ hcloud server screenshot [options] -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 diff --git a/internal/cmd/server/screenshot.go b/internal/cmd/server/screenshot.go index 2886e0f48..3515365a1 100644 --- a/internal/cmd/server/screenshot.go +++ b/internal/cmd/server/screenshot.go @@ -1,9 +1,12 @@ package server import ( + "bytes" + "context" "fmt" "io" "os" + "os/signal" "strings" "time" @@ -29,6 +32,9 @@ var ScreenshotCmd = base.Cmd{ 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", ""), @@ -37,15 +43,30 @@ var ScreenshotCmd = base.Cmd{ 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 { - if _, err := os.Stat(output); err == nil { - return fmt.Errorf("output file %s already exists", output) + 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) } } @@ -70,18 +91,6 @@ var ScreenshotCmd = base.Cmd{ 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 @@ -91,13 +100,91 @@ var ScreenshotCmd = base.Cmd{ debugOut = cmd.ErrOrStderr() } - if err := util.TakeScreenshot(s, result.WSSURL, w, debugOut, timeout); err != nil { + 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() - if !toStdout { - cmd.Printf("Screenshot captured and saved to '%s'\n", output) + 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/util/screenshot.go b/internal/cmd/util/screenshot.go index 47df5776e..0ccdd5f78 100644 --- a/internal/cmd/util/screenshot.go +++ b/internal/cmd/util/screenshot.go @@ -11,25 +11,22 @@ import ( "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() - } +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 err + return nil, err } - defer ws.Close() var logger vnc.Logger = &vnc.NoOpLogger{} if debugOut != nil { @@ -48,13 +45,24 @@ func TakeScreenshot(ctx context.Context, wsURL string, w io.Writer, debugOut io. vnc.WithLogger(logger), ) if err != nil { - return err + _ = ws.Close() + return nil, err } - defer vncConn.Close() + 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 := vncConn.GetFrameBufferSize() - if err := vncConn.FramebufferUpdateRequest(false, 0, 0, width, height); err != nil { + width, height := s.vncConn.GetFrameBufferSize() + if err := s.vncConn.FramebufferUpdateRequest(false, 0, 0, width, height); err != nil { return err } @@ -62,7 +70,7 @@ func TakeScreenshot(ctx context.Context, wsURL string, w io.Writer, debugOut io. var framebufferUpdate *vnc.FramebufferUpdateMessage for framebufferUpdate == nil { select { - case msg, ok := <-serverMessages: + case msg, ok := <-s.serverMessages: if !ok { return fmt.Errorf("vnc connection closed before receiving framebuffer update") } @@ -91,7 +99,7 @@ func TakeScreenshot(ctx context.Context, wsURL string, w io.Writer, debugOut io. { rect := framebufferUpdate.Rectangles[0] enc := rect.Enc.(*vnc.RawEncoding) - pf := vncConn.GetPixelFormat() + 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{ @@ -112,8 +120,8 @@ func TakeScreenshot(ctx context.Context, wsURL string, w io.Writer, debugOut io. } } - slog.Debug("writing image to file") - _, err = w.Write(imgData.Bytes()) + slog.Debug("writing image") + _, err := w.Write(imgData.Bytes()) return err } 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)) + } +}