mirror of
https://github.com/YuzuZensai/Termix.git
synced 2026-11-12 12:41:38 +00:00
* fix(sso): send the PKCE verifier for GitHub login (#1534) * fix(remote-desktop): clip cursor overflow without disabling zoom (#1535) * test(remote-desktop): preserve sessions beyond one hour (#1536) * fix(hosts): expose controls for overflowing editor tabs (#1537) * fix(hosts): expose controls for overflowing editor tabs * test(hosts): mock resize observation in admin panel tests * fix(status): skip TCP probes while host sessions are active (#1538) * fix(database): batch session activity persistence (#1539) * docs(api): describe cookie and API key authentication (#1540) * fix(snippets): repair missing note column on SQLite upgrades (#1541) * fix(docker): retain stored SSH credential association (#1543) * fix(file-manager): render transfer toasts without plugin hooks (#1546) * feat(hosts): keep compact row actions inline (#1547) * fix(auth): allow retrying unavailable second-factor interfaces (#1549) * fix(auth): reject unresolved legacy identity provisioning (#1550) * fix(desktop): ship the plugin SDK inside Mac App Store builds (#1551) * fix(tmux): pass full session info to the terminal's session picker (#1552) The tmux.sessions service reduced detected sessions to bare names, but the picker reads session.name, so every entry rendered blank and selecting one sent an empty name, which created a new session instead of attaching. * fix(ssh): read force keyboard-interactive as a boolean when resolving hosts (#1553) The column holds the text "true"/"false", and the resolver passed it through as-is. The string "false" is truthy, so the password provider treated every saved host as forced keyboard-interactive and left the password out. Jump hops are built straight from the resolved host, so a password hop whose server doesn't offer keyboard-interactive failed with "All configured authentication methods failed". * fix(database): batch database saves for bulk host writes (#1554) * fix(database): yield to the event loop between database saves Each SQLite save serializes and encrypts the whole database into new buffers, which V8 only releases once the event loop turns. Boot migrations and bulk host import write plugin settings per host and per key in an awaited loop, so hundreds of full copies piled up and the container ran out of memory. * fix(database): save once per bulk host write instead of once per row On SQLite every repository write force-saves the whole database. Boot plugin data migrations, host defaults materialization and the bulk host routes write one row per host per setting, so they serialized and encrypted the full database hundreds of times in a row. DatabaseSaveTrigger.batched wraps a function so force saves made while it runs collapse into a single save when it finishes. Nested scopes fold into the outer one, and work that outlives its scope saves normally. * feat(hosts): add instance-wide predefined tag suggestions (#1548) * feat(hosts): add shared predefined tag suggestions * style(hosts): format tag catalog routes * test(database): advance fake timers past the save yield (#1555) * fix(i18n): complete Simplified Chinese core and plugin translations (#1542) * fix(i18n): complete Chinese onboarding and navigation labels * fix(i18n): translate remaining Chinese core and plugin interfaces * fix(i18n): translate host editor tab overflow controls * fix(i18n): use consistent Chinese fleet terminology * fix(i18n): localize hardcoded controls and plugin views * fix(i18n): translate built-in homepage widget catalog * test(homepage): follow translated timezone placeholder * fix(i18n): translate plugin-provided homepage widgets * fix(i18n): localize feature settings section titles * fix: 2.8 oidc accounts unable to sign in after upgrading (#1381) * fix: plugins losing the saved ssh login when copying a host (#1391) * fix: fleets, proxmox and automations not getting the host sudo password * fix: host imports running a defaults pass and metrics restart per host (#1384) * fix: high cpu from status probes and full database saves on every sample (#1300) * fix: user data export freezing the server (#1393) * fix: op:// secret references rejected as ssh keys on credentials (#1394) * fix: slow file deletes from the trash lookups on every delete (#1390) * fix: add openapi docs and error handling to host tag routes * test: compare download stream buffers directly so it stops timing out * chore: drop em dash from host row comment * chore: update release notes for 2.9.1 * fix: restore space to add host tags and redesign predefined tags editor * fix: host key silently accepted when the host is missing from the database (#1397) * fix: clearer host login failed status label and fix its translations (#1396) * chore: add host key and status label fixes to release notes * fix: sso and ldap provider dialogs overflowing the screen and using mismatched toggles * chore: increment ver * fix(audit): store plugin entries with no acting user as a null user_id (#1556) Plugin audit entries written outside a request used the literal "system" as user_id. That column references users.id, so the insert was refused (Postgres logs it as an FK violation) and the entry was silently dropped. Write null instead and keep "system" / plugin:<id> in username. * chore: add sso/ldap dialog and audit log fixes to release notes * fix: tunnels and host settings missing from shared hosts on desktop * fix: remote desktop logins missing from shared hosts on desktop * fix: simplify host status to online/offline and keep it live without a refresh * chore: sync Crowdin translations for 2.9.1 --------- Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
1427 lines
41 KiB
TypeScript
1427 lines
41 KiB
TypeScript
import type { Request, Response, Router } from "express";
|
|
import multer from "multer";
|
|
import JSZip from "jszip";
|
|
import type { Client, SFTPWrapper } from "ssh2";
|
|
import type {
|
|
PluginContext,
|
|
PluginHostShareLevel,
|
|
} from "@termix/plugin-sdk/backend";
|
|
import {
|
|
execCommand,
|
|
detectPlatform,
|
|
buildPackageActionCommand,
|
|
buildPackageRemoveCommand,
|
|
isValidPackageName,
|
|
execElevated,
|
|
ElevationError,
|
|
} from "@termix/plugin-sdk/host-commands";
|
|
import type { FleetRepository } from "./repository.js";
|
|
|
|
const FLEET_TRANSFER_MAX_BYTES = 200 * 1024 * 1024;
|
|
const upload = multer({
|
|
storage: multer.memoryStorage(),
|
|
limits: { fileSize: FLEET_TRANSFER_MAX_BYTES },
|
|
});
|
|
|
|
function isNonEmptyString(val: unknown): val is string {
|
|
return typeof val === "string" && val.trim().length > 0;
|
|
}
|
|
|
|
function parseFleetId(raw: unknown): number | null {
|
|
const id = typeof raw === "string" ? parseInt(raw, 10) : NaN;
|
|
return Number.isInteger(id) ? id : null;
|
|
}
|
|
|
|
const SHARE_LEVELS: PluginHostShareLevel[] = [
|
|
"connect",
|
|
"view",
|
|
"edit",
|
|
"manage",
|
|
];
|
|
|
|
function isShareLevel(value: unknown): value is PluginHostShareLevel {
|
|
return SHARE_LEVELS.includes(value as PluginHostShareLevel);
|
|
}
|
|
|
|
interface ShareTargetInput {
|
|
type: "user" | "role";
|
|
id: string | number;
|
|
}
|
|
|
|
function parseShareTargets(
|
|
body: Record<string, unknown>,
|
|
): ShareTargetInput[] | null {
|
|
const rawTargets = body.targets;
|
|
if (!Array.isArray(rawTargets) || rawTargets.length === 0) return null;
|
|
|
|
const targets: ShareTargetInput[] = [];
|
|
for (const raw of rawTargets) {
|
|
if (!raw || typeof raw !== "object") return null;
|
|
const { type, id } = raw as { type?: unknown; id?: unknown };
|
|
if (type === "user" && isNonEmptyString(id)) {
|
|
targets.push({ type: "user", id });
|
|
} else if (
|
|
type === "role" &&
|
|
typeof id === "number" &&
|
|
Number.isInteger(id)
|
|
) {
|
|
targets.push({ type: "role", id });
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
return targets;
|
|
}
|
|
|
|
interface CommandVariables {
|
|
resolve: (
|
|
content: string,
|
|
host: {
|
|
ip?: string;
|
|
username?: string;
|
|
port?: number | string;
|
|
name?: string;
|
|
} | null,
|
|
inputValues?: Record<string, string>,
|
|
) => string;
|
|
}
|
|
|
|
/**
|
|
* $HOST, $USER, $PORT, $NAME and $INPUT_n in an ad hoc fleet command, through
|
|
* the snippets plugin, which owns the syntax. Without it the command runs as
|
|
* typed.
|
|
*/
|
|
export function resolveCommandVariables(
|
|
ctx: Pick<PluginContext, "registry">,
|
|
command: string,
|
|
host: Parameters<CommandVariables["resolve"]>[1],
|
|
inputValues: Record<string, string> = {},
|
|
): string {
|
|
const variables =
|
|
ctx.registry.consume<CommandVariables>("snippets.variables");
|
|
return variables ? variables.resolve(command, host, inputValues) : command;
|
|
}
|
|
|
|
function actor(ctx: PluginContext): string {
|
|
return ctx.currentActor() as string;
|
|
}
|
|
|
|
function logError(
|
|
ctx: PluginContext,
|
|
message: string,
|
|
error: unknown,
|
|
operation: string,
|
|
): void {
|
|
ctx.log.error(
|
|
`${message} (${operation})`,
|
|
error instanceof Error ? error : new Error(String(error)),
|
|
);
|
|
}
|
|
|
|
function getSftp(client: Client): Promise<SFTPWrapper> {
|
|
return new Promise((resolve, reject) => {
|
|
client.sftp((err, sftp) => {
|
|
if (err) reject(err);
|
|
else resolve(sftp);
|
|
});
|
|
});
|
|
}
|
|
|
|
function sftpWriteFile(
|
|
sftp: SFTPWrapper,
|
|
remotePath: string,
|
|
data: Buffer,
|
|
): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const stream = sftp.createWriteStream(remotePath);
|
|
stream.on("error", reject);
|
|
stream.on("close", resolve);
|
|
stream.end(data);
|
|
});
|
|
}
|
|
|
|
function sftpReadFile(sftp: SFTPWrapper, remotePath: string): Promise<Buffer> {
|
|
return new Promise((resolve, reject) => {
|
|
const chunks: Buffer[] = [];
|
|
const stream = sftp.createReadStream(remotePath);
|
|
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
stream.on("error", reject);
|
|
stream.on("close", () => resolve(Buffer.concat(chunks)));
|
|
});
|
|
}
|
|
|
|
interface FleetHostResult {
|
|
hostId: number;
|
|
hostName: string;
|
|
success: boolean;
|
|
output?: string;
|
|
error?: string;
|
|
}
|
|
|
|
// Kernel/arch/hostname/uptime are cheap, always-available facts not covered by
|
|
// detectPlatform's tooling probe. One combined command keeps this to a single
|
|
// round trip per host, same "key=value per line" shape as PLATFORM_PROBE_COMMAND.
|
|
const INVENTORY_PROBE_COMMAND = [
|
|
"echo kernel=$(uname -r)",
|
|
"echo arch=$(uname -m)",
|
|
"echo hostname=$(hostname)",
|
|
"echo uptime_seconds=$(cut -d. -f1 /proc/uptime 2>/dev/null || echo '')",
|
|
].join("; ");
|
|
|
|
export function parseInventoryProbe(output: string): {
|
|
kernel: string | null;
|
|
architecture: string | null;
|
|
hostname: string | null;
|
|
uptimeSeconds: number | null;
|
|
} {
|
|
const map = new Map<string, string>();
|
|
for (const line of output.split("\n")) {
|
|
const idx = line.indexOf("=");
|
|
if (idx === -1) continue;
|
|
map.set(line.slice(0, idx).trim(), line.slice(idx + 1).trim());
|
|
}
|
|
|
|
const uptimeRaw = map.get("uptime_seconds");
|
|
const uptimeSeconds =
|
|
uptimeRaw && /^\d+$/.test(uptimeRaw) ? parseInt(uptimeRaw, 10) : null;
|
|
|
|
return {
|
|
kernel: map.get("kernel") || null,
|
|
architecture: map.get("arch") || null,
|
|
hostname: map.get("hostname") || null,
|
|
uptimeSeconds,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Resolves a fleet's effective members, re-checks the caller's per-host
|
|
* access at `level` through ctx.hosts (fleet membership alone is not treated
|
|
* as authorization - a fleet-sharee can only act on member hosts they
|
|
* individually have access to), and runs `fn` against each authorized host
|
|
* concurrently. One host's rejection never aborts the others.
|
|
*/
|
|
async function runAcrossFleet(
|
|
ctx: PluginContext,
|
|
repo: FleetRepository,
|
|
userId: string,
|
|
fleetId: number,
|
|
level: PluginHostShareLevel,
|
|
fn: (host: {
|
|
id: number;
|
|
name: string;
|
|
}) => Promise<Pick<FleetHostResult, "success" | "output" | "error">>,
|
|
): Promise<{ results: FleetHostResult[]; fleetFound: boolean }> {
|
|
const fleet = await repo.findById(userId, fleetId);
|
|
if (!fleet) {
|
|
return { results: [], fleetFound: false };
|
|
}
|
|
|
|
const members = await repo.listEffectiveMembers(userId, fleetId);
|
|
|
|
const settled = await Promise.allSettled(
|
|
members.map(async (host) => {
|
|
const access = await ctx.hosts.checkAccess(host.id, level);
|
|
if (!access.hasAccess) {
|
|
return {
|
|
hostId: host.id,
|
|
hostName: host.name ?? String(host.id),
|
|
success: false,
|
|
error: `Access denied (requires '${level}' level)`,
|
|
} satisfies FleetHostResult;
|
|
}
|
|
|
|
try {
|
|
const outcome = await fn({
|
|
id: host.id,
|
|
name: host.name ?? String(host.id),
|
|
});
|
|
return {
|
|
hostId: host.id,
|
|
hostName: host.name ?? String(host.id),
|
|
...outcome,
|
|
} satisfies FleetHostResult;
|
|
} catch (err) {
|
|
return {
|
|
hostId: host.id,
|
|
hostName: host.name ?? String(host.id),
|
|
success: false,
|
|
error: err instanceof Error ? err.message : "Unknown error",
|
|
} satisfies FleetHostResult;
|
|
}
|
|
}),
|
|
);
|
|
|
|
const results = settled.map((r) =>
|
|
r.status === "fulfilled"
|
|
? r.value
|
|
: ({
|
|
hostId: -1,
|
|
hostName: "unknown",
|
|
success: false,
|
|
error: r.reason instanceof Error ? r.reason.message : "Unknown error",
|
|
} satisfies FleetHostResult),
|
|
);
|
|
|
|
return { results, fleetFound: true };
|
|
}
|
|
|
|
/**
|
|
* Mounts the fleets routes on the plugin's router, which core serves at
|
|
* /plugin-api/fleets/ with auth and the actor already applied.
|
|
*/
|
|
export function registerFleetRoutes(
|
|
router: Router,
|
|
repo: FleetRepository,
|
|
ctx: PluginContext,
|
|
): void {
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/fleets:
|
|
* get:
|
|
* summary: List the current user's fleets
|
|
* tags:
|
|
* - Fleets
|
|
* responses:
|
|
* 200:
|
|
* description: List of fleets with member counts.
|
|
*/
|
|
router.get(
|
|
"/",
|
|
ctx.rbac.require("view") as never,
|
|
async (req: Request, res: Response) => {
|
|
const userId = actor(ctx);
|
|
try {
|
|
const fleetList = await repo.listByUser(userId);
|
|
const withCounts = await Promise.all(
|
|
fleetList.map(async (fleet) => {
|
|
const members = await repo.listEffectiveMembers(userId, fleet.id);
|
|
return {
|
|
...fleet,
|
|
tagRules: fleet.tagRules ? JSON.parse(fleet.tagRules) : [],
|
|
memberCount: members.length,
|
|
};
|
|
}),
|
|
);
|
|
res.json(withCounts);
|
|
} catch (err) {
|
|
logError(ctx, "Failed to list fleets", err, "fleet_list_failed");
|
|
res.status(500).json({ error: "Failed to list fleets" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/fleets:
|
|
* post:
|
|
* summary: Create a fleet
|
|
* tags:
|
|
* - Fleets
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* name:
|
|
* type: string
|
|
* description:
|
|
* type: string
|
|
* color:
|
|
* type: string
|
|
* icon:
|
|
* type: string
|
|
* tagRules:
|
|
* type: array
|
|
* items:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Fleet created.
|
|
* 400:
|
|
* description: Invalid request body.
|
|
*/
|
|
router.post(
|
|
"/",
|
|
ctx.rbac.require("manage") as never,
|
|
async (req: Request, res: Response) => {
|
|
const userId = actor(ctx);
|
|
const { name, description, color, icon, tagRules } = req.body ?? {};
|
|
|
|
if (!isNonEmptyString(name)) {
|
|
return res.status(400).json({ error: "Fleet name is required" });
|
|
}
|
|
if (
|
|
tagRules !== undefined &&
|
|
(!Array.isArray(tagRules) ||
|
|
tagRules.some((t) => typeof t !== "string"))
|
|
) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "tagRules must be an array of strings" });
|
|
}
|
|
|
|
try {
|
|
const fleet = await repo.create(userId, {
|
|
name: name.trim(),
|
|
description: isNonEmptyString(description)
|
|
? description.trim()
|
|
: null,
|
|
color: isNonEmptyString(color) ? color : null,
|
|
icon: isNonEmptyString(icon) ? icon : null,
|
|
tagRules,
|
|
});
|
|
res.json({ ...fleet, tagRules: tagRules ?? [] });
|
|
} catch (err) {
|
|
logError(ctx, "Failed to create fleet", err, "fleet_create_failed");
|
|
res.status(500).json({ error: "Failed to create fleet" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/fleets/{id}:
|
|
* patch:
|
|
* summary: Update a fleet
|
|
* tags:
|
|
* - Fleets
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: Fleet updated.
|
|
* 404:
|
|
* description: Fleet not found.
|
|
*/
|
|
router.patch(
|
|
"/:id",
|
|
ctx.rbac.require("manage") as never,
|
|
async (req: Request, res: Response) => {
|
|
const userId = actor(ctx);
|
|
const fleetId = parseFleetId(req.params.id);
|
|
if (fleetId === null) {
|
|
return res.status(400).json({ error: "Invalid fleet ID" });
|
|
}
|
|
|
|
const { name, description, color, icon, tagRules } = req.body ?? {};
|
|
if (name !== undefined && !isNonEmptyString(name)) {
|
|
return res.status(400).json({ error: "Fleet name cannot be empty" });
|
|
}
|
|
if (
|
|
tagRules !== undefined &&
|
|
(!Array.isArray(tagRules) ||
|
|
tagRules.some((t) => typeof t !== "string"))
|
|
) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "tagRules must be an array of strings" });
|
|
}
|
|
|
|
try {
|
|
const updated = await repo.update(userId, fleetId, {
|
|
name: name !== undefined ? name.trim() : undefined,
|
|
description,
|
|
color,
|
|
icon,
|
|
tagRules,
|
|
});
|
|
if (!updated) {
|
|
return res.status(404).json({ error: "Fleet not found" });
|
|
}
|
|
res.json({
|
|
...updated,
|
|
tagRules: updated.tagRules ? JSON.parse(updated.tagRules) : [],
|
|
});
|
|
} catch (err) {
|
|
logError(ctx, "Failed to update fleet", err, "fleet_update_failed");
|
|
res.status(500).json({ error: "Failed to update fleet" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/fleets/{id}:
|
|
* delete:
|
|
* summary: Delete a fleet
|
|
* tags:
|
|
* - Fleets
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: Fleet deleted.
|
|
* 404:
|
|
* description: Fleet not found.
|
|
*/
|
|
router.delete(
|
|
"/:id",
|
|
ctx.rbac.require("manage") as never,
|
|
async (req: Request, res: Response) => {
|
|
const userId = actor(ctx);
|
|
const fleetId = parseFleetId(req.params.id);
|
|
if (fleetId === null) {
|
|
return res.status(400).json({ error: "Invalid fleet ID" });
|
|
}
|
|
try {
|
|
const deleted = await repo.delete(userId, fleetId);
|
|
if (!deleted) {
|
|
return res.status(404).json({ error: "Fleet not found" });
|
|
}
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
logError(ctx, "Failed to delete fleet", err, "fleet_delete_failed");
|
|
res.status(500).json({ error: "Failed to delete fleet" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/fleets/{id}/members:
|
|
* get:
|
|
* summary: List the resolved effective members of a fleet
|
|
* description: Returns the union of statically-added hosts and hosts matched by the fleet's tag rules, each annotated with the caller's permission level on that host.
|
|
* tags:
|
|
* - Fleets
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: Resolved member hosts.
|
|
* 404:
|
|
* description: Fleet not found.
|
|
*/
|
|
router.get(
|
|
"/:id/members",
|
|
ctx.rbac.require("view") as never,
|
|
async (req: Request, res: Response) => {
|
|
const userId = actor(ctx);
|
|
const fleetId = parseFleetId(req.params.id);
|
|
if (fleetId === null) {
|
|
return res.status(400).json({ error: "Invalid fleet ID" });
|
|
}
|
|
|
|
try {
|
|
const fleet = await repo.findById(userId, fleetId);
|
|
if (!fleet) {
|
|
return res.status(404).json({ error: "Fleet not found" });
|
|
}
|
|
|
|
const staticIds = await repo.listStaticMemberIds(fleetId);
|
|
const staticIdSet = new Set(staticIds);
|
|
const members = await repo.listEffectiveMembers(userId, fleetId);
|
|
|
|
const annotated = await Promise.all(
|
|
members.map(async (host) => {
|
|
const access = await ctx.hosts.checkAccess(host.id, "connect");
|
|
return {
|
|
id: host.id,
|
|
name: host.name,
|
|
ip: host.ip,
|
|
tags: host.tags ? host.tags.split(",").filter(Boolean) : [],
|
|
static: staticIdSet.has(host.id),
|
|
permissionLevel: access.isOwner
|
|
? "manage"
|
|
: (access.permissionLevel ?? null),
|
|
};
|
|
}),
|
|
);
|
|
|
|
res.json(annotated);
|
|
} catch (err) {
|
|
logError(
|
|
ctx,
|
|
"Failed to list fleet members",
|
|
err,
|
|
"fleet_members_list_failed",
|
|
);
|
|
res.status(500).json({ error: "Failed to list fleet members" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/fleets/{id}/members:
|
|
* post:
|
|
* summary: Add a host to a fleet's static membership
|
|
* tags:
|
|
* - Fleets
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* hostId:
|
|
* type: number
|
|
* responses:
|
|
* 200:
|
|
* description: Host added.
|
|
* 404:
|
|
* description: Fleet or host not found.
|
|
*/
|
|
router.post(
|
|
"/:id/members",
|
|
ctx.rbac.require("manage") as never,
|
|
async (req: Request, res: Response) => {
|
|
const userId = actor(ctx);
|
|
const fleetId = parseFleetId(req.params.id);
|
|
const hostId = Number(req.body?.hostId);
|
|
|
|
if (fleetId === null || !Number.isInteger(hostId)) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "Valid fleetId and hostId are required" });
|
|
}
|
|
|
|
try {
|
|
const fleet = await repo.findById(userId, fleetId);
|
|
if (!fleet) {
|
|
return res.status(404).json({ error: "Fleet not found" });
|
|
}
|
|
|
|
const access = await ctx.hosts.checkAccess(hostId, "connect");
|
|
if (!access.hasAccess) {
|
|
return res.status(404).json({ error: "Host not found" });
|
|
}
|
|
|
|
await repo.addMember(fleetId, hostId);
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
logError(
|
|
ctx,
|
|
"Failed to add fleet member",
|
|
err,
|
|
"fleet_member_add_failed",
|
|
);
|
|
res.status(500).json({ error: "Failed to add fleet member" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/fleets/{id}/members/{hostId}:
|
|
* delete:
|
|
* summary: Remove a host from a fleet's static membership
|
|
* description: Only removes the static membership row - a host still matched by the fleet's tag rules remains an effective member.
|
|
* tags:
|
|
* - Fleets
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* - in: path
|
|
* name: hostId
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: Host removed.
|
|
* 404:
|
|
* description: Fleet or membership not found.
|
|
*/
|
|
router.delete(
|
|
"/:id/members/:hostId",
|
|
ctx.rbac.require("manage") as never,
|
|
async (req: Request, res: Response) => {
|
|
const userId = actor(ctx);
|
|
const fleetId = parseFleetId(req.params.id);
|
|
const hostId = parseFleetId(req.params.hostId);
|
|
|
|
if (fleetId === null || hostId === null) {
|
|
return res.status(400).json({ error: "Invalid fleet or host ID" });
|
|
}
|
|
|
|
try {
|
|
const fleet = await repo.findById(userId, fleetId);
|
|
if (!fleet) {
|
|
return res.status(404).json({ error: "Fleet not found" });
|
|
}
|
|
|
|
const removed = await repo.removeMember(fleetId, hostId);
|
|
if (!removed) {
|
|
return res.status(404).json({ error: "Membership not found" });
|
|
}
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
logError(
|
|
ctx,
|
|
"Failed to remove fleet member",
|
|
err,
|
|
"fleet_member_remove_failed",
|
|
);
|
|
res.status(500).json({ error: "Failed to remove fleet member" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/fleets/{id}/share:
|
|
* post:
|
|
* summary: Share a fleet's current member hosts with users or roles
|
|
* description: Snapshot at share time - grants host access for every current member host to each target. Hosts added to the fleet later are not automatically shared; re-run this route to extend sharing to new members.
|
|
* tags:
|
|
* - Fleets
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* targets:
|
|
* type: array
|
|
* items:
|
|
* type: object
|
|
* permissionLevel:
|
|
* type: string
|
|
* durationHours:
|
|
* type: number
|
|
* responses:
|
|
* 200:
|
|
* description: Fleet shared.
|
|
* 404:
|
|
* description: Fleet not found.
|
|
*/
|
|
router.post(
|
|
"/:id/share",
|
|
ctx.rbac.require("manage") as never,
|
|
async (req: Request, res: Response) => {
|
|
const userId = actor(ctx);
|
|
const fleetId = parseFleetId(req.params.id);
|
|
if (fleetId === null) {
|
|
return res.status(400).json({ error: "Invalid fleet ID" });
|
|
}
|
|
|
|
try {
|
|
const fleet = await repo.findById(userId, fleetId);
|
|
if (!fleet) {
|
|
return res.status(404).json({ error: "Fleet not found" });
|
|
}
|
|
|
|
const targets = parseShareTargets(req.body ?? {});
|
|
if (!targets) {
|
|
return res.status(400).json({
|
|
error:
|
|
"targets must be a non-empty array of { type: 'user'|'role', id } entries",
|
|
});
|
|
}
|
|
|
|
const { durationHours, permissionLevel = "connect" } = req.body;
|
|
if (!isShareLevel(permissionLevel)) {
|
|
return res.status(400).json({ error: "Invalid permission level" });
|
|
}
|
|
|
|
const members = await repo.listEffectiveMembers(userId, fleetId);
|
|
const hostResults: Array<{
|
|
hostId: number;
|
|
shared: boolean;
|
|
reason?: string;
|
|
}> = [];
|
|
|
|
for (const host of members) {
|
|
if (targets.some((t) => t.type === "user" && t.id === host.userId)) {
|
|
hostResults.push({
|
|
hostId: host.id,
|
|
shared: false,
|
|
reason: "owner",
|
|
});
|
|
continue;
|
|
}
|
|
const result = await ctx.hosts.share(
|
|
host.id,
|
|
targets,
|
|
permissionLevel,
|
|
typeof durationHours === "number" ? durationHours : undefined,
|
|
);
|
|
hostResults.push(result);
|
|
}
|
|
|
|
const sharedCount = hostResults.filter((r) => r.shared).length;
|
|
const expiresAt =
|
|
typeof durationHours === "number" && durationHours > 0
|
|
? new Date(
|
|
Date.now() + durationHours * 60 * 60 * 1000,
|
|
).toISOString()
|
|
: null;
|
|
|
|
res.json({
|
|
success: true,
|
|
permissionLevel,
|
|
expiresAt,
|
|
hostsShared: sharedCount,
|
|
hostsTotal: members.length,
|
|
hostResults,
|
|
});
|
|
} catch (err) {
|
|
logError(ctx, "Failed to share fleet", err, "fleet_share_failed");
|
|
res.status(500).json({ error: "Failed to share fleet" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/fleets/share-targets/users:
|
|
* get:
|
|
* summary: Users the caller may pick as a fleet share target
|
|
* tags:
|
|
* - Fleets
|
|
* responses:
|
|
* 200:
|
|
* description: Users list.
|
|
*/
|
|
router.get(
|
|
"/share-targets/users",
|
|
ctx.rbac.require("manage") as never,
|
|
async (_req: Request, res: Response) => {
|
|
try {
|
|
res.json({ users: await ctx.hosts.listUsers() });
|
|
} catch (err) {
|
|
logError(
|
|
ctx,
|
|
"Failed to list share target users",
|
|
err,
|
|
"fleet_share_targets_users_failed",
|
|
);
|
|
res.status(500).json({ error: "Failed to list users" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/fleets/share-targets/roles:
|
|
* get:
|
|
* summary: Non-system roles the caller may pick as a fleet share target
|
|
* tags:
|
|
* - Fleets
|
|
* responses:
|
|
* 200:
|
|
* description: Roles list.
|
|
*/
|
|
router.get(
|
|
"/share-targets/roles",
|
|
ctx.rbac.require("manage") as never,
|
|
async (_req: Request, res: Response) => {
|
|
try {
|
|
res.json({ roles: await ctx.hosts.listRoles() });
|
|
} catch (err) {
|
|
logError(
|
|
ctx,
|
|
"Failed to list share target roles",
|
|
err,
|
|
"fleet_share_targets_roles_failed",
|
|
);
|
|
res.status(500).json({ error: "Failed to list roles" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/fleets/{id}/execute:
|
|
* post:
|
|
* summary: Run a command across every host in a fleet
|
|
* description: Fans out concurrently to every effective member host the caller has edit-level access to. $HOST/$USER/$PORT/$NAME/$INPUT_n substitution is applied per host, same grammar as snippet execution. One host failing does not stop the others.
|
|
* tags:
|
|
* - Fleets
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* command:
|
|
* type: string
|
|
* inputValues:
|
|
* type: object
|
|
* responses:
|
|
* 200:
|
|
* description: Per-host execution results.
|
|
* 404:
|
|
* description: Fleet not found.
|
|
*/
|
|
router.post(
|
|
"/:id/execute",
|
|
ctx.rbac.require("execute") as never,
|
|
async (req: Request, res: Response) => {
|
|
const userId = actor(ctx);
|
|
const fleetId = parseFleetId(req.params.id);
|
|
const { command, inputValues } = req.body ?? {};
|
|
|
|
if (fleetId === null) {
|
|
return res.status(400).json({ error: "Invalid fleet ID" });
|
|
}
|
|
if (!isNonEmptyString(command)) {
|
|
return res.status(400).json({ error: "Command is required" });
|
|
}
|
|
|
|
try {
|
|
const { results, fleetFound } = await runAcrossFleet(
|
|
ctx,
|
|
repo,
|
|
userId,
|
|
fleetId,
|
|
"edit",
|
|
async (host) => {
|
|
const fullHost = await ctx.hosts.get(host.id);
|
|
if (!fullHost) {
|
|
return { success: false, error: "Host not found" };
|
|
}
|
|
|
|
const resolvedCommand = resolveCommandVariables(
|
|
ctx,
|
|
command,
|
|
{
|
|
ip: fullHost.ip,
|
|
username: fullHost.username,
|
|
port: fullHost.port,
|
|
name: fullHost.name ?? undefined,
|
|
},
|
|
inputValues && typeof inputValues === "object" ? inputValues : {},
|
|
);
|
|
|
|
const { stdout, stderr, code } = await ctx.ssh.withConnection(
|
|
host.id,
|
|
{ pool: "fleet", purpose: "fleet" },
|
|
(client) => execCommand(client as Client, resolvedCommand, 60000),
|
|
);
|
|
|
|
return {
|
|
success: code === 0,
|
|
output: stdout,
|
|
error:
|
|
code !== 0 ? stderr || `Exited with code ${code}` : undefined,
|
|
};
|
|
},
|
|
);
|
|
|
|
if (!fleetFound) {
|
|
return res.status(404).json({ error: "Fleet not found" });
|
|
}
|
|
|
|
res.json({ results });
|
|
} catch (err) {
|
|
logError(
|
|
ctx,
|
|
"Failed to execute fleet command",
|
|
err,
|
|
"fleet_execute_failed",
|
|
);
|
|
res.status(500).json({ error: "Failed to execute fleet command" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/fleets/{id}/transfer/push:
|
|
* post:
|
|
* summary: Push an uploaded file to the same remote path on every host in a fleet
|
|
* description: Fans out concurrently to every effective member host the caller has edit-level access to. Single file only (v1) - the file is buffered once server-side and written to each host via SFTP.
|
|
* tags:
|
|
* - Fleets
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* multipart/form-data:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* file:
|
|
* type: string
|
|
* format: binary
|
|
* remotePath:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Per-host push results.
|
|
* 404:
|
|
* description: Fleet not found.
|
|
*/
|
|
router.post(
|
|
"/:id/transfer/push",
|
|
ctx.rbac.require("execute") as never,
|
|
upload.single("file"),
|
|
async (req: Request, res: Response) => {
|
|
const userId = actor(ctx);
|
|
const fleetId = parseFleetId(req.params.id);
|
|
const remotePath = req.body?.remotePath;
|
|
const file = (req as Request & { file?: Express.Multer.File }).file;
|
|
|
|
if (fleetId === null) {
|
|
return res.status(400).json({ error: "Invalid fleet ID" });
|
|
}
|
|
if (!isNonEmptyString(remotePath)) {
|
|
return res.status(400).json({ error: "remotePath is required" });
|
|
}
|
|
if (!file) {
|
|
return res.status(400).json({ error: "A file is required" });
|
|
}
|
|
|
|
try {
|
|
const { results, fleetFound } = await runAcrossFleet(
|
|
ctx,
|
|
repo,
|
|
userId,
|
|
fleetId,
|
|
"edit",
|
|
async (host) => {
|
|
await ctx.ssh.withConnection(
|
|
host.id,
|
|
{ pool: "fleet", purpose: "fleet" },
|
|
async (client) => {
|
|
const sftp = await getSftp(client as Client);
|
|
await sftpWriteFile(sftp, remotePath, file.buffer);
|
|
},
|
|
);
|
|
|
|
return { success: true, output: `Written to ${remotePath}` };
|
|
},
|
|
);
|
|
|
|
if (!fleetFound) {
|
|
return res.status(404).json({ error: "Fleet not found" });
|
|
}
|
|
|
|
res.json({ results });
|
|
} catch (err) {
|
|
logError(
|
|
ctx,
|
|
"Failed to push file across fleet",
|
|
err,
|
|
"fleet_transfer_push_failed",
|
|
);
|
|
res.status(500).json({ error: "Failed to push file across fleet" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/fleets/{id}/transfer/pull:
|
|
* post:
|
|
* summary: Pull the same remote path from every host in a fleet
|
|
* description: Fans out concurrently to every effective member host the caller has edit-level access to, reads remotePath via SFTP from each, and returns a single zip archive with one entry per successful host (<hostName>/<filename>). Single file only (v1).
|
|
* tags:
|
|
* - Fleets
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* remotePath:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Zip archive containing the per-host pulled files, plus an X-Fleet-Transfer-Results header with the per-host JSON result summary.
|
|
* 404:
|
|
* description: Fleet not found.
|
|
*/
|
|
router.post(
|
|
"/:id/transfer/pull",
|
|
ctx.rbac.require("execute") as never,
|
|
async (req: Request, res: Response) => {
|
|
const userId = actor(ctx);
|
|
const fleetId = parseFleetId(req.params.id);
|
|
const { remotePath } = req.body ?? {};
|
|
|
|
if (fleetId === null) {
|
|
return res.status(400).json({ error: "Invalid fleet ID" });
|
|
}
|
|
if (!isNonEmptyString(remotePath)) {
|
|
return res.status(400).json({ error: "remotePath is required" });
|
|
}
|
|
|
|
try {
|
|
const zip = new JSZip();
|
|
const fileName = remotePath.split("/").filter(Boolean).pop() || "file";
|
|
|
|
const { results, fleetFound } = await runAcrossFleet(
|
|
ctx,
|
|
repo,
|
|
userId,
|
|
fleetId,
|
|
"edit",
|
|
async (host) => {
|
|
const data = await ctx.ssh.withConnection<Buffer>(
|
|
host.id,
|
|
{ pool: "fleet", purpose: "fleet" },
|
|
async (client) => {
|
|
const sftp = await getSftp(client as Client);
|
|
return sftpReadFile(sftp, remotePath);
|
|
},
|
|
);
|
|
|
|
zip.file(`${host.name}/${fileName}`, data);
|
|
return { success: true, output: `Pulled ${data.length} bytes` };
|
|
},
|
|
);
|
|
|
|
if (!fleetFound) {
|
|
return res.status(404).json({ error: "Fleet not found" });
|
|
}
|
|
|
|
const archive = await zip.generateAsync({ type: "nodebuffer" });
|
|
res.setHeader("Content-Type", "application/zip");
|
|
res.setHeader(
|
|
"Content-Disposition",
|
|
`attachment; filename="fleet-${fleetId}-${fileName}.zip"`,
|
|
);
|
|
res.setHeader(
|
|
"X-Fleet-Transfer-Results",
|
|
Buffer.from(JSON.stringify(results)).toString("base64"),
|
|
);
|
|
res.send(archive);
|
|
} catch (err) {
|
|
logError(
|
|
ctx,
|
|
"Failed to pull file across fleet",
|
|
err,
|
|
"fleet_transfer_pull_failed",
|
|
);
|
|
res.status(500).json({ error: "Failed to pull file across fleet" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/fleets/{id}/inventory:
|
|
* get:
|
|
* summary: Read the last-known inventory snapshot for a fleet's members
|
|
* description: No live connection - reads back whatever the most recent POST refresh stored. Latest-only per host, no history.
|
|
* tags:
|
|
* - Fleets
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: Stored inventory rows for current members.
|
|
* 404:
|
|
* description: Fleet not found.
|
|
*/
|
|
router.get(
|
|
"/:id/inventory",
|
|
ctx.rbac.require("view") as never,
|
|
async (req: Request, res: Response) => {
|
|
const userId = actor(ctx);
|
|
const fleetId = parseFleetId(req.params.id);
|
|
if (fleetId === null) {
|
|
return res.status(400).json({ error: "Invalid fleet ID" });
|
|
}
|
|
|
|
try {
|
|
const fleet = await repo.findById(userId, fleetId);
|
|
if (!fleet) {
|
|
return res.status(404).json({ error: "Fleet not found" });
|
|
}
|
|
|
|
const members = await repo.listEffectiveMembers(userId, fleetId);
|
|
const inventory = await repo.listInventoryForHosts(
|
|
userId,
|
|
members.map((m) => m.id),
|
|
);
|
|
|
|
const byHostId = new Map(inventory.map((row) => [row.hostId, row]));
|
|
res.json(
|
|
members.map((host) => ({
|
|
hostId: host.id,
|
|
hostName: host.name,
|
|
inventory: byHostId.get(host.id) ?? null,
|
|
})),
|
|
);
|
|
} catch (err) {
|
|
logError(
|
|
ctx,
|
|
"Failed to read fleet inventory",
|
|
err,
|
|
"fleet_inventory_read_failed",
|
|
);
|
|
res.status(500).json({ error: "Failed to read fleet inventory" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/fleets/{id}/inventory:
|
|
* post:
|
|
* summary: Refresh the inventory snapshot for every host in a fleet
|
|
* description: Connects to every effective member host the caller has view-level access to, collects OS/kernel/arch/hostname/uptime, and overwrites the stored latest-only snapshot per host.
|
|
* tags:
|
|
* - Fleets
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: Per-host refresh results.
|
|
* 404:
|
|
* description: Fleet not found.
|
|
*/
|
|
router.post(
|
|
"/:id/inventory",
|
|
ctx.rbac.require("execute") as never,
|
|
async (req: Request, res: Response) => {
|
|
const userId = actor(ctx);
|
|
const fleetId = parseFleetId(req.params.id);
|
|
if (fleetId === null) {
|
|
return res.status(400).json({ error: "Invalid fleet ID" });
|
|
}
|
|
|
|
try {
|
|
const { results, fleetFound } = await runAcrossFleet(
|
|
ctx,
|
|
repo,
|
|
userId,
|
|
fleetId,
|
|
"view",
|
|
async (host) => {
|
|
const fullHost = await ctx.hosts.get(host.id);
|
|
if (!fullHost) {
|
|
return { success: false, error: "Host not found" };
|
|
}
|
|
|
|
const record = await ctx.ssh.withConnection(
|
|
host.id,
|
|
{ pool: "fleet", purpose: "fleet" },
|
|
async (client) => {
|
|
const sshClient = client as Client;
|
|
const platform = await detectPlatform(sshClient);
|
|
const { stdout } = await execCommand(
|
|
sshClient,
|
|
INVENTORY_PROBE_COMMAND,
|
|
15000,
|
|
);
|
|
const facts = parseInventoryProbe(stdout);
|
|
|
|
return repo.upsertInventory(userId, host.id, {
|
|
osPrettyName: platform.osPrettyName,
|
|
kernel: facts.kernel,
|
|
architecture: facts.architecture,
|
|
hostname: facts.hostname,
|
|
uptimeSeconds: facts.uptimeSeconds,
|
|
ip: fullHost.ip,
|
|
packageManager: platform.pkg,
|
|
});
|
|
},
|
|
);
|
|
|
|
return {
|
|
success: true,
|
|
output: JSON.stringify(record),
|
|
};
|
|
},
|
|
);
|
|
|
|
if (!fleetFound) {
|
|
return res.status(404).json({ error: "Fleet not found" });
|
|
}
|
|
|
|
res.json({ results });
|
|
} catch (err) {
|
|
logError(
|
|
ctx,
|
|
"Failed to refresh fleet inventory",
|
|
err,
|
|
"fleet_inventory_refresh_failed",
|
|
);
|
|
res.status(500).json({ error: "Failed to refresh fleet inventory" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/fleets/{id}/packages:
|
|
* post:
|
|
* summary: Run a package action across every host in a fleet
|
|
* description: Auto-detects each host's package manager (apt/dnf/yum/pacman) and runs install/remove/upgrade-all, elevating with the host's stored sudo password. Requires manage-level access per host.
|
|
* tags:
|
|
* - Fleets
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* action:
|
|
* type: string
|
|
* enum: [install, remove, upgrade-all]
|
|
* package:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Per-host package action results.
|
|
* 404:
|
|
* description: Fleet not found.
|
|
*/
|
|
router.post(
|
|
"/:id/packages",
|
|
ctx.rbac.require("manage") as never,
|
|
async (req: Request, res: Response) => {
|
|
const userId = actor(ctx);
|
|
const fleetId = parseFleetId(req.params.id);
|
|
const { action, package: packageName } = req.body ?? {};
|
|
|
|
if (fleetId === null) {
|
|
return res.status(400).json({ error: "Invalid fleet ID" });
|
|
}
|
|
if (
|
|
action !== "install" &&
|
|
action !== "remove" &&
|
|
action !== "upgrade-all"
|
|
) {
|
|
return res.status(400).json({ error: "Invalid action" });
|
|
}
|
|
if (action !== "upgrade-all" && !isValidPackageName(packageName)) {
|
|
return res.status(400).json({ error: "Invalid package name" });
|
|
}
|
|
|
|
try {
|
|
const { results, fleetFound } = await runAcrossFleet(
|
|
ctx,
|
|
repo,
|
|
userId,
|
|
fleetId,
|
|
"manage",
|
|
async (host) => {
|
|
// Package actions need the host's sudo password. connect() hands
|
|
// back a redacted host, so read it through resolveHost.
|
|
const full = await ctx.ssh.resolveHost(host.id);
|
|
const connection = await ctx.ssh.connect(host.id, {
|
|
purpose: "fleet",
|
|
});
|
|
try {
|
|
const sshClient = connection.client as Client;
|
|
const platform = await detectPlatform(sshClient);
|
|
const cmd =
|
|
action === "remove"
|
|
? buildPackageRemoveCommand(platform.pkg, packageName)
|
|
: buildPackageActionCommand(
|
|
platform.pkg,
|
|
action,
|
|
packageName,
|
|
);
|
|
|
|
if (!cmd) {
|
|
return {
|
|
success: false,
|
|
error: platform.pkg
|
|
? `Unsupported package action '${action}' for ${platform.pkg}`
|
|
: "No supported package manager detected on this host",
|
|
};
|
|
}
|
|
|
|
try {
|
|
const result = await execElevated(
|
|
sshClient,
|
|
cmd,
|
|
full?.sudoPassword as string | undefined,
|
|
{ forceSudo: true, timeoutMs: 600000 },
|
|
);
|
|
return {
|
|
success: result.code === 0,
|
|
output: (result.stdout || result.stderr).slice(-8000),
|
|
};
|
|
} catch (elevationError) {
|
|
if (elevationError instanceof ElevationError) {
|
|
return { success: false, error: elevationError.message };
|
|
}
|
|
throw elevationError;
|
|
}
|
|
} finally {
|
|
connection.dispose();
|
|
}
|
|
},
|
|
);
|
|
|
|
if (!fleetFound) {
|
|
return res.status(404).json({ error: "Fleet not found" });
|
|
}
|
|
|
|
res.json({ results });
|
|
} catch (err) {
|
|
logError(
|
|
ctx,
|
|
"Failed to run fleet package action",
|
|
err,
|
|
"fleet_packages_failed",
|
|
);
|
|
res.status(500).json({ error: "Failed to run fleet package action" });
|
|
}
|
|
},
|
|
);
|
|
}
|