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

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

View File

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

View File

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

View File

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