feat: provision managed Redis during enrollment

This commit is contained in:
lunanting 2026-09-12 17:13:59 +08:00
parent 86ab958911
commit 8c448bcd74
14 changed files with 258 additions and 21 deletions

View File

@ -116,8 +116,11 @@ S3_SECRET_ACCESS_KEY=your-secret-key
# 两套系统库 Redis 都不可用时,才使用下面的 .env 配置兜底。
REDIS_HOST=
REDIS_PORT=6379
REDIS_USERNAME=
REDIS_PASSWORD=
REDIS_DB=0
REDIS_TLS=false
REDIS_TLS_SERVER_NAME=
# 默认 stored 表示自动选择系统库 Redis;也可临时指定 default/changzhou/env。
REDIS_RUNTIME_PROFILE=stored
CHANGZHOU_REDIS_SERVER_IPS=192.168.150.234

View File

@ -41,9 +41,10 @@ function buildRedisClientConfig(config) {
return {
host: config.host,
port: parseInt(config.port || '6379', 10),
username: process.env.REDIS_USERNAME || process.env.REDIS_USER || undefined,
username: config.username || process.env.REDIS_USERNAME || process.env.REDIS_USER || undefined,
password: config.password || undefined,
db: parseInt(config.db || '0', 10),
tls: config.tls ? { servername: config.tlsServerName || config.host } : undefined,
connectTimeout: REDIS_CONNECT_TIMEOUT_MS,
retryStrategy: (times) => (times > 1 ? null : 200),
maxRetriesPerRequest: 1,
@ -61,8 +62,10 @@ function getConfigSignature(config) {
host: config.host,
port: parseInt(config.port || '6379', 10),
db: parseInt(config.db || '0', 10),
username: process.env.REDIS_USERNAME || process.env.REDIS_USER || '',
username: config.username || process.env.REDIS_USERNAME || process.env.REDIS_USER || '',
password: config.password || '',
tls: Boolean(config.tls),
tlsServerName: config.tlsServerName || '',
});
}

View File

@ -54,8 +54,11 @@ const STORED_PROFILE_LABELS = {
const ENV_MAPPING = {
host: 'REDIS_HOST',
port: 'REDIS_PORT',
username: 'REDIS_USERNAME',
password: 'REDIS_PASSWORD',
db: 'REDIS_DB',
tls: 'REDIS_TLS',
tlsServerName: 'REDIS_TLS_SERVER_NAME',
};
const DEFAULT_CHANGZHOU_SERVER_IP = '192.168.150.234';
@ -86,6 +89,12 @@ function normalizeDb(value) {
return Number.isFinite(db) && db >= 0 ? db : DEFAULT_REDIS_CONFIG.db;
}
function normalizeBoolean(value) {
return [true, 1, '1', 'true', 'yes', 'on'].includes(
typeof value === 'string' ? value.toLowerCase() : value
);
}
function normalizeRuntimeProfile(value) {
const requestedProfile = String(value || '').trim();
if (!requestedProfile) {
@ -189,7 +198,10 @@ function mapConfig(config = {}, { includePassword = false, source = 'database' }
profileName: config.profileName || config.profile_name || STORED_PROFILE_LABELS[profileKey] || profileKey,
host,
port: normalizePort(config.port),
username: String(config.username || '').trim(),
db: normalizeDb(config.db),
tls: normalizeBoolean(config.tls),
tlsServerName: String(config.tlsServerName || config.tls_server_name || '').trim(),
hasPassword: Boolean(password),
enabled,
source,
@ -203,8 +215,11 @@ function getEnvRedisConfig(options = {}) {
profileName: '.env Redis',
host: process.env[ENV_MAPPING.host] || '',
port: process.env[ENV_MAPPING.port],
username: process.env[ENV_MAPPING.username] || '',
db: process.env[ENV_MAPPING.db],
password: process.env[ENV_MAPPING.password] || '',
tls: process.env[ENV_MAPPING.tls],
tlsServerName: process.env[ENV_MAPPING.tlsServerName] || '',
enabled: Boolean(String(process.env[ENV_MAPPING.host] || '').trim()),
}, {
includePassword: Boolean(options.includePassword),
@ -354,8 +369,10 @@ async function probeRedisConnection(config) {
const client = new Redis({
host: config.host,
port: normalizePort(config.port),
username: config.username || undefined,
password: config.password || undefined,
db: normalizeDb(config.db),
tls: config.tls ? { servername: config.tlsServerName || config.host } : undefined,
lazyConnect: true,
connectTimeout: REDIS_PROFILE_CONNECT_TIMEOUT_MS,
maxRetriesPerRequest: 1,
@ -390,8 +407,11 @@ async function getRuntimeRedisConfig() {
return {
host: candidate.host,
port: candidate.port,
username: candidate.username || undefined,
password: candidate.password || undefined,
db: candidate.db,
tls: candidate.tls,
tlsServerName: candidate.tlsServerName,
source: candidate.source,
};
}
@ -400,8 +420,11 @@ async function getRuntimeRedisConfig() {
return {
host: candidate.host,
port: candidate.port,
username: candidate.username || undefined,
password: candidate.password || undefined,
db: candidate.db,
tls: candidate.tls,
tlsServerName: candidate.tlsServerName,
source: candidate.profileKey || candidate.source,
};
}
@ -411,8 +434,11 @@ async function getRuntimeRedisConfig() {
return {
host: envCandidate.host,
port: envCandidate.port,
username: envCandidate.username || undefined,
password: envCandidate.password || undefined,
db: envCandidate.db,
tls: envCandidate.tls,
tlsServerName: envCandidate.tlsServerName,
source: envCandidate.source,
};
}

View File

@ -39,6 +39,11 @@ async function enrollRemoteInstallation(input = {}) {
port: connection.localDatabasePort,
database: enrollment.database.name,
},
redis: {
host: enrollment.redis.host,
port: Number(enrollment.redis.port),
tls: true,
},
adminUsername: enrollment.application_admin.username,
adminPassword: enrollment.application_admin.password,
};

View File

@ -1,14 +1,16 @@
# 生产部署(Docker)
生产实例使用三个隔离容器:MySQL 系统数据库、Node.js 后端和部署注册服务。MySQL、后端与注册服务只映射到服务器回环地址,公网仅由 Nginx 暴露 `ck.yydd.ai:8443`。
生产实例使用 MySQL、Redis、Redis TLS 网关、Node.js 后端和部署注册服务容器。MySQL、后端与注册服务只监听内部网络或服务器回环地址;Nginx 暴露 `ck.yydd.ai:8443`,Redis TLS 网关暴露 `ck.yydd.ai:6380`。
服务器目录约定:
- `/opt/cangku/app`:仓库代码和前端构建结果
- `/opt/cangku/shared`:权限为 `0700` 的环境配置
- `/opt/cangku/data`:MySQL 和部署注册服务持久数据
- `/opt/cangku/data`:MySQL、Redis 和部署注册服务持久数据
- `/www/wwwroot/cangku-acme`:Let's Encrypt HTTP-01 校验目录
真实密码、JWT 密钥、部署 Key 和第三方数据库配置不得写入仓库。`mysql.env`、`backend.env` 和 `bootstrap.env` 只在服务器生成并保持 `0600` 权限。
真实密码、JWT 密钥、部署 Key 和第三方数据库配置不得写入仓库。`mysql.env`、`redis.env`、`backend.env` 和 `bootstrap.env` 只在服务器生成并保持 `0600` 权限。
SSH 隧道账号只接受由注册服务 CA 签发的短期证书,只允许转发到服务器 `127.0.0.1:3307`,并关闭终端、TTY、代理转发和密码认证。生产主机的 SSH 管理账号使用独立密钥,关闭密码登录。
Redis 原始端口不映射到宿主机。公网 `6380` 只接受 TLS,注册服务为每台设备创建独立 ACL 账号;停用安装会删除该账号。证书续期后需重新生成 `/opt/cangku/shared/redis-tls.pem` 并重启 TLS 网关。

View File

@ -25,6 +25,39 @@ services:
networks:
- cangku-internal
redis:
image: redis:7.4-alpine
container_name: cangku-redis
restart: unless-stopped
env_file:
- /opt/cangku/shared/redis.env
command: ["sh", "-c", "exec redis-server --appendonly yes --save 60 1000 --requirepass \"$$REDIS_PASSWORD\""]
volumes:
- /opt/cangku/data/redis:/data
healthcheck:
test: ["CMD-SHELL", "REDISCLI_AUTH=$$REDIS_PASSWORD redis-cli ping | grep -q PONG"]
interval: 5s
timeout: 5s
retries: 20
start_period: 10s
networks:
- cangku-internal
redis-gateway:
image: haproxy:3.0-alpine
container_name: cangku-redis-gateway
restart: unless-stopped
ports:
- 6380:6380
volumes:
- ./haproxy-redis.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro
- /opt/cangku/shared/redis-tls.pem:/usr/local/etc/haproxy/certs/redis.pem:ro
depends_on:
redis:
condition: service_healthy
networks:
- cangku-internal
backend:
build:
context: ../../backend
@ -40,6 +73,8 @@ services:
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "node", "-e", "const s=require('node:net').connect(4090,'127.0.0.1',()=>{s.end();process.exit(0)});s.setTimeout(3000,()=>process.exit(1));s.on('error',()=>process.exit(1))"]
interval: 10s
@ -65,6 +100,8 @@ services:
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:4092/health', timeout=3)"]
interval: 10s

View File

@ -0,0 +1,16 @@
global
log stdout format raw local0
defaults
log global
mode tcp
timeout connect 5s
timeout client 1h
timeout server 1h
frontend redis_tls
bind *:6380 ssl crt /usr/local/etc/haproxy/certs/redis.pem ssl-min-ver TLSv1.2
default_backend redis_plain
backend redis_plain
server redis redis:6379 check

View File

@ -203,7 +203,7 @@ const Install = () => {
});
remoteForm.setFieldValue('apiKey', '');
setRemoteResult(result);
message.success(result.message || '远程系统数据库配置完成');
message.success(result.message || '中央数据库和 Redis 配置完成');
startPollingForRestart();
} catch (error) {
if (!error?.errorFields) {
@ -271,7 +271,7 @@ const Install = () => {
closable={false}
maskClosable={false}
keyboard={false}
title="连接中央系统数据库"
title="连接中央运行环境"
width={560}
footer={remoteResult && !credentialsAcknowledged
? [
@ -326,8 +326,8 @@ const Install = () => {
<Alert
type="info"
showIcon
message="本机无需安装数据库"
description="保存后会通过 HTTPS 注册本机,自动配置到中央系统数据库,并从中读取第三方数据库连接配置。"
message="本机无需安装数据库或 Redis"
description="保存后会通过 HTTPS 注册本机,自动配置中央系统数据库、公网 TLS Redis 和第三方数据库连接。登录后可直接使用,无需再进入配置向导。"
/>
<Form
form={remoteForm}

View File

@ -1,6 +1,6 @@
# 远程系统数据库部署
本项目的本地实例可以通过部署 Key 注册到中央系统数据库。注册服务为每台设备创建独立数据库账号并签发短期 SSH 隧道证书;MySQL 继续只监听服务器回环地址。
本项目的本地实例可以通过部署 Key 注册到中央运行环境。注册服务为每台设备创建独立的系统数据库账号、Redis ACL 账号和应用管理员,并签发短期 MySQL SSH 隧道证书;MySQL 继续只监听服务器回环地址,Redis 通过公网 TLS 端口提供服务。
## 本地使用
@ -12,9 +12,9 @@ npm --prefix frontend ci
npm run remote:dev
```
首次执行 `npm run remote:dev` 后,浏览器会显示首次启动弹窗。填写 `https://ck.yydd.ai:8443/v1/enroll` 和管理员发放的部署 Key,然后点击“保存并获取配置”。Key 不会保存。成功后,数据库凭据写入被 Git 忽略的 `backend/.env`,SSH 私钥和证书写入 `.remote-access/`,文件权限仅限当前用户。
首次执行 `npm run remote:dev` 后,浏览器会显示首次启动弹窗。填写 `https://ck.yydd.ai:8443/v1/enroll` 和管理员发放的部署 Key,然后点击“保存并获取配置”。Key 不会保存。成功后,系统数据库与 Redis 凭据写入被 Git 忽略的 `backend/.env`,SSH 私钥和证书写入 `.remote-access/`,文件权限仅限当前用户。
注册服务会在中央系统数据库中创建一个独立管理员。弹窗只显示一次管理员账号和随机密码,开发者必须立即保存;服务端不保存可再次查看的密码明文。
注册服务会在中央系统数据库中创建一个独立管理员,并把托管环境标记为已完成配置。弹窗只显示一次管理员账号和随机密码,开发者必须立即保存;服务端不保存可再次查看的密码明文。使用该管理员登录后直接进入系统,不再显示业务数据库、Redis、登录方式和对象存储配置向导。
开发模式同时启动后端和前端。首次注册成功后,启动器会自动建立隧道并重启后端。没有浏览器时,可用 `npm run remote:setup` 在终端中完成同一注册流程。
@ -30,12 +30,13 @@ npm run remote:tunnel
npm run remote:start
```
默认本地数据库隧道端口为 `13307`,如被占用,可在注册前设置 `CANGKU_LOCAL_DB_PORT`。重新注册需使用 `npm run remote:setup -- --replace`,旧配置会改名备份;管理员还应在服务器停用旧设备。
默认本地数据库隧道端口为 `13307`,如被占用,可在注册前设置 `CANGKU_LOCAL_DB_PORT`。Redis 直接使用注册接口返回的 `ck.yydd.ai:6380` TLS 地址。重新注册需使用 `npm run remote:setup -- --replace`,旧配置会改名备份;管理员还应在服务器停用旧设备。
## 权限和密钥边界
- 部署 Key 只用于设备注册,具有有效期和使用次数限制,不在仓库及本地配置中保存。
- 每台设备获得独立数据库用户名和密码,可单独停用。账号只能访问 `cangku_system`,不能访问服务器的其他数据库或管理数据库用户。
- 每台设备获得独立 Redis ACL 用户,只允许执行应用缓存所需命令;公网端口强制 TLS,密码不会提交到 Git。
- SSH 证书只允许登录 `cangku-tunnel`,该账号不能打开终端,只能把本地端口转发至服务器的 `127.0.0.1:3307`。
- SSH 主机密钥由注册服务固定下发并校验;注册接口使用 `ck.yydd.ai:8443` 的公开可信 HTTPS 证书,证书由 Certbot 定时任务自动续期。
- 本地实例设置 `CLOUD_DB_RUNTIME_PROFILE=public`,从中央系统数据库读取已配置的第三方公网数据库连接。
@ -62,4 +63,4 @@ sudo cangku-bootstrapctl revoke-key <KEY_ID>
sudo cangku-bootstrapctl revoke-installation <INSTALLATION_ID>
```
`list` 不显示 Key 或数据库密码。Key 明文仅在创建时输出一次。停用设备会删除它的数据库账号;已签发的 SSH 证书在到期前仍可能建立受限隧道,但不能再登录系统数据库。
`list` 不显示 Key、数据库密码或 Redis 密码。Key 明文仅在创建时输出一次。停用设备会删除它的数据库账号与 Redis ACL 用户并禁用应用管理员;已签发的 SSH 证书在到期前仍可能建立受限隧道,但不能再登录系统数据库。

View File

@ -16,6 +16,7 @@ from pathlib import Path
import pymysql
import bcrypt
import redis
UTC = dt.timezone.utc
PUBLIC_KEY_RE = re.compile(r'^ssh-ed25519 [A-Za-z0-9+/=]+(?: .{0,200})?$')
@ -48,9 +49,24 @@ class Config:
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.redis_admin_host = os.environ.get('BOOTSTRAP_REDIS_ADMIN_HOST', 'redis')
self.redis_admin_port = int(os.environ.get('BOOTSTRAP_REDIS_ADMIN_PORT', '6379'))
self.redis_admin_password = os.environ['BOOTSTRAP_REDIS_ADMIN_PASSWORD']
self.redis_public_host = os.environ.get('BOOTSTRAP_REDIS_PUBLIC_HOST', 'ck.yydd.ai')
self.redis_public_port = int(os.environ.get('BOOTSTRAP_REDIS_PUBLIC_PORT', '6380'))
self.redis_tls_server_name = os.environ.get('BOOTSTRAP_REDIS_TLS_SERVER_NAME', self.redis_public_host)
self.redis_db = int(os.environ.get('BOOTSTRAP_REDIS_DB', '0'))
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')
for name, port in (
('BOOTSTRAP_REDIS_ADMIN_PORT', self.redis_admin_port),
('BOOTSTRAP_REDIS_PUBLIC_PORT', self.redis_public_port),
):
if port < 1 or port > 65535:
raise ValueError(f'{name} must be between 1 and 65535')
if self.redis_db < 0 or self.redis_db > 15:
raise ValueError('BOOTSTRAP_REDIS_DB must be between 0 and 15')
class Broker:
@ -100,6 +116,8 @@ class Broker:
}
if 'admin_username' not in columns:
connection.execute('ALTER TABLE installations ADD COLUMN admin_username TEXT')
if 'redis_username' not in columns:
connection.execute('ALTER TABLE installations ADD COLUMN redis_username TEXT')
def token_hash(self, token):
return hmac.new(self.config.token_pepper, token.encode(), hashlib.sha256).hexdigest()
@ -157,6 +175,32 @@ class Broker:
with connection.cursor() as cursor:
cursor.execute(f"DROP USER IF EXISTS '{username}'@'%'")
def _redis_connection(self):
return redis.Redis(
host=self.config.redis_admin_host,
port=self.config.redis_admin_port,
password=self.config.redis_admin_password,
decode_responses=True,
socket_connect_timeout=8,
socket_timeout=8,
)
def _create_redis_user(self, username, password):
if not re.fullmatch(r'cangku_[a-f0-9]{16}', username):
raise ValueError('Refusing to create an unexpected Redis username')
self._redis_connection().execute_command(
'ACL', 'SETUSER', username,
'reset', 'on', f'>{password}', '~*',
'+ping', '+get', '+set', '+setex', '+del', '+unlink', '+exists',
'+expire', '+ttl', '+scan', '+eval', '+quit',
'+client|setname', '+client|setinfo',
)
def _drop_redis_user(self, username):
if not username or not re.fullmatch(r'cangku_[a-f0-9]{16}', username):
return
self._redis_connection().execute_command('ACL', 'DELUSER', 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'
@ -170,6 +214,23 @@ class Broker:
(username, password_hash, display_name, email),
)
def _mark_managed_setup_completed(self):
completed_at = iso(now())
with self._mysql_connection() as connection:
with connection.cursor() as cursor:
cursor.executemany(
f'''INSERT INTO `{self.config.mysql_database}`.system_config
(config_key,config_value,config_type)
VALUES(%s,%s,%s)
ON DUPLICATE KEY UPDATE
config_value=VALUES(config_value),
config_type=VALUES(config_type)''',
[
('setup_completed', 'true', 'boolean'),
('setup_completed_at', completed_at, 'string'),
],
)
def _remove_application_admin(self, username):
with self._mysql_connection() as connection:
with connection.cursor() as cursor:
@ -218,10 +279,13 @@ class Broker:
db_password = secrets.token_urlsafe(32)
admin_username = f'dev_{installation_id[:12]}'
admin_password = secrets.token_urlsafe(18)
redis_username = f'cangku_{secrets.token_hex(8)}'
redis_password = secrets.token_urlsafe(32)
created = now()
expires = created + dt.timedelta(days=self.config.cert_days)
db_created = False
admin_created = False
redis_created = False
with self._connect_state() as connection:
connection.execute('BEGIN IMMEDIATE')
token_row = self._find_token(connection, token)
@ -232,11 +296,14 @@ class Broker:
db_created = True
self._create_application_admin(admin_username, admin_password, label)
admin_created = True
self._create_redis_user(redis_username, redis_password)
redis_created = True
self._mark_managed_setup_completed()
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),
(id,token_id,label,db_username,certificate_serial,created_at,expires_at,admin_username,redis_username)
VALUES(?,?,?,?,?,?,?,?,?)''',
(installation_id, token_row['id'], label, db_username, serial, iso(created), iso(expires), admin_username, redis_username),
)
connection.execute(
'UPDATE enrollment_tokens SET use_count=use_count+1 WHERE id=?', (token_row['id'],)
@ -246,6 +313,8 @@ class Broker:
connection.rollback()
if admin_created:
self._remove_application_admin(admin_username)
if redis_created:
self._drop_redis_user(redis_username)
if db_created:
self._drop_database_user(db_username)
raise
@ -266,6 +335,15 @@ class Broker:
'username': db_username,
'password': db_password,
},
'redis': {
'host': self.config.redis_public_host,
'port': self.config.redis_public_port,
'username': redis_username,
'password': redis_password,
'db': self.config.redis_db,
'tls': True,
'tls_server_name': self.config.redis_tls_server_name,
},
'application_admin': {
'username': admin_username,
'password': admin_password,
@ -278,7 +356,7 @@ class Broker:
'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'
'SELECT id,token_id,label,db_username,redis_username,admin_username,certificate_serial,created_at,expires_at,revoked_at FROM installations ORDER BY created_at DESC'
)]
return {'tokens': tokens, 'installations': installations}
@ -289,6 +367,7 @@ class Broker:
raise ValueError('未找到设备')
if not row['revoked_at']:
self._drop_database_user(row['db_username'])
self._drop_redis_user(row['redis_username'])
self._deactivate_application_admin(row['admin_username'])
connection.execute('UPDATE installations SET revoked_at=? WHERE id=?', (iso(now()), installation_id))

View File

@ -1,3 +1,4 @@
PyMySQL==1.1.2
bcrypt==5.0.0
cryptography==46.0.3
redis==6.4.0

View File

@ -11,6 +11,13 @@ export const REQUIRED_ENROLLMENT_FIELDS = Object.freeze([
'database.name',
'database.username',
'database.password',
'redis.host',
'redis.port',
'redis.username',
'redis.password',
'redis.db',
'redis.tls',
'redis.tls_server_name',
'application_admin.username',
'application_admin.password',
]);
@ -37,6 +44,22 @@ export function validateEnrollmentResponse(value) {
if (!/^[A-Za-z0-9_]+$/u.test(String(value.database.name))) {
throw new Error('注册服务返回的数据库名无效');
}
for (const [field, minimum] of [['redis.port', 1]]) {
const port = Number(readNested(value, field));
if (!Number.isInteger(port) || port < minimum || port > 65535) {
throw new Error(`注册服务返回的端口无效:${field}`);
}
}
if (!String(value.redis.host).trim() || !String(value.redis.tls_server_name).trim()) {
throw new Error('注册服务返回的 Redis TLS 地址无效');
}
if (value.redis.tls !== true) {
throw new Error('注册服务未启用 Redis TLS');
}
const redisDb = Number(value.redis.db);
if (!Number.isInteger(redisDb) || redisDb < 0 || redisDb > 15) {
throw new Error('注册服务返回的 Redis 数据库编号无效');
}
return value;
}

View File

@ -139,7 +139,7 @@ function backupExistingAccessDirectory() {
return backupPath;
}
function updateBackendEnvironment(enrollment, localPort) {
function updateBackendEnvironment(enrollment, localDatabasePort) {
const examplePath = path.join(ROOT_DIR, 'backend', '.env.example');
const envPath = path.join(ROOT_DIR, 'backend', '.env');
const original = fs.existsSync(envPath)
@ -148,10 +148,18 @@ function updateBackendEnvironment(enrollment, localPort) {
const updates = {
LOCAL_DB_TYPE: 'mysql',
LOCAL_DB_HOST: '127.0.0.1',
LOCAL_DB_PORT: String(localPort),
LOCAL_DB_PORT: String(localDatabasePort),
LOCAL_DB_NAME: enrollment.database.name,
LOCAL_DB_USER: enrollment.database.username,
LOCAL_DB_PASSWORD: enrollment.database.password,
REDIS_HOST: enrollment.redis.host,
REDIS_PORT: String(enrollment.redis.port),
REDIS_USERNAME: enrollment.redis.username,
REDIS_PASSWORD: enrollment.redis.password,
REDIS_DB: String(enrollment.redis.db),
REDIS_TLS: 'true',
REDIS_TLS_SERVER_NAME: enrollment.redis.tls_server_name,
REDIS_RUNTIME_PROFILE: 'env',
CLOUD_DB_RUNTIME_PROFILE: 'public',
REMOTE_SYSTEM_DB: 'true',
ENABLE_BACKGROUND_JOBS: 'false',
@ -190,7 +198,6 @@ export async function configureRemoteAccess({
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 {
@ -220,6 +227,14 @@ export async function configureRemoteAccess({
bootstrapEndpoint: endpointUrl.toString(),
certificateValidUntil: enrollment.ssh.valid_until,
localDatabasePort: localPort,
redis: {
host: enrollment.redis.host,
port: Number(enrollment.redis.port),
username: enrollment.redis.username,
db: Number(enrollment.redis.db),
tls: true,
tlsServerName: enrollment.redis.tls_server_name,
},
databaseName: enrollment.database.name,
ssh: {
host: enrollment.ssh.host,
@ -252,6 +267,7 @@ export async function main(args = process.argv.slice(2)) {
const { enrollment, connection, backupPath } = result;
console.log(`注册完成,设备编号:${enrollment.installation_id}`);
console.log(`系统数据库:127.0.0.1:${connection.localDatabasePort}/${enrollment.database.name}(通过 SSH 隧道)`);
console.log(`Redis 缓存:${connection.redis.host}:${connection.redis.port}/${connection.redis.db}(TLS 公网直连)`);
console.log(`SSH 证书有效期至:${enrollment.ssh.valid_until}`);
console.log('以下系统管理员凭据只显示一次,请立即保存:');
console.log(`管理员账号:${enrollment.application_admin.username}`);

View File

@ -1,6 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { getKnownHostsLine, mergeEnv, validateEnrollmentResponse } from '../scripts/remote-access-common.mjs';
import { buildSshArgs } from '../scripts/start-remote.mjs';
const enrollment = {
installation_id: 'dev-123',
@ -16,6 +17,15 @@ const enrollment = {
username: 'client_123',
password: 'a password with # symbols',
},
redis: {
host: 'redis.example.test',
port: 6380,
username: 'cangku_1234567890abcdef',
password: 'redis secret',
db: 0,
tls: true,
tls_server_name: 'redis.example.test',
},
application_admin: {
username: 'dev_123',
password: 'one-time-password',
@ -30,6 +40,21 @@ test('validates complete enrollment responses', () => {
);
});
test('builds an SSH tunnel only for the system database', () => {
const args = buildSshArgs({
localDatabasePort: 13307,
ssh: {
host: 'example.test',
port: 50232,
user: 'cangku-tunnel',
remoteDatabaseHost: '127.0.0.1',
remoteDatabasePort: 3307,
},
});
assert.ok(args.includes('127.0.0.1:13307:127.0.0.1:3307'));
assert.equal(args.some((value) => String(value).includes(':6380')), false);
});
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',