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
This commit is contained in:
yusing
2026-01-25 22:21:35 +08:00
parent f96884c62b
commit 211c466fc3
10 changed files with 411 additions and 59 deletions

View File

@@ -146,6 +146,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)

View File

@@ -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,

View File

@@ -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:

View File

@@ -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"))

View File

@@ -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
}
}