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
This commit is contained in:
yusing
2026-01-24 21:26:07 +08:00
parent 71e5a507ba
commit 95a72930b5
6 changed files with 298 additions and 9 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 <vmid> -- <command>`
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))
}