feat: bootstrap local instances from central system database
This commit is contained in:
parent
9b173a0ffa
commit
3239d6a558
2
.gitignore
vendored
2
.gitignore
vendored
@ -54,5 +54,7 @@ tmall-board-speed-check.json
|
||||
**/.env.*
|
||||
!**/.env.example
|
||||
**/uploads/
|
||||
.remote-access/
|
||||
.remote-access.backup-*/
|
||||
.workbuddy/
|
||||
**/node_modules
|
||||
|
||||
@ -10,6 +10,8 @@
|
||||
|
||||
需要 Node.js 22、MySQL 系统库,以及业务实际使用的数据库和外部服务。仓库不包含生产环境配置、密钥、业务数据或原仓库历史。
|
||||
|
||||
团队成员如需免部署本地系统数据库,可使用管理员发放的部署 Key,通过加密隧道连接中央系统数据库。完整步骤及安全边界见 [远程系统数据库部署](./remote-access/README.md)。
|
||||
|
||||
```bash
|
||||
npm --prefix backend ci
|
||||
npm --prefix frontend ci
|
||||
|
||||
@ -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();
|
||||
|
||||
62
backend/src/services/remoteEnrollmentService.js
Normal file
62
backend/src/services/remoteEnrollmentService.js
Normal 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,
|
||||
};
|
||||
17
backend/test/remoteEnrollmentService.test.js
Normal file
17
backend/test/remoteEnrollmentService.test.js
Normal 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);
|
||||
});
|
||||
@ -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);
|
||||
};
|
||||
|
||||
@ -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 (
|
||||
<SycmStandalonePage centered>
|
||||
@ -239,6 +266,98 @@ const Install = () => {
|
||||
|
||||
return (
|
||||
<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}>
|
||||
<SycmFlowHeader
|
||||
title="首次安装"
|
||||
|
||||
12
package.json
Normal file
12
package.json
Normal 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
57
remote-access/README.md
Normal 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 证书在到期前仍可能建立受限隧道,但不能再登录系统数据库。
|
||||
392
remote-access/server/bootstrap_service.py
Normal file
392
remote-access/server/bootstrap_service.py
Normal 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()
|
||||
26
remote-access/server/cangku-bootstrap.service
Normal file
26
remote-access/server/cangku-bootstrap.service
Normal 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
|
||||
9
remote-access/server/cangku-bootstrapctl
Normal file
9
remote-access/server/cangku-bootstrapctl
Normal 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 "$@"
|
||||
116
remote-access/server/install.sh
Normal file
116
remote-access/server/install.sh
Normal 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
|
||||
50
remote-access/server/nginx-bootstrap.conf
Normal file
50
remote-access/server/nginx-bootstrap.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
57
remote-access/server/provision-certificate.sh
Normal file
57
remote-access/server/provision-certificate.sh
Normal 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
|
||||
3
remote-access/server/requirements.txt
Normal file
3
remote-access/server/requirements.txt
Normal file
@ -0,0 +1,3 @@
|
||||
PyMySQL==1.1.2
|
||||
bcrypt==5.0.0
|
||||
cryptography==46.0.3
|
||||
11
remote-access/server/sshd-cangku-tunnel.conf
Normal file
11
remote-access/server/sshd-cangku-tunnel.conf
Normal 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
|
||||
80
scripts/remote-access-common.mjs
Normal file
80
scripts/remote-access-common.mjs
Normal 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
271
scripts/setup-remote.mjs
Normal 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
186
scripts/start-remote.mjs
Normal 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;
|
||||
});
|
||||
}
|
||||
46
test/remote-access.test.mjs
Normal file
46
test/remote-access.test.mjs
Normal 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');
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user