@soyrageagency/proxmox-mcp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +792 -0
- package/dist/assets.d.ts +18 -0
- package/dist/assets.js +53 -0
- package/dist/assets.js.map +1 -0
- package/dist/branding.d.ts +28 -0
- package/dist/branding.js +58 -0
- package/dist/branding.js.map +1 -0
- package/dist/config.d.ts +70 -0
- package/dist/config.js +119 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +89 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +23 -0
- package/dist/logger.js +65 -0
- package/dist/logger.js.map +1 -0
- package/dist/plugins.d.ts +32 -0
- package/dist/plugins.js +149 -0
- package/dist/plugins.js.map +1 -0
- package/dist/proxmox/client.d.ts +153 -0
- package/dist/proxmox/client.js +620 -0
- package/dist/proxmox/client.js.map +1 -0
- package/dist/resilience/backup-verifier.d.ts +28 -0
- package/dist/resilience/backup-verifier.js +222 -0
- package/dist/resilience/backup-verifier.js.map +1 -0
- package/dist/resilience/dr-drill.d.ts +17 -0
- package/dist/resilience/dr-drill.js +138 -0
- package/dist/resilience/dr-drill.js.map +1 -0
- package/dist/resilience/engine.d.ts +68 -0
- package/dist/resilience/engine.js +151 -0
- package/dist/resilience/engine.js.map +1 -0
- package/dist/resilience/patch-orchestrator.d.ts +31 -0
- package/dist/resilience/patch-orchestrator.js +188 -0
- package/dist/resilience/patch-orchestrator.js.map +1 -0
- package/dist/resilience/report.d.ts +33 -0
- package/dist/resilience/report.js +264 -0
- package/dist/resilience/report.js.map +1 -0
- package/dist/resilience/runbook.d.ts +42 -0
- package/dist/resilience/runbook.js +165 -0
- package/dist/resilience/runbook.js.map +1 -0
- package/dist/resilience/signing.d.ts +43 -0
- package/dist/resilience/signing.js +107 -0
- package/dist/resilience/signing.js.map +1 -0
- package/dist/resilience/types.d.ts +150 -0
- package/dist/resilience/types.js +14 -0
- package/dist/resilience/types.js.map +1 -0
- package/dist/resilience/util.d.ts +16 -0
- package/dist/resilience/util.js +32 -0
- package/dist/resilience/util.js.map +1 -0
- package/dist/tools/about.d.ts +14 -0
- package/dist/tools/about.js +54 -0
- package/dist/tools/about.js.map +1 -0
- package/dist/tools/backups.d.ts +15 -0
- package/dist/tools/backups.js +91 -0
- package/dist/tools/backups.js.map +1 -0
- package/dist/tools/cluster.d.ts +12 -0
- package/dist/tools/cluster.js +48 -0
- package/dist/tools/cluster.js.map +1 -0
- package/dist/tools/context.d.ts +30 -0
- package/dist/tools/context.js +12 -0
- package/dist/tools/context.js.map +1 -0
- package/dist/tools/guests.d.ts +15 -0
- package/dist/tools/guests.js +96 -0
- package/dist/tools/guests.js.map +1 -0
- package/dist/tools/lifecycle.d.ts +16 -0
- package/dist/tools/lifecycle.js +93 -0
- package/dist/tools/lifecycle.js.map +1 -0
- package/dist/tools/management.d.ts +17 -0
- package/dist/tools/management.js +135 -0
- package/dist/tools/management.js.map +1 -0
- package/dist/tools/nodes.d.ts +12 -0
- package/dist/tools/nodes.js +44 -0
- package/dist/tools/nodes.js.map +1 -0
- package/dist/tools/provisioning.d.ts +16 -0
- package/dist/tools/provisioning.js +122 -0
- package/dist/tools/provisioning.js.map +1 -0
- package/dist/tools/resilience.d.ts +21 -0
- package/dist/tools/resilience.js +116 -0
- package/dist/tools/resilience.js.map +1 -0
- package/dist/tools/snapshots.d.ts +17 -0
- package/dist/tools/snapshots.js +89 -0
- package/dist/tools/snapshots.js.map +1 -0
- package/dist/tools/storage.d.ts +11 -0
- package/dist/tools/storage.js +39 -0
- package/dist/tools/storage.js.map +1 -0
- package/dist/tools/tasks.d.ts +11 -0
- package/dist/tools/tasks.js +43 -0
- package/dist/tools/tasks.js.map +1 -0
- package/dist/tui/ansi.d.ts +63 -0
- package/dist/tui/ansi.js +115 -0
- package/dist/tui/ansi.js.map +1 -0
- package/dist/tui/app.d.ts +109 -0
- package/dist/tui/app.js +904 -0
- package/dist/tui/app.js.map +1 -0
- package/dist/tui/box.d.ts +18 -0
- package/dist/tui/box.js +38 -0
- package/dist/tui/box.js.map +1 -0
- package/dist/tui/index.d.ts +20 -0
- package/dist/tui/index.js +65 -0
- package/dist/tui/index.js.map +1 -0
- package/dist/update/channel.d.ts +69 -0
- package/dist/update/channel.js +165 -0
- package/dist/update/channel.js.map +1 -0
- package/dist/update/notice.d.ts +11 -0
- package/dist/update/notice.js +27 -0
- package/dist/update/notice.js.map +1 -0
- package/dist/utils/format.d.ts +21 -0
- package/dist/utils/format.js +63 -0
- package/dist/utils/format.js.map +1 -0
- package/dist/utils/result.d.ts +25 -0
- package/dist/utils/result.js +26 -0
- package/dist/utils/result.js.map +1 -0
- package/package.json +87 -0
- package/scripts/install.mjs +301 -0
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proxmox VE API client.
|
|
3
|
+
*
|
|
4
|
+
* A small, typed wrapper around the Proxmox VE REST API (`/api2/json`). It
|
|
5
|
+
* supports both API-token auth (recommended) and username/password ticket
|
|
6
|
+
* auth, controls TLS verification (Proxmox ships self-signed certs), applies
|
|
7
|
+
* the optional guest allowlist and exposes the helpers the tools build on.
|
|
8
|
+
*
|
|
9
|
+
* Part of Proxmox MCP Server.
|
|
10
|
+
* Crafted by SoyRage Agency — https://soyrage.es/
|
|
11
|
+
* MIT licensed (see LICENSE).
|
|
12
|
+
*/
|
|
13
|
+
import { Agent } from "undici";
|
|
14
|
+
/** Raised when a guest is excluded by the allowlist. */
|
|
15
|
+
export class GuestNotAllowedError extends Error {
|
|
16
|
+
constructor(ref) {
|
|
17
|
+
super(`Guest "${ref}" is not covered by PROXMOX_MCP_ALLOWLIST and cannot be accessed.`);
|
|
18
|
+
this.name = "GuestNotAllowedError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** Raised when a referenced guest does not exist. */
|
|
22
|
+
export class GuestNotFoundError extends Error {
|
|
23
|
+
constructor(ref) {
|
|
24
|
+
super(`No VM or container matches "${ref}".`);
|
|
25
|
+
this.name = "GuestNotFoundError";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** Application-facing Proxmox client. One instance is shared across tools. */
|
|
29
|
+
export class ProxmoxClient {
|
|
30
|
+
config;
|
|
31
|
+
log;
|
|
32
|
+
agent;
|
|
33
|
+
allowlist;
|
|
34
|
+
demo;
|
|
35
|
+
ticket = null;
|
|
36
|
+
constructor(config, logger) {
|
|
37
|
+
this.config = config;
|
|
38
|
+
this.log = logger.child("api");
|
|
39
|
+
this.allowlist = config.allowlist;
|
|
40
|
+
this.demo = config.demo;
|
|
41
|
+
if (this.demo)
|
|
42
|
+
this.log.info("Running in DEMO mode (fabricated cluster data).");
|
|
43
|
+
// Control TLS verification (Proxmox nodes are usually self-signed).
|
|
44
|
+
this.agent = new Agent({ connect: { rejectUnauthorized: config.verifyTls } });
|
|
45
|
+
}
|
|
46
|
+
/** Whether the client is serving fabricated demo data. */
|
|
47
|
+
get isDemo() {
|
|
48
|
+
return this.demo;
|
|
49
|
+
}
|
|
50
|
+
/** True when using username/password ticket auth (vs. an API token). */
|
|
51
|
+
get usingTicket() {
|
|
52
|
+
return !this.config.tokenSecret && Boolean(this.config.user && this.config.password);
|
|
53
|
+
}
|
|
54
|
+
/** Verify connectivity/credentials by hitting /version. */
|
|
55
|
+
async ping() {
|
|
56
|
+
if (this.demo)
|
|
57
|
+
return;
|
|
58
|
+
await this.get("/version");
|
|
59
|
+
this.log.debug("Proxmox API reachable");
|
|
60
|
+
}
|
|
61
|
+
// ---- Low-level request layer ------------------------------------------
|
|
62
|
+
async authHeaders(method) {
|
|
63
|
+
if (this.config.tokenSecret) {
|
|
64
|
+
return { Authorization: `PVEAPIToken=${this.config.tokenId}=${this.config.tokenSecret}` };
|
|
65
|
+
}
|
|
66
|
+
if (this.usingTicket) {
|
|
67
|
+
if (!this.ticket)
|
|
68
|
+
await this.login();
|
|
69
|
+
const headers = { Cookie: `PVEAuthCookie=${this.ticket.cookie}` };
|
|
70
|
+
if (method !== "GET")
|
|
71
|
+
headers.CSRFPreventionToken = this.ticket.csrf;
|
|
72
|
+
return headers;
|
|
73
|
+
}
|
|
74
|
+
throw new Error("Proxmox is not set up yet. Ask the user to run the guided installer (`npm run setup`) " +
|
|
75
|
+
"and paste their Proxmox host + API token, or to try demo mode (PROXMOX_MCP_DEMO=true) " +
|
|
76
|
+
"to explore without a real cluster.");
|
|
77
|
+
}
|
|
78
|
+
/** Acquire a login ticket (username/password auth). */
|
|
79
|
+
async login() {
|
|
80
|
+
const body = new URLSearchParams({
|
|
81
|
+
username: this.config.user,
|
|
82
|
+
password: this.config.password,
|
|
83
|
+
}).toString();
|
|
84
|
+
const res = await this.fetch("POST", "/access/ticket", body, {
|
|
85
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
86
|
+
});
|
|
87
|
+
const text = await res.text();
|
|
88
|
+
if (!res.ok)
|
|
89
|
+
throw new Error(`Proxmox login failed (${res.status}): ${text.slice(0, 200)}`);
|
|
90
|
+
const data = (JSON.parse(text).data ?? {});
|
|
91
|
+
if (!data.ticket)
|
|
92
|
+
throw new Error("Proxmox login did not return a ticket.");
|
|
93
|
+
this.ticket = { cookie: data.ticket, csrf: data.CSRFPreventionToken ?? "" };
|
|
94
|
+
this.log.debug("Acquired Proxmox login ticket");
|
|
95
|
+
}
|
|
96
|
+
fetch(method, path, body, extraHeaders = {}) {
|
|
97
|
+
const url = `${this.config.host}/api2/json${path}`;
|
|
98
|
+
const options = {
|
|
99
|
+
method,
|
|
100
|
+
headers: extraHeaders,
|
|
101
|
+
body,
|
|
102
|
+
dispatcher: this.agent,
|
|
103
|
+
};
|
|
104
|
+
return fetch(url, options);
|
|
105
|
+
}
|
|
106
|
+
/** Perform an authenticated request and return the `data` payload. */
|
|
107
|
+
async request(method, path, params) {
|
|
108
|
+
if (!this.config.host) {
|
|
109
|
+
throw new Error("Proxmox is not set up yet (no host). Ask the user to run the guided installer " +
|
|
110
|
+
"(`npm run setup`) and paste their Proxmox host + API token, or to try demo mode " +
|
|
111
|
+
"(PROXMOX_MCP_DEMO=true).");
|
|
112
|
+
}
|
|
113
|
+
const headers = await this.authHeaders(method);
|
|
114
|
+
let body;
|
|
115
|
+
if (params && method !== "GET") {
|
|
116
|
+
headers["Content-Type"] = "application/x-www-form-urlencoded";
|
|
117
|
+
body = new URLSearchParams(Object.fromEntries(Object.entries(params).map(([k, v]) => [k, String(v)]))).toString();
|
|
118
|
+
}
|
|
119
|
+
let res = await this.fetch(method, path, body, headers);
|
|
120
|
+
if (res.status === 401 && this.usingTicket) {
|
|
121
|
+
// Ticket may have expired — re-authenticate once and retry.
|
|
122
|
+
this.ticket = null;
|
|
123
|
+
const retryHeaders = await this.authHeaders(method);
|
|
124
|
+
if (body)
|
|
125
|
+
retryHeaders["Content-Type"] = "application/x-www-form-urlencoded";
|
|
126
|
+
res = await this.fetch(method, path, body, retryHeaders);
|
|
127
|
+
}
|
|
128
|
+
const text = await res.text();
|
|
129
|
+
if (!res.ok) {
|
|
130
|
+
throw new Error(`Proxmox API ${res.status} on ${path}: ${text.slice(0, 300)}`);
|
|
131
|
+
}
|
|
132
|
+
const json = text ? JSON.parse(text) : { data: undefined };
|
|
133
|
+
return json.data;
|
|
134
|
+
}
|
|
135
|
+
/** GET helper. */
|
|
136
|
+
get(path) {
|
|
137
|
+
return this.request("GET", path);
|
|
138
|
+
}
|
|
139
|
+
/** POST helper (form-encoded params). */
|
|
140
|
+
post(path, params) {
|
|
141
|
+
if (this.demo) {
|
|
142
|
+
this.log.info(`(demo) POST ${path}`);
|
|
143
|
+
return Promise.resolve(`UPID:demo:00000000:${path}`);
|
|
144
|
+
}
|
|
145
|
+
return this.request("POST", path, params);
|
|
146
|
+
}
|
|
147
|
+
/** DELETE helper. */
|
|
148
|
+
del(path) {
|
|
149
|
+
if (this.demo) {
|
|
150
|
+
this.log.info(`(demo) DELETE ${path}`);
|
|
151
|
+
return Promise.resolve({});
|
|
152
|
+
}
|
|
153
|
+
return this.request("DELETE", path);
|
|
154
|
+
}
|
|
155
|
+
// ---- High-level helpers ------------------------------------------------
|
|
156
|
+
/** Proxmox version info. */
|
|
157
|
+
version() {
|
|
158
|
+
if (this.demo)
|
|
159
|
+
return Promise.resolve({ version: "8.2.4", release: "8.2", repoid: "demo" });
|
|
160
|
+
return this.get("/version");
|
|
161
|
+
}
|
|
162
|
+
/** Detailed status of a single node. */
|
|
163
|
+
nodeStatus(node) {
|
|
164
|
+
if (this.demo)
|
|
165
|
+
return Promise.resolve(demoNodeStatus(node));
|
|
166
|
+
return this.get(`/nodes/${node}/status`);
|
|
167
|
+
}
|
|
168
|
+
/** All cluster nodes with load summary. */
|
|
169
|
+
async nodes() {
|
|
170
|
+
if (this.demo)
|
|
171
|
+
return demoNodes();
|
|
172
|
+
const list = await this.get("/nodes");
|
|
173
|
+
return (list ?? []).map((n) => ({
|
|
174
|
+
node: String(n.node ?? ""),
|
|
175
|
+
status: String(n.status ?? "unknown"),
|
|
176
|
+
cpu: Number(n.cpu ?? 0),
|
|
177
|
+
maxcpu: Number(n.maxcpu ?? 0),
|
|
178
|
+
mem: Number(n.mem ?? 0),
|
|
179
|
+
maxmem: Number(n.maxmem ?? 0),
|
|
180
|
+
uptime: Number(n.uptime ?? 0),
|
|
181
|
+
}));
|
|
182
|
+
}
|
|
183
|
+
/** Raw cluster resources (optionally filtered by type). */
|
|
184
|
+
clusterResources(type) {
|
|
185
|
+
if (this.demo) {
|
|
186
|
+
const all = demoResources();
|
|
187
|
+
return Promise.resolve(type === "vm" ? all.filter((r) => r.type === "qemu" || r.type === "lxc")
|
|
188
|
+
: type ? all.filter((r) => r.type === type)
|
|
189
|
+
: all);
|
|
190
|
+
}
|
|
191
|
+
const q = type ? `?type=${encodeURIComponent(type)}` : "";
|
|
192
|
+
return this.get(`/cluster/resources${q}`);
|
|
193
|
+
}
|
|
194
|
+
/** Cluster status (quorum, members). */
|
|
195
|
+
clusterStatus() {
|
|
196
|
+
if (this.demo)
|
|
197
|
+
return Promise.resolve(demoClusterStatus());
|
|
198
|
+
return this.get("/cluster/status");
|
|
199
|
+
}
|
|
200
|
+
/** All guests (QEMU + LXC), honouring the allowlist. */
|
|
201
|
+
async guests() {
|
|
202
|
+
const resources = await this.clusterResources("vm");
|
|
203
|
+
const guests = (resources ?? [])
|
|
204
|
+
.filter((r) => r.type === "qemu" || r.type === "lxc")
|
|
205
|
+
.map((r) => ({
|
|
206
|
+
type: r.type,
|
|
207
|
+
vmid: Number(r.vmid ?? 0),
|
|
208
|
+
name: String(r.name ?? `vm-${r.vmid}`),
|
|
209
|
+
node: String(r.node ?? ""),
|
|
210
|
+
status: String(r.status ?? "unknown"),
|
|
211
|
+
cpu: Number(r.cpu ?? 0),
|
|
212
|
+
maxcpu: Number(r.maxcpu ?? 0),
|
|
213
|
+
mem: Number(r.mem ?? 0),
|
|
214
|
+
maxmem: Number(r.maxmem ?? 0),
|
|
215
|
+
disk: Number(r.disk ?? 0),
|
|
216
|
+
maxdisk: Number(r.maxdisk ?? 0),
|
|
217
|
+
uptime: Number(r.uptime ?? 0),
|
|
218
|
+
template: Boolean(r.template),
|
|
219
|
+
}));
|
|
220
|
+
if (this.allowlist.length === 0)
|
|
221
|
+
return guests;
|
|
222
|
+
return guests.filter((g) => this.isAllowed(g));
|
|
223
|
+
}
|
|
224
|
+
/** Whether a guest is permitted by the allowlist (by VMID or name). */
|
|
225
|
+
isAllowed(guest) {
|
|
226
|
+
if (this.allowlist.length === 0)
|
|
227
|
+
return true;
|
|
228
|
+
return this.allowlist.some((entry) => entry === String(guest.vmid) || entry === guest.name);
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Resolve a reference (VMID or name) to a guest, enforcing the allowlist.
|
|
232
|
+
* Returns the guest including its node — required for every guest API call.
|
|
233
|
+
*/
|
|
234
|
+
async resolveGuest(reference) {
|
|
235
|
+
const ref = reference.trim();
|
|
236
|
+
const all = await this.guestsUnfiltered();
|
|
237
|
+
const match = all.find((g) => String(g.vmid) === ref || g.name === ref);
|
|
238
|
+
if (!match)
|
|
239
|
+
throw new GuestNotFoundError(reference);
|
|
240
|
+
if (!this.isAllowed(match))
|
|
241
|
+
throw new GuestNotAllowedError(reference);
|
|
242
|
+
return match;
|
|
243
|
+
}
|
|
244
|
+
/** Internal: guests without allowlist filtering (for resolution/errors). */
|
|
245
|
+
async guestsUnfiltered() {
|
|
246
|
+
const resources = await this.clusterResources("vm");
|
|
247
|
+
return (resources ?? [])
|
|
248
|
+
.filter((r) => r.type === "qemu" || r.type === "lxc")
|
|
249
|
+
.map((r) => ({
|
|
250
|
+
type: r.type,
|
|
251
|
+
vmid: Number(r.vmid ?? 0),
|
|
252
|
+
name: String(r.name ?? `vm-${r.vmid}`),
|
|
253
|
+
node: String(r.node ?? ""),
|
|
254
|
+
status: String(r.status ?? "unknown"),
|
|
255
|
+
cpu: Number(r.cpu ?? 0),
|
|
256
|
+
maxcpu: Number(r.maxcpu ?? 0),
|
|
257
|
+
mem: Number(r.mem ?? 0),
|
|
258
|
+
maxmem: Number(r.maxmem ?? 0),
|
|
259
|
+
disk: Number(r.disk ?? 0),
|
|
260
|
+
maxdisk: Number(r.maxdisk ?? 0),
|
|
261
|
+
uptime: Number(r.uptime ?? 0),
|
|
262
|
+
template: Boolean(r.template),
|
|
263
|
+
}));
|
|
264
|
+
}
|
|
265
|
+
/** Base API path for a guest, e.g. /nodes/pve/qemu/100. */
|
|
266
|
+
guestBase(guest) {
|
|
267
|
+
return `/nodes/${guest.node}/${guest.type}/${guest.vmid}`;
|
|
268
|
+
}
|
|
269
|
+
/** Current runtime status of a guest. */
|
|
270
|
+
guestStatus(guest) {
|
|
271
|
+
if (this.demo)
|
|
272
|
+
return Promise.resolve(demoGuestStatus(guest));
|
|
273
|
+
return this.get(`${this.guestBase(guest)}/status/current`);
|
|
274
|
+
}
|
|
275
|
+
/** Full configuration of a guest. */
|
|
276
|
+
guestConfig(guest) {
|
|
277
|
+
if (this.demo)
|
|
278
|
+
return Promise.resolve(demoGuestConfig(guest));
|
|
279
|
+
return this.get(`${this.guestBase(guest)}/config`);
|
|
280
|
+
}
|
|
281
|
+
/** Storage available on a node. */
|
|
282
|
+
storage(node) {
|
|
283
|
+
if (this.demo)
|
|
284
|
+
return Promise.resolve(demoStorage());
|
|
285
|
+
return this.get(`/nodes/${node}/storage`);
|
|
286
|
+
}
|
|
287
|
+
/** Recent tasks on a node. */
|
|
288
|
+
tasks(node, limit = 25) {
|
|
289
|
+
if (this.demo)
|
|
290
|
+
return Promise.resolve(demoTasks().slice(0, limit));
|
|
291
|
+
return this.get(`/nodes/${node}/tasks?limit=${limit}`);
|
|
292
|
+
}
|
|
293
|
+
/** Snapshots of a guest. */
|
|
294
|
+
snapshots(guest) {
|
|
295
|
+
if (this.demo)
|
|
296
|
+
return Promise.resolve(demoSnapshots(guest));
|
|
297
|
+
return this.get(`${this.guestBase(guest)}/snapshot`);
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Operating-system info for a guest: the configured `ostype` plus, for
|
|
301
|
+
* running QEMU VMs with the guest agent, the detected OS and IP addresses.
|
|
302
|
+
*/
|
|
303
|
+
async osInfo(guest) {
|
|
304
|
+
if (this.demo)
|
|
305
|
+
return demoOsInfo(guest);
|
|
306
|
+
const config = await this.guestConfig(guest);
|
|
307
|
+
const result = {
|
|
308
|
+
vmid: guest.vmid,
|
|
309
|
+
name: guest.name,
|
|
310
|
+
type: guest.type,
|
|
311
|
+
ostype: config.ostype ?? "unknown",
|
|
312
|
+
};
|
|
313
|
+
if (guest.type === "qemu" && guest.status === "running") {
|
|
314
|
+
// Requires the QEMU guest agent to be installed and enabled.
|
|
315
|
+
try {
|
|
316
|
+
result.agent = await this.get(`${this.guestBase(guest)}/agent/get-osinfo`);
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
result.agentNote = "QEMU guest agent not available (install/enable it for live OS info).";
|
|
320
|
+
}
|
|
321
|
+
try {
|
|
322
|
+
result.interfaces = await this.get(`${this.guestBase(guest)}/agent/network-get-interfaces`);
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
/* interfaces are best-effort */
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return result;
|
|
329
|
+
}
|
|
330
|
+
/** List the content of a storage (backups, ISOs, templates, disks…). */
|
|
331
|
+
storageContent(node, storage, content) {
|
|
332
|
+
if (this.demo)
|
|
333
|
+
return Promise.resolve(demoStorageContent(content));
|
|
334
|
+
const q = content ? `?content=${encodeURIComponent(content)}` : "";
|
|
335
|
+
return this.get(`/nodes/${node}/storage/${storage}/content${q}`);
|
|
336
|
+
}
|
|
337
|
+
/** Storages on a node that can hold backups. */
|
|
338
|
+
async backupStorages(node) {
|
|
339
|
+
if (this.demo)
|
|
340
|
+
return ["nas-backups"];
|
|
341
|
+
const s = await this.storage(node);
|
|
342
|
+
return s.filter((x) => String(x.content ?? "").includes("backup")).map((x) => String(x.storage));
|
|
343
|
+
}
|
|
344
|
+
/** Resolve a node name, defaulting to the first cluster node. */
|
|
345
|
+
async resolveNode(node) {
|
|
346
|
+
if (node)
|
|
347
|
+
return node;
|
|
348
|
+
const nodes = await this.nodes();
|
|
349
|
+
if (nodes.length === 0)
|
|
350
|
+
throw new Error("No Proxmox nodes found.");
|
|
351
|
+
return nodes[0].node;
|
|
352
|
+
}
|
|
353
|
+
/** Whether the AI copilot is available (demo always simulates it). */
|
|
354
|
+
get aiEnabled() {
|
|
355
|
+
return this.demo || Boolean(this.config.aiEndpoint);
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* AI copilot: interpret a natural-language order against the current guests
|
|
359
|
+
* and return a structured intent — either an action to run on a guest, or a
|
|
360
|
+
* plain-language answer. Uses an OpenAI-compatible endpoint when configured;
|
|
361
|
+
* demo mode simulates it with a small rule engine.
|
|
362
|
+
*/
|
|
363
|
+
async aiAssist(prompt) {
|
|
364
|
+
const guests = await this.guests();
|
|
365
|
+
if (this.demo || !this.config.aiEndpoint) {
|
|
366
|
+
return demoAi(prompt, guests, this.demo ? "demo-ai" : "unconfigured");
|
|
367
|
+
}
|
|
368
|
+
const roster = guests
|
|
369
|
+
.map((g) => `${g.vmid} ${g.name} (${g.type === "qemu" ? "VM" : "CT"}, ${g.status})`)
|
|
370
|
+
.join("; ");
|
|
371
|
+
const system = "You operate a Proxmox VE cluster. Given a user's order in plain language, reply ONLY with a compact JSON object " +
|
|
372
|
+
'{"action":"start|shutdown|stop|reboot|snapshot|none","guest":"<vmid or name, empty if none>","answer":"<short answer if it is a question, else empty>","explanation":"<one short sentence>"}. ' +
|
|
373
|
+
`Available guests: ${roster}. Prefer 'shutdown' over 'stop'. If the request is a question, set action to none and fill 'answer'.`;
|
|
374
|
+
try {
|
|
375
|
+
const url = this.config.aiEndpoint.replace(/\/$/, "") + "/chat/completions";
|
|
376
|
+
const res = await fetch(url, {
|
|
377
|
+
method: "POST",
|
|
378
|
+
headers: {
|
|
379
|
+
"Content-Type": "application/json",
|
|
380
|
+
...(this.config.aiKey ? { Authorization: `Bearer ${this.config.aiKey}` } : {}),
|
|
381
|
+
},
|
|
382
|
+
body: JSON.stringify({
|
|
383
|
+
model: this.config.aiModel,
|
|
384
|
+
temperature: 0.1,
|
|
385
|
+
messages: [
|
|
386
|
+
{ role: "system", content: system },
|
|
387
|
+
{ role: "user", content: prompt },
|
|
388
|
+
],
|
|
389
|
+
}),
|
|
390
|
+
});
|
|
391
|
+
if (!res.ok)
|
|
392
|
+
throw new Error(`AI endpoint returned ${res.status}`);
|
|
393
|
+
const data = (await res.json());
|
|
394
|
+
const raw = data.choices?.[0]?.message?.content ?? "";
|
|
395
|
+
const json = JSON.parse(raw.slice(raw.indexOf("{"), raw.lastIndexOf("}") + 1));
|
|
396
|
+
return {
|
|
397
|
+
action: ["start", "shutdown", "stop", "reboot", "snapshot"].includes(json.action) ? json.action : "none",
|
|
398
|
+
guest: String(json.guest ?? ""),
|
|
399
|
+
answer: String(json.answer ?? ""),
|
|
400
|
+
explanation: String(json.explanation ?? ""),
|
|
401
|
+
source: "ai",
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
catch (err) {
|
|
405
|
+
return { action: "none", guest: "", answer: `AI error: ${err.message}`, explanation: "", source: "error" };
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
// Demo data — a believable two-node cluster so the server can be tried without
|
|
411
|
+
// a real Proxmox host (PROXMOX_MCP_DEMO=true).
|
|
412
|
+
// ---------------------------------------------------------------------------
|
|
413
|
+
const GiB = 1024 ** 3;
|
|
414
|
+
function demoNodes() {
|
|
415
|
+
return [
|
|
416
|
+
{ node: "pve", status: "online", cpu: 0.14, maxcpu: 16, mem: 18 * GiB, maxmem: 64 * GiB, uptime: 1_987_200 },
|
|
417
|
+
{ node: "pve2", status: "online", cpu: 0.06, maxcpu: 8, mem: 9 * GiB, maxmem: 32 * GiB, uptime: 1_814_400 },
|
|
418
|
+
];
|
|
419
|
+
}
|
|
420
|
+
function demoNodeStatus(node) {
|
|
421
|
+
const n = demoNodes().find((x) => x.node === node) ?? demoNodes()[0];
|
|
422
|
+
return {
|
|
423
|
+
uptime: n.uptime,
|
|
424
|
+
cpu: n.cpu,
|
|
425
|
+
loadavg: ["0.42", "0.51", "0.48"],
|
|
426
|
+
memory: { total: n.maxmem, used: n.mem, free: n.maxmem - n.mem },
|
|
427
|
+
swap: { total: 8 * GiB, used: 0.2 * GiB },
|
|
428
|
+
rootfs: { total: 100 * GiB, used: 34 * GiB },
|
|
429
|
+
pveversion: "pve-manager/8.2.4",
|
|
430
|
+
kversion: "Linux 6.8.12-1-pve",
|
|
431
|
+
cpuinfo: { model: "AMD Ryzen 7 5825U", cores: n.maxcpu, sockets: 1 },
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
function demoResources() {
|
|
435
|
+
const g = (type, vmid, name, node, status, cpu, maxcpu, mem, maxmem, disk, maxdisk, uptime) => ({ type, vmid, name, node, status, cpu, maxcpu, mem, maxmem, disk, maxdisk, uptime, template: 0 });
|
|
436
|
+
return [
|
|
437
|
+
{ type: "node", node: "pve", status: "online", cpu: 0.14, maxcpu: 16, mem: 18 * GiB, maxmem: 64 * GiB },
|
|
438
|
+
{ type: "node", node: "pve2", status: "online", cpu: 0.06, maxcpu: 8, mem: 9 * GiB, maxmem: 32 * GiB },
|
|
439
|
+
g("qemu", 100, "web", "pve", "running", 0.031, 4, 1.8 * GiB, 4 * GiB, 12 * GiB, 32 * GiB, 1_987_000),
|
|
440
|
+
g("qemu", 101, "db", "pve", "running", 0.087, 4, 6.2 * GiB, 8 * GiB, 48 * GiB, 120 * GiB, 1_986_000),
|
|
441
|
+
g("qemu", 102, "windows-rdp", "pve2", "stopped", 0, 6, 0, 16 * GiB, 0, 200 * GiB, 0),
|
|
442
|
+
g("lxc", 200, "nginx-proxy", "pve", "running", 0.004, 1, 96 * 1024 * 1024, 512 * 1024 * 1024, 1.2 * GiB, 8 * GiB, 1_980_000),
|
|
443
|
+
g("lxc", 201, "grafana", "pve", "running", 0.012, 2, 240 * 1024 * 1024, 2 * GiB, 3.5 * GiB, 16 * GiB, 1_200_000),
|
|
444
|
+
g("lxc", 202, "pihole", "pve2", "running", 0.002, 1, 64 * 1024 * 1024, 512 * 1024 * 1024, 0.9 * GiB, 8 * GiB, 900_000),
|
|
445
|
+
g("lxc", 203, "backup-runner", "pve2", "stopped", 0, 1, 0, 512 * 1024 * 1024, 0, 8 * GiB, 0),
|
|
446
|
+
{ type: "storage", node: "pve", name: "local", status: "available" },
|
|
447
|
+
{ type: "storage", node: "pve", name: "local-lvm", status: "available" },
|
|
448
|
+
];
|
|
449
|
+
}
|
|
450
|
+
function demoClusterStatus() {
|
|
451
|
+
return [
|
|
452
|
+
{ type: "cluster", name: "soyrage-lab", nodes: 2, quorate: 1, version: 4 },
|
|
453
|
+
{ type: "node", name: "pve", online: 1, local: 1, ip: "10.0.0.11" },
|
|
454
|
+
{ type: "node", name: "pve2", online: 1, local: 0, ip: "10.0.0.12" },
|
|
455
|
+
];
|
|
456
|
+
}
|
|
457
|
+
function demoGuestStatus(guest) {
|
|
458
|
+
const running = guest.status === "running";
|
|
459
|
+
return {
|
|
460
|
+
status: guest.status,
|
|
461
|
+
vmid: guest.vmid,
|
|
462
|
+
name: guest.name,
|
|
463
|
+
cpus: guest.maxcpu,
|
|
464
|
+
cpu: guest.cpu,
|
|
465
|
+
mem: guest.mem,
|
|
466
|
+
maxmem: guest.maxmem,
|
|
467
|
+
disk: guest.disk,
|
|
468
|
+
maxdisk: guest.maxdisk,
|
|
469
|
+
uptime: guest.uptime,
|
|
470
|
+
...(guest.type === "qemu" ? { qmpstatus: guest.status, agent: running ? 1 : 0 } : { type: "lxc" }),
|
|
471
|
+
ha: { managed: 0 },
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
function demoGuestConfig(guest) {
|
|
475
|
+
if (guest.type === "qemu") {
|
|
476
|
+
return {
|
|
477
|
+
name: guest.name,
|
|
478
|
+
cores: guest.maxcpu,
|
|
479
|
+
sockets: 1,
|
|
480
|
+
memory: Math.round(guest.maxmem / (1024 * 1024)),
|
|
481
|
+
ostype: "l26",
|
|
482
|
+
scsi0: `local-lvm:vm-${guest.vmid}-disk-0,size=${Math.round(guest.maxdisk / GiB)}G`,
|
|
483
|
+
net0: "virtio=DE:AD:BE:EF:00:0A,bridge=vmbr0",
|
|
484
|
+
boot: "order=scsi0",
|
|
485
|
+
agent: 1,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
return {
|
|
489
|
+
hostname: guest.name,
|
|
490
|
+
cores: guest.maxcpu,
|
|
491
|
+
memory: Math.round(guest.maxmem / (1024 * 1024)),
|
|
492
|
+
rootfs: `local-lvm:subvol-${guest.vmid}-disk-0,size=${Math.round(guest.maxdisk / GiB)}G`,
|
|
493
|
+
net0: "name=eth0,bridge=vmbr0,ip=dhcp",
|
|
494
|
+
ostype: "debian",
|
|
495
|
+
unprivileged: 1,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
function demoStorage() {
|
|
499
|
+
return [
|
|
500
|
+
{ storage: "local", type: "dir", content: "iso,vztmpl,backup", active: 1, total: 100 * GiB, used: 34 * GiB, avail: 66 * GiB },
|
|
501
|
+
{ storage: "local-lvm", type: "lvmthin", content: "images,rootdir", active: 1, total: 400 * GiB, used: 176 * GiB, avail: 224 * GiB },
|
|
502
|
+
{ storage: "nas-backups", type: "nfs", content: "backup", active: 1, total: 4000 * GiB, used: 1200 * GiB, avail: 2800 * GiB },
|
|
503
|
+
];
|
|
504
|
+
}
|
|
505
|
+
function demoTasks() {
|
|
506
|
+
const now = Math.floor(Date.now() / 1000);
|
|
507
|
+
return [
|
|
508
|
+
{ type: "vzdump", id: "101", user: "root@pam", status: "OK", starttime: now - 3600, endtime: now - 3400 },
|
|
509
|
+
{ type: "qmstart", id: "100", user: "root@pam!mcp", status: "OK", starttime: now - 7200, endtime: now - 7199 },
|
|
510
|
+
{ type: "vzsnapshot", id: "201", user: "root@pam", status: "OK", starttime: now - 10800, endtime: now - 10797 },
|
|
511
|
+
{ type: "vncproxy", id: "100", user: "admin@pve", status: "OK", starttime: now - 14400, endtime: now - 14395 },
|
|
512
|
+
{ type: "vzdump", id: "200", user: "root@pam", status: "OK", starttime: now - 90000, endtime: now - 89700 },
|
|
513
|
+
];
|
|
514
|
+
}
|
|
515
|
+
const DEMO_OS = {
|
|
516
|
+
web: "Debian GNU/Linux 12 (bookworm)",
|
|
517
|
+
db: "Debian GNU/Linux 12 (bookworm)",
|
|
518
|
+
"windows-rdp": "Windows 11 Pro",
|
|
519
|
+
"nginx-proxy": "Alpine Linux v3.20",
|
|
520
|
+
grafana: "Ubuntu 24.04 LTS",
|
|
521
|
+
pihole: "Debian GNU/Linux 12 (bookworm)",
|
|
522
|
+
"backup-runner": "Debian GNU/Linux 12 (bookworm)",
|
|
523
|
+
};
|
|
524
|
+
function demoOsInfo(guest) {
|
|
525
|
+
const pretty = DEMO_OS[guest.name] ?? "Debian GNU/Linux 12 (bookworm)";
|
|
526
|
+
const isWin = /windows/i.test(pretty);
|
|
527
|
+
const ostype = guest.type === "lxc" ? "debian" : isWin ? "win11" : "l26";
|
|
528
|
+
const info = { vmid: guest.vmid, name: guest.name, type: guest.type, ostype };
|
|
529
|
+
if (guest.status === "running") {
|
|
530
|
+
info.agent = {
|
|
531
|
+
result: {
|
|
532
|
+
name: isWin ? "Microsoft Windows" : pretty.split(" ")[0],
|
|
533
|
+
"pretty-name": pretty,
|
|
534
|
+
"kernel-release": isWin ? "10.0.22631" : "6.8.12-1-pve",
|
|
535
|
+
machine: "x86_64",
|
|
536
|
+
},
|
|
537
|
+
};
|
|
538
|
+
info.interfaces = {
|
|
539
|
+
result: [
|
|
540
|
+
{ name: guest.type === "lxc" ? "eth0" : isWin ? "Ethernet" : "ens18", "ip-addresses": [{ "ip-address": `10.0.0.${guest.vmid}`, "ip-address-type": "ipv4" }] },
|
|
541
|
+
],
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
else {
|
|
545
|
+
info.agentNote = "Guest is not running.";
|
|
546
|
+
}
|
|
547
|
+
return info;
|
|
548
|
+
}
|
|
549
|
+
/** Rule-based AI copilot used in demo mode (and as an unconfigured fallback). */
|
|
550
|
+
function demoAi(prompt, guests, source) {
|
|
551
|
+
const p = prompt.toLowerCase();
|
|
552
|
+
const g = guests.find((x) => p.includes(x.name.toLowerCase()) || new RegExp(`\\b${x.vmid}\\b`).test(p));
|
|
553
|
+
// Questions first.
|
|
554
|
+
if (/\b(which|what|list|show|how many|down|running|stopped|status|using|ram|memory|cpu)\b/.test(p) && !/\b(restart|reboot|stop|start|shutdown|snapshot)\b/.test(p)) {
|
|
555
|
+
if (/\b(down|stopped|off)\b/.test(p)) {
|
|
556
|
+
const down = guests.filter((x) => x.status !== "running");
|
|
557
|
+
const ans = down.length ? down.map((x) => `${x.name} (VMID ${x.vmid})`).join(", ") : "none — everything is running.";
|
|
558
|
+
return { action: "none", guest: "", answer: `Stopped guests: ${ans}`, explanation: "", source };
|
|
559
|
+
}
|
|
560
|
+
if (/\b(ram|memory)\b/.test(p) && g) {
|
|
561
|
+
return { action: "none", guest: g.name, answer: `${g.name} (VMID ${g.vmid}) is using ${(g.mem / 1024 ** 3).toFixed(1)} GB of ${(g.maxmem / 1024 ** 3).toFixed(1)} GB RAM.`, explanation: "", source };
|
|
562
|
+
}
|
|
563
|
+
const running = guests.filter((x) => x.status === "running");
|
|
564
|
+
return { action: "none", guest: "", answer: `${running.length}/${guests.length} guests running: ${running.map((x) => x.name).join(", ")}.`, explanation: "", source };
|
|
565
|
+
}
|
|
566
|
+
// Actions.
|
|
567
|
+
let action = "none";
|
|
568
|
+
if (/\b(reboot|restart)\b/.test(p))
|
|
569
|
+
action = "reboot";
|
|
570
|
+
else if (/\b(stop|kill|force)\b/.test(p))
|
|
571
|
+
action = "stop";
|
|
572
|
+
else if (/\b(shutdown|shut down|turn off|power off)\b/.test(p))
|
|
573
|
+
action = "shutdown";
|
|
574
|
+
else if (/\b(start|boot|power on|turn on)\b/.test(p))
|
|
575
|
+
action = "start";
|
|
576
|
+
else if (/\bsnapshot\b/.test(p))
|
|
577
|
+
action = "snapshot";
|
|
578
|
+
if (action !== "none" && g) {
|
|
579
|
+
return { action, guest: g.name, answer: "", explanation: `Interpreted "${prompt}" as ${action} ${g.name}.${source === "unconfigured" ? " (rule-based — set PROXMOX_MCP_AI_ENDPOINT for a real model)" : ""}`, source };
|
|
580
|
+
}
|
|
581
|
+
if (action !== "none" && !g) {
|
|
582
|
+
return { action: "none", guest: "", answer: `Which guest should I ${action}? Try e.g. "${action} db".`, explanation: "", source };
|
|
583
|
+
}
|
|
584
|
+
return { action: "none", guest: "", answer: `I didn't catch an action. Try "restart db", "shutdown 200", or ask "which VMs are down?".`, explanation: "", source };
|
|
585
|
+
}
|
|
586
|
+
function demoStorageContent(content) {
|
|
587
|
+
const now = Math.floor(Date.now() / 1000);
|
|
588
|
+
const backups = [
|
|
589
|
+
{ volid: "nas-backups:backup/vzdump-qemu-101-2026_07_16-03_00_04.vma.zst", content: "backup", format: "vma.zst", size: 21_495_808_000, vmid: 101, ctime: now - 3600 },
|
|
590
|
+
{ volid: "nas-backups:backup/vzdump-lxc-201-2026_07_16-03_02_10.tar.zst", content: "backup", format: "tar.zst", size: 3_650_000_000, vmid: 201, ctime: now - 3300 },
|
|
591
|
+
{ volid: "nas-backups:backup/vzdump-qemu-100-2026_07_15-03_00_00.vma.zst", content: "backup", format: "vma.zst", size: 12_800_000_000, vmid: 100, ctime: now - 90000 },
|
|
592
|
+
];
|
|
593
|
+
const templates = [
|
|
594
|
+
{ volid: "local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst", content: "vztmpl", size: 130_000_000 },
|
|
595
|
+
{ volid: "local:vztmpl/ubuntu-24.04-standard_24.04-2_amd64.tar.zst", content: "vztmpl", size: 145_000_000 },
|
|
596
|
+
{ volid: "local:vztmpl/alpine-3.20-default_20240908_amd64.tar.xz", content: "vztmpl", size: 3_400_000 },
|
|
597
|
+
];
|
|
598
|
+
const isos = [
|
|
599
|
+
{ volid: "local:iso/debian-12.7.0-amd64-netinst.iso", content: "iso", size: 660_000_000 },
|
|
600
|
+
{ volid: "local:iso/ubuntu-24.04.1-live-server-amd64.iso", content: "iso", size: 2_800_000_000 },
|
|
601
|
+
];
|
|
602
|
+
if (content === "backup")
|
|
603
|
+
return backups;
|
|
604
|
+
if (content === "vztmpl")
|
|
605
|
+
return templates;
|
|
606
|
+
if (content === "iso")
|
|
607
|
+
return isos;
|
|
608
|
+
return [...backups, ...templates, ...isos];
|
|
609
|
+
}
|
|
610
|
+
function demoSnapshots(guest) {
|
|
611
|
+
if (guest.vmid === 101) {
|
|
612
|
+
return [
|
|
613
|
+
{ name: "pre-upgrade", parent: "", description: "before PostgreSQL 16 upgrade" },
|
|
614
|
+
{ name: "nightly", parent: "pre-upgrade", description: "automated nightly" },
|
|
615
|
+
{ name: "current", parent: "nightly", description: "You are here!" },
|
|
616
|
+
];
|
|
617
|
+
}
|
|
618
|
+
return [{ name: "current", parent: "", description: "You are here!" }];
|
|
619
|
+
}
|
|
620
|
+
//# sourceMappingURL=client.js.map
|