cangku/scripts/setup-remote.mjs
2026-09-12 16:11:05 +08:00

272 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import crypto from 'node:crypto';
import fs from 'node:fs';
import https from 'node:https';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
import readline from 'node:readline';
import { execFileSync, spawnSync } from 'node:child_process';
import { fileURLToPath, pathToFileURL } from 'node:url';
import {
getKnownHostsLine,
mergeEnv,
validateEnrollmentResponse,
} from './remote-access-common.mjs';
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const ROOT_DIR = path.resolve(SCRIPT_DIR, '..');
const ACCESS_DIR = path.join(ROOT_DIR, '.remote-access');
export const DEFAULT_ENDPOINT = 'https://ck.yydd.ai:8443/v1/enroll';
const DEFAULT_LOCAL_DB_PORT = 13307;
function parseArgs(args) {
const result = { label: os.hostname(), replace: false };
for (let index = 0; index < args.length; index += 1) {
if (args[index] === '--label') {
result.label = String(args[index + 1] || '').trim();
index += 1;
} else if (args[index] === '--replace') {
result.replace = true;
} else {
throw new Error(`未知参数:${args[index]}`);
}
}
if (!result.label || result.label.length > 80) {
throw new Error('设备名称必须为 1 到 80 个字符');
}
return result;
}
async function readSecret(prompt) {
if (process.env.CANGKU_ENROLL_KEY) return process.env.CANGKU_ENROLL_KEY.trim();
if (!process.stdin.isTTY || !process.stdin.setRawMode) {
throw new Error('当前终端不能隐藏输入;请临时通过 CANGKU_ENROLL_KEY 环境变量提供 Key');
}
process.stdout.write(prompt);
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.setEncoding('utf8');
return new Promise((resolve, reject) => {
let value = '';
const cleanup = () => {
process.stdin.setRawMode(false);
process.stdin.pause();
process.stdin.removeListener('data', onData);
};
const onData = (character) => {
if (character === '\u0003') {
cleanup();
process.stdout.write('\n');
reject(new Error('已取消'));
} else if (character === '\r' || character === '\n') {
cleanup();
process.stdout.write('\n');
resolve(value.trim());
} else if (character === '\u007f') {
value = value.slice(0, -1);
} else {
value += character;
}
};
process.stdin.on('data', onData);
});
}
function requestEnrollment(endpoint, key, payload) {
return new Promise((resolve, reject) => {
const body = JSON.stringify(payload);
const request = https.request(endpoint, {
method: 'POST',
headers: {
Authorization: `Bearer ${key}`,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
},
timeout: 15000,
}, (response) => {
const chunks = [];
let length = 0;
response.on('data', (chunk) => {
length += chunk.length;
if (length > 128 * 1024) {
request.destroy(new Error('注册服务响应过大'));
return;
}
chunks.push(chunk);
});
response.on('end', () => {
let parsed;
try {
parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'));
} catch (_error) {
reject(new Error(`注册服务返回了无法解析的数据(HTTP ${response.statusCode})`));
return;
}
if (response.statusCode !== 201) {
reject(new Error(parsed.error || `注册失败(HTTP ${response.statusCode})`));
return;
}
try {
resolve(validateEnrollmentResponse(parsed));
} catch (error) {
reject(error);
}
});
});
request.on('timeout', () => request.destroy(new Error('连接注册服务超时')));
request.on('error', reject);
request.end(body);
});
}
function commandExists(command) {
const result = spawnSync(command, ['-V'], { stdio: 'ignore' });
return !result.error || result.error.code !== 'ENOENT';
}
function ensureDependencies() {
for (const command of ['ssh', 'ssh-keygen']) {
if (!commandExists(command)) {
throw new Error(`缺少 ${command}。请先安装 OpenSSH 客户端后重试`);
}
}
}
function backupExistingAccessDirectory() {
if (!fs.existsSync(ACCESS_DIR)) return null;
const backupPath = `${ACCESS_DIR}.backup-${new Date().toISOString().replace(/[:.]/gu, '-')}`;
fs.renameSync(ACCESS_DIR, backupPath);
return backupPath;
}
function updateBackendEnvironment(enrollment, localPort) {
const examplePath = path.join(ROOT_DIR, 'backend', '.env.example');
const envPath = path.join(ROOT_DIR, 'backend', '.env');
const original = fs.existsSync(envPath)
? fs.readFileSync(envPath, 'utf8')
: fs.readFileSync(examplePath, 'utf8');
const updates = {
LOCAL_DB_TYPE: 'mysql',
LOCAL_DB_HOST: '127.0.0.1',
LOCAL_DB_PORT: String(localPort),
LOCAL_DB_NAME: enrollment.database.name,
LOCAL_DB_USER: enrollment.database.username,
LOCAL_DB_PASSWORD: enrollment.database.password,
CLOUD_DB_RUNTIME_PROFILE: 'public',
REMOTE_SYSTEM_DB: 'true',
ENABLE_BACKGROUND_JOBS: 'false',
DISABLE_CUBEJS: 'true',
};
for (const key of ['JWT_SECRET', 'CUBEJS_SECRET', 'CUBEJS_API_SECRET']) {
const existing = original.match(new RegExp(`^${key}=(.*)$`, 'mu'))?.[1]?.trim();
if (!existing) updates[key] = crypto.randomBytes(32).toString('hex');
}
fs.writeFileSync(envPath, mergeEnv(original, updates), { mode: 0o600 });
fs.chmodSync(envPath, 0o600);
for (const [key, value] of Object.entries(updates)) {
process.env[key] = String(value);
}
}
export async function configureRemoteAccess({
endpoint = DEFAULT_ENDPOINT,
key,
label = os.hostname(),
replace = false,
localPort = Number(process.env.CANGKU_LOCAL_DB_PORT || DEFAULT_LOCAL_DB_PORT),
} = {}) {
ensureDependencies();
if (!label || String(label).trim().length > 80) {
throw new Error('设备名称必须为 1 到 80 个字符');
}
const endpointUrl = new URL(endpoint);
if (endpointUrl.protocol !== 'https:' || endpointUrl.username || endpointUrl.password) {
throw new Error('配置请求地址必须是没有内嵌账号密码的 HTTPS 地址');
}
if (!key || !String(key).trim()) throw new Error('部署 Key 不能为空');
if (fs.existsSync(ACCESS_DIR) && !replace) {
throw new Error('已存在 .remote-access 配置。如需重新注册,请确认旧设备可停用后使用 --replace');
}
if (!Number.isInteger(localPort) || localPort < 1024 || localPort > 65535) {
throw new Error('本地数据库端口必须是 1024 到 65535 之间的端口');
}
const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'cangku-enroll-'));
const temporaryPrivateKey = path.join(temporaryDirectory, 'id_ed25519');
try {
execFileSync('ssh-keygen', [
'-q', '-t', 'ed25519', '-N', '', '-C', String(label).trim(),
'-f', temporaryPrivateKey,
], { stdio: 'ignore' });
const publicKey = fs.readFileSync(`${temporaryPrivateKey}.pub`, 'utf8').trim();
const enrollment = await requestEnrollment(endpointUrl, String(key).trim(), {
label: String(label).trim(),
public_key: publicKey,
});
const backupPath = replace ? backupExistingAccessDirectory() : null;
fs.mkdirSync(ACCESS_DIR, { recursive: true, mode: 0o700 });
fs.copyFileSync(temporaryPrivateKey, path.join(ACCESS_DIR, 'id_ed25519'));
fs.copyFileSync(`${temporaryPrivateKey}.pub`, path.join(ACCESS_DIR, 'id_ed25519.pub'));
fs.writeFileSync(path.join(ACCESS_DIR, 'id_ed25519-cert.pub'), `${enrollment.ssh.certificate.trim()}\n`);
fs.writeFileSync(
path.join(ACCESS_DIR, 'known_hosts'),
getKnownHostsLine(enrollment.ssh.host, enrollment.ssh.port, enrollment.ssh.host_key)
);
fs.chmodSync(path.join(ACCESS_DIR, 'id_ed25519'), 0o600);
const connection = {
installationId: enrollment.installation_id,
bootstrapEndpoint: endpointUrl.toString(),
certificateValidUntil: enrollment.ssh.valid_until,
localDatabasePort: localPort,
databaseName: enrollment.database.name,
ssh: {
host: enrollment.ssh.host,
port: Number(enrollment.ssh.port),
user: enrollment.ssh.user,
remoteDatabaseHost: enrollment.database.remote_host || '127.0.0.1',
remoteDatabasePort: Number(enrollment.database.remote_port || 3307),
},
};
const connectionPath = path.join(ACCESS_DIR, 'connection.json');
fs.writeFileSync(connectionPath, `${JSON.stringify(connection, null, 2)}\n`, { mode: 0o600 });
fs.chmodSync(connectionPath, 0o600);
updateBackendEnvironment(enrollment, localPort);
return { enrollment, connection, backupPath };
} finally {
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
}
}
export async function main(args = process.argv.slice(2)) {
const options = parseArgs(args);
const key = await readSecret('请输入部署 Key(输入内容不会显示):');
try {
const result = await configureRemoteAccess({
endpoint: process.env.CANGKU_BOOTSTRAP_URL || DEFAULT_ENDPOINT,
key,
label: options.label,
replace: options.replace,
});
const { enrollment, connection, backupPath } = result;
console.log(`注册完成,设备编号:${enrollment.installation_id}`);
console.log(`系统数据库:127.0.0.1:${connection.localDatabasePort}/${enrollment.database.name}(通过 SSH 隧道)`);
console.log(`SSH 证书有效期至:${enrollment.ssh.valid_until}`);
console.log('以下系统管理员凭据只显示一次,请立即保存:');
console.log(`管理员账号:${enrollment.application_admin.username}`);
console.log(`管理员密码:${enrollment.application_admin.password}`);
if (backupPath) console.log(`旧配置已保存在:${path.basename(backupPath)}`);
console.log('运行 npm run remote:dev 可启动隧道和本地前后端。');
} finally {
delete process.env.CANGKU_ENROLL_KEY;
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error) => {
console.error(`远程部署配置失败:${error.message}`);
process.exitCode = 1;
});
}