feat(proxmox): support node-level routes and journalctl access

This change enables Proxmox node-level operations without requiring a specific
LXC container VMID.

**Features added:**
- New `/proxmox/journalctl/{node}` API endpoint for streaming node journalctl
- Route configuration support for Proxmox nodes (VMID = 0)
- `ReverseLookupNode` function for node discovery by hostname/IP/alias
- `NodeJournalctl` method for executing journalctl on nodes

**Behavior changes:**
- VMID parameter in journalctl endpoints is now optional
- Routes targeting nodes (without specific containers) are now valid

**Bug fixes:**
- Fixed error message variable reference in route validation
This commit is contained in:
yusing
2026-01-25 13:04:09 +08:00
parent 09ddb925a3
commit 57a2ca26db
7 changed files with 263 additions and 84 deletions

View File

@@ -146,6 +146,7 @@ func NewHandler(requireAuth bool) *gin.Engine {
proxmox := v1.Group("/proxmox")
{
proxmox.GET("/journalctl/:node", proxmoxApi.Journalctl)
proxmox.GET("/journalctl/:node/:vmid", proxmoxApi.Journalctl)
proxmox.GET("/journalctl/:node/:vmid/:service", proxmoxApi.Journalctl)
proxmox.GET("/stats/:node/:vmid", proxmoxApi.Stats)

View File

@@ -2077,9 +2077,9 @@
"operationId": "uptime"
}
},
"/proxmox/journalctl/{node}/{vmid}": {
"/proxmox/journalctl/{node}": {
"get": {
"description": "Get journalctl output",
"description": "Get journalctl output for node or LXC container. If vmid is not provided, streams node journalctl.",
"consumes": [
"application/json"
],
@@ -2094,19 +2094,85 @@
"parameters": [
{
"type": "string",
"description": "Node name",
"name": "node",
"in": "path",
"required": true
},
{
"type": "integer",
"name": "vmid",
"description": "Limit output lines (1-1000)",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "Journalctl 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": "journalctl",
"operationId": "journalctl"
}
},
"/proxmox/journalctl/{node}/{vmid}": {
"get": {
"description": "Get journalctl output for node or LXC container. If vmid is not provided, streams node journalctl.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"proxmox",
"websocket"
],
"summary": "Get journalctl output",
"parameters": [
{
"type": "string",
"description": "Node name",
"name": "node",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "limit",
"description": "Container VMID (optional - if not provided, streams node journalctl)",
"name": "vmid",
"in": "path"
},
{
"type": "integer",
"description": "Limit output lines (1-1000)",
"name": "limit",
"in": "query"
}
@@ -2149,7 +2215,7 @@
},
"/proxmox/journalctl/{node}/{vmid}/{service}": {
"get": {
"description": "Get journalctl output",
"description": "Get journalctl output for node or LXC container. If vmid is not provided, streams node journalctl.",
"consumes": [
"application/json"
],
@@ -2164,24 +2230,26 @@
"parameters": [
{
"type": "string",
"description": "Node name",
"name": "node",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "Container VMID (optional - if not provided, streams node journalctl)",
"name": "vmid",
"in": "path"
},
{
"type": "string",
"description": "Service name (e.g., 'pveproxy' for node, 'container@.service' format for LXC)",
"name": "service",
"in": "path"
},
{
"type": "integer",
"name": "vmid",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "limit",
"description": "Limit output lines (1-1000)",
"name": "limit",
"in": "query"
}

View File

@@ -3361,21 +3361,67 @@ paths:
- metrics
- websocket
x-id: uptime
/proxmox/journalctl/{node}:
get:
consumes:
- application/json
description: Get journalctl output for node or LXC container. If vmid is not
provided, streams node journalctl.
parameters:
- description: Node name
in: path
name: node
required: true
type: string
- description: Limit output lines (1-1000)
in: query
name: limit
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'
"404":
description: Node not found
schema:
$ref: '#/definitions/ErrorResponse'
"500":
description: Internal server error
schema:
$ref: '#/definitions/ErrorResponse'
summary: Get journalctl output
tags:
- proxmox
- websocket
x-id: journalctl
/proxmox/journalctl/{node}/{vmid}:
get:
consumes:
- application/json
description: Get journalctl output
description: Get journalctl output for node or LXC container. If vmid is not
provided, streams node journalctl.
parameters:
- in: path
- description: Node name
in: path
name: node
required: true
type: string
- in: path
- description: Container VMID (optional - if not provided, streams node journalctl)
in: path
name: vmid
required: true
type: integer
- description: limit
- description: Limit output lines (1-1000)
in: query
name: limit
type: integer
@@ -3411,20 +3457,24 @@ paths:
get:
consumes:
- application/json
description: Get journalctl output
description: Get journalctl output for node or LXC container. If vmid is not
provided, streams node journalctl.
parameters:
- in: path
- description: Node name
in: path
name: node
required: true
type: string
- in: path
- description: Container VMID (optional - if not provided, streams node journalctl)
in: path
name: vmid
type: integer
- description: Service name (e.g., 'pveproxy' for node, 'container@.service'
format for LXC)
in: path
name: service
type: string
- in: path
name: vmid
required: true
type: integer
- description: limit
- description: Limit output lines (1-1000)
in: query
name: limit
type: integer

View File

@@ -12,7 +12,7 @@ import (
type JournalctlRequest struct {
Node string `uri:"node" binding:"required"`
VMID int `uri:"vmid" binding:"required"`
VMID *int `uri:"vmid"` // optional - if not provided, streams node journalctl
Service string `uri:"service"`
Limit int `query:"limit" binding:"omitempty,min=1,max=1000"`
}
@@ -20,17 +20,20 @@ type JournalctlRequest struct {
// @x-id "journalctl"
// @BasePath /api/v1
// @Summary Get journalctl output
// @Description Get journalctl output
// @Description Get journalctl output for node or LXC container. If vmid is not provided, streams node journalctl.
// @Tags proxmox,websocket
// @Accept json
// @Produce application/json
// @Param path path JournalctlRequest true "Request"
// @Param limit query int false "limit"
// @Success 200 string plain "Journalctl output"
// @Param node path string true "Node name"
// @Param vmid path int false "Container VMID (optional - if not provided, streams node journalctl)"
// @Param service path string false "Service name (e.g., 'pveproxy' for node, 'container@.service' format for LXC)"
// @Param limit query int false "Limit output lines (1-1000)"
// @Success 200 string plain "Journalctl 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/journalctl/{node} [get]
// @Router /proxmox/journalctl/{node}/{vmid} [get]
// @Router /proxmox/journalctl/{node}/{vmid}/{service} [get]
func Journalctl(c *gin.Context) {
@@ -53,7 +56,12 @@ func Journalctl(c *gin.Context) {
}
defer manager.Close()
reader, err := node.LXCJournalctl(c.Request.Context(), request.VMID, request.Service, request.Limit)
var reader io.ReadCloser
if request.VMID == nil {
reader, err = node.NodeJournalctl(c.Request.Context(), request.Service, request.Limit)
} else {
reader, err = node.LXCJournalctl(c.Request.Context(), *request.VMID, request.Service, request.Limit)
}
if err != nil {
c.Error(apitypes.InternalServerError(err, "failed to get journalctl output"))
return

View File

@@ -147,6 +147,34 @@ func (c *Client) ReverseLookupResource(ip net.IP, hostname string, alias string)
return nil, ErrResourceNotFound
}
// ReverseLookupNode looks up a node by name or IP address.
// Returns the node name if found.
func (c *Client) ReverseLookupNode(hostname string, ip net.IP, alias string) string {
shouldCheckHostname := hostname != ""
shouldCheckIP := ip != nil && !ip.IsLoopback() && !ip.IsUnspecified()
shouldCheckAlias := alias != ""
if shouldCheckHostname {
hostname, _, _ = strings.Cut(hostname, ".")
}
for _, node := range c.Cluster.Nodes {
if shouldCheckHostname && node.Name == hostname {
return node.Name
}
if shouldCheckIP {
nodeIP := net.ParseIP(node.IP)
if nodeIP != nil && nodeIP.Equal(ip) {
return node.Name
}
}
if shouldCheckAlias && node.Name == alias {
return node.Name
}
}
return ""
}
// Key implements pool.Object
func (c *Client) Key() string {
return c.Cluster.ID

View File

@@ -186,3 +186,14 @@ 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)
}
return n.NodeCommand(ctx, command)
}

View File

@@ -196,68 +196,68 @@ func (r *Route) validate() gperr.Error {
if nodeName == "" {
return gperr.Errorf("node (proxmox node name) is required")
}
if vmid <= 0 {
return gperr.Errorf("vmid (lxc id) is required")
}
node, ok := proxmox.Nodes.Get(nodeName)
if !ok {
return gperr.Errorf("proxmox node %s not found in pool", node)
return gperr.Errorf("proxmox node %s not found in pool", nodeName)
}
res, err := node.Client().GetResource("lxc", vmid)
if err != nil {
return gperr.Wrap(err) // ErrResourceNotFound
}
r.Proxmox.VMName = res.Name
if r.Host == DefaultHost {
containerName := r.Idlewatcher.ContainerName()
// get ip addresses of the vmid
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ips := res.IPs
if len(ips) == 0 {
return gperr.Multiline().
Addf("no ip addresses found for %s", containerName).
Adds("make sure you have set static ip address for container instead of dhcp").
Subject(containerName)
}
l := log.With().Str("container", containerName).Logger()
l.Info().Msg("checking if container is running")
running, err := node.LXCIsRunning(ctx, vmid)
// Node-level route (VMID = 0) - no container control needed
if vmid > 0 {
res, err := node.Client().GetResource("lxc", vmid)
if err != nil {
return gperr.New("failed to check container state").With(err)
return gperr.Wrap(err) // ErrResourceNotFound
}
if !running {
l.Info().Msg("starting container")
if err := node.LXCAction(ctx, vmid, proxmox.LXCStart); err != nil {
return gperr.New("failed to start container").With(err)
}
}
r.Proxmox.VMName = res.Name
l.Info().Msgf("finding reachable ip addresses")
errs := gperr.NewBuilder("failed to find reachable ip addresses")
for _, ip := range ips {
if err := netutils.PingTCP(ctx, ip, r.Port.Proxy); err != nil {
errs.Add(gperr.Unwrap(err).Subjectf("%s:%d", ip, r.Port.Proxy))
} else {
r.Host = ip.String()
l.Info().Msgf("using ip %s", r.Host)
break
}
}
if r.Host == DefaultHost {
return gperr.Multiline().
Addf("no reachable ip addresses found, tried %d IPs", len(ips)).
With(errs.Error()).
Subject(containerName)
containerName := r.Idlewatcher.ContainerName()
// get ip addresses of the vmid
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ips := res.IPs
if len(ips) == 0 {
return gperr.Multiline().
Addf("no ip addresses found for %s", containerName).
Adds("make sure you have set static ip address for container instead of dhcp").
Subject(containerName)
}
l := log.With().Str("container", containerName).Logger()
l.Info().Msg("checking if container is running")
running, err := node.LXCIsRunning(ctx, vmid)
if err != nil {
return gperr.New("failed to check container state").With(err)
}
if !running {
l.Info().Msg("starting container")
if err := node.LXCAction(ctx, vmid, proxmox.LXCStart); err != nil {
return gperr.New("failed to start container").With(err)
}
}
l.Info().Msgf("finding reachable ip addresses")
errs := gperr.NewBuilder("failed to find reachable ip addresses")
for _, ip := range ips {
if err := netutils.PingTCP(ctx, ip, r.Port.Proxy); err != nil {
errs.Add(gperr.Unwrap(err).Subjectf("%s:%d", ip, r.Port.Proxy))
} else {
r.Host = ip.String()
l.Info().Msgf("using ip %s", r.Host)
break
}
}
if r.Host == DefaultHost {
return gperr.Multiline().
Addf("no reachable ip addresses found, tried %d IPs", len(ips)).
With(errs.Error()).
Subject(containerName)
}
}
}
}
@@ -337,8 +337,21 @@ func (r *Route) validate() gperr.Error {
hostname := r.ProxyURL.Hostname()
ip := net.ParseIP(hostname)
for _, p := range config.WorkingState.Load().Value().Providers.Proxmox {
// First check if hostname, IP, or alias matches a node (node-level route)
if nodeName := p.Client().ReverseLookupNode(hostname, ip, r.Alias); nodeName != "" {
r.Proxmox = &proxmox.NodeConfig{
Node: nodeName,
VMID: 0, // node-level route, no specific VM
VMName: "",
}
log.Info().
Str("node", nodeName).
Msgf("found proxmox node for route %q", r.Alias)
break
}
// Then check if hostname, IP, or alias matches a VM resource
resource, _ := p.Client().ReverseLookupResource(ip, hostname, r.Alias)
// reverse lookup resource by ip address, hostname or alias
if resource != nil {
r.Proxmox = &proxmox.NodeConfig{
Node: resource.Node,