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; 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('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 { 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 { 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 { return await this.createSetupScriptResponse(this.getCodeFromContext(ctxArg)); } public async handleClaimHttpRequest(ctxArg: plugins.typedserver.IRequestContext): Promise { try { const requestData = await this.readJsonBody(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 { 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(); const node = new this.cloudlyRef.nodeManager.CClusterNode(); node.id = nodeId; 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, 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 = ` Cloudly Jump
Cloudly Jump

Connect System

Cluster: ${this.escapeHtml(clusterName)}

Run this command on the Linux system you want to connect:

${this.escapeHtml(command)}
${isUsable ? 'Ready to use' : 'This jump code is invalid, expired, or already used'}
`; 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);')" spark installdaemon --mode=coreflow-node --cloudlyUrl="\${CLOUDLY_URL}" --jumpcode="\${COREFLOW_JUMPCODE}" 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(ctxArg: plugins.typedserver.IRequestContext): Promise { 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("'", "'\\''"); } }