374 lines
13 KiB
TypeScript
374 lines
13 KiB
TypeScript
|
|
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<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();
|
||
|
|
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 = `<!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);')"
|
||
|
|
|
||
|
|
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<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("'", "'\\''");
|
||
|
|
}
|
||
|
|
}
|