From 100dc8f732f2a4a73225a304612a424335035133 Mon Sep 17 00:00:00 2001 From: Lucas Bremgartner Date: Wed, 27 Nov 2024 10:55:29 +0100 Subject: [PATCH] wip Signed-off-by: Lucas Bremgartner --- cmd/incusd/instance_console.go | 36 +++++++++++++--- doc/api-extensions.md | 4 ++ .../server/instance/drivers/driver_qemu.go | 42 ++++++++++++++++++- .../server/instance/drivers/qmp/commands.go | 23 ++++++++++ .../server/instance/instance_interface.go | 1 + internal/server/response/response.go | 6 ++- internal/version/api.go | 1 + 7 files changed, 106 insertions(+), 7 deletions(-) diff --git a/cmd/incusd/instance_console.go b/cmd/incusd/instance_console.go index e97be8777a6..dddc9fb135a 100644 --- a/cmd/incusd/instance_console.go +++ b/cmd/incusd/instance_console.go @@ -546,6 +546,12 @@ func instanceConsolePost(d *Daemon, r *http.Request) response.Response { // description: Project name // type: string // example: default +// - in: query +// name: type +// description: Console type +// type: string +// enum: [log, vga] +// example: vga // responses: // "200": // description: Raw console log @@ -554,6 +560,10 @@ func instanceConsolePost(d *Daemon, r *http.Request) response.Response { // schema: // type: string // example: some-text +// image/png: +// schema: +// type: string +// format: binary // "400": // $ref: "#/responses/BadRequest" // "403": @@ -576,6 +586,11 @@ func instanceConsoleLogGet(d *Daemon, r *http.Request) response.Response { return response.SmartError(err) } + consoleLogType := request.QueryParam(r, "type") + if consoleLogType != "" && consoleLogType != "log" && consoleLogType != "vga" { + return response.SmartError(fmt.Errorf("Invalid value for type parameter: %s", consoleLogType)) + } + if internalInstance.IsSnapshot(name) { return response.BadRequest(fmt.Errorf("Invalid instance name")) } @@ -649,14 +664,25 @@ func instanceConsoleLogGet(d *Daemon, r *http.Request) response.Response { return response.SmartError(fmt.Errorf("Failed to cast inst to VM")) } - logContents, err := v.ConsoleLog() - if err != nil { - return response.SmartError(err) + if consoleLogType == "vga" { + screenshot, err := v.ConsoleScreenshot() + if err != nil { + return response.SmartError(err) + } + + ent.File = bytes.NewReader(screenshot) + ent.FileSize = int64(len(screenshot)) + } else { + logContents, err := v.ConsoleLog() + if err != nil { + return response.SmartError(err) + } + + ent.File = bytes.NewReader([]byte(logContents)) + ent.FileSize = int64(len(logContents)) } - ent.File = bytes.NewReader([]byte(logContents)) ent.FileModified = time.Now() - ent.FileSize = int64(len(logContents)) return response.FileResponse(r, []response.FileResponseEntry{ent}, nil) } diff --git a/doc/api-extensions.md b/doc/api-extensions.md index 31ee653b62c..2c1f39b26b3 100644 --- a/doc/api-extensions.md +++ b/doc/api-extensions.md @@ -2660,3 +2660,7 @@ The following configuration options have been added: ## `storage_live_migration` This adds support for virtual-machines live-migration between storage pools. + +## `instance_console_screenshot` + +This adds support to take screenshots of the current VGA console of a VM. diff --git a/internal/server/instance/drivers/driver_qemu.go b/internal/server/instance/drivers/driver_qemu.go index 6fb27bdd753..ccea0f3c36d 100644 --- a/internal/server/instance/drivers/driver_qemu.go +++ b/internal/server/instance/drivers/driver_qemu.go @@ -9371,7 +9371,7 @@ func (d *qemu) ConsoleLog() (string, error) { // If we got data back, append it to the log file for this instance. if logString != "" { - logFile, err := os.OpenFile(d.common.ConsoleBufferLogPath(), os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0600) + logFile, err := os.OpenFile(d.common.ConsoleBufferLogPath(), os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o600) if err != nil { return "", err } @@ -9445,3 +9445,43 @@ func (d *qemu) consoleSwapSocketWithRB() error { return monitor.ChardevChange("console", qmp.ChardevChangeInfo{Type: "ringbuf"}) } + +// ConsoleScreenshot returns a screenshot of the current VGA console in PNG format. +func (d *qemu) ConsoleScreenshot() ([]byte, error) { + if !d.IsRunning() { + return nil, fmt.Errorf("Instance is not running") + } + + // Check if the agent is running. + monitor, err := qmp.Connect(d.monitorPath(), qemuSerialChardevName, d.getMonitorEventHandler(), d.QMPLogFilePath()) + if err != nil { + return nil, err + } + + // Create a target screenshot file. + screenshotPath := filepath.Join(d.Path(), "screenshot.png") + f, err := os.Create(screenshotPath) + if err != nil { + return nil, fmt.Errorf("Couldn't create screenshot path") + } + + defer f.Close() + + err = f.Chown(int(d.state.OS.UnprivUID), -1) + if err != nil { + return nil, fmt.Errorf("Failed to chown screenshot path: %w", err) + } + + // Take the screenshot. + err = monitor.Screendump(filepath.Join(d.Path(), "screenshot.png")) + if err != nil { + return nil, fmt.Errorf("Failed taking screenshot: %w", err) + } + + screenshot, err := os.ReadFile(filepath.Join(d.Path(), "screenshot.png")) + if err != nil { + return nil, fmt.Errorf("Failed to read screenshot: %w", err) + } + + return screenshot, nil +} diff --git a/internal/server/instance/drivers/qmp/commands.go b/internal/server/instance/drivers/qmp/commands.go index 2af767a2855..0a02d7198fa 100644 --- a/internal/server/instance/drivers/qmp/commands.go +++ b/internal/server/instance/drivers/qmp/commands.go @@ -1274,3 +1274,26 @@ func (m *Monitor) ChardevChange(device string, info ChardevChangeInfo) error { return fmt.Errorf("Unsupported chardev type %q", info.Type) } + +// Screendump takes a screenshot of the current VGA console. +// The screendump is stored to the filename provided as argument. +// +// In order to have access to the screendump from the outside of the VM +// one needs to first send a file and then pass /proc/self/fd/NUMBER +// as the filename to this function. +func (m *Monitor) Screendump(filename string) error { + var args struct { + Filename string `json:"filename"` + Device string `json:"device,omitempty"` + Head int `json:"head,omitempty"` + Format string `json:"format,omitempty"` + } + args.Filename = filename + args.Format = "png" + + var queryResp struct { + Return struct{} `json:"return"` + } + + return m.Run("screendump", args, &queryResp) +} diff --git a/internal/server/instance/instance_interface.go b/internal/server/instance/instance_interface.go index 6f7debfdb9e..07097ddbd4c 100644 --- a/internal/server/instance/instance_interface.go +++ b/internal/server/instance/instance_interface.go @@ -191,6 +191,7 @@ type VM interface { AgentCertificate() *x509.Certificate ConsoleLog() (string, error) + ConsoleScreenshot() ([]byte, error) } // CriuMigrationArgs arguments for CRIU migration. diff --git a/internal/server/response/response.go b/internal/server/response/response.go index 6ef394f45f8..e99ccea329d 100644 --- a/internal/server/response/response.go +++ b/internal/server/response/response.go @@ -452,7 +452,11 @@ func (r *fileResponse) Render(w http.ResponseWriter) error { rs = f } - w.Header().Set("Content-Type", "application/octet-stream") + // Only set Content-Type header if not yet set. + if w.Header().Get("Content-Type") == "" { + w.Header().Set("Content-Type", "application/octet-stream") + } + w.Header().Set("Content-Length", fmt.Sprintf("%d", sz)) w.Header().Set("Content-Disposition", fmt.Sprintf("inline;filename=%s", r.files[0].Filename)) diff --git a/internal/version/api.go b/internal/version/api.go index a6124132029..602485ff27b 100644 --- a/internal/version/api.go +++ b/internal/version/api.go @@ -452,6 +452,7 @@ var APIExtensions = []string{ "custom_volume_refresh_exclude_older_snapshots", "storage_initial_owner", "storage_live_migration", + "instance_console_screenshot", } // APIExtensionsCount returns the number of available API extensions.