From 3239d6a558a5371ee121350902f161956a10b3f5 Mon Sep 17 00:00:00 2001 From: lunanting Date: Sat, 12 Sep 2026 16:06:01 +0800 Subject: [PATCH] feat: bootstrap local instances from central system database --- .gitignore | 2 + README.md | 2 + backend/src/api/install.js | 46 ++ .../src/services/remoteEnrollmentService.js | 62 +++ backend/test/remoteEnrollmentService.test.js | 17 + frontend/src/api/install.js | 8 + frontend/src/pages/Install/index.jsx | 119 ++++++ package.json | 12 + remote-access/README.md | 57 +++ remote-access/server/bootstrap_service.py | 392 ++++++++++++++++++ remote-access/server/cangku-bootstrap.service | 26 ++ remote-access/server/cangku-bootstrapctl | 9 + remote-access/server/install.sh | 116 ++++++ remote-access/server/nginx-bootstrap.conf | 50 +++ remote-access/server/provision-certificate.sh | 57 +++ remote-access/server/requirements.txt | 3 + remote-access/server/sshd-cangku-tunnel.conf | 11 + scripts/remote-access-common.mjs | 80 ++++ scripts/setup-remote.mjs | 271 ++++++++++++ scripts/start-remote.mjs | 186 +++++++++ test/remote-access.test.mjs | 46 ++ 21 files changed, 1572 insertions(+) create mode 100644 backend/src/services/remoteEnrollmentService.js create mode 100644 backend/test/remoteEnrollmentService.test.js create mode 100644 package.json create mode 100644 remote-access/README.md create mode 100644 remote-access/server/bootstrap_service.py create mode 100644 remote-access/server/cangku-bootstrap.service create mode 100644 remote-access/server/cangku-bootstrapctl create mode 100644 remote-access/server/install.sh create mode 100644 remote-access/server/nginx-bootstrap.conf create mode 100644 remote-access/server/provision-certificate.sh create mode 100644 remote-access/server/requirements.txt create mode 100644 remote-access/server/sshd-cangku-tunnel.conf create mode 100644 scripts/remote-access-common.mjs create mode 100644 scripts/setup-remote.mjs create mode 100644 scripts/start-remote.mjs create mode 100644 test/remote-access.test.mjs diff --git a/.gitignore b/.gitignore index 372ec7d..82c5087 100644 --- a/.gitignore +++ b/.gitignore @@ -54,5 +54,7 @@ tmall-board-speed-check.json **/.env.* !**/.env.example **/uploads/ +.remote-access/ +.remote-access.backup-*/ .workbuddy/ **/node_modules diff --git a/README.md b/README.md index 88b4511..87563e8 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ 需要 Node.js 22、MySQL 系统库,以及业务实际使用的数据库和外部服务。仓库不包含生产环境配置、密钥、业务数据或原仓库历史。 +团队成员如需免部署本地系统数据库,可使用管理员发放的部署 Key,通过加密隧道连接中央系统数据库。完整步骤及安全边界见 [远程系统数据库部署](./remote-access/README.md)。 + ```bash npm --prefix backend ci npm --prefix frontend ci diff --git a/backend/src/api/install.js b/backend/src/api/install.js index 02d0b15..7625c62 100644 --- a/backend/src/api/install.js +++ b/backend/src/api/install.js @@ -5,6 +5,11 @@ const { shouldAutoRestartAfterInstall, testSystemDatabaseBootstrapConnection, } = require('../services/installService'); +const { + DEFAULT_BOOTSTRAP_ENDPOINT, + enrollRemoteInstallation, + scheduleRemoteRestart, +} = require('../services/remoteEnrollmentService'); const router = express.Router(); @@ -18,6 +23,47 @@ router.get('/status', async (_req, res) => { } }); +router.get('/remote-bootstrap', (_req, res) => { + res.json({ + enabled: true, + defaultEndpoint: DEFAULT_BOOTSTRAP_ENDPOINT, + supervised: process.env.CANGKU_REMOTE_SUPERVISOR === 'true', + }); +}); + +router.post('/remote-bootstrap', async (req, res) => { + try { + res.set('Cache-Control', 'no-store'); + const status = await getInstallStatus(); + if (!status.required) { + return res.status(409).json({ error: '系统数据库已经配置,不能重复注册远程部署' }); + } + + const result = await enrollRemoteInstallation(req.body || {}); + const restartScheduled = process.env.CANGKU_REMOTE_SUPERVISOR === 'true'; + res.once('finish', () => { + setTimeout(() => { + try { + scheduleRemoteRestart(); + } catch (error) { + console.error('写入远程启动标记失败:', error.message); + } + }, 800); + }); + return res.json({ + success: true, + message: restartScheduled + ? '远程系统数据库配置完成,服务正在自动重启' + : '远程系统数据库配置完成,请使用 npm run remote:dev 重新启动', + restartScheduled, + ...result, + }); + } catch (error) { + console.error('远程部署注册失败:', error.message); + return res.status(400).json({ error: error.message || '远程部署注册失败' }); + } +}); + router.post('/system-database/test', async (req, res) => { try { const startTime = Date.now(); diff --git a/backend/src/services/remoteEnrollmentService.js b/backend/src/services/remoteEnrollmentService.js new file mode 100644 index 0000000..020f368 --- /dev/null +++ b/backend/src/services/remoteEnrollmentService.js @@ -0,0 +1,62 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { pathToFileURL } = require('url'); + +const REPOSITORY_ROOT = path.resolve(__dirname, '../../..'); +const SETUP_MODULE_PATH = path.join(REPOSITORY_ROOT, 'scripts', 'setup-remote.mjs'); +const RESTART_MARKER_PATH = path.join(REPOSITORY_ROOT, '.remote-access', 'restart-requested.json'); +const DEFAULT_BOOTSTRAP_ENDPOINT = 'https://ck.yydd.ai/v1/enroll'; + +function normalizeEndpoint(value) { + const endpoint = new URL(String(value || DEFAULT_BOOTSTRAP_ENDPOINT).trim()); + if (endpoint.protocol !== 'https:' || endpoint.username || endpoint.password) { + throw new Error('配置请求地址必须是没有内嵌账号密码的 HTTPS 地址'); + } + return endpoint.toString(); +} + +async function enrollRemoteInstallation(input = {}) { + const endpoint = normalizeEndpoint(input.endpoint); + const apiKey = String(input.apiKey || '').trim(); + const label = String(input.label || os.hostname()).trim(); + if (!apiKey) throw new Error('请输入部署 Key'); + if (!label || label.length > 80) throw new Error('设备名称必须为 1 到 80 个字符'); + + const setupModule = await import(pathToFileURL(SETUP_MODULE_PATH).href); + const { enrollment, connection } = await setupModule.configureRemoteAccess({ + endpoint, + key: apiKey, + label, + replace: false, + }); + + return { + installationId: enrollment.installation_id, + certificateValidUntil: enrollment.ssh.valid_until, + systemDatabase: { + host: '127.0.0.1', + port: connection.localDatabasePort, + database: enrollment.database.name, + }, + adminUsername: enrollment.application_admin.username, + adminPassword: enrollment.application_admin.password, + }; +} + +function scheduleRemoteRestart() { + fs.mkdirSync(path.dirname(RESTART_MARKER_PATH), { recursive: true, mode: 0o700 }); + fs.writeFileSync( + RESTART_MARKER_PATH, + `${JSON.stringify({ requestedAt: new Date().toISOString() })}\n`, + { mode: 0o600 } + ); + fs.chmodSync(RESTART_MARKER_PATH, 0o600); +} + +module.exports = { + DEFAULT_BOOTSTRAP_ENDPOINT, + enrollRemoteInstallation, + normalizeEndpoint, + scheduleRemoteRestart, +}; diff --git a/backend/test/remoteEnrollmentService.test.js b/backend/test/remoteEnrollmentService.test.js new file mode 100644 index 0000000..1f527c9 --- /dev/null +++ b/backend/test/remoteEnrollmentService.test.js @@ -0,0 +1,17 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + DEFAULT_BOOTSTRAP_ENDPOINT, + normalizeEndpoint, +} = require('../src/services/remoteEnrollmentService'); + +test('remote enrollment defaults to the managed HTTPS endpoint', () => { + assert.equal(normalizeEndpoint(''), DEFAULT_BOOTSTRAP_ENDPOINT); + assert.equal(normalizeEndpoint('https://ck.yydd.ai/v1/enroll'), 'https://ck.yydd.ai/v1/enroll'); +}); + +test('remote enrollment refuses insecure or credential-bearing endpoints', () => { + assert.throws(() => normalizeEndpoint('http://ck.yydd.ai/v1/enroll'), /HTTPS/u); + assert.throws(() => normalizeEndpoint('https://user:secret@ck.yydd.ai/v1/enroll'), /HTTPS/u); +}); diff --git a/frontend/src/api/install.js b/frontend/src/api/install.js index ee89b7a..393dd94 100644 --- a/frontend/src/api/install.js +++ b/frontend/src/api/install.js @@ -4,6 +4,14 @@ export const getInstallStatus = () => { return request.get('/api/install/status'); }; +export const getRemoteBootstrapConfig = () => { + return request.get('/api/install/remote-bootstrap'); +}; + +export const bootstrapRemoteInstallation = (data) => { + return request.post('/api/install/remote-bootstrap', data); +}; + export const testInstallSystemDatabase = (data) => { return request.post('/api/install/system-database/test', data); }; diff --git a/frontend/src/pages/Install/index.jsx b/frontend/src/pages/Install/index.jsx index 14ba169..ba8019c 100644 --- a/frontend/src/pages/Install/index.jsx +++ b/frontend/src/pages/Install/index.jsx @@ -6,6 +6,7 @@ import { Form, Input, InputNumber, + Modal, Result, Row, Space, @@ -25,6 +26,7 @@ import { import { useNavigate } from 'react-router-dom'; import { bootstrapInstallation, + bootstrapRemoteInstallation, getInstallStatus, testInstallSystemDatabase, } from '../../api/install'; @@ -40,6 +42,7 @@ const { Title, Text } = Typography; const Install = () => { const navigate = useNavigate(); const [form] = Form.useForm(); + const [remoteForm] = Form.useForm(); const prefetchedRef = useRef(false); const pollingTimerRef = useRef(null); const statusRetryTimerRef = useRef(null); @@ -48,6 +51,9 @@ const Install = () => { const [installing, setInstalling] = useState(false); const [installStatus, setInstallStatus] = useState(null); const [installResult, setInstallResult] = useState(null); + const [remoteInstalling, setRemoteInstalling] = useState(false); + const [remoteResult, setRemoteResult] = useState(null); + const [credentialsAcknowledged, setCredentialsAcknowledged] = useState(false); const statusTag = useMemo(() => { if (!installStatus?.required) { @@ -187,6 +193,27 @@ const Install = () => { } }; + const handleRemoteInstall = async () => { + try { + const values = await remoteForm.validateFields(); + setRemoteInstalling(true); + const result = await bootstrapRemoteInstallation({ + endpoint: values.endpoint, + apiKey: values.apiKey, + }); + remoteForm.setFieldValue('apiKey', ''); + setRemoteResult(result); + message.success(result.message || '远程系统数据库配置完成'); + startPollingForRestart(); + } catch (error) { + if (!error?.errorFields) { + message.error(error.response?.data?.error || '获取远程系统数据库配置失败'); + } + } finally { + setRemoteInstalling(false); + } + }; + if (bootstrapping) { return ( @@ -239,6 +266,98 @@ const Install = () => { return ( + setCredentialsAcknowledged(true)}> + 我已保存账号和密码 + , + ] + : (remoteResult + ? null + : [ + , + ])} + > + {remoteResult ? ( + credentialsAcknowledged ? ( + } + /> + ) : ( + + +
+ 管理员账号 +
{remoteResult.adminUsername}
+
+
+ 管理员密码 +
{remoteResult.adminPassword}
+
+ 设备编号:{remoteResult.installationId} +
+ ) + ) : ( + + +
+ + + + + + +
+
+ )} +
+sudo cangku-bootstrapctl revoke-installation +``` + +`list` 不显示 Key 或数据库密码。Key 明文仅在创建时输出一次。停用设备会删除它的数据库账号;已签发的 SSH 证书在到期前仍可能建立受限隧道,但不能再登录系统数据库。 diff --git a/remote-access/server/bootstrap_service.py b/remote-access/server/bootstrap_service.py new file mode 100644 index 0000000..139f4bc --- /dev/null +++ b/remote-access/server/bootstrap_service.py @@ -0,0 +1,392 @@ +#!/usr/bin/env python3 +import argparse +import datetime as dt +import hashlib +import hmac +import json +import os +import re +import secrets +import sqlite3 +import subprocess +import tempfile +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +import pymysql +import bcrypt + +UTC = dt.timezone.utc +PUBLIC_KEY_RE = re.compile(r'^ssh-ed25519 [A-Za-z0-9+/=]+(?: .{0,200})?$') +IDENTIFIER_RE = re.compile(r'^[A-Za-z0-9_]+$') + + +def now(): + return dt.datetime.now(UTC) + + +def iso(value): + return value.astimezone(UTC).isoformat().replace('+00:00', 'Z') + + +class Config: + def __init__(self): + self.bind_host = os.environ.get('BOOTSTRAP_BIND_HOST', '127.0.0.1') + self.port = int(os.environ.get('BOOTSTRAP_PORT', '4092')) + self.state_db = Path(os.environ.get('BOOTSTRAP_STATE_DB', '/var/lib/cangku-bootstrap/state.sqlite3')) + self.token_pepper = os.environ['BOOTSTRAP_TOKEN_PEPPER'].encode() + self.mysql_host = os.environ.get('BOOTSTRAP_MYSQL_HOST', '127.0.0.1') + self.mysql_port = int(os.environ.get('BOOTSTRAP_MYSQL_PORT', '3307')) + self.mysql_database = os.environ.get('BOOTSTRAP_MYSQL_DATABASE', 'cangku_system') + self.mysql_user = os.environ['BOOTSTRAP_MYSQL_USER'] + self.mysql_password = os.environ['BOOTSTRAP_MYSQL_PASSWORD'] + self.ssh_ca_key = Path(os.environ.get('BOOTSTRAP_SSH_CA_KEY', '/var/lib/cangku-bootstrap/ssh_user_ca')) + self.ssh_host_key = Path(os.environ.get('BOOTSTRAP_SSH_HOST_KEY', '/etc/ssh/ssh_host_ed25519_key.pub')) + self.ssh_host = os.environ['BOOTSTRAP_SSH_HOST'] + self.ssh_port = int(os.environ.get('BOOTSTRAP_SSH_PORT', '22')) + self.ssh_user = os.environ.get('BOOTSTRAP_SSH_USER', 'cangku-tunnel') + self.ssh_remote_db_host = os.environ.get('BOOTSTRAP_REMOTE_DB_HOST', '127.0.0.1') + self.ssh_remote_db_port = int(os.environ.get('BOOTSTRAP_REMOTE_DB_PORT', '3307')) + self.cert_days = int(os.environ.get('BOOTSTRAP_CERT_DAYS', '30')) + if not IDENTIFIER_RE.fullmatch(self.mysql_database): + raise ValueError('BOOTSTRAP_MYSQL_DATABASE contains an invalid identifier') + + +class Broker: + def __init__(self, config): + self.config = config + self.lock = threading.Lock() + config.state_db.parent.mkdir(parents=True, exist_ok=True) + self._initialize_state() + + def _connect_state(self): + connection = sqlite3.connect(self.config.state_db, timeout=15) + connection.row_factory = sqlite3.Row + return connection + + def _initialize_state(self): + with self._connect_state() as connection: + connection.executescript(''' + CREATE TABLE IF NOT EXISTS enrollment_tokens ( + id TEXT PRIMARY KEY, + label TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + max_uses INTEGER NOT NULL, + use_count INTEGER NOT NULL DEFAULT 0, + revoked_at TEXT + ); + CREATE TABLE IF NOT EXISTS installations ( + id TEXT PRIMARY KEY, + token_id TEXT NOT NULL, + label TEXT NOT NULL, + db_username TEXT NOT NULL UNIQUE, + certificate_serial INTEGER NOT NULL UNIQUE, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + revoked_at TEXT, + FOREIGN KEY(token_id) REFERENCES enrollment_tokens(id) + ); + CREATE TABLE IF NOT EXISTS counters ( + name TEXT PRIMARY KEY, + value INTEGER NOT NULL + ); + INSERT OR IGNORE INTO counters(name, value) VALUES ('certificate_serial', 1000); + ''') + columns = { + row['name'] for row in connection.execute('PRAGMA table_info(installations)') + } + if 'admin_username' not in columns: + connection.execute('ALTER TABLE installations ADD COLUMN admin_username TEXT') + + def token_hash(self, token): + return hmac.new(self.config.token_pepper, token.encode(), hashlib.sha256).hexdigest() + + def create_token(self, label, max_uses, days): + token_id = secrets.token_hex(8) + token = f'ck_live_{token_id}_{secrets.token_urlsafe(32)}' + created = now() + expires = created + dt.timedelta(days=days) + with self._connect_state() as connection: + connection.execute( + 'INSERT INTO enrollment_tokens(id,label,token_hash,created_at,expires_at,max_uses) VALUES(?,?,?,?,?,?)', + (token_id, label, self.token_hash(token), iso(created), iso(expires), max_uses), + ) + return token, token_id, expires + + def _find_token(self, connection, token): + row = connection.execute( + 'SELECT * FROM enrollment_tokens WHERE token_hash=?', (self.token_hash(token),) + ).fetchone() + if not row or row['revoked_at']: + raise PermissionError('部署 Key 无效或已停用') + if dt.datetime.fromisoformat(row['expires_at'].replace('Z', '+00:00')) <= now(): + raise PermissionError('部署 Key 已过期') + if row['use_count'] >= row['max_uses']: + raise PermissionError('部署 Key 已达到使用次数上限') + return row + + def _next_serial(self, connection): + connection.execute("UPDATE counters SET value=value+1 WHERE name='certificate_serial'") + return connection.execute("SELECT value FROM counters WHERE name='certificate_serial'").fetchone()[0] + + def _mysql_connection(self): + return pymysql.connect( + host=self.config.mysql_host, + port=self.config.mysql_port, + user=self.config.mysql_user, + password=self.config.mysql_password, + charset='utf8mb4', + autocommit=True, + connect_timeout=8, + ) + + def _create_database_user(self, username, password): + database = self.config.mysql_database + with self._mysql_connection() as connection: + with connection.cursor() as cursor: + cursor.execute(f"CREATE USER '{username}'@'%%' IDENTIFIED BY %s", (password,)) + cursor.execute(f"GRANT ALL PRIVILEGES ON `{database}`.* TO '{username}'@'%'") + + def _drop_database_user(self, username): + if not re.fullmatch(r'cangku_[a-f0-9]{16}', username): + raise ValueError('Refusing to drop an unexpected database username') + with self._mysql_connection() as connection: + with connection.cursor() as cursor: + cursor.execute(f"DROP USER IF EXISTS '{username}'@'%'") + + def _create_application_admin(self, username, password, label): + password_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12)).decode() + email = f'{username}@remote.local' + display_name = f'远程开发者 {label}'[:50] + with self._mysql_connection() as connection: + with connection.cursor() as cursor: + cursor.execute( + f'''INSERT INTO `{self.config.mysql_database}`.users + (username,password_hash,name,email,role,status,auth_source) + VALUES(%s,%s,%s,%s,'admin','active','local')''', + (username, password_hash, display_name, email), + ) + + def _remove_application_admin(self, username): + with self._mysql_connection() as connection: + with connection.cursor() as cursor: + cursor.execute( + f'DELETE FROM `{self.config.mysql_database}`.users WHERE username=%s', + (username,), + ) + + def _deactivate_application_admin(self, username): + if not username: + return + with self._mysql_connection() as connection: + with connection.cursor() as cursor: + cursor.execute( + f"UPDATE `{self.config.mysql_database}`.users SET status='inactive' WHERE username=%s", + (username,), + ) + + def _sign_public_key(self, public_key, installation_id, serial, expires): + if len(public_key) > 1024 or not PUBLIC_KEY_RE.fullmatch(public_key): + raise ValueError('仅接受有效的 Ed25519 SSH 公钥') + with tempfile.TemporaryDirectory(dir=self.config.state_db.parent) as directory: + public_key_path = Path(directory) / 'client.pub' + public_key_path.write_text(public_key + '\n', encoding='utf-8') + subprocess.run( + [ + 'ssh-keygen', '-q', '-s', str(self.config.ssh_ca_key), + '-I', installation_id, '-z', str(serial), + '-n', self.config.ssh_user, '-V', f'-5m:+{self.config.cert_days}d', + str(public_key_path), + ], + check=True, + capture_output=True, + text=True, + timeout=10, + ) + return public_key_path.with_name('client-cert.pub').read_text(encoding='utf-8').strip() + + def enroll(self, token, label, public_key): + label = str(label or '').strip() + if not label or len(label) > 80 or any(ord(char) < 32 for char in label): + raise ValueError('设备名称必须为 1 到 80 个可见字符') + with self.lock: + installation_id = secrets.token_hex(16) + db_username = f'cangku_{secrets.token_hex(8)}' + db_password = secrets.token_urlsafe(32) + admin_username = f'dev_{installation_id[:12]}' + admin_password = secrets.token_urlsafe(18) + created = now() + expires = created + dt.timedelta(days=self.config.cert_days) + db_created = False + admin_created = False + with self._connect_state() as connection: + connection.execute('BEGIN IMMEDIATE') + token_row = self._find_token(connection, token) + serial = self._next_serial(connection) + try: + certificate = self._sign_public_key(public_key, installation_id, serial, expires) + self._create_database_user(db_username, db_password) + db_created = True + self._create_application_admin(admin_username, admin_password, label) + admin_created = True + connection.execute( + '''INSERT INTO installations + (id,token_id,label,db_username,certificate_serial,created_at,expires_at,admin_username) + VALUES(?,?,?,?,?,?,?,?)''', + (installation_id, token_row['id'], label, db_username, serial, iso(created), iso(expires), admin_username), + ) + connection.execute( + 'UPDATE enrollment_tokens SET use_count=use_count+1 WHERE id=?', (token_row['id'],) + ) + connection.commit() + except Exception: + connection.rollback() + if admin_created: + self._remove_application_admin(admin_username) + if db_created: + self._drop_database_user(db_username) + raise + return { + 'installation_id': installation_id, + 'ssh': { + 'host': self.config.ssh_host, + 'port': self.config.ssh_port, + 'user': self.config.ssh_user, + 'certificate': certificate, + 'host_key': self.config.ssh_host_key.read_text(encoding='utf-8').strip(), + 'valid_until': iso(expires), + }, + 'database': { + 'remote_host': self.config.ssh_remote_db_host, + 'remote_port': self.config.ssh_remote_db_port, + 'name': self.config.mysql_database, + 'username': db_username, + 'password': db_password, + }, + 'application_admin': { + 'username': admin_username, + 'password': admin_password, + }, + } + + def list_state(self): + with self._connect_state() as connection: + tokens = [dict(row) for row in connection.execute( + 'SELECT id,label,created_at,expires_at,max_uses,use_count,revoked_at FROM enrollment_tokens ORDER BY created_at DESC' + )] + installations = [dict(row) for row in connection.execute( + 'SELECT id,token_id,label,db_username,admin_username,certificate_serial,created_at,expires_at,revoked_at FROM installations ORDER BY created_at DESC' + )] + return {'tokens': tokens, 'installations': installations} + + def revoke_installation(self, installation_id): + with self.lock, self._connect_state() as connection: + row = connection.execute('SELECT * FROM installations WHERE id=?', (installation_id,)).fetchone() + if not row: + raise ValueError('未找到设备') + if not row['revoked_at']: + self._drop_database_user(row['db_username']) + self._deactivate_application_admin(row['admin_username']) + connection.execute('UPDATE installations SET revoked_at=? WHERE id=?', (iso(now()), installation_id)) + + def revoke_token(self, token_id): + with self._connect_state() as connection: + result = connection.execute( + 'UPDATE enrollment_tokens SET revoked_at=? WHERE id=? AND revoked_at IS NULL', (iso(now()), token_id) + ) + if result.rowcount != 1: + raise ValueError('未找到可停用的 Key') + + +class Handler(BaseHTTPRequestHandler): + server_version = 'CangkuBootstrap/1.0' + + def send_json(self, status, value): + body = json.dumps(value, ensure_ascii=False).encode() + self.send_response(status) + self.send_header('Content-Type', 'application/json; charset=utf-8') + self.send_header('Content-Length', str(len(body))) + self.send_header('Cache-Control', 'no-store') + self.send_header('X-Content-Type-Options', 'nosniff') + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + if self.path == '/health': + self.send_json(200, {'status': 'ok'}) + else: + self.send_json(404, {'error': 'not found'}) + + def do_POST(self): + if self.path != '/v1/enroll': + self.send_json(404, {'error': 'not found'}) + return + try: + length = int(self.headers.get('Content-Length', '0')) + if length <= 0 or length > 16 * 1024: + raise ValueError('请求大小无效') + authorization = self.headers.get('Authorization', '') + if not authorization.startswith('Bearer '): + raise PermissionError('缺少部署 Key') + payload = json.loads(self.rfile.read(length)) + result = self.server.broker.enroll( + authorization.removeprefix('Bearer ').strip(), + payload.get('label'), + payload.get('public_key', '').strip(), + ) + self.send_json(201, result) + except PermissionError as error: + self.send_json(401, {'error': str(error)}) + except (ValueError, json.JSONDecodeError) as error: + self.send_json(400, {'error': str(error)}) + except Exception as error: + print(f'enrollment failed: {type(error).__name__}: {error}', flush=True) + self.send_json(500, {'error': '注册服务内部错误'}) + + def log_message(self, format_string, *args): + client = self.headers.get('X-Real-IP') or self.client_address[0] + print(f'{client} - {format_string % args}', flush=True) + + +def build_parser(): + parser = argparse.ArgumentParser(description='Cangku remote deployment enrollment broker') + subparsers = parser.add_subparsers(dest='command', required=True) + subparsers.add_parser('serve') + create = subparsers.add_parser('create-key') + create.add_argument('--label', required=True) + create.add_argument('--max-uses', type=int, default=1) + create.add_argument('--days', type=int, default=30) + subparsers.add_parser('list') + revoke = subparsers.add_parser('revoke-installation') + revoke.add_argument('installation_id') + revoke_key = subparsers.add_parser('revoke-key') + revoke_key.add_argument('token_id') + return parser + + +def main(): + args = build_parser().parse_args() + config = Config() + broker = Broker(config) + if args.command == 'serve': + server = ThreadingHTTPServer((config.bind_host, config.port), Handler) + server.broker = broker + server.serve_forever() + elif args.command == 'create-key': + if args.max_uses < 1 or args.max_uses > 1000 or args.days < 1 or args.days > 365: + raise SystemExit('max-uses must be 1..1000 and days must be 1..365') + token, token_id, expires = broker.create_token(args.label, args.max_uses, args.days) + print(json.dumps({'id': token_id, 'key': token, 'expires_at': iso(expires), 'max_uses': args.max_uses})) + elif args.command == 'list': + print(json.dumps(broker.list_state(), ensure_ascii=False, indent=2)) + elif args.command == 'revoke-installation': + broker.revoke_installation(args.installation_id) + elif args.command == 'revoke-key': + broker.revoke_token(args.token_id) + + +if __name__ == '__main__': + main() diff --git a/remote-access/server/cangku-bootstrap.service b/remote-access/server/cangku-bootstrap.service new file mode 100644 index 0000000..39ecbf2 --- /dev/null +++ b/remote-access/server/cangku-bootstrap.service @@ -0,0 +1,26 @@ +[Unit] +Description=Cangku remote deployment enrollment broker +After=network-online.target docker.service +Wants=network-online.target + +[Service] +Type=simple +User=cangku-bootstrap +Group=cangku-bootstrap +WorkingDirectory=/opt/cangku-bootstrap/app +EnvironmentFile=/etc/cangku-bootstrap/service.env +ExecStart=/opt/cangku-bootstrap/venv/bin/python /opt/cangku-bootstrap/app/bootstrap_service.py serve +Restart=on-failure +RestartSec=5 +NoNewPrivileges=true +PrivateTmp=true +ProtectHome=true +ProtectSystem=strict +StateDirectory=cangku-bootstrap +ReadWritePaths=/var/lib/cangku-bootstrap +MemoryMax=256M +TasksMax=64 +UMask=0077 + +[Install] +WantedBy=multi-user.target diff --git a/remote-access/server/cangku-bootstrapctl b/remote-access/server/cangku-bootstrapctl new file mode 100644 index 0000000..e0d9b7f --- /dev/null +++ b/remote-access/server/cangku-bootstrapctl @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +exec runuser -u cangku-bootstrap -- /bin/bash -c ' + set -a + . /etc/cangku-bootstrap/service.env + set +a + exec /opt/cangku-bootstrap/venv/bin/python /opt/cangku-bootstrap/app/bootstrap_service.py "$@" +' cangku-bootstrapctl "$@" diff --git a/remote-access/server/install.sh b/remote-access/server/install.sh new file mode 100644 index 0000000..0a13b4f --- /dev/null +++ b/remote-access/server/install.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ ${EUID} -ne 0 ]]; then + echo "This installer must run as root" >&2 + exit 1 +fi + +SOURCE_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +MYSQL_ENV=/opt/cangku/shared/mysql.env +SERVICE_ENV=/etc/cangku-bootstrap/service.env + +test -f "${MYSQL_ENV}" +test -f "${SOURCE_DIR}/bootstrap_service.py" + +apt-get update -qq +DEBIAN_FRONTEND=noninteractive apt-get install -y -qq python3-venv openssl certbot >/dev/null + +if ! getent passwd cangku-bootstrap >/dev/null; then + useradd --system --home-dir /var/lib/cangku-bootstrap --create-home --shell /usr/sbin/nologin cangku-bootstrap +fi +if ! getent passwd cangku-tunnel >/dev/null; then + useradd --system --home-dir /var/lib/cangku-tunnel --create-home --shell /bin/bash cangku-tunnel +fi +passwd -d cangku-tunnel >/dev/null 2>&1 + +install -d -m 0755 /opt/cangku-bootstrap/app +install -d -m 0700 -o cangku-bootstrap -g cangku-bootstrap /var/lib/cangku-bootstrap +install -d -m 0750 -o root -g cangku-bootstrap /etc/cangku-bootstrap +install -m 0755 "${SOURCE_DIR}/bootstrap_service.py" /opt/cangku-bootstrap/app/bootstrap_service.py +install -m 0750 "${SOURCE_DIR}/cangku-bootstrapctl" /usr/local/sbin/cangku-bootstrapctl + +if [[ ! -x /opt/cangku-bootstrap/venv/bin/python ]]; then + python3 -m venv /opt/cangku-bootstrap/venv +fi +/opt/cangku-bootstrap/venv/bin/pip install --disable-pip-version-check -q -r "${SOURCE_DIR}/requirements.txt" + +if [[ ! -f /var/lib/cangku-bootstrap/ssh_user_ca ]]; then + sudo -u cangku-bootstrap ssh-keygen -q -t ed25519 -N '' -C cangku-bootstrap-user-ca -f /var/lib/cangku-bootstrap/ssh_user_ca +fi +chmod 0600 /var/lib/cangku-bootstrap/ssh_user_ca +chmod 0644 /var/lib/cangku-bootstrap/ssh_user_ca.pub + +install -d -m 0700 -o cangku-tunnel -g cangku-tunnel /var/lib/cangku-tunnel/.ssh +{ + printf 'cert-authority,principals="cangku-tunnel" ' + cat /var/lib/cangku-bootstrap/ssh_user_ca.pub +} > /var/lib/cangku-tunnel/.ssh/authorized_keys +chown cangku-tunnel:cangku-tunnel /var/lib/cangku-tunnel/.ssh/authorized_keys +chmod 0600 /var/lib/cangku-tunnel/.ssh/authorized_keys + +install -m 0644 "${SOURCE_DIR}/sshd-cangku-tunnel.conf" /etc/ssh/sshd_config.d/60-cangku-tunnel.conf +sshd -t +systemctl reload ssh + +if [[ ! -f "${SERVICE_ENV}" ]]; then + provisioner_password=$(openssl rand -hex 32) + token_pepper=$(openssl rand -hex 32) + umask 0077 + { + printf 'BOOTSTRAP_BIND_HOST=127.0.0.1\n' + printf 'BOOTSTRAP_PORT=4092\n' + printf 'BOOTSTRAP_STATE_DB=/var/lib/cangku-bootstrap/state.sqlite3\n' + printf 'BOOTSTRAP_TOKEN_PEPPER=%s\n' "${token_pepper}" + printf 'BOOTSTRAP_MYSQL_HOST=127.0.0.1\n' + printf 'BOOTSTRAP_MYSQL_PORT=3307\n' + printf 'BOOTSTRAP_MYSQL_DATABASE=cangku_system\n' + printf 'BOOTSTRAP_MYSQL_USER=cangku_bootstrap\n' + printf 'BOOTSTRAP_MYSQL_PASSWORD=%s\n' "${provisioner_password}" + printf 'BOOTSTRAP_SSH_CA_KEY=/var/lib/cangku-bootstrap/ssh_user_ca\n' + printf 'BOOTSTRAP_SSH_HOST_KEY=/etc/ssh/ssh_host_ed25519_key.pub\n' + printf 'BOOTSTRAP_SSH_HOST=103.236.66.132\n' + printf 'BOOTSTRAP_SSH_PORT=22\n' + printf 'BOOTSTRAP_SSH_USER=cangku-tunnel\n' + printf 'BOOTSTRAP_REMOTE_DB_HOST=127.0.0.1\n' + printf 'BOOTSTRAP_REMOTE_DB_PORT=3307\n' + printf 'BOOTSTRAP_CERT_DAYS=30\n' + } > "${SERVICE_ENV}" + chown root:cangku-bootstrap "${SERVICE_ENV}" + chmod 0640 "${SERVICE_ENV}" +else + provisioner_password=$(sed -n 's/^BOOTSTRAP_MYSQL_PASSWORD=//p' "${SERVICE_ENV}") +fi + +set -a +source "${MYSQL_ENV}" +set +a +docker exec -i -e MYSQL_PWD="${MYSQL_ROOT_PASSWORD}" cangku-mysql mysql -uroot </dev/null +ufw allow 443/tcp comment 'HTTPS' >/dev/null +systemctl reload nginx + +for _ in {1..20}; do + if curl --silent --show-error --fail https://ck.yydd.ai/health >/dev/null; then + exit 0 + fi + sleep 0.5 +done + +journalctl -u cangku-bootstrap -n 50 --no-pager >&2 +exit 1 diff --git a/remote-access/server/nginx-bootstrap.conf b/remote-access/server/nginx-bootstrap.conf new file mode 100644 index 0000000..9f4f35b --- /dev/null +++ b/remote-access/server/nginx-bootstrap.conf @@ -0,0 +1,50 @@ +limit_req_zone $binary_remote_addr zone=cangku_bootstrap_limit:10m rate=5r/m; + +server { + listen 80; + listen [::]:80; + server_name ck.yydd.ai; + + location /.well-known/acme-challenge/ { + root /var/www/cangku-bootstrap-acme; + } + + location / { + return 301 https://ck.yydd.ai$request_uri; + } +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + server_name ck.yydd.ai; + + ssl_certificate /etc/letsencrypt/live/ck.yydd.ai/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/ck.yydd.ai/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + ssl_session_tickets off; + + add_header Cache-Control "no-store" always; + add_header X-Content-Type-Options nosniff always; + client_max_body_size 16k; + + location = /health { + proxy_pass http://127.0.0.1:4092; + proxy_set_header Host $host; + } + + location = /v1/enroll { + limit_req zone=cangku_bootstrap_limit burst=3 nodelay; + proxy_pass http://127.0.0.1:4092; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 20s; + } + + location / { + return 404; + } +} diff --git a/remote-access/server/provision-certificate.sh b/remote-access/server/provision-certificate.sh new file mode 100644 index 0000000..f7e50e0 --- /dev/null +++ b/remote-access/server/provision-certificate.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ ${EUID} -ne 0 ]]; then + echo "This script must run as root" >&2 + exit 1 +fi + +DOMAIN=ck.yydd.ai +WEBROOT=/var/www/cangku-bootstrap-acme +TEMP_SITE=/etc/nginx/sites-available/cangku-bootstrap-acme +TLS_ROOT=/etc/cangku-tls +EAB_FILE=${TLS_ROOT}/eab.json +ACME_SERVER=https://acme.zerossl.com/v2/DV90 + +DEBIAN_FRONTEND=noninteractive apt-get install -y -qq certbot >/dev/null +install -d -m 0755 "${WEBROOT}/.well-known/acme-challenge" +install -d -m 0700 "${TLS_ROOT}" + +if [[ ! -f "/etc/letsencrypt/live/${DOMAIN}/fullchain.pem" ]]; then + if [[ ! -f "${EAB_FILE}" ]]; then + echo "Missing ${EAB_FILE}; create ZeroSSL EAB credentials and store them there with mode 0600" >&2 + exit 1 + fi + umask 0022 + { + printf 'server {\n' + printf ' listen 80;\n' + printf ' listen [::]:80;\n' + printf ' server_name %s;\n' "${DOMAIN}" + printf ' location /.well-known/acme-challenge/ { root %s; }\n' "${WEBROOT}" + printf ' location / { return 404; }\n' + printf '}\n' + } > "${TEMP_SITE}" + ln -sfn "${TEMP_SITE}" /etc/nginx/sites-enabled/cangku-bootstrap-acme + nginx -t + systemctl reload nginx + readarray -t eab < <(python3 - "${EAB_FILE}" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding='utf-8') as handle: + value = json.load(handle) +print(value['eab_kid']) +print(value['eab_hmac_key']) +PY + ) + certbot certonly --server "${ACME_SERVER}" \ + --eab-kid "${eab[0]}" --eab-hmac-key "${eab[1]}" \ + --webroot -w "${WEBROOT}" -d "${DOMAIN}" \ + --email certs@yydd.ai --agree-tos --non-interactive --no-eff-email + unlink "${EAB_FILE}" + unlink /etc/nginx/sites-enabled/cangku-bootstrap-acme + unlink "${TEMP_SITE}" +fi + +systemctl enable --now certbot.timer diff --git a/remote-access/server/requirements.txt b/remote-access/server/requirements.txt new file mode 100644 index 0000000..582b2ef --- /dev/null +++ b/remote-access/server/requirements.txt @@ -0,0 +1,3 @@ +PyMySQL==1.1.2 +bcrypt==5.0.0 +cryptography==46.0.3 diff --git a/remote-access/server/sshd-cangku-tunnel.conf b/remote-access/server/sshd-cangku-tunnel.conf new file mode 100644 index 0000000..ea72354 --- /dev/null +++ b/remote-access/server/sshd-cangku-tunnel.conf @@ -0,0 +1,11 @@ +Match User cangku-tunnel + AuthenticationMethods publickey + PasswordAuthentication no + KbdInteractiveAuthentication no + AllowTcpForwarding local + PermitOpen 127.0.0.1:3307 + GatewayPorts no + X11Forwarding no + AllowAgentForwarding no + PermitTTY no + MaxSessions 0 diff --git a/scripts/remote-access-common.mjs b/scripts/remote-access-common.mjs new file mode 100644 index 0000000..7686bec --- /dev/null +++ b/scripts/remote-access-common.mjs @@ -0,0 +1,80 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export const REQUIRED_ENROLLMENT_FIELDS = Object.freeze([ + 'installation_id', + 'ssh.host', + 'ssh.port', + 'ssh.user', + 'ssh.certificate', + 'ssh.host_key', + 'database.name', + 'database.username', + 'database.password', + 'application_admin.username', + 'application_admin.password', +]); + +function readNested(value, field) { + return field.split('.').reduce((current, part) => current?.[part], value); +} + +export function validateEnrollmentResponse(value) { + if (!value || typeof value !== 'object') { + throw new Error('注册服务返回了无效数据'); + } + for (const field of REQUIRED_ENROLLMENT_FIELDS) { + if (readNested(value, field) === undefined || readNested(value, field) === '') { + throw new Error(`注册服务缺少字段:${field}`); + } + } + if (!String(value.ssh.certificate).startsWith('ssh-ed25519-cert-v01@openssh.com ')) { + throw new Error('注册服务返回的 SSH 证书格式无效'); + } + if (!/^ssh-ed25519\s+[A-Za-z0-9+/=]+(?:\s.*)?$/u.test(String(value.ssh.host_key))) { + throw new Error('注册服务返回的 SSH 主机公钥格式无效'); + } + if (!/^[A-Za-z0-9_]+$/u.test(String(value.database.name))) { + throw new Error('注册服务返回的数据库名无效'); + } + return value; +} + +export function serializeEnvValue(value) { + const text = String(value ?? ''); + if (!text) return ''; + if (/[^A-Za-z0-9_./:@+-]/u.test(text) || /[#$'"\\]/u.test(text)) { + return JSON.stringify(text); + } + return text; +} + +export function mergeEnv(content, updates) { + const lines = String(content || '').split(/\r?\n/u); + const handled = new Set(); + const output = lines.map((line) => { + const match = line.match(/^([A-Z][A-Z0-9_]*)=(.*)$/u); + if (!match || !(match[1] in updates)) return line; + handled.add(match[1]); + return `${match[1]}=${serializeEnvValue(updates[match[1]])}`; + }); + for (const [key, value] of Object.entries(updates)) { + if (!handled.has(key)) output.push(`${key}=${serializeEnvValue(value)}`); + } + while (output.length > 0 && output.at(-1) === '') output.pop(); + return `${output.join('\n')}\n`; +} + +export function getKnownHostsLine(host, port, hostKey) { + const target = Number(port) === 22 ? host : `[${host}]:${port}`; + return `${target} ${String(hostKey).trim()}\n`; +} + +export function loadConnectionConfig(rootDirectory) { + const configPath = path.join(rootDirectory, '.remote-access', 'connection.json'); + const value = JSON.parse(fs.readFileSync(configPath, 'utf8')); + if (!value?.installationId || !value?.ssh?.host || !value?.localDatabasePort) { + throw new Error('本地远程访问配置不完整,请重新执行 npm run remote:setup'); + } + return { configPath, value }; +} diff --git a/scripts/setup-remote.mjs b/scripts/setup-remote.mjs new file mode 100644 index 0000000..d0dda25 --- /dev/null +++ b/scripts/setup-remote.mjs @@ -0,0 +1,271 @@ +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; + }); +} diff --git a/scripts/start-remote.mjs b/scripts/start-remote.mjs new file mode 100644 index 0000000..7d9662d --- /dev/null +++ b/scripts/start-remote.mjs @@ -0,0 +1,186 @@ +import fs from 'node:fs'; +import net from 'node:net'; +import path from 'node:path'; +import process from 'node:process'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { loadConnectionConfig } from './remote-access-common.mjs'; + +const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const ACCESS_DIR = path.join(ROOT_DIR, '.remote-access'); +const CONNECTION_PATH = path.join(ACCESS_DIR, 'connection.json'); +const RESTART_MARKER_PATH = path.join(ACCESS_DIR, 'restart-requested.json'); + +export function buildSshArgs(config) { + return [ + '-N', '-T', + '-o', 'BatchMode=yes', + '-o', 'ExitOnForwardFailure=yes', + '-o', 'ServerAliveInterval=30', + '-o', 'ServerAliveCountMax=3', + '-o', 'StrictHostKeyChecking=yes', + '-o', `UserKnownHostsFile=${path.join(ACCESS_DIR, 'known_hosts')}`, + '-o', `CertificateFile=${path.join(ACCESS_DIR, 'id_ed25519-cert.pub')}`, + '-i', path.join(ACCESS_DIR, 'id_ed25519'), + '-p', String(config.ssh.port), + '-L', `127.0.0.1:${config.localDatabasePort}:${config.ssh.remoteDatabaseHost}:${config.ssh.remoteDatabasePort}`, + `${config.ssh.user}@${config.ssh.host}`, + ]; +} + +function waitForPort(port, timeoutMs = 60000) { + const started = Date.now(); + return new Promise((resolve, reject) => { + const attempt = () => { + const socket = net.createConnection({ host: '127.0.0.1', port }); + socket.once('connect', () => { + socket.destroy(); + resolve(); + }); + socket.once('error', () => { + socket.destroy(); + if (Date.now() - started >= timeoutMs) { + reject(new Error(`SSH 隧道未能在 ${timeoutMs / 1000} 秒内监听端口 ${port}`)); + return; + } + setTimeout(attempt, 250); + }); + }; + attempt(); + }); +} + +function waitForFile(filePath) { + return new Promise((resolve) => { + const timer = setInterval(() => { + if (fs.existsSync(filePath)) { + clearInterval(timer); + resolve(); + } + }, 500); + }); +} + +function waitForExit(child, timeoutMs = 5000) { + if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(); + return Promise.race([ + new Promise((resolve) => child.once('exit', resolve)), + new Promise((resolve) => setTimeout(resolve, timeoutMs)), + ]); +} + +export async function main(mode = process.argv[2] || 'tunnel') { + if (!['tunnel', 'dev', 'backend'].includes(mode)) { + throw new Error(`未知启动模式:${mode}`); + } + + const children = new Set(); + let shuttingDown = false; + let restartingBackend = false; + let backend = null; + + const stopChildren = () => { + shuttingDown = true; + for (const child of children) { + if (!child.killed) child.kill('SIGTERM'); + } + }; + for (const signal of ['SIGINT', 'SIGTERM']) { + process.once(signal, stopChildren); + } + + const startTunnel = async () => { + const { value: config } = loadConnectionConfig(ROOT_DIR); + const tunnel = spawn('ssh', buildSshArgs(config), { cwd: ROOT_DIR, stdio: 'inherit' }); + children.add(tunnel); + let ready = false; + const earlyExit = new Promise((resolve, reject) => { + tunnel.once('exit', (code) => { + children.delete(tunnel); + if (!ready) { + reject(new Error(`SSH 隧道在建立前退出(${code ?? 'unknown'})`)); + } else if (!shuttingDown) { + console.error(`SSH 隧道已退出(${code ?? 'unknown'})`); + stopChildren(); + process.exitCode = code || 1; + } + }); + }); + try { + await Promise.race([waitForPort(Number(config.localDatabasePort)), earlyExit]); + ready = true; + } catch (error) { + if (!tunnel.killed) tunnel.kill('SIGTERM'); + throw error; + } + console.log(`系统数据库隧道已建立:127.0.0.1:${config.localDatabasePort}/${config.databaseName}`); + return tunnel; + }; + + const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + const startApplication = (args, kind) => { + const childEnvironment = { + ...process.env, + CANGKU_REMOTE_SUPERVISOR: 'true', + }; + if (kind === 'frontend' && !childEnvironment.VITE_API_PROXY_TARGET) { + childEnvironment.VITE_API_PROXY_TARGET = `http://127.0.0.1:${process.env.CANGKU_BACKEND_PORT || 4000}`; + } + const child = spawn(npmCommand, args, { + cwd: ROOT_DIR, + stdio: 'inherit', + env: childEnvironment, + }); + children.add(child); + child.once('exit', (code) => { + children.delete(child); + if (kind === 'backend' && restartingBackend) return; + if (!shuttingDown) { + console.error(`${kind === 'backend' ? '后端' : '前端'}进程已退出(${code ?? 'unknown'})`); + stopChildren(); + process.exitCode = code || 1; + } + }); + return child; + }; + + const hasConnection = fs.existsSync(CONNECTION_PATH); + if (hasConnection) { + if (fs.existsSync(RESTART_MARKER_PATH)) fs.unlinkSync(RESTART_MARKER_PATH); + await startTunnel(); + } else if (mode === 'tunnel') { + throw new Error('尚未配置远程系统数据库,请先运行应用并在首次启动弹窗中输入请求地址和 API Key'); + } + + if (mode === 'tunnel') return; + + backend = startApplication( + ['--prefix', 'backend', 'run', mode === 'dev' ? 'dev' : 'start'], + 'backend' + ); + if (mode === 'dev') { + startApplication(['--prefix', 'frontend', 'run', 'dev'], 'frontend'); + } + + if (!hasConnection) { + console.log('首次启动:请在安装页面输入配置请求地址和 API Key。'); + await waitForFile(RESTART_MARKER_PATH); + restartingBackend = true; + if (!backend.killed) backend.kill('SIGTERM'); + await waitForExit(backend); + restartingBackend = false; + fs.unlinkSync(RESTART_MARKER_PATH); + await startTunnel(); + backend = startApplication( + ['--prefix', 'backend', 'run', mode === 'dev' ? 'dev' : 'start'], + 'backend' + ); + } +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error(`远程运行启动失败:${error.message}`); + process.exitCode = 1; + }); +} diff --git a/test/remote-access.test.mjs b/test/remote-access.test.mjs new file mode 100644 index 0000000..851c39f --- /dev/null +++ b/test/remote-access.test.mjs @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { getKnownHostsLine, mergeEnv, validateEnrollmentResponse } from '../scripts/remote-access-common.mjs'; + +const enrollment = { + installation_id: 'dev-123', + ssh: { + host: '203.0.113.10', + port: 22, + user: 'tunnel', + certificate: 'ssh-ed25519-cert-v01@openssh.com AAAATEST cert', + host_key: 'ssh-ed25519 AAAATEST host', + }, + database: { + name: 'system_db', + username: 'client_123', + password: 'a password with # symbols', + }, + application_admin: { + username: 'dev_123', + password: 'one-time-password', + }, +}; + +test('validates complete enrollment responses', () => { + assert.equal(validateEnrollmentResponse(enrollment), enrollment); + assert.throws( + () => validateEnrollmentResponse({ ...enrollment, database: { ...enrollment.database, password: '' } }), + /database\.password/u + ); +}); + +test('merges environment values without losing unrelated settings', () => { + const result = mergeEnv('KEEP=yes\nLOCAL_DB_HOST=old\n', { + LOCAL_DB_HOST: '127.0.0.1', + LOCAL_DB_PASSWORD: 'secret # value', + }); + assert.match(result, /^KEEP=yes$/mu); + assert.match(result, /^LOCAL_DB_HOST=127\.0\.0\.1$/mu); + assert.match(result, /^LOCAL_DB_PASSWORD="secret # value"$/mu); +}); + +test('pins standard and non-standard SSH host targets', () => { + assert.equal(getKnownHostsLine('example.test', 22, 'ssh-ed25519 KEY'), 'example.test ssh-ed25519 KEY\n'); + assert.equal(getKnownHostsLine('example.test', 2222, 'ssh-ed25519 KEY'), '[example.test]:2222 ssh-ed25519 KEY\n'); +});