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; }); }