feat: bootstrap local instances from central system database

This commit is contained in:
lunanting 2026-09-12 16:06:01 +08:00
parent 9b173a0ffa
commit 3239d6a558
21 changed files with 1572 additions and 0 deletions

2
.gitignore vendored
View File

@ -54,5 +54,7 @@ tmall-board-speed-check.json
**/.env.* **/.env.*
!**/.env.example !**/.env.example
**/uploads/ **/uploads/
.remote-access/
.remote-access.backup-*/
.workbuddy/ .workbuddy/
**/node_modules **/node_modules

View File

@ -10,6 +10,8 @@
需要 Node.js 22、MySQL 系统库,以及业务实际使用的数据库和外部服务。仓库不包含生产环境配置、密钥、业务数据或原仓库历史。 需要 Node.js 22、MySQL 系统库,以及业务实际使用的数据库和外部服务。仓库不包含生产环境配置、密钥、业务数据或原仓库历史。
团队成员如需免部署本地系统数据库,可使用管理员发放的部署 Key,通过加密隧道连接中央系统数据库。完整步骤及安全边界见 [远程系统数据库部署](./remote-access/README.md)。
```bash ```bash
npm --prefix backend ci npm --prefix backend ci
npm --prefix frontend ci npm --prefix frontend ci

View File

@ -5,6 +5,11 @@ const {
shouldAutoRestartAfterInstall, shouldAutoRestartAfterInstall,
testSystemDatabaseBootstrapConnection, testSystemDatabaseBootstrapConnection,
} = require('../services/installService'); } = require('../services/installService');
const {
DEFAULT_BOOTSTRAP_ENDPOINT,
enrollRemoteInstallation,
scheduleRemoteRestart,
} = require('../services/remoteEnrollmentService');
const router = express.Router(); 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) => { router.post('/system-database/test', async (req, res) => {
try { try {
const startTime = Date.now(); const startTime = Date.now();

View File

@ -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,
};

View File

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

View File

@ -4,6 +4,14 @@ export const getInstallStatus = () => {
return request.get('/api/install/status'); 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) => { export const testInstallSystemDatabase = (data) => {
return request.post('/api/install/system-database/test', data); return request.post('/api/install/system-database/test', data);
}; };

View File

@ -6,6 +6,7 @@ import {
Form, Form,
Input, Input,
InputNumber, InputNumber,
Modal,
Result, Result,
Row, Row,
Space, Space,
@ -25,6 +26,7 @@ import {
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
bootstrapInstallation, bootstrapInstallation,
bootstrapRemoteInstallation,
getInstallStatus, getInstallStatus,
testInstallSystemDatabase, testInstallSystemDatabase,
} from '../../api/install'; } from '../../api/install';
@ -40,6 +42,7 @@ const { Title, Text } = Typography;
const Install = () => { const Install = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [form] = Form.useForm(); const [form] = Form.useForm();
const [remoteForm] = Form.useForm();
const prefetchedRef = useRef(false); const prefetchedRef = useRef(false);
const pollingTimerRef = useRef(null); const pollingTimerRef = useRef(null);
const statusRetryTimerRef = useRef(null); const statusRetryTimerRef = useRef(null);
@ -48,6 +51,9 @@ const Install = () => {
const [installing, setInstalling] = useState(false); const [installing, setInstalling] = useState(false);
const [installStatus, setInstallStatus] = useState(null); const [installStatus, setInstallStatus] = useState(null);
const [installResult, setInstallResult] = 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(() => { const statusTag = useMemo(() => {
if (!installStatus?.required) { 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) { if (bootstrapping) {
return ( return (
<SycmStandalonePage centered> <SycmStandalonePage centered>
@ -239,6 +266,98 @@ const Install = () => {
return ( return (
<SycmStandalonePage> <SycmStandalonePage>
<Modal
open
closable={false}
maskClosable={false}
keyboard={false}
title="连接中央系统数据库"
width={560}
footer={remoteResult && !credentialsAcknowledged
? [
<Button key="saved" type="primary" onClick={() => setCredentialsAcknowledged(true)}>
我已保存账号和密码
</Button>,
]
: (remoteResult
? null
: [
<Button
key="enroll"
type="primary"
loading={remoteInstalling}
onClick={handleRemoteInstall}
>
保存并获取配置
</Button>,
])}
>
{remoteResult ? (
credentialsAcknowledged ? (
<Result
status="success"
title="凭据已确认保存"
subTitle={remoteResult.restartScheduled
? '正在建立加密隧道并重启本地服务,完成后将自动进入登录页。'
: '请使用 npm run remote:dev 重新启动,随后用刚才保存的管理员账号登录。'}
extra={<Spin />}
/>
) : (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Alert
type="warning"
showIcon
message="管理员账号和密码只显示这一次"
description="请立即复制并保存在密码管理器中。关闭或刷新页面后无法再次查看密码。"
/>
<div>
<Text type="secondary">管理员账号</Text>
<div><Text code copyable>{remoteResult.adminUsername}</Text></div>
</div>
<div>
<Text type="secondary">管理员密码</Text>
<div><Text code copyable>{remoteResult.adminPassword}</Text></div>
</div>
<Text type="secondary">设备编号:{remoteResult.installationId}</Text>
</Space>
)
) : (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Alert
type="info"
showIcon
message="本机无需安装数据库"
description="保存后会通过 HTTPS 注册本机,自动配置到中央系统数据库,并从中读取第三方数据库连接配置。"
/>
<Form
form={remoteForm}
layout="vertical"
initialValues={{ endpoint: 'https://ck.yydd.ai/v1/enroll' }}
>
<Form.Item
label="数据库配置请求地址"
name="endpoint"
rules={[
{ required: true, message: '请输入配置请求地址' },
{ type: 'url', message: '请输入有效的 HTTPS 地址' },
]}
>
<Input placeholder="https://ck.yydd.ai/v1/enroll" autoComplete="url" />
</Form.Item>
<Form.Item
label="API Key"
name="apiKey"
rules={[{ required: true, message: '请输入 API Key' }]}
>
<Input.Password
placeholder="请输入管理员发放的部署 Key"
autoComplete="off"
/>
</Form.Item>
</Form>
</Space>
)}
</Modal>
<SycmStandaloneFrame maxWidth={1120}> <SycmStandaloneFrame maxWidth={1120}>
<SycmFlowHeader <SycmFlowHeader
title="首次安装" title="首次安装"

12
package.json Normal file
View File

@ -0,0 +1,12 @@
{
"name": "cangku-remote-runtime",
"private": true,
"type": "module",
"scripts": {
"remote:setup": "node scripts/setup-remote.mjs",
"remote:tunnel": "node scripts/start-remote.mjs tunnel",
"remote:dev": "node scripts/start-remote.mjs dev",
"remote:start": "node scripts/start-remote.mjs backend",
"test:remote": "node --test test/remote-access.test.mjs"
}
}

57
remote-access/README.md Normal file
View File

@ -0,0 +1,57 @@
# 远程系统数据库部署
本项目的本地实例可以通过部署 Key 注册到中央系统数据库。注册服务为每台设备创建独立数据库账号并签发短期 SSH 隧道证书;MySQL 继续只监听服务器回环地址。
## 本地使用
需要 Node.js 22、npm 和 OpenSSH 客户端。首次运行:
```bash
npm --prefix backend ci
npm --prefix frontend ci
npm run remote:dev
```
首次执行 `npm run remote:dev` 后,浏览器会显示首次启动弹窗。填写 `https://ck.yydd.ai/v1/enroll` 和管理员发放的部署 Key,然后点击“保存并获取配置”。Key 不会保存。成功后,数据库凭据写入被 Git 忽略的 `backend/.env`,SSH 私钥和证书写入 `.remote-access/`,文件权限仅限当前用户。
注册服务会在中央系统数据库中创建一个独立管理员。弹窗只显示一次管理员账号和随机密码,开发者必须立即保存;服务端不保存可再次查看的密码明文。
开发模式同时启动后端和前端。首次注册成功后,启动器会自动建立隧道并重启后端。没有浏览器时,可用 `npm run remote:setup` 在终端中完成同一注册流程。
仅启动数据库隧道:
```bash
npm run remote:tunnel
```
生产构建完成后,可启动隧道及后端:
```bash
npm run remote:start
```
默认本地数据库隧道端口为 `13307`,如被占用,可在注册前设置 `CANGKU_LOCAL_DB_PORT`。重新注册需使用 `npm run remote:setup -- --replace`,旧配置会改名备份;管理员还应在服务器停用旧设备。
## 权限和密钥边界
- 部署 Key 只用于设备注册,具有有效期和使用次数限制,不在仓库及本地配置中保存。
- 每台设备获得独立数据库用户名和密码,可单独停用。账号只能访问 `cangku_system`,不能访问服务器的其他数据库或管理数据库用户。
- SSH 证书只允许登录 `cangku-tunnel`,该账号不能打开终端,只能把本地端口转发至服务器的 `127.0.0.1:3307`。
- SSH 主机密钥由注册服务固定下发并校验;注册接口使用 `ck.yydd.ai` 的公开可信 HTTPS 证书,证书由 systemd 定时任务自动续期。
- 本地实例设置 `CLOUD_DB_RUNTIME_PROFILE=public`,从中央系统数据库读取已配置的第三方公网数据库连接。
- 本地实例关闭后台同步任务,避免多台开发机重复执行定时同步。
数据库账号在 `cangku_system` 内拥有应用运行所需权限,包括表结构操作;它没有其他数据库权限,也没有 `GRANT OPTION` 或创建用户权限。只有受信任的开发者才能获得部署 Key。本地设备一旦失陷,设备账号和第三方数据库连接配置可能泄露,应立即由管理员停用该设备。
## 服务器管理
服务器使用 `cangku-bootstrap` systemd 服务、独立的受限 MySQL 发号账号和 SSH 用户证书 CA。常用命令:
```bash
sudo cangku-bootstrapctl list
sudo cangku-bootstrapctl create-key --label team --max-uses 20 --days 30
sudo cangku-bootstrapctl revoke-key <KEY_ID>
sudo cangku-bootstrapctl revoke-installation <INSTALLATION_ID>
```
`list` 不显示 Key 或数据库密码。Key 明文仅在创建时输出一次。停用设备会删除它的数据库账号;已签发的 SSH 证书在到期前仍可能建立受限隧道,但不能再登录系统数据库。

View File

@ -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()

View File

@ -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

View File

@ -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 "$@"

View File

@ -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 <<EOF
CREATE USER IF NOT EXISTS 'cangku_bootstrap'@'%' IDENTIFIED BY '${provisioner_password}';
ALTER USER 'cangku_bootstrap'@'%' IDENTIFIED BY '${provisioner_password}';
GRANT CREATE USER ON *.* TO 'cangku_bootstrap'@'%';
GRANT ALL PRIVILEGES ON cangku_system.* TO 'cangku_bootstrap'@'%' WITH GRANT OPTION;
EOF
unset MYSQL_ROOT_PASSWORD MYSQL_PASSWORD provisioner_password token_pepper
install -m 0644 "${SOURCE_DIR}/cangku-bootstrap.service" /etc/systemd/system/cangku-bootstrap.service
test -f /etc/letsencrypt/live/ck.yydd.ai/fullchain.pem
install -m 0644 "${SOURCE_DIR}/nginx-bootstrap.conf" /etc/nginx/sites-available/cangku-bootstrap
ln -sfn /etc/nginx/sites-available/cangku-bootstrap /etc/nginx/sites-enabled/cangku-bootstrap
nginx -t
systemctl daemon-reload
systemctl enable --now cangku-bootstrap
systemctl enable --now certbot.timer
ufw allow 80/tcp comment 'HTTP and ACME' >/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

View File

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

View File

@ -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

View File

@ -0,0 +1,3 @@
PyMySQL==1.1.2
bcrypt==5.0.0
cryptography==46.0.3

View File

@ -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

View File

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

271
scripts/setup-remote.mjs Normal file
View File

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

186
scripts/start-remote.mjs Normal file
View File

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

View File

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