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 { 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 { 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 { 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>, ): 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 (/). 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(); 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(); }