mirror of
https://github.com/yusing/godoxy.git
synced 2026-04-24 01:08:31 +02:00
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:
@@ -16,6 +16,7 @@ import (
|
|||||||
fileApi "github.com/yusing/godoxy/internal/api/v1/file"
|
fileApi "github.com/yusing/godoxy/internal/api/v1/file"
|
||||||
homepageApi "github.com/yusing/godoxy/internal/api/v1/homepage"
|
homepageApi "github.com/yusing/godoxy/internal/api/v1/homepage"
|
||||||
metricsApi "github.com/yusing/godoxy/internal/api/v1/metrics"
|
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"
|
routeApi "github.com/yusing/godoxy/internal/api/v1/route"
|
||||||
"github.com/yusing/godoxy/internal/auth"
|
"github.com/yusing/godoxy/internal/auth"
|
||||||
"github.com/yusing/godoxy/internal/common"
|
"github.com/yusing/godoxy/internal/common"
|
||||||
@@ -142,6 +143,11 @@ func NewHandler(requireAuth bool) *gin.Engine {
|
|||||||
docker.POST("/restart", dockerApi.Restart)
|
docker.POST("/restart", dockerApi.Restart)
|
||||||
docker.GET("/stats/:id", dockerApi.Stats)
|
docker.GET("/stats/:id", dockerApi.Stats)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
proxmox := v1.Group("/proxmox")
|
||||||
|
{
|
||||||
|
proxmox.GET("/journalctl/:node/:vmid/:service", proxmoxApi.Journalctl)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return r
|
return r
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ Types are defined in `goutils/apitypes`:
|
|||||||
| `file` | Configuration file read/write operations |
|
| `file` | Configuration file read/write operations |
|
||||||
| `auth` | Authentication and session management |
|
| `auth` | Authentication and session management |
|
||||||
| `agent` | Remote agent creation and management |
|
| `agent` | Remote agent creation and management |
|
||||||
|
| `proxmox` | Proxmox API management and monitoring |
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -77,15 +78,16 @@ API listening address is configured with `GODOXY_API_ADDR` environment variable.
|
|||||||
|
|
||||||
### Internal Dependencies
|
### Internal Dependencies
|
||||||
|
|
||||||
| Package | Purpose |
|
| Package | Purpose |
|
||||||
| ----------------------- | --------------------------- |
|
| ----------------------- | ------------------------------------- |
|
||||||
| `internal/route/routes` | Route storage and iteration |
|
| `internal/route/routes` | Route storage and iteration |
|
||||||
| `internal/docker` | Docker client management |
|
| `internal/docker` | Docker client management |
|
||||||
| `internal/config` | Configuration access |
|
| `internal/config` | Configuration access |
|
||||||
| `internal/metrics` | System metrics collection |
|
| `internal/metrics` | System metrics collection |
|
||||||
| `internal/homepage` | Homepage item generation |
|
| `internal/homepage` | Homepage item generation |
|
||||||
| `internal/agentpool` | Remote agent management |
|
| `internal/agentpool` | Remote agent management |
|
||||||
| `internal/auth` | Authentication services |
|
| `internal/auth` | Authentication services |
|
||||||
|
| `internal/proxmox` | Proxmox API management and monitoring |
|
||||||
|
|
||||||
### External Dependencies
|
### External Dependencies
|
||||||
|
|
||||||
|
|||||||
@@ -165,6 +165,70 @@
|
|||||||
"operationId": "verify"
|
"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": {
|
"/auth/callback": {
|
||||||
"post": {
|
"post": {
|
||||||
"description": "Handles the callback from the provider after successful authentication",
|
"description": "Handles the callback from the provider after successful authentication",
|
||||||
|
|||||||
@@ -2076,6 +2076,48 @@ paths:
|
|||||||
tags:
|
tags:
|
||||||
- agent
|
- agent
|
||||||
x-id: verify
|
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:
|
/auth/callback:
|
||||||
post:
|
post:
|
||||||
description: Handles the callback from the provider after successful authentication
|
description: Handles the callback from the provider after successful authentication
|
||||||
|
|||||||
65
internal/api/v1/proxmox/journalctl.go
Normal file
65
internal/api/v1/proxmox/journalctl.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
110
internal/proxmox/lxc_command.go
Normal file
110
internal/proxmox/lxc_command.go
Normal 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))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user