@astrofoundry/pi-astro 0.19.3 → 0.20.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/README.md +4 -4
- package/agents/arcane.md +1 -1
- package/agents/backup.md +22 -0
- package/agents/dns.md +1 -1
- package/agents/edge.md +1 -1
- package/agents/identity.md +1 -1
- package/agents/inference.md +21 -0
- package/agents/network.md +1 -1
- package/agents/proxmox.md +23 -0
- package/agents/security.md +1 -1
- package/package.json +1 -1
- package/skills/backup/SKILL.md +47 -0
- package/skills/inference/SKILL.md +53 -0
- package/skills/proxmox/SKILL.md +62 -0
- package/skills/security/SKILL.md +1 -1
- package/specialists/AGENTS.md +9 -5
- package/specialists/README.md +24 -7
- package/specialists/backup/run.ts +122 -0
- package/specialists/entry/remote/spc-backup-arcane-entry.sh +21 -0
- package/specialists/entry/remote/spc-backup-pulsar-entry.sh +51 -0
- package/specialists/entry/remote/spc-inference-hermes-entry.sh +27 -0
- package/specialists/entry/remote/spc-inference-nexus-entry.sh +37 -0
- package/specialists/entry/remote/spc-inference-pulsar-entry.sh +14 -0
- package/specialists/entry/remote/spc-proxmox-pulsar-entry.sh +44 -0
- package/specialists/inference/run.ts +167 -0
- package/specialists/install/install.sh +2 -2
- package/specialists/lib/pinned.ts +49 -0
- package/specialists/network/run.ts +6 -35
- package/specialists/proxmox/run.ts +330 -0
- package/specialists/security/run.ts +27 -12
- package/specialists/wrappers.test.ts +105 -2
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { readConfig } from "../lib/config.ts";
|
|
3
|
+
import { ServiceError, UsageError } from "../lib/errors.ts";
|
|
4
|
+
import { main } from "../lib/main.ts";
|
|
5
|
+
import { printJson, printRaw } from "../lib/output.ts";
|
|
6
|
+
import { specialistsHome } from "../lib/paths.ts";
|
|
7
|
+
import { pinnedRequest } from "../lib/pinned.ts";
|
|
8
|
+
import { redactSecrets } from "../lib/sanitize.ts";
|
|
9
|
+
import { readSecret, secretPath } from "../lib/secrets.ts";
|
|
10
|
+
import { sshFixed } from "../lib/ssh.ts";
|
|
11
|
+
|
|
12
|
+
const SERVICE = "proxmox";
|
|
13
|
+
|
|
14
|
+
interface ProxmoxConfig extends Record<string, string> {
|
|
15
|
+
/** e.g. https://10.0.10.37:8006 */
|
|
16
|
+
apiUrl: string;
|
|
17
|
+
/** SHA-256 fingerprint of the pveproxy certificate, colon-separated uppercase hex. */
|
|
18
|
+
certSha256: string;
|
|
19
|
+
node: string;
|
|
20
|
+
pulsarHost: string;
|
|
21
|
+
pulsarUser: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const SHAPE = { apiUrl: "string", certSha256: "string", node: "string", pulsarHost: "string", pulsarUser: "string" } as const;
|
|
25
|
+
|
|
26
|
+
const TIMEOUT_MS = 30_000;
|
|
27
|
+
const TASK_POLL_MS = 2_000;
|
|
28
|
+
const VMID = /^\d{3,9}$/;
|
|
29
|
+
const SNAPNAME = /^[A-Za-z][A-Za-z0-9_-]{0,39}$/;
|
|
30
|
+
const API_PATH = /^\/[A-Za-z0-9_\-/.?=&%]{1,300}$/;
|
|
31
|
+
|
|
32
|
+
/** Guest power actions; all but start need --confirm. */
|
|
33
|
+
export const POWER_ACTIONS = ["start", "stop", "shutdown", "reboot"] as const;
|
|
34
|
+
type PowerAction = (typeof POWER_ACTIONS)[number];
|
|
35
|
+
|
|
36
|
+
/** Config keys `set` may change, per guest type; the role grants no disk or allocation privilege. */
|
|
37
|
+
export const SET_KEYS: Readonly<Record<"qemu" | "lxc", readonly string[]>> = {
|
|
38
|
+
qemu: ["cores", "sockets", "memory", "balloon", "onboot", "startup", "description", "tags", "protection"],
|
|
39
|
+
lxc: ["cores", "memory", "swap", "onboot", "startup", "description", "tags", "protection"],
|
|
40
|
+
};
|
|
41
|
+
const NET_KEY = /^net\d{1,2}$/;
|
|
42
|
+
|
|
43
|
+
/** Fixed remote commands on Pulsar; the forced command accepts exactly these names. */
|
|
44
|
+
export const HOST_COMMANDS: Readonly<Record<string, { args: number; help: string }>> = {
|
|
45
|
+
"host-status": { args: 0, help: "pveversion, uptime, load, memory, root disk, temperatures" },
|
|
46
|
+
"host-journal": { args: 1, help: "host-journal <since> warnings and errors of the host journal" },
|
|
47
|
+
updates: { args: 0, help: "upgradable packages on the host" },
|
|
48
|
+
"guest-exec": { args: 2, help: "guest-exec <vmid> <status|journal|df|updates> fixed read inside a guest (pct exec or qm guest exec)" },
|
|
49
|
+
};
|
|
50
|
+
export const GUEST_EXEC = ["status", "journal", "df", "updates"];
|
|
51
|
+
const SINCE = /^[A-Za-z0-9 :\-+]{1,40}$/;
|
|
52
|
+
|
|
53
|
+
const HELP = `proxmox specialist (Proxmox VE on Pulsar)
|
|
54
|
+
|
|
55
|
+
guests every VM and container: vmid, name, type, status, resources (JSON)
|
|
56
|
+
guest <vmid> current status and configuration of one guest (JSON)
|
|
57
|
+
get <path> any GET under /api2/json, e.g. /nodes/pulsar/status, /cluster/resources?type=storage
|
|
58
|
+
tasks [n] last n tasks on the node (default 20)
|
|
59
|
+
task <upid> status and log of one task
|
|
60
|
+
${POWER_ACTIONS.join("|")} <vmid> [--confirm] power action, waits for the task; --confirm needed except for start
|
|
61
|
+
snapshots <vmid> snapshot list
|
|
62
|
+
snapshot-create <vmid> <name> [description]
|
|
63
|
+
snapshot-delete <vmid> <name> --confirm
|
|
64
|
+
snapshot-rollback <vmid> <name> --confirm
|
|
65
|
+
set <vmid> key=value... VM: ${SET_KEYS.qemu.join(", ")}, netN; CT: ${SET_KEYS.lxc.join(", ")}, netN
|
|
66
|
+
${Object.entries(HOST_COMMANDS)
|
|
67
|
+
.map(([name, c]) => ` ${name.padEnd(35)} ${c.help}`)
|
|
68
|
+
.join("\n")}
|
|
69
|
+
|
|
70
|
+
Not available: create, clone, destroy, disk changes, host power or configuration. Those are operator tasks.`;
|
|
71
|
+
|
|
72
|
+
interface PveEnvelope {
|
|
73
|
+
data?: unknown;
|
|
74
|
+
errors?: Record<string, string>;
|
|
75
|
+
message?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface GuestResource {
|
|
79
|
+
vmid: number;
|
|
80
|
+
name?: string;
|
|
81
|
+
type: "qemu" | "lxc";
|
|
82
|
+
node: string;
|
|
83
|
+
status: string;
|
|
84
|
+
maxmem?: number;
|
|
85
|
+
mem?: number;
|
|
86
|
+
maxcpu?: number;
|
|
87
|
+
cpu?: number;
|
|
88
|
+
maxdisk?: number;
|
|
89
|
+
uptime?: number;
|
|
90
|
+
tags?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function api(config: ProxmoxConfig, method: string, path: string, form?: Record<string, string>): Promise<unknown> {
|
|
94
|
+
const tokenId = readSecret(SERVICE, "PVE_TOKEN_ID");
|
|
95
|
+
const secret = readSecret(SERVICE, "PVE_TOKEN_SECRET");
|
|
96
|
+
const body = form === undefined ? undefined : new URLSearchParams(form).toString();
|
|
97
|
+
const response = await pinnedRequest(new URL(`/api2/json${path}`, config.apiUrl), {
|
|
98
|
+
method,
|
|
99
|
+
headers: {
|
|
100
|
+
Authorization: `PVEAPIToken=${tokenId}=${secret}`,
|
|
101
|
+
Accept: "application/json",
|
|
102
|
+
...(body === undefined ? {} : { "Content-Type": "application/x-www-form-urlencoded", "Content-Length": String(Buffer.byteLength(body)) }),
|
|
103
|
+
},
|
|
104
|
+
body,
|
|
105
|
+
expectedFingerprint: config.certSha256,
|
|
106
|
+
timeoutMs: TIMEOUT_MS,
|
|
107
|
+
});
|
|
108
|
+
let envelope: PveEnvelope = {};
|
|
109
|
+
try {
|
|
110
|
+
envelope = JSON.parse(response.body) as PveEnvelope;
|
|
111
|
+
} catch {
|
|
112
|
+
/* error bodies can be plain text */
|
|
113
|
+
}
|
|
114
|
+
if (response.status < 200 || response.status >= 300) {
|
|
115
|
+
const detail = envelope.errors ? Object.entries(envelope.errors).map(([k, v]) => `${k}: ${v}`).join("; ") : envelope.message ?? response.body.trim();
|
|
116
|
+
throw new ServiceError(`Proxmox ${method} ${path} answered ${response.status}${detail ? `: ${detail}` : ""}`);
|
|
117
|
+
}
|
|
118
|
+
return envelope.data;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function parseVmid(value: string | undefined): number {
|
|
122
|
+
if (value === undefined || !VMID.test(value)) throw new UsageError("vmid must be a number from 100 upwards");
|
|
123
|
+
return Number(value);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function validateApiPath(path: string | undefined): string {
|
|
127
|
+
if (path === undefined || !API_PATH.test(path) || path.includes("..")) throw new UsageError("path must start with / and contain no spaces");
|
|
128
|
+
return path;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function parseSetPairs(type: "qemu" | "lxc", pairs: string[]): Record<string, string> {
|
|
132
|
+
if (pairs.length === 0) throw new UsageError("set <vmid> key=value...");
|
|
133
|
+
const form: Record<string, string> = {};
|
|
134
|
+
for (const pair of pairs) {
|
|
135
|
+
const eq = pair.indexOf("=");
|
|
136
|
+
if (eq <= 0) throw new UsageError(`expected key=value, got ${pair}`);
|
|
137
|
+
const key = pair.slice(0, eq);
|
|
138
|
+
if (!SET_KEYS[type].includes(key) && !NET_KEY.test(key)) throw new UsageError(`${key} is not a key this specialist may set on a ${type} guest`);
|
|
139
|
+
form[key] = pair.slice(eq + 1);
|
|
140
|
+
}
|
|
141
|
+
return form;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function needsConfirm(args: string[]): string[] {
|
|
145
|
+
const index = args.indexOf("--confirm");
|
|
146
|
+
if (index === -1) throw new UsageError(`${args[0]} interrupts a guest; add --confirm when the task asks for it explicitly`);
|
|
147
|
+
return args.filter((_, i) => i !== index);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function guestResource(config: ProxmoxConfig, vmid: number): Promise<GuestResource> {
|
|
151
|
+
const resources = (await api(config, "GET", "/cluster/resources?type=vm")) as GuestResource[];
|
|
152
|
+
const guest = resources.find((r) => r.vmid === vmid);
|
|
153
|
+
if (!guest) throw new UsageError(`no guest with vmid ${vmid}`);
|
|
154
|
+
return guest;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function guestPath(guest: GuestResource): string {
|
|
158
|
+
return `/nodes/${guest.node}/${guest.type}/${guest.vmid}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
interface TaskStatus {
|
|
162
|
+
status: string;
|
|
163
|
+
exitstatus?: string;
|
|
164
|
+
type?: string;
|
|
165
|
+
starttime?: number;
|
|
166
|
+
endtime?: number;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function waitTask(config: ProxmoxConfig, upid: string, timeoutMs: number): Promise<TaskStatus> {
|
|
170
|
+
const deadline = Date.now() + timeoutMs;
|
|
171
|
+
for (;;) {
|
|
172
|
+
const status = (await api(config, "GET", `/nodes/${config.node}/tasks/${encodeURIComponent(upid)}/status`)) as TaskStatus;
|
|
173
|
+
if (status.status === "stopped") return status;
|
|
174
|
+
if (Date.now() > deadline) throw new ServiceError(`task ${upid} still running after ${timeoutMs} ms`);
|
|
175
|
+
await new Promise((resolve) => setTimeout(resolve, TASK_POLL_MS));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function runTask(config: ProxmoxConfig, method: string, path: string, form: Record<string, string> | undefined, label: string): Promise<number> {
|
|
180
|
+
const upid = (await api(config, method, path, form)) as string;
|
|
181
|
+
const status = await waitTask(config, upid, 300_000);
|
|
182
|
+
printJson({ action: label, upid, exitstatus: status.exitstatus, endtime: status.endtime });
|
|
183
|
+
if (status.exitstatus !== "OK") throw new ServiceError(`${label} failed: ${status.exitstatus ?? "unknown"}`);
|
|
184
|
+
return 0;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function apiCommand(config: ProxmoxConfig, args: string[]): Promise<number> {
|
|
188
|
+
const [name, ...rest] = args;
|
|
189
|
+
switch (name) {
|
|
190
|
+
case "guests": {
|
|
191
|
+
const resources = (await api(config, "GET", "/cluster/resources?type=vm")) as GuestResource[];
|
|
192
|
+
printJson(
|
|
193
|
+
resources
|
|
194
|
+
.sort((a, b) => a.vmid - b.vmid)
|
|
195
|
+
.map(({ vmid, name: guestName, type, status, node, maxmem, mem, maxcpu, cpu, maxdisk, uptime, tags }) => ({
|
|
196
|
+
vmid,
|
|
197
|
+
name: guestName,
|
|
198
|
+
type,
|
|
199
|
+
status,
|
|
200
|
+
node,
|
|
201
|
+
maxmem,
|
|
202
|
+
mem,
|
|
203
|
+
maxcpu,
|
|
204
|
+
cpu,
|
|
205
|
+
maxdisk,
|
|
206
|
+
uptime,
|
|
207
|
+
tags,
|
|
208
|
+
})),
|
|
209
|
+
);
|
|
210
|
+
return 0;
|
|
211
|
+
}
|
|
212
|
+
case "guest": {
|
|
213
|
+
const guest = await guestResource(config, parseVmid(rest[0]));
|
|
214
|
+
const base = guestPath(guest);
|
|
215
|
+
const [status, guestConfig] = await Promise.all([api(config, "GET", `${base}/status/current`), api(config, "GET", `${base}/config`)]);
|
|
216
|
+
printJson({ guest, status, config: redactSecrets(guestConfig) });
|
|
217
|
+
return 0;
|
|
218
|
+
}
|
|
219
|
+
case "get":
|
|
220
|
+
if (rest.length !== 1) throw new UsageError("get <path>");
|
|
221
|
+
printJson(redactSecrets(await api(config, "GET", validateApiPath(rest[0]))));
|
|
222
|
+
return 0;
|
|
223
|
+
case "tasks": {
|
|
224
|
+
const n = rest[0] === undefined ? 20 : Number(rest[0]);
|
|
225
|
+
if (!Number.isInteger(n) || n < 1 || n > 200) throw new UsageError("tasks [n]: n must be 1..200");
|
|
226
|
+
printJson(await api(config, "GET", `/nodes/${config.node}/tasks?limit=${n}`));
|
|
227
|
+
return 0;
|
|
228
|
+
}
|
|
229
|
+
case "task": {
|
|
230
|
+
if (rest.length !== 1 || !/^UPID:[A-Za-z0-9:_.@-]+$/.test(rest[0])) throw new UsageError("task <upid>");
|
|
231
|
+
const upid = encodeURIComponent(rest[0]);
|
|
232
|
+
const [status, log] = await Promise.all([
|
|
233
|
+
api(config, "GET", `/nodes/${config.node}/tasks/${upid}/status`),
|
|
234
|
+
api(config, "GET", `/nodes/${config.node}/tasks/${upid}/log?limit=200`),
|
|
235
|
+
]);
|
|
236
|
+
printJson({ status, log });
|
|
237
|
+
return 0;
|
|
238
|
+
}
|
|
239
|
+
case "start":
|
|
240
|
+
case "stop":
|
|
241
|
+
case "shutdown":
|
|
242
|
+
case "reboot": {
|
|
243
|
+
const action = name as PowerAction;
|
|
244
|
+
const positional = action === "start" ? rest : needsConfirm(args).slice(1);
|
|
245
|
+
if (positional.length !== 1) throw new UsageError(`${action} <vmid>${action === "start" ? "" : " --confirm"}`);
|
|
246
|
+
const guest = await guestResource(config, parseVmid(positional[0]));
|
|
247
|
+
return runTask(config, "POST", `${guestPath(guest)}/status/${action}`, {}, `${action} ${guest.vmid}`);
|
|
248
|
+
}
|
|
249
|
+
case "snapshots": {
|
|
250
|
+
const guest = await guestResource(config, parseVmid(rest[0]));
|
|
251
|
+
printJson(await api(config, "GET", `${guestPath(guest)}/snapshot`));
|
|
252
|
+
return 0;
|
|
253
|
+
}
|
|
254
|
+
case "snapshot-create": {
|
|
255
|
+
if (rest.length < 2 || rest.length > 3) throw new UsageError("snapshot-create <vmid> <name> [description]");
|
|
256
|
+
if (!SNAPNAME.test(rest[1])) throw new UsageError("snapshot name: letter first, then letters, digits, dash, underscore");
|
|
257
|
+
const guest = await guestResource(config, parseVmid(rest[0]));
|
|
258
|
+
const form: Record<string, string> = { snapname: rest[1], ...(rest[2] === undefined ? {} : { description: rest[2] }) };
|
|
259
|
+
return runTask(config, "POST", `${guestPath(guest)}/snapshot`, form, `snapshot-create ${guest.vmid} ${rest[1]}`);
|
|
260
|
+
}
|
|
261
|
+
case "snapshot-delete":
|
|
262
|
+
case "snapshot-rollback": {
|
|
263
|
+
const positional = needsConfirm(args).slice(1);
|
|
264
|
+
if (positional.length !== 2 || !SNAPNAME.test(positional[1])) throw new UsageError(`${name} <vmid> <name> --confirm`);
|
|
265
|
+
const guest = await guestResource(config, parseVmid(positional[0]));
|
|
266
|
+
const snapshotPath = `${guestPath(guest)}/snapshot/${positional[1]}`;
|
|
267
|
+
return name === "snapshot-delete"
|
|
268
|
+
? runTask(config, "DELETE", snapshotPath, undefined, `snapshot-delete ${guest.vmid} ${positional[1]}`)
|
|
269
|
+
: runTask(config, "POST", `${snapshotPath}/rollback`, {}, `snapshot-rollback ${guest.vmid} ${positional[1]}`);
|
|
270
|
+
}
|
|
271
|
+
case "set": {
|
|
272
|
+
const guest = await guestResource(config, parseVmid(rest[0]));
|
|
273
|
+
const form = parseSetPairs(guest.type, rest.slice(1));
|
|
274
|
+
await api(config, "PUT", `${guestPath(guest)}/config`, form);
|
|
275
|
+
printJson({ vmid: guest.vmid, set: form, config: redactSecrets(await api(config, "GET", `${guestPath(guest)}/config`)) });
|
|
276
|
+
return 0;
|
|
277
|
+
}
|
|
278
|
+
default:
|
|
279
|
+
throw new UsageError(`unknown command: ${name ?? "(none)"}\n${HELP}`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function buildHostRemote(args: string[]): string {
|
|
284
|
+
const [name, ...rest] = args;
|
|
285
|
+
const spec = name === undefined ? undefined : HOST_COMMANDS[name];
|
|
286
|
+
if (!spec) throw new UsageError(`unknown host command: ${name ?? "(none)"}`);
|
|
287
|
+
if (rest.length !== spec.args) throw new UsageError(`${name} takes ${spec.args} argument(s)`);
|
|
288
|
+
if (name === "host-journal" && !SINCE.test(rest[0])) throw new UsageError("since: letters, digits, spaces, colon, plus, minus only");
|
|
289
|
+
if (name === "guest-exec") {
|
|
290
|
+
parseVmid(rest[0]);
|
|
291
|
+
if (!GUEST_EXEC.includes(rest[1])) throw new UsageError(`guest-exec action must be one of ${GUEST_EXEC.join(", ")}`);
|
|
292
|
+
}
|
|
293
|
+
return [name, ...rest].join(" ");
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function host(config: ProxmoxConfig, args: string[]): Promise<number> {
|
|
297
|
+
const remote = buildHostRemote(args);
|
|
298
|
+
const result = await sshFixed(
|
|
299
|
+
{
|
|
300
|
+
host: config.pulsarHost,
|
|
301
|
+
user: config.pulsarUser,
|
|
302
|
+
keyFile: secretPath(SERVICE, "SSH_KEY_PULSAR"),
|
|
303
|
+
knownHostsFile: join(specialistsHome(), "config", "known_hosts"),
|
|
304
|
+
},
|
|
305
|
+
remote,
|
|
306
|
+
120_000,
|
|
307
|
+
);
|
|
308
|
+
if (result.stdout.length > 0) printRaw(result.stdout);
|
|
309
|
+
if (result.code !== 0) throw new ServiceError(result.stderr.trim() || `${args[0]} exited ${result.code}`);
|
|
310
|
+
return 0;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export async function command(args: string[]): Promise<number> {
|
|
314
|
+
if (args.length === 0 || args[0] === "--help" || args[0] === "help") {
|
|
315
|
+
printRaw(HELP);
|
|
316
|
+
return 0;
|
|
317
|
+
}
|
|
318
|
+
const config = readConfig<ProxmoxConfig>(SERVICE, SHAPE);
|
|
319
|
+
if (args[0] === "fingerprint") {
|
|
320
|
+
const probe = await pinnedRequest(new URL("/", config.apiUrl), { method: "GET", headers: {}, expectedFingerprint: null, timeoutMs: TIMEOUT_MS });
|
|
321
|
+
printJson({ host: new URL(config.apiUrl).host, fingerprint256: probe.fingerprint });
|
|
322
|
+
return 0;
|
|
323
|
+
}
|
|
324
|
+
if (args[0] in HOST_COMMANDS) return host(config, args);
|
|
325
|
+
return apiCommand(config, args);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) {
|
|
329
|
+
await main(SERVICE, command);
|
|
330
|
+
}
|
|
@@ -92,7 +92,7 @@ interface CrowdsecAlert {
|
|
|
92
92
|
scenario?: string;
|
|
93
93
|
events_count?: number;
|
|
94
94
|
source?: { ip?: string; range?: string; as_number?: string; as_name?: string; cn?: string };
|
|
95
|
-
decisions?:
|
|
95
|
+
decisions?: CrowdsecDecision[];
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
interface CompactAlert {
|
|
@@ -188,17 +188,32 @@ interface CrowdsecDecision {
|
|
|
188
188
|
origin?: string;
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
191
|
+
interface CompactDecision extends CrowdsecDecision {
|
|
192
|
+
alertId?: number;
|
|
193
|
+
country?: string;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* `cscli decisions list -o json` returns alerts, each with its active decisions
|
|
198
|
+
* (the table's ALERT ID, COUNTRY, and AS columns are alert fields). Flattens to one row per decision.
|
|
199
|
+
*/
|
|
200
|
+
export function compactDecisions(text: string): { count: number; decisions: CompactDecision[] } {
|
|
201
|
+
const decisions: CompactDecision[] = [];
|
|
202
|
+
for (const alert of parseJsonArray<CrowdsecAlert & { decisions?: CrowdsecDecision[] }>(text, "decisions")) {
|
|
203
|
+
for (const d of alert.decisions ?? []) {
|
|
204
|
+
decisions.push({
|
|
205
|
+
id: d.id,
|
|
206
|
+
value: d.value,
|
|
207
|
+
scope: d.scope,
|
|
208
|
+
type: d.type,
|
|
209
|
+
duration: d.duration,
|
|
210
|
+
scenario: d.scenario ?? alert.scenario,
|
|
211
|
+
origin: d.origin,
|
|
212
|
+
alertId: alert.id,
|
|
213
|
+
country: alert.source?.cn,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
202
217
|
return { count: decisions.length, decisions };
|
|
203
218
|
}
|
|
204
219
|
|
|
@@ -3,12 +3,15 @@ import { tmpdir } from "node:os";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
import { refusedPrefix, command as arcaneCommand } from "./arcane/run.ts";
|
|
6
|
+
import { buildBackupRemote, command as backupCommand } from "./backup/run.ts";
|
|
6
7
|
import { parseParams, parseRecordFilters, technitiumRequest, command as dnsCommand } from "./dns/run.ts";
|
|
7
8
|
import { buildVpsRemote, command as edgeCommand } from "./edge/run.ts";
|
|
8
9
|
import { API_PREFIXES, buildDmzRemote, validateApiPath, command as identityCommand } from "./identity/run.ts";
|
|
10
|
+
import { buildInferenceRemote, compactReleases, command as inferenceCommand } from "./inference/run.ts";
|
|
9
11
|
import { UsageError } from "./lib/errors.ts";
|
|
10
12
|
import { safeRepoPath, safeWritePath } from "./lib/repo.ts";
|
|
11
13
|
import { buildPulsarRemote, parseQuery, unifiPath, command as networkCommand } from "./network/run.ts";
|
|
14
|
+
import { buildHostRemote, needsConfirm, parseSetPairs, parseVmid, validateApiPath as validatePvePath, command as proxmoxCommand } from "./proxmox/run.ts";
|
|
12
15
|
import { buildObsRemote, compactDecisions, summariseAlerts, summariseWazuh, command as securityCommand } from "./security/run.ts";
|
|
13
16
|
|
|
14
17
|
describe("arcane wrapper", () => {
|
|
@@ -244,9 +247,18 @@ describe("security wrapper", () => {
|
|
|
244
247
|
expect(summary.latest[0]).toMatchObject({ id: 3, ip: "203.0.113.9", decisions: ["ban 4h"], country: "US" });
|
|
245
248
|
expect(summariseAlerts(text, 7, "45.79.207.181").latest.map((a) => a.id)).toEqual([2, 1]);
|
|
246
249
|
expect(summariseAlerts("null", 7)).toMatchObject({ count: 0, uniqueIps: 0 });
|
|
247
|
-
|
|
250
|
+
const decisionsText = JSON.stringify([
|
|
251
|
+
{
|
|
252
|
+
id: 364,
|
|
253
|
+
scenario: "37pla/frontdoor-sni-scanning",
|
|
254
|
+
source: { ip: "203.0.113.9", cn: "US" },
|
|
255
|
+
decisions: [{ id: 9, value: "203.0.113.9", scope: "Ip", type: "ban", duration: "3h59m", origin: "crowdsec", simulated: false }],
|
|
256
|
+
},
|
|
257
|
+
{ id: 365, scenario: "x", source: {}, decisions: [] },
|
|
258
|
+
]);
|
|
259
|
+
expect(compactDecisions(decisionsText)).toEqual({
|
|
248
260
|
count: 1,
|
|
249
|
-
decisions: [{ id: 9, value: "203.0.113.9", scope: "Ip", type: "ban", duration: "3h59m", scenario: "
|
|
261
|
+
decisions: [{ id: 9, value: "203.0.113.9", scope: "Ip", type: "ban", duration: "3h59m", scenario: "37pla/frontdoor-sni-scanning", origin: "crowdsec", alertId: 364, country: "US" }],
|
|
250
262
|
});
|
|
251
263
|
expect(() => summariseAlerts("not json", 7)).toThrow(/no JSON/);
|
|
252
264
|
});
|
|
@@ -259,6 +271,94 @@ describe("security wrapper", () => {
|
|
|
259
271
|
});
|
|
260
272
|
});
|
|
261
273
|
|
|
274
|
+
describe("backup wrapper", () => {
|
|
275
|
+
it("maps commands to targets and validates arguments", () => {
|
|
276
|
+
expect(buildBackupRemote(["vzdump-tasks"])).toMatchObject({ remote: "vzdump-tasks", spec: { target: "pulsar" } });
|
|
277
|
+
expect(buildBackupRemote(["vzdump-run", "107"]).remote).toBe("vzdump-run 107");
|
|
278
|
+
expect(buildBackupRemote(["restic-snapshots", "pulsar-host"]).remote).toBe("restic-snapshots pulsar-host");
|
|
279
|
+
expect(buildBackupRemote(["restic-ls", "latest", "/etc/pve"]).remote).toBe("restic-ls latest /etc/pve");
|
|
280
|
+
expect(buildBackupRemote(["restic-restore", "08a509d6", "/etc/pve/storage.cfg"]).remote).toBe("restic-restore 08a509d6 /etc/pve/storage.cfg");
|
|
281
|
+
expect(buildBackupRemote(["arcane-backup-journal", "12 hours ago"])).toMatchObject({ spec: { target: "arcane" } });
|
|
282
|
+
expect(() => buildBackupRemote(["vzdump-run", "200"])).toThrow(/vmid/);
|
|
283
|
+
expect(() => buildBackupRemote(["restic-restore", "latest", "../etc"])).toThrow(/absolute/);
|
|
284
|
+
expect(() => buildBackupRemote(["restic-restore", "latest", "/etc/pve; rm -rf /"])).toThrow(/absolute/);
|
|
285
|
+
expect(() => buildBackupRemote(["restic-ls", "xyz"])).toThrow(/snapshot/);
|
|
286
|
+
expect(() => buildBackupRemote(["restic-snapshots", "a b"])).toThrow(/tag/);
|
|
287
|
+
expect(() => buildBackupRemote(["gcs-journal", "x; id"])).toThrow(/since/);
|
|
288
|
+
expect(() => buildBackupRemote(["restic-forget"])).toThrow(/unknown command/);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("prints help without touching config", async () => {
|
|
292
|
+
const write = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
293
|
+
expect(await backupCommand(["--help"])).toBe(0);
|
|
294
|
+
expect(String(write.mock.calls[0][0])).toContain("restore-clean");
|
|
295
|
+
write.mockRestore();
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
describe("proxmox wrapper", () => {
|
|
300
|
+
it("validates vmids, paths, confirmation, and set keys", () => {
|
|
301
|
+
expect(parseVmid("106")).toBe(106);
|
|
302
|
+
expect(() => parseVmid("6")).toThrow(/vmid/);
|
|
303
|
+
expect(validatePvePath("/nodes/pulsar/status")).toBe("/nodes/pulsar/status");
|
|
304
|
+
expect(validatePvePath("/cluster/resources?type=storage")).toBe("/cluster/resources?type=storage");
|
|
305
|
+
expect(() => validatePvePath("nodes")).toThrow(UsageError);
|
|
306
|
+
expect(() => validatePvePath("/nodes/../access")).toThrow(UsageError);
|
|
307
|
+
expect(needsConfirm(["stop", "106", "--confirm"])).toEqual(["stop", "106"]);
|
|
308
|
+
expect(() => needsConfirm(["stop", "106"])).toThrow(/--confirm/);
|
|
309
|
+
expect(parseSetPairs("qemu", ["memory=4096", "cores=2", "net0=virtio,bridge=vmbr0"])).toEqual({ memory: "4096", cores: "2", net0: "virtio,bridge=vmbr0" });
|
|
310
|
+
expect(parseSetPairs("lxc", ["swap=512"])).toEqual({ swap: "512" });
|
|
311
|
+
expect(() => parseSetPairs("qemu", ["swap=512"])).toThrow(/may set/);
|
|
312
|
+
expect(() => parseSetPairs("lxc", ["scsi0=local-lvm:32"])).toThrow(/may set/);
|
|
313
|
+
expect(() => parseSetPairs("qemu", [])).toThrow(/key=value/);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("maps host commands to fixed remote strings", () => {
|
|
317
|
+
expect(buildHostRemote(["host-status"])).toBe("host-status");
|
|
318
|
+
expect(buildHostRemote(["guest-exec", "101", "df"])).toBe("guest-exec 101 df");
|
|
319
|
+
expect(() => buildHostRemote(["guest-exec", "101", "rm"])).toThrow(/action/);
|
|
320
|
+
expect(() => buildHostRemote(["host-journal", "1 hour ago; id"])).toThrow(/since/);
|
|
321
|
+
expect(() => buildHostRemote(["shell"])).toThrow(/unknown host command/);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("prints help without touching config", async () => {
|
|
325
|
+
const write = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
326
|
+
expect(await proxmoxCommand([])).toBe(0);
|
|
327
|
+
expect(String(write.mock.calls[0][0])).toContain("snapshot-rollback");
|
|
328
|
+
write.mockRestore();
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
describe("inference wrapper", () => {
|
|
333
|
+
it("maps commands to targets and enforces confirmation", () => {
|
|
334
|
+
expect(buildInferenceRemote(["nexus-status"])).toMatchObject({ remote: "nexus-status", spec: { target: "nexus" } });
|
|
335
|
+
expect(buildInferenceRemote(["nexus-log", "embed", "100"]).remote).toBe("nexus-log embed 100");
|
|
336
|
+
expect(buildInferenceRemote(["nexus-stop", "rerank", "--confirm"]).remote).toBe("nexus-stop rerank");
|
|
337
|
+
expect(buildInferenceRemote(["hermes-upgrade", "--confirm"])).toMatchObject({ remote: "hermes-upgrade", spec: { target: "pulsar" } });
|
|
338
|
+
expect(buildInferenceRemote(["hermes-journal", "1 hour ago"])).toMatchObject({ spec: { target: "hermes" } });
|
|
339
|
+
expect(() => buildInferenceRemote(["nexus-stop", "rerank"])).toThrow(/--confirm/);
|
|
340
|
+
expect(() => buildInferenceRemote(["hermes-upgrade"])).toThrow(/--confirm/);
|
|
341
|
+
expect(() => buildInferenceRemote(["nexus-restart", "server"])).toThrow(/service/);
|
|
342
|
+
expect(() => buildInferenceRemote(["nexus-log", "embed", "5000"])).toThrow(/lines/);
|
|
343
|
+
expect(() => buildInferenceRemote(["hermes-restart", "discord"])).toThrow(/service/);
|
|
344
|
+
expect(() => buildInferenceRemote(["europa-start"])).toThrow(/unknown command/);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("compacts github releases", () => {
|
|
348
|
+
const releases = compactReleases([{ tag_name: "v2026.9.1", name: "Sept", published_at: "2026-09-10T00:00:00Z", html_url: "https://x/y", prerelease: false, body: "x".repeat(7000) }, {}]);
|
|
349
|
+
expect(releases[0]).toMatchObject({ tag: "v2026.9.1", name: "Sept", prerelease: false, url: "https://x/y" });
|
|
350
|
+
expect(releases[0].notes.length).toBe(6000);
|
|
351
|
+
expect(releases[1]).toEqual({ tag: "?", name: "", published: "", prerelease: false, url: "", notes: "" });
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("prints help without touching config", async () => {
|
|
355
|
+
const write = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
356
|
+
expect(await inferenceCommand(["help"])).toBe(0);
|
|
357
|
+
expect(String(write.mock.calls[0][0])).toContain("hermes-releases");
|
|
358
|
+
write.mockRestore();
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
262
362
|
describe("wrapper config errors", () => {
|
|
263
363
|
let home: string;
|
|
264
364
|
const originalHome = process.env.HOME;
|
|
@@ -279,5 +379,8 @@ describe("wrapper config errors", () => {
|
|
|
279
379
|
await expect(dnsCommand(["technitium", "primary", "zones"])).rejects.toThrow(/dns\.json/);
|
|
280
380
|
await expect(edgeCommand(["probe"])).rejects.toThrow(/edge\.json/);
|
|
281
381
|
await expect(securityCommand(["attention"])).rejects.toThrow(/security\.json/);
|
|
382
|
+
await expect(backupCommand(["vzdump-tasks"])).rejects.toThrow(/backup\.json/);
|
|
383
|
+
await expect(proxmoxCommand(["guests"])).rejects.toThrow(/proxmox\.json/);
|
|
384
|
+
await expect(inferenceCommand(["nexus-status"])).rejects.toThrow(/inference\.json/);
|
|
282
385
|
});
|
|
283
386
|
});
|