mirror of
https://github.com/anand34577/ferrum.git
synced 2026-09-29 21:49:27 +00:00
22c1dd382c
- User-scoped API keys (Profile > API Keys) for 3rd-party REST API access
and MCP clients, each locked to one scope at creation, with expiry,
revocation, and last-used tracking.
- A hand-rolled MCP (Model Context Protocol) server exposing the fleet
(connections, nodes, guests, storage, pools, alerts, cluster status) as
read tools plus one admin-gated power-action tool, so Claude Code/Desktop
or any other MCP client can query and operate the fleet directly.
- Both the REST API and MCP are off by default and toggleable instance-wide
from Settings > API & MCP, enforced live on every request.
- Admin-managed AI providers (any OpenAI-chat-completions-compatible
endpoint) backing the AI Assistant's tool-calling loop, replacing the
single hardcoded provider.
- A built-in, zero-config, no-API-key local provider backed by Needle 2
(internal/needle) for fully offline tool-calling, wired in as a one-click
preset. Requires the operator to separately download the Needle 2 binary
and point FERRUM_NEEDLE_BIN at it -- Ferrum never fetches executable
content from the network itself; see README "Built-in LLM (Needle 2)".
- System settings (CORS allow-list, instance-wide toggles) moved to the
admin Settings UI; environment variables are now scoped to true
bootstrap-level config only (listen address, TLS, DB connection, secret,
optional Needle binary path).
- Fixed: node Journal tab 502'ing with "unexpected end of JSON input" on an
empty response, and separately with a decode error on PVE versions that
return a bare-string journal line instead of the documented {n,t} object.
- Fixed: bottom content padding disappearing on every page except the AI
Assistant (an unconditional h-full on the content wrapper let overflowing
content bleed through where the padding should render).
- Fixed: Profile page felt cramped despite a wide viewport (stray max-w-2xl
cap not present on the equivalent Settings page).
- Test coverage added for the previously-untested MCP package and the new
Needle adapter (20 new Go tests), plus a regression test for the journal
decode fix.
268 lines
9.9 KiB
Go
268 lines
9.9 KiB
Go
package pve
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/url"
|
|
)
|
|
|
|
// GuestAgentPing checks that the QEMU guest agent inside a VM is reachable
|
|
// (returns an error otherwise — e.g. agent not installed/running). QEMU-only;
|
|
// LXC has no equivalent since the host already has direct namespace access.
|
|
func (c *Client) GuestAgentPing(ctx context.Context, node string, vmid int) error {
|
|
return c.post(ctx, fmt.Sprintf("/nodes/%s/qemu/%d/agent/ping", PathEscape(node), vmid), url.Values{}, nil)
|
|
}
|
|
|
|
// GuestAgentExecResult is the pid handle returned by agent/exec — poll it
|
|
// with GuestAgentExecStatus to get the command's output and exit code.
|
|
type GuestAgentExecResult struct {
|
|
PID int `json:"pid"`
|
|
}
|
|
|
|
// GuestAgentExec runs a command inside the VM via the guest agent. command is
|
|
// argv (command[0] is the executable, no shell involved unless the caller
|
|
// passes one explicitly, e.g. []string{"/bin/sh", "-c", "..."}).
|
|
func (c *Client) GuestAgentExec(ctx context.Context, node string, vmid int, command []string, input string) (*GuestAgentExecResult, error) {
|
|
cmdJSON, err := json.Marshal(command)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
form := url.Values{"command": {string(cmdJSON)}}
|
|
if input != "" {
|
|
form.Set("input-data", input)
|
|
}
|
|
var out struct {
|
|
Data GuestAgentExecResult `json:"data"`
|
|
}
|
|
if err := c.post(ctx, fmt.Sprintf("/nodes/%s/qemu/%d/agent/exec", PathEscape(node), vmid), form, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return &out.Data, nil
|
|
}
|
|
|
|
// GuestAgentExecStatus is the outcome of a command started with GuestAgentExec.
|
|
// Exited is false while the command is still running.
|
|
type GuestAgentExecStatus struct {
|
|
Exited bool `json:"exited"`
|
|
ExitCode int `json:"exitcode,omitempty"`
|
|
Signal int `json:"signal,omitempty"`
|
|
OutData string `json:"out-data,omitempty"`
|
|
ErrData string `json:"err-data,omitempty"`
|
|
Truncated bool `json:"truncated,omitempty"`
|
|
}
|
|
|
|
func (c *Client) GuestAgentExecStatus(ctx context.Context, node string, vmid, pid int) (*GuestAgentExecStatus, error) {
|
|
var out struct {
|
|
Data GuestAgentExecStatus `json:"data"`
|
|
}
|
|
path := fmt.Sprintf("/nodes/%s/qemu/%d/agent/exec-status?pid=%d", PathEscape(node), vmid, pid)
|
|
if err := c.get(ctx, path, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return &out.Data, nil
|
|
}
|
|
|
|
// GuestAgentFsfreeze freezes (thaw=false) or thaws (thaw=true) the VM's
|
|
// filesystems via the guest agent — used to get a consistent snapshot of a
|
|
// running guest without stopping it.
|
|
func (c *Client) GuestAgentFsfreeze(ctx context.Context, node string, vmid int, thaw bool) error {
|
|
action := "fsfreeze-freeze"
|
|
if thaw {
|
|
action = "fsfreeze-thaw"
|
|
}
|
|
return c.post(ctx, fmt.Sprintf("/nodes/%s/qemu/%d/agent/%s", PathEscape(node), vmid, action), url.Values{}, nil)
|
|
}
|
|
|
|
// GuestAgentShutdown asks the guest agent to cleanly shut down the guest OS
|
|
// (distinct from the hypervisor-level GuestPowerAction "shutdown", which
|
|
// relies on ACPI rather than the agent).
|
|
func (c *Client) GuestAgentShutdown(ctx context.Context, node string, vmid int) error {
|
|
return c.post(ctx, fmt.Sprintf("/nodes/%s/qemu/%d/agent/shutdown", PathEscape(node), vmid), url.Values{}, nil)
|
|
}
|
|
|
|
// GuestAgentSetUserPassword sets a user's password inside the guest via the
|
|
// agent. crypted indicates password is already a crypt(3) hash rather than
|
|
// plaintext.
|
|
func (c *Client) GuestAgentSetUserPassword(ctx context.Context, node string, vmid int, username, password string, crypted bool) error {
|
|
form := url.Values{"username": {username}, "password": {password}}
|
|
if crypted {
|
|
form.Set("crypted", "1")
|
|
}
|
|
return c.post(ctx, fmt.Sprintf("/nodes/%s/qemu/%d/agent/set-user-password", PathEscape(node), vmid), form, nil)
|
|
}
|
|
|
|
// GuestAgentOSInfo returns the guest's os-info as reported by the agent
|
|
// (fields like "name", "pretty-name", "kernel-release", "version" — these
|
|
// vary a lot by guest OS, so this is returned raw rather than over-typed,
|
|
// same approach as GuestConfig.Raw elsewhere in this package).
|
|
func (c *Client) GuestAgentOSInfo(ctx context.Context, node string, vmid int) (map[string]any, error) {
|
|
var out struct {
|
|
Data struct {
|
|
Result map[string]any `json:"result"`
|
|
} `json:"data"`
|
|
}
|
|
path := fmt.Sprintf("/nodes/%s/qemu/%d/agent/get-osinfo", PathEscape(node), vmid)
|
|
if err := c.get(ctx, path, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return out.Data.Result, nil
|
|
}
|
|
|
|
// GuestAgentFSInfo returns the guest's mounted filesystems (name, mountpoint,
|
|
// type, total-bytes, used-bytes, ...) as reported by the agent. Returned raw
|
|
// per entry — same reasoning as GuestAgentOSInfo.
|
|
func (c *Client) GuestAgentFSInfo(ctx context.Context, node string, vmid int) ([]map[string]any, error) {
|
|
var out struct {
|
|
Data struct {
|
|
Result []map[string]any `json:"result"`
|
|
} `json:"data"`
|
|
}
|
|
path := fmt.Sprintf("/nodes/%s/qemu/%d/agent/get-fsinfo", PathEscape(node), vmid)
|
|
if err := c.get(ctx, path, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return out.Data.Result, nil
|
|
}
|
|
|
|
// GuestAgentVCPUs returns the guest's vCPU list as reported by the agent.
|
|
func (c *Client) GuestAgentVCPUs(ctx context.Context, node string, vmid int) ([]map[string]any, error) {
|
|
var out struct {
|
|
Data struct {
|
|
Result []map[string]any `json:"result"`
|
|
} `json:"data"`
|
|
}
|
|
path := fmt.Sprintf("/nodes/%s/qemu/%d/agent/get-vcpus", PathEscape(node), vmid)
|
|
if err := c.get(ctx, path, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return out.Data.Result, nil
|
|
}
|
|
|
|
// GuestAgentHostname returns the guest's hostname as reported by the agent.
|
|
func (c *Client) GuestAgentHostname(ctx context.Context, node string, vmid int) (string, error) {
|
|
var out struct {
|
|
Data struct {
|
|
Result struct {
|
|
HostName string `json:"host-name"`
|
|
} `json:"result"`
|
|
} `json:"data"`
|
|
}
|
|
path := fmt.Sprintf("/nodes/%s/qemu/%d/agent/get-host-name", PathEscape(node), vmid)
|
|
if err := c.get(ctx, path, &out); err != nil {
|
|
return "", err
|
|
}
|
|
return out.Data.Result.HostName, nil
|
|
}
|
|
|
|
// GuestAgentTimezone returns the guest's timezone name as reported by the
|
|
// agent.
|
|
func (c *Client) GuestAgentTimezone(ctx context.Context, node string, vmid int) (string, error) {
|
|
var out struct {
|
|
Data struct {
|
|
Result struct {
|
|
Zone string `json:"zone"`
|
|
} `json:"result"`
|
|
} `json:"data"`
|
|
}
|
|
path := fmt.Sprintf("/nodes/%s/qemu/%d/agent/get-timezone", PathEscape(node), vmid)
|
|
if err := c.get(ctx, path, &out); err != nil {
|
|
return "", err
|
|
}
|
|
return out.Data.Result.Zone, nil
|
|
}
|
|
|
|
// guestAgentFileOpen opens a file inside the guest via the agent (QEMU GA's
|
|
// guest-file-open) and returns the file handle to use with file-read/
|
|
// file-write/file-close. mode follows fopen(3) semantics, e.g. "r" or "w".
|
|
func (c *Client) guestAgentFileOpen(ctx context.Context, node string, vmid int, path, mode string) (int, error) {
|
|
var out struct {
|
|
Data struct {
|
|
Result int `json:"result"`
|
|
} `json:"data"`
|
|
}
|
|
form := url.Values{"file": {path}, "mode": {mode}}
|
|
reqPath := fmt.Sprintf("/nodes/%s/qemu/%d/agent/file-open", PathEscape(node), vmid)
|
|
if err := c.post(ctx, reqPath, form, &out); err != nil {
|
|
return 0, err
|
|
}
|
|
return out.Data.Result, nil
|
|
}
|
|
|
|
// guestAgentFileClose closes a handle opened by guestAgentFileOpen.
|
|
func (c *Client) guestAgentFileClose(ctx context.Context, node string, vmid int, handle int) error {
|
|
form := url.Values{"handle": {fmt.Sprintf("%d", handle)}}
|
|
path := fmt.Sprintf("/nodes/%s/qemu/%d/agent/file-close", PathEscape(node), vmid)
|
|
return c.post(ctx, path, form, nil)
|
|
}
|
|
|
|
// fileReadChunkSize and fileReadMaxBytes bound GuestAgentFileRead's read
|
|
// loop: it reads this many bytes at a time, up to this many bytes total, so
|
|
// a huge or unbounded file inside the guest can't hang the request forever.
|
|
const (
|
|
fileReadChunkSize = 1 << 20 // 1MB per file-read call
|
|
fileReadMaxBytes = 16 << 20 // 16MB cap across the whole read
|
|
)
|
|
|
|
// GuestAgentFileRead reads a file from inside the guest via the agent's
|
|
// file-open/file-read/file-close trio (QEMU GA has no single "read file"
|
|
// call). Assumed wire shape for file-read, per the QEMU GA spec: the result
|
|
// carries base64 content under "buf-b64" and a boolean "eof"; every unwrap
|
|
// step is nil-checked defensively in case PVE's actual field names differ.
|
|
// Reads loop in fileReadChunkSize chunks until eof or fileReadMaxBytes is
|
|
// reached, to avoid hanging on a very large file.
|
|
func (c *Client) GuestAgentFileRead(ctx context.Context, node string, vmid int, path string) (string, error) {
|
|
handle, err := c.guestAgentFileOpen(ctx, node, vmid, path, "r")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer c.guestAgentFileClose(ctx, node, vmid, handle)
|
|
|
|
var content []byte
|
|
for len(content) < fileReadMaxBytes {
|
|
var out struct {
|
|
Data struct {
|
|
Result struct {
|
|
BufB64 string `json:"buf-b64"`
|
|
EOF bool `json:"eof"`
|
|
} `json:"result"`
|
|
} `json:"data"`
|
|
}
|
|
reqPath := fmt.Sprintf("/nodes/%s/qemu/%d/agent/file-read?handle=%d&count=%d", PathEscape(node), vmid, handle, fileReadChunkSize)
|
|
if err := c.get(ctx, reqPath, &out); err != nil {
|
|
return "", err
|
|
}
|
|
if out.Data.Result.BufB64 != "" {
|
|
chunk, err := base64.StdEncoding.DecodeString(out.Data.Result.BufB64)
|
|
if err != nil {
|
|
return "", fmt.Errorf("decoding file-read response: %w", err)
|
|
}
|
|
content = append(content, chunk...)
|
|
}
|
|
if out.Data.Result.EOF {
|
|
break
|
|
}
|
|
}
|
|
return string(content), nil
|
|
}
|
|
|
|
// GuestAgentFileWrite writes content to a file inside the guest via the
|
|
// agent's file-open/file-write/file-close trio, in a single file-write call
|
|
// (fine for config-file-sized writes; not a general chunked/streaming
|
|
// writer).
|
|
func (c *Client) GuestAgentFileWrite(ctx context.Context, node string, vmid int, path, content string) error {
|
|
handle, err := c.guestAgentFileOpen(ctx, node, vmid, path, "w")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer c.guestAgentFileClose(ctx, node, vmid, handle)
|
|
|
|
form := url.Values{
|
|
"handle": {fmt.Sprintf("%d", handle)},
|
|
"content": {base64.StdEncoding.EncodeToString([]byte(content))},
|
|
}
|
|
reqPath := fmt.Sprintf("/nodes/%s/qemu/%d/agent/file-write", PathEscape(node), vmid)
|
|
return c.post(ctx, reqPath, form, nil)
|
|
}
|