From 95a72930b5fa91b0f7f461927a32d702a4aa32eb Mon Sep 17 00:00:00 2001 From: yusing Date: Sat, 24 Jan 2026 21:26:07 +0800 Subject: [PATCH] feat(proxmox): add journalctl streaming API endpoint for LXC containers Add new /api/v1/proxmox/journalctl/:node/:vmid/:service endpoint that streams real-time journalctl output from Proxmox LXC containers via WebSocket connection. This enables live monitoring of container services from the GoDoxy WebUI. Implementation includes: - New proxmox API handler with path parameter validation - WebSocket upgrade for streaming output - LXCCommand helper for executing commands over Proxmox VNC websocket - LXCJournalctl wrapper for convenient journalctl -u service -f invocation - Updated API documentation with proxmox integration --- internal/api/handler.go | 6 ++ internal/api/v1/README.md | 20 ++--- internal/api/v1/docs/swagger.json | 64 +++++++++++++++ internal/api/v1/docs/swagger.yaml | 42 ++++++++++ internal/api/v1/proxmox/journalctl.go | 65 +++++++++++++++ internal/proxmox/lxc_command.go | 110 ++++++++++++++++++++++++++ 6 files changed, 298 insertions(+), 9 deletions(-) create mode 100644 internal/api/v1/proxmox/journalctl.go create mode 100644 internal/proxmox/lxc_command.go diff --git a/internal/api/handler.go b/internal/api/handler.go index a61c8358..4c471fa1 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -16,6 +16,7 @@ import ( fileApi "github.com/yusing/godoxy/internal/api/v1/file" homepageApi "github.com/yusing/godoxy/internal/api/v1/homepage" metricsApi "github.com/yusing/godoxy/internal/api/v1/metrics" + proxmoxApi "github.com/yusing/godoxy/internal/api/v1/proxmox" routeApi "github.com/yusing/godoxy/internal/api/v1/route" "github.com/yusing/godoxy/internal/auth" "github.com/yusing/godoxy/internal/common" @@ -142,6 +143,11 @@ func NewHandler(requireAuth bool) *gin.Engine { docker.POST("/restart", dockerApi.Restart) docker.GET("/stats/:id", dockerApi.Stats) } + + proxmox := v1.Group("/proxmox") + { + proxmox.GET("/journalctl/:node/:vmid/:service", proxmoxApi.Journalctl) + } } return r diff --git a/internal/api/v1/README.md b/internal/api/v1/README.md index 658e3d4e..55a8147c 100644 --- a/internal/api/v1/README.md +++ b/internal/api/v1/README.md @@ -44,6 +44,7 @@ Types are defined in `goutils/apitypes`: | `file` | Configuration file read/write operations | | `auth` | Authentication and session management | | `agent` | Remote agent creation and management | +| `proxmox` | Proxmox API management and monitoring | ## Architecture @@ -77,15 +78,16 @@ API listening address is configured with `GODOXY_API_ADDR` environment variable. ### Internal Dependencies -| Package | Purpose | -| ----------------------- | --------------------------- | -| `internal/route/routes` | Route storage and iteration | -| `internal/docker` | Docker client management | -| `internal/config` | Configuration access | -| `internal/metrics` | System metrics collection | -| `internal/homepage` | Homepage item generation | -| `internal/agentpool` | Remote agent management | -| `internal/auth` | Authentication services | +| Package | Purpose | +| ----------------------- | ------------------------------------- | +| `internal/route/routes` | Route storage and iteration | +| `internal/docker` | Docker client management | +| `internal/config` | Configuration access | +| `internal/metrics` | System metrics collection | +| `internal/homepage` | Homepage item generation | +| `internal/agentpool` | Remote agent management | +| `internal/auth` | Authentication services | +| `internal/proxmox` | Proxmox API management and monitoring | ### External Dependencies diff --git a/internal/api/v1/docs/swagger.json b/internal/api/v1/docs/swagger.json index 438af7d7..39401166 100644 --- a/internal/api/v1/docs/swagger.json +++ b/internal/api/v1/docs/swagger.json @@ -165,6 +165,70 @@ "operationId": "verify" } }, + "/api/v1/proxmox/journalctl/{node}/{vmid}/{service}": { + "get": { + "description": "Get journalctl output", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "proxmox", + "websocket" + ], + "summary": "Get journalctl output", + "parameters": [ + { + "type": "string", + "name": "node", + "in": "path", + "required": true + }, + { + "type": "string", + "name": "service", + "in": "path", + "required": true + }, + { + "type": "integer", + "name": "vmid", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Journalctl output", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Invalid request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "403": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-id": "journalctl", + "operationId": "journalctl" + } + }, "/auth/callback": { "post": { "description": "Handles the callback from the provider after successful authentication", diff --git a/internal/api/v1/docs/swagger.yaml b/internal/api/v1/docs/swagger.yaml index cb17023d..7ea31f27 100644 --- a/internal/api/v1/docs/swagger.yaml +++ b/internal/api/v1/docs/swagger.yaml @@ -2076,6 +2076,48 @@ paths: tags: - agent x-id: verify + /api/v1/proxmox/journalctl/{node}/{vmid}/{service}: + get: + consumes: + - application/json + description: Get journalctl output + parameters: + - in: path + name: node + required: true + type: string + - in: path + name: service + required: true + type: string + - in: path + name: vmid + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Journalctl output + schema: + type: string + "400": + description: Invalid request + schema: + $ref: '#/definitions/ErrorResponse' + "403": + description: Unauthorized + schema: + $ref: '#/definitions/ErrorResponse' + "500": + description: Internal server error + schema: + $ref: '#/definitions/ErrorResponse' + summary: Get journalctl output + tags: + - proxmox + - websocket + x-id: journalctl /auth/callback: post: description: Handles the callback from the provider after successful authentication diff --git a/internal/api/v1/proxmox/journalctl.go b/internal/api/v1/proxmox/journalctl.go new file mode 100644 index 00000000..ebbb4199 --- /dev/null +++ b/internal/api/v1/proxmox/journalctl.go @@ -0,0 +1,65 @@ +package proxmoxapi + +import ( + "io" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/yusing/godoxy/internal/proxmox" + "github.com/yusing/goutils/apitypes" + "github.com/yusing/goutils/http/websocket" +) + +type JournalctlRequest struct { + Node string `uri:"node" binding:"required"` + VMID int `uri:"vmid" binding:"required"` + Service string `uri:"service" binding:"required"` +} + +// @x-id "journalctl" +// @BasePath /api/v1 +// @Summary Get journalctl output +// @Description Get journalctl output +// @Tags proxmox,websocket +// @Accept json +// @Produce application/json +// @Param path path JournalctlRequest true "Request" +// @Success 200 string plain "Journalctl output" +// @Failure 400 {object} apitypes.ErrorResponse "Invalid request" +// @Failure 403 {object} apitypes.ErrorResponse "Unauthorized" +// @Failure 500 {object} apitypes.ErrorResponse "Internal server error" +// @Router /api/v1/proxmox/journalctl/{node}/{vmid}/{service} [get] +func Journalctl(c *gin.Context) { + var request JournalctlRequest + if err := c.ShouldBindUri(&request); err != nil { + c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", err)) + return + } + + node, ok := proxmox.Nodes.Get(request.Node) + if !ok { + c.JSON(http.StatusNotFound, apitypes.Error("node not found")) + return + } + + manager, err := websocket.NewManagerWithUpgrade(c) + if err != nil { + c.Error(apitypes.InternalServerError(err, "failed to upgrade to websocket")) + return + } + defer manager.Close() + + reader, err := node.LXCJournalctl(c.Request.Context(), request.VMID, request.Service) + if err != nil { + c.Error(apitypes.InternalServerError(err, "failed to get journalctl output")) + return + } + defer reader.Close() + + writer := manager.NewWriter(websocket.TextMessage) + _, err = io.Copy(writer, reader) + if err != nil { + c.Error(apitypes.InternalServerError(err, "failed to copy journalctl output")) + return + } +} diff --git a/internal/proxmox/lxc_command.go b/internal/proxmox/lxc_command.go new file mode 100644 index 00000000..902e5a66 --- /dev/null +++ b/internal/proxmox/lxc_command.go @@ -0,0 +1,110 @@ +package proxmox + +import ( + "bytes" + "context" + "fmt" + "io" + + "github.com/gorilla/websocket" +) + +var ErrNoSession = fmt.Errorf("no session found, make sure username and password are set") + +// LXCCommand connects to the Proxmox VNC websocket and streams command output. +// It returns an io.ReadCloser that streams the command output. +func (n *Node) LXCCommand(ctx context.Context, vmid int, command string) (io.ReadCloser, error) { + if !n.client.HasSession() { + return nil, ErrNoSession + } + + node, err := n.client.Node(ctx, n.name) + if err != nil { + return nil, fmt.Errorf("failed to get node: %w", err) + } + + term, err := node.TermProxy(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get term proxy: %w", err) + } + + send, recv, errs, close, err := node.TermWebSocket(term) + if err != nil { + return nil, fmt.Errorf("failed to connect to term websocket: %w", err) + } + + handleSend := func(data []byte) error { + select { + case <-ctx.Done(): + return ctx.Err() + case send <- data: + return nil + case err := <-errs: + return fmt.Errorf("failed to send: %w", err) + } + } + + // Send command: `pct exec -- ` + cmd := fmt.Appendf(nil, "pct exec %d -- %s\n", vmid, command) + if err := handleSend(cmd); err != nil { + return nil, err + } + + // Create a pipe to stream the websocket messages + pr, pw := io.Pipe() + + shouldSkip := true + + // Start a goroutine to read from websocket and write to pipe + go func() { + defer close() + defer pw.Close() + + for { + select { + case <-ctx.Done(): + return + case msg := <-recv: + // skip the header message like + + // Linux pve 6.17.4-1-pve #1 SMP PREEMPT_DYNAMIC PMX 6.17.4-1 (2025-12-03T15:42Z) x86_64 + // + // The programs included with the Debian GNU/Linux system are free software; + // the exact distribution terms for each program are described in the + // individual files in /usr/share/doc/*/copyright. + // + // Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent + // permitted by applicable law. + // + // root@pve:~# pct exec 101 -- journalctl -u "sftpgo" -f + // + // send begins after the line above + if shouldSkip { + if bytes.Contains(msg, cmd[:len(cmd)-2]) { // without the \n + shouldSkip = false + } + continue + } + if _, err := pw.Write(msg); err != nil { + return + } + case err := <-errs: + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + _ = pw.Close() + return + } + _ = pw.CloseWithError(err) + return + } + } + } + }() + + return pr, nil +} + +// LXCJournalctl streams journalctl output for the given service. +func (n *Node) LXCJournalctl(ctx context.Context, vmid int, service string) (io.ReadCloser, error) { + return n.LXCCommand(ctx, vmid, fmt.Sprintf("journalctl -u %q -f", service)) +}