mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
1516 lines
44 KiB
TypeScript
1516 lines
44 KiB
TypeScript
import { getErrorMessage } from "../../../src/backend/utils/error-message.js";
|
|
import type { AuthenticatedRequest } from "../../../src/types/index.js";
|
|
import express, { type Request, type Response } from "express";
|
|
import multer from "multer";
|
|
import JSZip from "jszip";
|
|
import type { Client, SFTPWrapper } from "ssh2";
|
|
import {
|
|
authLogger,
|
|
databaseLogger,
|
|
} from "../../../src/backend/utils/logger.js";
|
|
import { AuthManager } from "../../../src/backend/utils/auth-manager.js";
|
|
import {
|
|
PermissionManager,
|
|
type HostAction,
|
|
} from "../../../src/backend/utils/permission-manager.js";
|
|
import {
|
|
isSharePermissionLevel,
|
|
expiryFromDuration,
|
|
parseShareTargets,
|
|
} from "../../../src/backend/database/routes/rbac.js";
|
|
import {
|
|
createCurrentFleetRepository,
|
|
createCurrentFleetInventoryRepository,
|
|
createCurrentRbacAccessRepository,
|
|
createCurrentRoleRepository,
|
|
createCurrentUserRepository,
|
|
} from "../../../src/backend/database/repositories/factory.js";
|
|
import { resolveHostById } from "../../../src/backend/hosts/host-resolver.js";
|
|
import {
|
|
getFleetPoolKey,
|
|
createFleetSshFactory,
|
|
} from "../../../src/backend/hosts/ssh-client-factory.js";
|
|
import { withConnection } from "../../../src/backend/hosts/ssh-connection-pool.js";
|
|
import { execCommand } from "../../../src/backend/hosts/metrics-shared/common-utils.js";
|
|
import { detectPlatform } from "../../../src/backend/hosts/metrics-shared/platform.js";
|
|
import {
|
|
execElevated,
|
|
ElevationError,
|
|
} from "../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
|
import { buildPackageActionCommand } from "../../../src/backend/hosts/metrics-shared/package-commands.js";
|
|
import { isValidPackageName } from "../../../src/backend/hosts/metrics-shared/validation.js";
|
|
import { resolveSnippetCommand } from "../../../src/backend/database/routes/snippets-execution.js";
|
|
import {
|
|
registerFleetsRouter,
|
|
unregisterFleetsRouter,
|
|
} from "../../../src/backend/database/routes/fleet-dispatch.js";
|
|
|
|
export const router = express.Router();
|
|
|
|
const authManager = AuthManager.getInstance();
|
|
const permissionManager = PermissionManager.getInstance();
|
|
|
|
const authenticateJWT = authManager.createAuthMiddleware();
|
|
const requireDataAccess = authManager.createDataAccessMiddleware();
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* @openapi
|
|
* /fleets:
|
|
* get:
|
|
* summary: List the current user's fleets
|
|
* tags:
|
|
* - Fleets
|
|
* responses:
|
|
* 200:
|
|
* description: List of fleets with member counts.
|
|
*/
|
|
router.get(
|
|
"/",
|
|
authenticateJWT,
|
|
requireDataAccess,
|
|
async (req: Request, res: Response) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
|
|
try {
|
|
const fleetRepository = createCurrentFleetRepository();
|
|
const fleetList = await fleetRepository.listByUser(userId);
|
|
|
|
const withCounts = await Promise.all(
|
|
fleetList.map(async (fleet) => {
|
|
const members = await fleetRepository.listEffectiveMembers(
|
|
userId,
|
|
fleet.id,
|
|
);
|
|
return {
|
|
...fleet,
|
|
tagRules: fleet.tagRules ? JSON.parse(fleet.tagRules) : [],
|
|
memberCount: members.length,
|
|
};
|
|
}),
|
|
);
|
|
|
|
res.json(withCounts);
|
|
} catch (err) {
|
|
databaseLogger.error("Failed to list fleets", err, {
|
|
operation: "fleet_list_failed",
|
|
userId,
|
|
});
|
|
res.status(500).json({ error: "Failed to list fleets" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /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(
|
|
"/",
|
|
authenticateJWT,
|
|
requireDataAccess,
|
|
async (req: Request, res: Response) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
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 createCurrentFleetRepository().create(userId, {
|
|
name: name.trim(),
|
|
description: isNonEmptyString(description) ? description.trim() : null,
|
|
color: isNonEmptyString(color) ? color : null,
|
|
icon: isNonEmptyString(icon) ? icon : null,
|
|
tagRules,
|
|
});
|
|
|
|
authLogger.success(`Fleet created: ${fleet.name}`, {
|
|
operation: "fleet_create_success",
|
|
userId,
|
|
fleetId: fleet.id,
|
|
});
|
|
|
|
res.json({ ...fleet, tagRules: tagRules ?? [] });
|
|
} catch (err) {
|
|
databaseLogger.error("Failed to create fleet", err, {
|
|
operation: "fleet_create_failed",
|
|
userId,
|
|
});
|
|
res.status(500).json({ error: "Failed to create fleet" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /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",
|
|
authenticateJWT,
|
|
requireDataAccess,
|
|
async (req: Request, res: Response) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
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 createCurrentFleetRepository().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) {
|
|
databaseLogger.error("Failed to update fleet", err, {
|
|
operation: "fleet_update_failed",
|
|
userId,
|
|
fleetId,
|
|
});
|
|
res.status(500).json({ error: "Failed to update fleet" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /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",
|
|
authenticateJWT,
|
|
requireDataAccess,
|
|
async (req: Request, res: Response) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
const fleetId = parseFleetId(req.params.id);
|
|
if (fleetId === null) {
|
|
return res.status(400).json({ error: "Invalid fleet ID" });
|
|
}
|
|
|
|
try {
|
|
const deleted = await createCurrentFleetRepository().delete(
|
|
userId,
|
|
fleetId,
|
|
);
|
|
if (!deleted) {
|
|
return res.status(404).json({ error: "Fleet not found" });
|
|
}
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
databaseLogger.error("Failed to delete fleet", err, {
|
|
operation: "fleet_delete_failed",
|
|
userId,
|
|
fleetId,
|
|
});
|
|
res.status(500).json({ error: "Failed to delete fleet" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /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",
|
|
authenticateJWT,
|
|
requireDataAccess,
|
|
async (req: Request, res: Response) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
const fleetId = parseFleetId(req.params.id);
|
|
if (fleetId === null) {
|
|
return res.status(400).json({ error: "Invalid fleet ID" });
|
|
}
|
|
|
|
try {
|
|
const fleetRepository = createCurrentFleetRepository();
|
|
const fleet = await fleetRepository.findById(userId, fleetId);
|
|
if (!fleet) {
|
|
return res.status(404).json({ error: "Fleet not found" });
|
|
}
|
|
|
|
const staticIds = await fleetRepository.listStaticMemberIds(fleetId);
|
|
const staticIdSet = new Set(staticIds);
|
|
const members = await fleetRepository.listEffectiveMembers(
|
|
userId,
|
|
fleetId,
|
|
);
|
|
|
|
const annotated = await Promise.all(
|
|
members.map(async (host) => {
|
|
const access = await permissionManager.canAccessHost(
|
|
userId,
|
|
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) {
|
|
databaseLogger.error("Failed to list fleet members", err, {
|
|
operation: "fleet_members_list_failed",
|
|
userId,
|
|
fleetId,
|
|
});
|
|
res.status(500).json({ error: "Failed to list fleet members" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /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",
|
|
authenticateJWT,
|
|
requireDataAccess,
|
|
async (req: Request, res: Response) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
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 fleetRepository = createCurrentFleetRepository();
|
|
const fleet = await fleetRepository.findById(userId, fleetId);
|
|
if (!fleet) {
|
|
return res.status(404).json({ error: "Fleet not found" });
|
|
}
|
|
|
|
const access = await permissionManager.canAccessHost(
|
|
userId,
|
|
hostId,
|
|
"connect",
|
|
);
|
|
if (!access.hasAccess) {
|
|
return res.status(404).json({ error: "Host not found" });
|
|
}
|
|
|
|
await fleetRepository.addMember(fleetId, hostId);
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
databaseLogger.error("Failed to add fleet member", err, {
|
|
operation: "fleet_member_add_failed",
|
|
userId,
|
|
fleetId,
|
|
hostId,
|
|
});
|
|
res.status(500).json({ error: "Failed to add fleet member" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /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",
|
|
authenticateJWT,
|
|
requireDataAccess,
|
|
async (req: Request, res: Response) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
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 fleetRepository = createCurrentFleetRepository();
|
|
const fleet = await fleetRepository.findById(userId, fleetId);
|
|
if (!fleet) {
|
|
return res.status(404).json({ error: "Fleet not found" });
|
|
}
|
|
|
|
const removed = await fleetRepository.removeMember(fleetId, hostId);
|
|
if (!removed) {
|
|
return res.status(404).json({ error: "Membership not found" });
|
|
}
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
databaseLogger.error("Failed to remove fleet member", err, {
|
|
operation: "fleet_member_remove_failed",
|
|
userId,
|
|
fleetId,
|
|
hostId,
|
|
});
|
|
res.status(500).json({ error: "Failed to remove fleet member" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /fleets/{id}/share:
|
|
* post:
|
|
* summary: Share a fleet's current member hosts with users or roles
|
|
* description: Snapshot at share time - grants hostAccess 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",
|
|
authenticateJWT,
|
|
requireDataAccess,
|
|
async (req: Request, res: Response) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
const fleetId = parseFleetId(req.params.id);
|
|
if (fleetId === null) {
|
|
return res.status(400).json({ error: "Invalid fleet ID" });
|
|
}
|
|
|
|
try {
|
|
const fleetRepository = createCurrentFleetRepository();
|
|
const fleet = await fleetRepository.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 (!isSharePermissionLevel(permissionLevel)) {
|
|
return res.status(400).json({ error: "Invalid permission level" });
|
|
}
|
|
|
|
const userRepository = createCurrentUserRepository();
|
|
const roleRepository = createCurrentRoleRepository();
|
|
for (const target of targets) {
|
|
if (target.type === "user") {
|
|
const targetUser = await userRepository.findById(target.id as string);
|
|
if (!targetUser) {
|
|
return res
|
|
.status(404)
|
|
.json({ error: "Target user not found", targetId: target.id });
|
|
}
|
|
} else {
|
|
const targetRole = await roleRepository.findRoleById(
|
|
target.id as number,
|
|
);
|
|
if (!targetRole) {
|
|
return res
|
|
.status(404)
|
|
.json({ error: "Target role not found", targetId: target.id });
|
|
}
|
|
}
|
|
}
|
|
|
|
const members = await fleetRepository.listEffectiveMembers(
|
|
userId,
|
|
fleetId,
|
|
);
|
|
const expiresAt = expiryFromDuration(durationHours);
|
|
const rbacAccessRepository = createCurrentRbacAccessRepository();
|
|
const { SharedHostSecretsManager } =
|
|
await import("../../../src/backend/utils/shared-host-secrets-manager.js");
|
|
const secretsManager = SharedHostSecretsManager.getInstance();
|
|
|
|
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 access = await permissionManager.canAccessHost(
|
|
userId,
|
|
host.id,
|
|
"manage",
|
|
);
|
|
if (!access.hasAccess) {
|
|
hostResults.push({
|
|
hostId: host.id,
|
|
shared: false,
|
|
reason: "forbidden",
|
|
});
|
|
continue;
|
|
}
|
|
|
|
for (const target of targets) {
|
|
const accessGrant = await rbacAccessRepository.upsertHostAccess({
|
|
hostId: host.id,
|
|
grantedBy: userId,
|
|
permissionLevel,
|
|
expiresAt,
|
|
...(target.type === "user"
|
|
? {
|
|
targetType: "user" as const,
|
|
targetUserId: target.id as string,
|
|
}
|
|
: {
|
|
targetType: "role" as const,
|
|
targetRoleId: target.id as number,
|
|
}),
|
|
});
|
|
|
|
try {
|
|
if (target.type === "user") {
|
|
await secretsManager.snapshotForUser(
|
|
accessGrant.id,
|
|
host.id,
|
|
target.id as string,
|
|
host.userId,
|
|
);
|
|
} else {
|
|
await secretsManager.snapshotForRole(
|
|
accessGrant.id,
|
|
host.id,
|
|
target.id as number,
|
|
host.userId,
|
|
);
|
|
}
|
|
} catch (snapshotError) {
|
|
databaseLogger.warn("Fleet shared but secret snapshot failed", {
|
|
operation: "fleet_share_snapshot_failed",
|
|
hostId: host.id,
|
|
accessId: accessGrant.id,
|
|
error: getErrorMessage(snapshotError),
|
|
});
|
|
}
|
|
}
|
|
|
|
hostResults.push({ hostId: host.id, shared: true });
|
|
}
|
|
|
|
const sharedCount = hostResults.filter((r) => r.shared).length;
|
|
|
|
databaseLogger.success("Fleet shared successfully", {
|
|
operation: "fleet_share_success",
|
|
userId,
|
|
fleetId,
|
|
hostsShared: sharedCount,
|
|
targets: targets.length,
|
|
permissionLevel,
|
|
});
|
|
|
|
res.json({
|
|
success: true,
|
|
permissionLevel,
|
|
expiresAt,
|
|
hostsShared: sharedCount,
|
|
hostsTotal: members.length,
|
|
hostResults,
|
|
});
|
|
} catch (err) {
|
|
databaseLogger.error("Failed to share fleet", err, {
|
|
operation: "fleet_share_failed",
|
|
userId,
|
|
fleetId,
|
|
});
|
|
res.status(500).json({ error: "Failed to share fleet" });
|
|
}
|
|
},
|
|
);
|
|
|
|
// Minimal promisified SFTP primitives for whole-file push/pull. Deliberately
|
|
// not reusing transfer-engine.ts - that file's resumable/segmented/tar
|
|
// machinery is built for the interactive file manager's large-transfer UX
|
|
// and is overbuilt for a fleet action operating on one buffered file per host.
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Resolves a fleet's effective members, re-checks the caller's per-host
|
|
* access at `level` (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(
|
|
userId: string,
|
|
fleetId: number,
|
|
level: HostAction,
|
|
fn: (host: {
|
|
id: number;
|
|
name: string;
|
|
}) => Promise<Pick<FleetHostResult, "success" | "output" | "error">>,
|
|
): Promise<{ results: FleetHostResult[]; fleetFound: boolean }> {
|
|
const fleetRepository = createCurrentFleetRepository();
|
|
const fleet = await fleetRepository.findById(userId, fleetId);
|
|
if (!fleet) {
|
|
return { results: [], fleetFound: false };
|
|
}
|
|
|
|
const members = await fleetRepository.listEffectiveMembers(userId, fleetId);
|
|
|
|
const settled = await Promise.allSettled(
|
|
members.map(async (host) => {
|
|
const access = await permissionManager.canAccessHost(
|
|
userId,
|
|
host.id,
|
|
level,
|
|
);
|
|
if (!access.hasAccess) {
|
|
return {
|
|
hostId: host.id,
|
|
hostName: host.name,
|
|
success: false,
|
|
error: `Access denied (requires '${level}' level)`,
|
|
} satisfies FleetHostResult;
|
|
}
|
|
|
|
try {
|
|
const outcome = await fn({ id: host.id, name: host.name });
|
|
return {
|
|
hostId: host.id,
|
|
hostName: host.name,
|
|
...outcome,
|
|
} satisfies FleetHostResult;
|
|
} catch (err) {
|
|
return {
|
|
hostId: host.id,
|
|
hostName: host.name,
|
|
success: false,
|
|
error: getErrorMessage(err),
|
|
} 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 };
|
|
}
|
|
|
|
/**
|
|
* @openapi
|
|
* /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",
|
|
authenticateJWT,
|
|
requireDataAccess,
|
|
async (req: Request, res: Response) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
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(
|
|
userId,
|
|
fleetId,
|
|
"edit",
|
|
async (host) => {
|
|
const fullHost = await resolveHostById(host.id, userId);
|
|
if (!fullHost) {
|
|
return { success: false, error: "Host not found" };
|
|
}
|
|
|
|
const resolvedCommand = resolveSnippetCommand(
|
|
command,
|
|
{
|
|
ip: fullHost.ip,
|
|
username: fullHost.username,
|
|
port: fullHost.port,
|
|
name: fullHost.name,
|
|
},
|
|
inputValues && typeof inputValues === "object" ? inputValues : {},
|
|
);
|
|
|
|
const { stdout, stderr, code } = await withConnection(
|
|
getFleetPoolKey(fullHost),
|
|
createFleetSshFactory(fullHost),
|
|
(client) => execCommand(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" });
|
|
}
|
|
|
|
authLogger.success(`Fleet command executed on fleet ${fleetId}`, {
|
|
operation: "fleet_execute_success",
|
|
userId,
|
|
fleetId,
|
|
hostCount: results.length,
|
|
});
|
|
|
|
res.json({ results });
|
|
} catch (err) {
|
|
databaseLogger.error("Failed to execute fleet command", err, {
|
|
operation: "fleet_execute_failed",
|
|
userId,
|
|
fleetId,
|
|
});
|
|
res.status(500).json({ error: "Failed to execute fleet command" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /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",
|
|
authenticateJWT,
|
|
requireDataAccess,
|
|
upload.single("file"),
|
|
async (req: Request, res: Response) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
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(
|
|
userId,
|
|
fleetId,
|
|
"edit",
|
|
async (host) => {
|
|
const fullHost = await resolveHostById(host.id, userId);
|
|
if (!fullHost) {
|
|
return { success: false, error: "Host not found" };
|
|
}
|
|
|
|
await withConnection(
|
|
getFleetPoolKey(fullHost),
|
|
createFleetSshFactory(fullHost),
|
|
async (client) => {
|
|
const sftp = await getSftp(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" });
|
|
}
|
|
|
|
authLogger.success(`Fleet file push executed on fleet ${fleetId}`, {
|
|
operation: "fleet_transfer_push_success",
|
|
userId,
|
|
fleetId,
|
|
hostCount: results.length,
|
|
});
|
|
|
|
res.json({ results });
|
|
} catch (err) {
|
|
databaseLogger.error("Failed to push file across fleet", err, {
|
|
operation: "fleet_transfer_push_failed",
|
|
userId,
|
|
fleetId,
|
|
});
|
|
res.status(500).json({ error: "Failed to push file across fleet" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /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",
|
|
authenticateJWT,
|
|
requireDataAccess,
|
|
async (req: Request, res: Response) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
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(
|
|
userId,
|
|
fleetId,
|
|
"edit",
|
|
async (host) => {
|
|
const fullHost = await resolveHostById(host.id, userId);
|
|
if (!fullHost) {
|
|
return { success: false, error: "Host not found" };
|
|
}
|
|
|
|
const data = await withConnection(
|
|
getFleetPoolKey(fullHost),
|
|
createFleetSshFactory(fullHost),
|
|
async (client) => {
|
|
const sftp = await getSftp(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" });
|
|
}
|
|
|
|
authLogger.success(`Fleet file pull executed on fleet ${fleetId}`, {
|
|
operation: "fleet_transfer_pull_success",
|
|
userId,
|
|
fleetId,
|
|
hostCount: results.length,
|
|
});
|
|
|
|
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) {
|
|
databaseLogger.error("Failed to pull file across fleet", err, {
|
|
operation: "fleet_transfer_pull_failed",
|
|
userId,
|
|
fleetId,
|
|
});
|
|
res.status(500).json({ error: "Failed to pull file across fleet" });
|
|
}
|
|
},
|
|
);
|
|
|
|
// 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,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @openapi
|
|
* /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",
|
|
authenticateJWT,
|
|
requireDataAccess,
|
|
async (req: Request, res: Response) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
const fleetId = parseFleetId(req.params.id);
|
|
if (fleetId === null) {
|
|
return res.status(400).json({ error: "Invalid fleet ID" });
|
|
}
|
|
|
|
try {
|
|
const fleetRepository = createCurrentFleetRepository();
|
|
const fleet = await fleetRepository.findById(userId, fleetId);
|
|
if (!fleet) {
|
|
return res.status(404).json({ error: "Fleet not found" });
|
|
}
|
|
|
|
const members = await fleetRepository.listEffectiveMembers(
|
|
userId,
|
|
fleetId,
|
|
);
|
|
const inventory =
|
|
await createCurrentFleetInventoryRepository().listForHosts(
|
|
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) {
|
|
databaseLogger.error("Failed to read fleet inventory", err, {
|
|
operation: "fleet_inventory_read_failed",
|
|
userId,
|
|
fleetId,
|
|
});
|
|
res.status(500).json({ error: "Failed to read fleet inventory" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /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",
|
|
authenticateJWT,
|
|
requireDataAccess,
|
|
async (req: Request, res: Response) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
const fleetId = parseFleetId(req.params.id);
|
|
if (fleetId === null) {
|
|
return res.status(400).json({ error: "Invalid fleet ID" });
|
|
}
|
|
|
|
try {
|
|
const inventoryRepository = createCurrentFleetInventoryRepository();
|
|
|
|
const { results, fleetFound } = await runAcrossFleet(
|
|
userId,
|
|
fleetId,
|
|
"view",
|
|
async (host) => {
|
|
const fullHost = await resolveHostById(host.id, userId);
|
|
if (!fullHost) {
|
|
return { success: false, error: "Host not found" };
|
|
}
|
|
|
|
const record = await withConnection(
|
|
getFleetPoolKey(fullHost),
|
|
createFleetSshFactory(fullHost),
|
|
async (client) => {
|
|
const platform = await detectPlatform(client);
|
|
const { stdout } = await execCommand(
|
|
client,
|
|
INVENTORY_PROBE_COMMAND,
|
|
15000,
|
|
);
|
|
const facts = parseInventoryProbe(stdout);
|
|
|
|
return inventoryRepository.upsert(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) {
|
|
databaseLogger.error("Failed to refresh fleet inventory", err, {
|
|
operation: "fleet_inventory_refresh_failed",
|
|
userId,
|
|
fleetId,
|
|
});
|
|
res.status(500).json({ error: "Failed to refresh fleet inventory" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /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",
|
|
authenticateJWT,
|
|
requireDataAccess,
|
|
async (req: Request, res: Response) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
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(
|
|
userId,
|
|
fleetId,
|
|
"manage",
|
|
async (host) => {
|
|
const fullHost = await resolveHostById(host.id, userId);
|
|
if (!fullHost) {
|
|
return { success: false, error: "Host not found" };
|
|
}
|
|
|
|
return withConnection(
|
|
getFleetPoolKey(fullHost),
|
|
createFleetSshFactory(fullHost),
|
|
async (client) => {
|
|
const platform = await detectPlatform(client);
|
|
// "remove" has no PackageAction counterpart in buildPackageActionCommand
|
|
// (install/upgrade-all only) - build it here per distro instead.
|
|
const cmd =
|
|
action === "remove"
|
|
? buildRemoveCommand(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(
|
|
client,
|
|
cmd,
|
|
fullHost.sudoPassword,
|
|
{ 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;
|
|
}
|
|
},
|
|
);
|
|
},
|
|
);
|
|
|
|
if (!fleetFound) {
|
|
return res.status(404).json({ error: "Fleet not found" });
|
|
}
|
|
|
|
res.json({ results });
|
|
} catch (err) {
|
|
databaseLogger.error("Failed to run fleet package action", err, {
|
|
operation: "fleet_packages_failed",
|
|
userId,
|
|
fleetId,
|
|
});
|
|
res.status(500).json({ error: "Failed to run fleet package action" });
|
|
}
|
|
},
|
|
);
|
|
|
|
export function buildRemoveCommand(
|
|
pkg: "apt" | "dnf" | "yum" | "pacman" | null,
|
|
name: string,
|
|
): string | null {
|
|
switch (pkg) {
|
|
case "apt":
|
|
return `DEBIAN_FRONTEND=noninteractive apt-get -y remove ${name}`;
|
|
case "dnf":
|
|
return `dnf -y remove ${name}`;
|
|
case "yum":
|
|
return `yum -y remove ${name}`;
|
|
case "pacman":
|
|
return `pacman -R --noconfirm ${name}`;
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Called from activate(). Mounts this router at /fleets via the shared
|
|
* dispatcher. */
|
|
export function startFleetsService(): void {
|
|
registerFleetsRouter(router);
|
|
}
|
|
|
|
/** Called from deactivate(). /fleets/* falls back to 404 until reactivated. */
|
|
export function stopFleetsService(): void {
|
|
unregisterFleetsRouter();
|
|
}
|