2026-10-01 16:20:16 -05:00
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 ) => {
2026-10-04 15:11:41 -05:00
// 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 );
2026-10-01 16:20:16 -05:00
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 ,
2026-10-04 15:11:41 -05:00
full ? . sudoPassword as string | undefined ,
2026-10-01 16:20:16 -05:00
{ 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" });
}
},
);
}