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.*
|
||||||
!**/.env.example
|
!**/.env.example
|
||||||
**/uploads/
|
**/uploads/
|
||||||
|
.remote-access/
|
||||||
|
.remote-access.backup-*/
|
||||||
.workbuddy/
|
.workbuddy/
|
||||||
**/node_modules
|
**/node_modules
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
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');
|
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);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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
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