2026-05-23 10:46:52 +00:00
import * as plugins from '../plugins.js' ;
import type { Cloudly } from '../classes.cloudly.js' ;
import { logger } from '../logger.js' ;
import { JumpCode } from './classes.jumpcode.js' ;
type IReqCreateNodeJumpCommand = plugins . servezoneInterfaces . requests . node . IReq_Any_Cloudly_CreateNodeJumpCommand [ 'request' ] ;
type IResCreateNodeJumpCommand = plugins . servezoneInterfaces . requests . node . IReq_Any_Cloudly_CreateNodeJumpCommand [ 'response' ] ;
interface IClaimJumpCodeRequest {
jumpCode? : string ;
hostname? : string ;
}
interface IClaimJumpCodeResponse {
accepted : boolean ;
message? : string ;
nodeId? : string ;
2026-05-24 12:47:15 +00:00
sparkNodeToken? : string ;
2026-05-23 10:46:52 +00:00
cloudlyUrl? : string ;
coreflowJumpCode? : string ;
}
export class CloudlyJumpManager {
public cloudlyRef : Cloudly ;
public typedRouter = new plugins . typedrequest . TypedRouter ( ) ;
public CJumpCode = plugins . smartdata . setDefaultManagerForDoc ( this , JumpCode ) ;
public get db() {
return this . cloudlyRef . mongodbConnector . smartdataDb ;
}
private defaultTtlMs = 1000 * 60 * 30 ;
private maxTtlMs = 1000 * 60 * 60 * 24 ;
constructor ( cloudlyRefArg : Cloudly ) {
this . cloudlyRef = cloudlyRefArg ;
this . cloudlyRef . typedrouter . addTypedRouter ( this . typedRouter ) ;
this . typedRouter . addTypedHandler (
new plugins . typedrequest . TypedHandler < plugins.servezoneInterfaces.requests.node.IReq_Any_Cloudly_CreateNodeJumpCommand > ( 'createNodeJumpCommand' , async ( requestDataArg ) = > {
await plugins . smartguard . passGuardsOrReject (
{ identity : requestDataArg.identity } ,
[ this . cloudlyRef . authManager . adminIdentityGuard ] ,
) ;
return await this . createNodeJumpCommand ( requestDataArg ) ;
} ) ,
) ;
}
public async start() {
logger . log ( 'info' , 'Jump manager started' ) ;
}
public async stop() {
logger . log ( 'info' , 'Jump manager stopped' ) ;
}
public async createNodeJumpCommand ( optionsArg : IReqCreateNodeJumpCommand ) : Promise < IResCreateNodeJumpCommand > {
const cluster = await this . cloudlyRef . clusterManager . CCluster . getInstance ( {
id : optionsArg.clusterId ,
} ) ;
if ( ! cluster ) {
throw new plugins . typedrequest . TypedResponseError ( ` Cluster ${ optionsArg . clusterId } not found ` ) ;
}
const now = Date . now ( ) ;
const ttlMs = this . normalizeTtl ( optionsArg . ttlMs ) ;
const jumpCode = this . createJumpCode ( ) ;
const jumpCodeDoc = new this . CJumpCode ( {
id : await this . CJumpCode . getNewId ( ) ,
tokenHash : this.hashSecret ( jumpCode ) ,
data : {
clusterId : cluster.id ,
createdBy : optionsArg.identity.userId ,
role : optionsArg.role || 'worker' ,
nodeType : optionsArg.nodeType || 'baremetal' ,
createdAt : now ,
expiresAt : now + ttlMs ,
} ,
} ) ;
await jumpCodeDoc . save ( ) ;
const jumpUrl = ` ${ this . getPublicCloudlyUrl ( ) } /jump/ ${ encodeURIComponent ( jumpCode ) } ` ;
const setupUrl = ` ${ jumpUrl } /setup.sh ` ;
return {
jumpCode ,
jumpUrl ,
setupUrl ,
command : ` curl -fsSL ' ${ jumpUrl } ' | sudo bash ` ,
expiresAt : jumpCodeDoc.data.expiresAt ,
} ;
}
public async handleJumpHttpRequest ( ctxArg : plugins.typedserver.IRequestContext ) : Promise < Response > {
const jumpCode = this . getCodeFromContext ( ctxArg ) ;
if ( this . shouldRenderHtml ( ctxArg ) ) {
return await this . createLandingPageResponse ( jumpCode ) ;
}
return await this . createSetupScriptResponse ( jumpCode ) ;
}
public async handleSetupScriptHttpRequest ( ctxArg : plugins.typedserver.IRequestContext ) : Promise < Response > {
return await this . createSetupScriptResponse ( this . getCodeFromContext ( ctxArg ) ) ;
}
public async handleClaimHttpRequest ( ctxArg : plugins.typedserver.IRequestContext ) : Promise < Response > {
try {
const requestData = await this . readJsonBody < IClaimJumpCodeRequest > ( ctxArg ) ;
const response = await this . claimJumpCode ( requestData ) ;
return this . createJsonResponse ( 200 , response ) ;
} catch ( error ) {
return this . createJsonResponse ( 400 , {
accepted : false ,
message : ( error as Error ) . message ,
} satisfies IClaimJumpCodeResponse ) ;
}
}
public async claimJumpCode ( requestDataArg : IClaimJumpCodeRequest ) : Promise < IClaimJumpCodeResponse > {
if ( ! requestDataArg . jumpCode ) {
throw new Error ( 'Jump code is missing' ) ;
}
const jumpCodeDoc = await this . getJumpCodeByCode ( requestDataArg . jumpCode ) ;
if ( ! jumpCodeDoc ) {
throw new Error ( 'Jump code is invalid' ) ;
}
if ( jumpCodeDoc . data . consumedAt ) {
throw new Error ( 'Jump code has already been used' ) ;
}
if ( jumpCodeDoc . data . expiresAt <= Date . now ( ) ) {
throw new Error ( 'Jump code has expired' ) ;
}
const cluster = await this . cloudlyRef . clusterManager . CCluster . getInstance ( {
id : jumpCodeDoc.data.clusterId ,
} ) ;
if ( ! cluster ) {
throw new Error ( 'Jump code references a missing cluster' ) ;
}
const clusterUser = await this . cloudlyRef . authManager . CUser . getInstance ( {
id : cluster.data.userId ,
} ) ;
const coreflowJumpCode = clusterUser ? . data . tokens ? . find ( ( tokenArg ) = > tokenArg . expiresAt > Date . now ( ) ) ? . token ;
if ( ! coreflowJumpCode ) {
throw new Error ( 'Cluster runtime token is missing or expired' ) ;
}
const nodeId = plugins . smartunique . shortId ( 8 ) ;
const now = Date . now ( ) ;
2026-05-24 12:47:15 +00:00
const sparkNodeToken = await this . cloudlyRef . authManager . createNewSecureToken ( ) ;
2026-05-23 10:46:52 +00:00
const node = new this . cloudlyRef . nodeManager . CClusterNode ( ) ;
node . id = nodeId ;
2026-05-24 12:47:15 +00:00
node . sparkNodeTokenHash = this . hashSecret ( sparkNodeToken ) ;
2026-05-23 10:46:52 +00:00
node . data = {
clusterId : cluster.id ,
nodeType : jumpCodeDoc.data.nodeType ,
status : 'initializing' ,
role : jumpCodeDoc.data.role ,
joinedAt : now ,
lastHealthCheck : now ,
sshKeys : [ ] ,
requiredDebianPackages : [ ] ,
} ;
await node . save ( ) ;
cluster . data . nodes = [
. . . ( cluster . data . nodes || [ ] ) . filter ( ( nodeArg ) = > nodeArg . id !== node . id ) ,
await node . createSavableObject ( ) ,
] ;
await cluster . save ( ) ;
jumpCodeDoc . data = {
. . . jumpCodeDoc . data ,
consumedAt : now ,
consumedByNodeId : node.id ,
} ;
await jumpCodeDoc . save ( ) ;
return {
accepted : true ,
nodeId : node.id ,
2026-05-24 12:47:15 +00:00
sparkNodeToken ,
2026-05-23 10:46:52 +00:00
cloudlyUrl : cluster.data.cloudlyUrl || ` ${ this . getPublicCloudlyUrl ( ) } / ` ,
coreflowJumpCode ,
} ;
}
private async createLandingPageResponse ( jumpCodeArg : string ) {
const jumpCodeDoc = await this . getJumpCodeByCode ( jumpCodeArg ) ;
let clusterName = 'Unknown cluster' ;
let isUsable = false ;
if ( jumpCodeDoc && ! jumpCodeDoc . data . consumedAt && jumpCodeDoc . data . expiresAt > Date . now ( ) ) {
const cluster = await this . cloudlyRef . clusterManager . CCluster . getInstance ( {
id : jumpCodeDoc.data.clusterId ,
} ) ;
clusterName = cluster ? . data . name || jumpCodeDoc . data . clusterId ;
isUsable = true ;
}
const jumpUrl = ` ${ this . getPublicCloudlyUrl ( ) } /jump/ ${ encodeURIComponent ( jumpCodeArg ) } ` ;
const command = ` curl -fsSL ' ${ jumpUrl } ' | sudo bash ` ;
const html = ` <!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cloudly Jump</title>
<style>
body { margin: 0; font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #0d1117; color: #f0f6fc; }
main { max-width: 760px; margin: 10vh auto; padding: 32px; }
.card { border: 1px solid #30363d; border-radius: 18px; background: #161b22; padding: 28px; box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35); }
.label { color: #8b949e; font-size: 13px; text-transform: uppercase; letter-spacing: 0.08em; }
h1 { margin: 8px 0 12px; font-size: 34px; }
p { color: #c9d1d9; line-height: 1.55; }
pre { white-space: pre-wrap; word-break: break-all; background: #0d1117; border: 1px solid #30363d; border-radius: 12px; padding: 16px; color: #7ee787; }
.status { display: inline-block; margin-top: 16px; padding: 6px 10px; border-radius: 999px; background: ${ isUsable ? '#17391f' : '#3d1d1d' } ; color: ${ isUsable ? '#7ee787' : '#ff7b72' } ; }
</style>
</head>
<body>
<main>
<div class="card">
<div class="label">Cloudly Jump</div>
<h1>Connect System</h1>
<p>Cluster: <strong> ${ this . escapeHtml ( clusterName ) } </strong></p>
<p>Run this command on the Linux system you want to connect:</p>
<pre> ${ this . escapeHtml ( command ) } </pre>
<div class="status"> ${ isUsable ? 'Ready to use' : 'This jump code is invalid, expired, or already used' } </div>
</div>
</main>
</body>
</html> ` ;
return new Response ( html , {
status : isUsable ? 200 : 404 ,
headers : {
'Content-Type' : 'text/html; charset=utf-8' ,
} ,
} ) ;
}
private async createSetupScriptResponse ( jumpCodeArg : string ) {
if ( ! jumpCodeArg || ! ( await this . isJumpCodeUsable ( jumpCodeArg ) ) ) {
return new Response ( 'jump code is invalid, expired, or already used\n' , {
status : 404 ,
headers : {
'Content-Type' : 'text/plain; charset=utf-8' ,
} ,
} ) ;
}
return new Response ( this . createSetupScript ( jumpCodeArg ) , {
headers : {
'Content-Type' : 'application/x-sh; charset=utf-8' ,
} ,
} ) ;
}
private createSetupScript ( jumpCodeArg : string ) {
const claimUrl = ` ${ this . getPublicCloudlyUrl ( ) } /jump/v1/claim ` ;
return ` #!/usr/bin/env bash
set -euo pipefail
if [ " $ (id -u)" -ne 0 ]; then
echo "Cloudly jump setup must run as root. Re-run with sudo." >&2
exit 1
fi
export DEBIAN_FRONTEND=noninteractive
export JUMP_CODE=' ${ this . escapeShellValue ( jumpCodeArg ) } '
export CLAIM_URL=' ${ this . escapeShellValue ( claimUrl ) } '
echo "Preparing system for Cloudly jump..."
apt-get update
apt-get install -y --force-yes curl ca-certificates git
if ! command -v docker >/dev/null 2>&1; then
curl -sSL https://get.docker.com/ | sh
fi
if ! command -v node >/dev/null 2>&1; then
curl -sL https://deb.nodesource.com/setup_18.x | bash
apt-get install -y --force-yes nodejs
fi
if ! command -v pnpm >/dev/null 2>&1; then
curl -fsSL https://get.pnpm.io/install.sh | sh -
fi
export PNPM_HOME=" \ ${ PNPM_HOME : - /root/ . local / share / pnpm } "
export PATH=" \ ${ PNPM_HOME } : \ ${ PATH } "
pnpm install -g @serve.zone/spark
REQUEST_BODY=" $ (node -e 'process.stdout.write(JSON.stringify({ jumpCode: process.env.JUMP_CODE, hostname: require("os").hostname() }))')"
CLAIM_RESPONSE=" $ (curl -fsSL -X POST " \ ${ CLAIM_URL } " -H 'content-type: application/json' --data " \ ${ REQUEST_BODY } ")"
export CLAIM_RESPONSE
CLOUDLY_URL=" $ (node -e 'const data = JSON.parse(process.env.CLAIM_RESPONSE); if (!data.accepted) { throw new Error(data.message || "Cloudly rejected jump code"); } process.stdout.write(data.cloudlyUrl);')"
COREFLOW_JUMPCODE=" $ (node -e 'const data = JSON.parse(process.env.CLAIM_RESPONSE); if (!data.coreflowJumpCode) { throw new Error("Cloudly did not return a Coreflow jump code"); } process.stdout.write(data.coreflowJumpCode);')"
2026-05-24 12:47:15 +00:00
SPARK_NODE_ID=" $ (node -e 'const data = JSON.parse(process.env.CLAIM_RESPONSE); if (!data.nodeId) { throw new Error("Cloudly did not return a Spark node id"); } process.stdout.write(data.nodeId);')"
SPARK_NODE_TOKEN=" $ (node -e 'const data = JSON.parse(process.env.CLAIM_RESPONSE); if (!data.sparkNodeToken) { throw new Error("Cloudly did not return a Spark node token"); } process.stdout.write(data.sparkNodeToken);')"
2026-05-23 10:46:52 +00:00
2026-05-24 12:47:15 +00:00
spark installdaemon --mode=coreflow-node --cloudlyUrl=" \ ${ CLOUDLY_URL } " --jumpcode=" \ ${ COREFLOW_JUMPCODE } " --nodeId=" \ ${ SPARK_NODE_ID } " --nodeToken=" \ ${ SPARK_NODE_TOKEN } "
2026-05-23 10:46:52 +00:00
echo "Cloudly jump completed. This system is now connected."
` ;
}
private async getJumpCodeByCode ( jumpCodeArg : string ) {
const jumpCodes = await this . CJumpCode . getInstances ( {
tokenHash : this.hashSecret ( jumpCodeArg ) ,
} ) ;
return jumpCodes [ 0 ] || null ;
}
private async isJumpCodeUsable ( jumpCodeArg : string ) {
const jumpCodeDoc = await this . getJumpCodeByCode ( jumpCodeArg ) ;
return Boolean ( jumpCodeDoc && ! jumpCodeDoc . data . consumedAt && jumpCodeDoc . data . expiresAt > Date . now ( ) ) ;
}
private getCodeFromContext ( ctxArg : plugins.typedserver.IRequestContext ) {
return ctxArg . params . code || ctxArg . url . pathname . split ( '/' ) . filter ( Boolean ) [ 1 ] || '' ;
}
private shouldRenderHtml ( ctxArg : plugins.typedserver.IRequestContext ) {
const acceptHeader = ctxArg . headers . get ( 'accept' ) || '' ;
const userAgent = ctxArg . headers . get ( 'user-agent' ) || '' ;
return acceptHeader . includes ( 'text/html' ) && ! /(curl|wget|httpie|fetch)/i . test ( userAgent ) ;
}
private createJumpCode() {
return plugins . crypto . randomBytes ( 12 ) . toString ( 'base64url' ) ;
}
private normalizeTtl ( ttlMsArg? : number ) {
if ( ! ttlMsArg || ! Number . isFinite ( ttlMsArg ) ) {
return this . defaultTtlMs ;
}
return Math . min ( Math . max ( ttlMsArg , 1000 * 60 ) , this . maxTtlMs ) ;
}
private hashSecret ( secretArg : string ) {
return plugins . crypto . createHash ( 'sha256' ) . update ( secretArg ) . digest ( 'hex' ) ;
}
private getPublicCloudlyUrl() {
const sslMode = this . cloudlyRef . config . data . sslMode ;
const protocol = sslMode === 'none' ? 'http' : 'https' ;
const port = String ( this . cloudlyRef . config . data . publicPort || ( protocol === 'https' ? '443' : '80' ) ) ;
const includePort = ! ( ( protocol === 'https' && port === '443' ) || ( protocol === 'http' && port === '80' ) ) ;
return ` ${ protocol } :// ${ this . cloudlyRef . config . data . publicUrl } ${ includePort ? ` : ${ port } ` : '' } ` ;
}
private async readJsonBody < T > ( ctxArg : plugins.typedserver.IRequestContext ) : Promise < T > {
const bodyString = ( await ctxArg . text ( ) ) . trim ( ) ;
return bodyString ? JSON . parse ( bodyString ) as T : { } as T ;
}
private createJsonResponse ( statusCodeArg : number , bodyArg : object ) : Response {
return new Response ( JSON . stringify ( bodyArg ) , {
status : statusCodeArg ,
headers : {
'Content-Type' : 'application/json' ,
} ,
} ) ;
}
private escapeHtml ( valueArg : string ) {
return valueArg
. replaceAll ( '&' , '&' )
. replaceAll ( '<' , '<' )
. replaceAll ( '>' , '>' )
. replaceAll ( '"' , '"' )
. replaceAll ( "'" , ''' ) ;
}
private escapeShellValue ( valueArg : string ) {
return valueArg . replaceAll ( "'" , "'\\''" ) ;
}
}