forked from lunanting/cangku
272 lines
10 KiB
JavaScript
272 lines
10 KiB
JavaScript
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/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;
|
||
});
|
||
}
|