From e7b19c472bf17a7b523634192a250651c204467d Mon Sep 17 00:00:00 2001 From: yusing Date: Sun, 25 Jan 2026 22:21:35 +0800 Subject: [PATCH] feat(proxmox): add tail endpoint and enhance journalctl with multi-service support Add new `/proxmox/tail` API endpoint for streaming file contents from Proxmox nodes and LXC containers via WebSocket. Extend journalctl endpoint to support filtering by multiple services simultaneously. Changes: - Add `GET /proxmox/tail` endpoint supporting node-level and LXC container file tailing - Change `service` parameter from string to array in journalctl endpoints - Add input validation (`checkValidInput`) to prevent command injection - Refactor command formatting with proper shell quoting Security: All command inputs are validated for dangerous characters before --- internal/api/handler.go | 1 + internal/api/v1/docs/swagger.json | 151 +++++++++++++++++++++++--- internal/api/v1/docs/swagger.yaml | 114 +++++++++++++++---- internal/api/v1/proxmox/journalctl.go | 15 ++- internal/api/v1/proxmox/tail.go | 77 +++++++++++++ internal/go-proxmox | 2 +- internal/proxmox/command_common.go | 59 ++++++++++ internal/proxmox/lxc_command.go | 24 ++-- internal/proxmox/node.go | 1 + internal/proxmox/node_command.go | 26 +++-- 10 files changed, 411 insertions(+), 59 deletions(-) create mode 100644 internal/api/v1/proxmox/tail.go create mode 100644 internal/proxmox/command_common.go diff --git a/internal/api/handler.go b/internal/api/handler.go index f6b11e97..a700edef 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -142,6 +142,7 @@ func NewHandler(requireAuth bool) *gin.Engine { proxmox := v1.Group("/proxmox") { + proxmox.GET("/tail", proxmoxApi.Tail) proxmox.GET("/journalctl", proxmoxApi.Journalctl) proxmox.GET("/journalctl/:node", proxmoxApi.Journalctl) proxmox.GET("/journalctl/:node/:vmid", proxmoxApi.Journalctl) diff --git a/internal/api/v1/docs/swagger.json b/internal/api/v1/docs/swagger.json index 3636fbbe..ff08028c 100644 --- a/internal/api/v1/docs/swagger.json +++ b/internal/api/v1/docs/swagger.json @@ -2109,8 +2109,12 @@ "required": true }, { - "type": "string", - "description": "Service name (e.g., 'pveproxy' for node, 'container@.service' format for LXC)", + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "description": "Service names", "name": "service", "in": "query" }, @@ -2189,8 +2193,12 @@ "required": true }, { - "type": "string", - "description": "Service name (e.g., 'pveproxy' for node, 'container@.service' format for LXC)", + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "description": "Service names", "name": "service", "in": "query" }, @@ -2276,8 +2284,12 @@ "required": true }, { - "type": "string", - "description": "Service name (e.g., 'pveproxy' for node, 'container@.service' format for LXC)", + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "description": "Service names", "name": "service", "in": "query" }, @@ -2369,8 +2381,12 @@ "required": true }, { - "type": "string", - "description": "Service name (e.g., 'pveproxy' for node, 'container@.service' format for LXC)", + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "description": "Service names", "name": "service", "in": "query" }, @@ -2388,8 +2404,12 @@ "required": true }, { - "type": "string", - "description": "Service name (e.g., 'pveproxy' for node, 'container@.service' format for LXC)", + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "description": "Service names", "name": "service", "in": "path" }, @@ -2715,6 +2735,91 @@ "operationId": "vmStats" } }, + "/proxmox/tail": { + "get": { + "description": "Get tail output for node or LXC container. If vmid is not provided, streams node tail.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "proxmox", + "websocket" + ], + "summary": "Get tail output", + "parameters": [ + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "description": "File paths", + "name": "file", + "in": "query", + "required": true + }, + { + "maximum": 1000, + "minimum": 1, + "type": "integer", + "default": 100, + "description": "Limit output lines (1-1000)", + "name": "limit", + "in": "query" + }, + { + "type": "string", + "description": "Node name", + "name": "node", + "in": "query", + "required": true + }, + { + "type": "integer", + "description": "Container VMID (optional - if not provided, streams node journalctl)", + "name": "vmid", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Tail output", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Invalid request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "403": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "404": { + "description": "Node not found", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-id": "tail", + "operationId": "tail" + } + }, "/reload": { "post": { "description": "Reload config", @@ -4972,26 +5077,38 @@ }, "ProxmoxNodeConfig": { "type": "object", - "required": [ - "node", - "vmid" - ], "properties": { + "files": { + "type": "array", + "items": { + "type": "string" + }, + "x-nullable": false, + "x-omitempty": false + }, "node": { "type": "string", "x-nullable": false, "x-omitempty": false }, - "service": { - "type": "string" + "services": { + "type": "array", + "items": { + "type": "string" + }, + "x-nullable": false, + "x-omitempty": false }, "vmid": { + "description": "unset: auto discover; explicit 0: node-level route; >0: lxc/qemu resource route", "type": "integer", "x-nullable": false, "x-omitempty": false }, "vmname": { - "type": "string" + "type": "string", + "x-nullable": false, + "x-omitempty": false } }, "x-nullable": false, diff --git a/internal/api/v1/docs/swagger.yaml b/internal/api/v1/docs/swagger.yaml index 8de1bdb8..5492e1f7 100644 --- a/internal/api/v1/docs/swagger.yaml +++ b/internal/api/v1/docs/swagger.yaml @@ -945,17 +945,22 @@ definitions: - ProviderTypeAgent ProxmoxNodeConfig: properties: + files: + items: + type: string + type: array node: type: string - service: - type: string + services: + items: + type: string + type: array vmid: + description: 'unset: auto discover; explicit 0: node-level route; >0: lxc/qemu + resource route' type: integer vmname: type: string - required: - - node - - vmid type: object ProxyStats: properties: @@ -3421,11 +3426,13 @@ paths: name: node required: true type: string - - description: Service name (e.g., 'pveproxy' for node, 'container@.service' - format for LXC) + - collectionFormat: csv + description: Service names in: query + items: + type: string name: service - type: string + type: array - description: Container VMID (optional - if not provided, streams node journalctl) in: query name: vmid @@ -3477,11 +3484,13 @@ paths: name: node required: true type: string - - description: Service name (e.g., 'pveproxy' for node, 'container@.service' - format for LXC) + - collectionFormat: csv + description: Service names in: query + items: + type: string name: service - type: string + type: array - description: Container VMID (optional - if not provided, streams node journalctl) in: query name: vmid @@ -3538,11 +3547,13 @@ paths: name: node required: true type: string - - description: Service name (e.g., 'pveproxy' for node, 'container@.service' - format for LXC) + - collectionFormat: csv + description: Service names in: query + items: + type: string name: service - type: string + type: array - description: Container VMID (optional - if not provided, streams node journalctl) in: query name: vmid @@ -3603,11 +3614,13 @@ paths: name: node required: true type: string - - description: Service name (e.g., 'pveproxy' for node, 'container@.service' - format for LXC) + - collectionFormat: csv + description: Service names in: query + items: + type: string name: service - type: string + type: array - description: Container VMID (optional - if not provided, streams node journalctl) in: query name: vmid @@ -3617,11 +3630,13 @@ paths: name: node required: true type: string - - description: Service name (e.g., 'pveproxy' for node, 'container@.service' - format for LXC) + - collectionFormat: csv + description: Service names in: path + items: + type: string name: service - type: string + type: array - description: Container VMID (optional - if not provided, streams node journalctl) in: path name: vmid @@ -3837,6 +3852,65 @@ paths: - proxmox - websocket x-id: vmStats + /proxmox/tail: + get: + consumes: + - application/json + description: Get tail output for node or LXC container. If vmid is not provided, + streams node tail. + parameters: + - collectionFormat: csv + description: File paths + in: query + items: + type: string + name: file + required: true + type: array + - default: 100 + description: Limit output lines (1-1000) + in: query + maximum: 1000 + minimum: 1 + name: limit + type: integer + - description: Node name + in: query + name: node + required: true + type: string + - description: Container VMID (optional - if not provided, streams node journalctl) + in: query + name: vmid + type: integer + produces: + - application/json + responses: + "200": + description: Tail output + schema: + type: string + "400": + description: Invalid request + schema: + $ref: '#/definitions/ErrorResponse' + "403": + description: Unauthorized + schema: + $ref: '#/definitions/ErrorResponse' + "404": + description: Node not found + schema: + $ref: '#/definitions/ErrorResponse' + "500": + description: Internal server error + schema: + $ref: '#/definitions/ErrorResponse' + summary: Get tail output + tags: + - proxmox + - websocket + x-id: tail /reload: post: consumes: diff --git a/internal/api/v1/proxmox/journalctl.go b/internal/api/v1/proxmox/journalctl.go index decfa2b6..06734734 100644 --- a/internal/api/v1/proxmox/journalctl.go +++ b/internal/api/v1/proxmox/journalctl.go @@ -11,11 +11,14 @@ import ( "github.com/yusing/goutils/http/websocket" ) +// e.g. ws://localhost:8889/api/v1/proxmox/journalctl?node=pve&vmid=127&service=pveproxy&service=pvedaemon&limit=10 +// e.g. ws://localhost:8889/api/v1/proxmox/journalctl/pve/127?service=pveproxy&service=pvedaemon&limit=10 + type JournalctlRequest struct { - Node string `form:"node" uri:"node" binding:"required"` // Node name - VMID *int `form:"vmid" uri:"vmid"` // Container VMID (optional - if not provided, streams node journalctl) - Service string `form:"service" uri:"service"` // Service name (e.g., 'pveproxy' for node, 'container@.service' format for LXC) - Limit *int `form:"limit" uri:"limit" default:"100" binding:"min=1,max=1000"` // Limit output lines (1-1000) + Node string `form:"node" uri:"node" binding:"required"` // Node name + VMID *int `form:"vmid" uri:"vmid"` // Container VMID (optional - if not provided, streams node journalctl) + Services []string `form:"service" uri:"service"` // Service names + Limit *int `form:"limit" uri:"limit" default:"100" binding:"min=1,max=1000"` // Limit output lines (1-1000) } // @name ProxmoxJournalctlRequest // @x-id "journalctl" @@ -56,9 +59,9 @@ func Journalctl(c *gin.Context) { var reader io.ReadCloser var err error if request.VMID == nil { - reader, err = node.NodeJournalctl(c.Request.Context(), request.Service, *request.Limit) + reader, err = node.NodeJournalctl(c.Request.Context(), request.Services, *request.Limit) } else { - reader, err = node.LXCJournalctl(c.Request.Context(), *request.VMID, request.Service, *request.Limit) + reader, err = node.LXCJournalctl(c.Request.Context(), *request.VMID, request.Services, *request.Limit) } if err != nil { c.Error(apitypes.InternalServerError(err, "failed to get journalctl output")) diff --git a/internal/api/v1/proxmox/tail.go b/internal/api/v1/proxmox/tail.go new file mode 100644 index 00000000..eb005109 --- /dev/null +++ b/internal/api/v1/proxmox/tail.go @@ -0,0 +1,77 @@ +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" +) + +// e.g. ws://localhost:8889/api/v1/proxmox/tail?node=pve&vmid=127&file=/var/log/immich/web.log&file=/var/log/immich/ml.log&limit=10 + +type TailRequest struct { + Node string `form:"node" binding:"required"` // Node name + VMID *int `form:"vmid"` // Container VMID (optional - if not provided, streams node journalctl) + Files []string `form:"file" binding:"required,dive,filepath"` // File paths + Limit int `form:"limit" default:"100" binding:"min=1,max=1000"` // Limit output lines (1-1000) +} // @name ProxmoxTailRequest + +// @x-id "tail" +// @BasePath /api/v1 +// @Summary Get tail output +// @Description Get tail output for node or LXC container. If vmid is not provided, streams node tail. +// @Tags proxmox,websocket +// @Accept json +// @Produce application/json +// @Param query query TailRequest true "Request" +// @Success 200 string plain "Tail output" +// @Failure 400 {object} apitypes.ErrorResponse "Invalid request" +// @Failure 403 {object} apitypes.ErrorResponse "Unauthorized" +// @Failure 404 {object} apitypes.ErrorResponse "Node not found" +// @Failure 500 {object} apitypes.ErrorResponse "Internal server error" +// @Router /proxmox/tail [get] +func Tail(c *gin.Context) { + var request TailRequest + if err := c.ShouldBindQuery(&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 + } + + c.Status(http.StatusContinue) + + var reader io.ReadCloser + var err error + if request.VMID == nil { + reader, err = node.NodeTail(c.Request.Context(), request.Files, request.Limit) + } else { + reader, err = node.LXCTail(c.Request.Context(), *request.VMID, request.Files, request.Limit) + } + if err != nil { + c.Error(apitypes.InternalServerError(err, "failed to get journalctl output")) + return + } + defer reader.Close() + + manager, err := websocket.NewManagerWithUpgrade(c) + if err != nil { + c.Error(apitypes.InternalServerError(err, "failed to upgrade to websocket")) + return + } + defer manager.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/go-proxmox b/internal/go-proxmox index 9970e19e..7a07c21f 160000 --- a/internal/go-proxmox +++ b/internal/go-proxmox @@ -1 +1 @@ -Subproject commit 9970e19e6cf5c8e9ec6c0d3cd9554c3a01c8f490 +Subproject commit 7a07c21f07c3cff1294596eb147b060fc88d856e diff --git a/internal/proxmox/command_common.go b/internal/proxmox/command_common.go new file mode 100644 index 00000000..f3404ef4 --- /dev/null +++ b/internal/proxmox/command_common.go @@ -0,0 +1,59 @@ +package proxmox + +import ( + "fmt" + "strings" +) + +// checkValidInput checks if the input contains invalid characters. +// +// The characters are: & | $ ; ' " ` $( ${ < > +// These characters are used in the command line to escape the input or to expand variables. +// We need to check if the input contains these characters and return an error if it does. +// This is to prevent command injection. +func checkValidInput(input string) error { + if strings.ContainsAny(input, "&|$;'\"`<>") { + return fmt.Errorf("input contains invalid characters: %q", input) + } + if strings.Contains(input, "$(") { + return fmt.Errorf("input contains $(: %q", input) + } + if strings.Contains(input, "${") { + return fmt.Errorf("input contains ${: %q", input) + } + return nil +} + +func formatTail(files []string, limit int) (string, error) { + for _, file := range files { + if err := checkValidInput(file); err != nil { + return "", err + } + } + var command strings.Builder + command.WriteString("tail -f -q --retry ") + for _, file := range files { + fmt.Fprintf(&command, " %q ", file) + } + if limit > 0 { + fmt.Fprintf(&command, " -n %d", limit) + } + return command.String(), nil +} + +func formatJournalctl(services []string, limit int) (string, error) { + for _, service := range services { + if err := checkValidInput(service); err != nil { + return "", err + } + } + var command strings.Builder + command.WriteString("journalctl -f") + for _, service := range services { + fmt.Fprintf(&command, " -u %q ", service) + } + if limit > 0 { + fmt.Fprintf(&command, " -n %d", limit) + } + return command.String(), nil +} diff --git a/internal/proxmox/lxc_command.go b/internal/proxmox/lxc_command.go index b2d29bdb..f0c82fab 100644 --- a/internal/proxmox/lxc_command.go +++ b/internal/proxmox/lxc_command.go @@ -39,15 +39,23 @@ func (n *Node) LXCCommand(ctx context.Context, vmid int, command string) (io.Rea // LXCJournalctl streams journalctl output for the given service. // -// If service is not empty, it will be used to filter the output by service. +// If services are not empty, it will be used to filter the output by service. // If limit is greater than 0, it will be used to limit the number of lines of output. -func (n *Node) LXCJournalctl(ctx context.Context, vmid int, service string, limit int) (io.ReadCloser, error) { - command := "journalctl -f" - if service != "" { - command = fmt.Sprintf("journalctl -u %q -f", service) - } - if limit > 0 { - command = fmt.Sprintf("%s -n %d", command, limit) +func (n *Node) LXCJournalctl(ctx context.Context, vmid int, services []string, limit int) (io.ReadCloser, error) { + command, err := formatJournalctl(services, limit) + if err != nil { + return nil, err + } + return n.LXCCommand(ctx, vmid, command) +} + +// LXCTail streams tail output for the given file. +// +// If limit is greater than 0, it will be used to limit the number of lines of output. +func (n *Node) LXCTail(ctx context.Context, vmid int, files []string, limit int) (io.ReadCloser, error) { + command, err := formatTail(files, limit) + if err != nil { + return nil, err } return n.LXCCommand(ctx, vmid, command) } diff --git a/internal/proxmox/node.go b/internal/proxmox/node.go index cf2864e2..ff6f7c78 100644 --- a/internal/proxmox/node.go +++ b/internal/proxmox/node.go @@ -14,6 +14,7 @@ type NodeConfig struct { VMID *int `json:"vmid"` // unset: auto discover; explicit 0: node-level route; >0: lxc/qemu resource route VMName string `json:"vmname,omitempty"` Services []string `json:"services,omitempty" aliases:"service"` + Files []string `json:"files,omitempty" aliases:"file"` } // @name ProxmoxNodeConfig type Node struct { diff --git a/internal/proxmox/node_command.go b/internal/proxmox/node_command.go index 804d1bff..435bdd18 100644 --- a/internal/proxmox/node_command.go +++ b/internal/proxmox/node_command.go @@ -120,13 +120,25 @@ func (n *Node) NodeCommand(ctx context.Context, command string) (io.ReadCloser, return pr, nil } -func (n *Node) NodeJournalctl(ctx context.Context, service string, limit int) (io.ReadCloser, error) { - command := "journalctl -f" - if service != "" { - command = fmt.Sprintf("journalctl -u %q -f", service) - } - if limit > 0 { - command = fmt.Sprintf("%s -n %d", command, limit) +// NodeJournalctl streams journalctl output for the given service. +// +// If services are not empty, it will be used to filter the output by services. +// If limit is greater than 0, it will be used to limit the number of lines of output. +func (n *Node) NodeJournalctl(ctx context.Context, services []string, limit int) (io.ReadCloser, error) { + command, err := formatJournalctl(services, limit) + if err != nil { + return nil, err + } + return n.NodeCommand(ctx, command) +} + +// NodeTail streams tail output for the given file. +// +// If limit is greater than 0, it will be used to limit the number of lines of output. +func (n *Node) NodeTail(ctx context.Context, files []string, limit int) (io.ReadCloser, error) { + command, err := formatTail(files, limit) + if err != nil { + return nil, err } return n.NodeCommand(ctx, command) }