forked from lunanting/cangku
feat: provision managed Redis during enrollment
This commit is contained in:
parent
86ab958911
commit
8c448bcd74
@ -116,8 +116,11 @@ S3_SECRET_ACCESS_KEY=your-secret-key
|
|||||||
# 两套系统库 Redis 都不可用时,才使用下面的 .env 配置兜底。
|
# 两套系统库 Redis 都不可用时,才使用下面的 .env 配置兜底。
|
||||||
REDIS_HOST=
|
REDIS_HOST=
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
|
REDIS_USERNAME=
|
||||||
REDIS_PASSWORD=
|
REDIS_PASSWORD=
|
||||||
REDIS_DB=0
|
REDIS_DB=0
|
||||||
|
REDIS_TLS=false
|
||||||
|
REDIS_TLS_SERVER_NAME=
|
||||||
# 默认 stored 表示自动选择系统库 Redis;也可临时指定 default/changzhou/env。
|
# 默认 stored 表示自动选择系统库 Redis;也可临时指定 default/changzhou/env。
|
||||||
REDIS_RUNTIME_PROFILE=stored
|
REDIS_RUNTIME_PROFILE=stored
|
||||||
CHANGZHOU_REDIS_SERVER_IPS=192.168.150.234
|
CHANGZHOU_REDIS_SERVER_IPS=192.168.150.234
|
||||||
|
|||||||
@ -41,9 +41,10 @@ function buildRedisClientConfig(config) {
|
|||||||
return {
|
return {
|
||||||
host: config.host,
|
host: config.host,
|
||||||
port: parseInt(config.port || '6379', 10),
|
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,
|
password: config.password || undefined,
|
||||||
db: parseInt(config.db || '0', 10),
|
db: parseInt(config.db || '0', 10),
|
||||||
|
tls: config.tls ? { servername: config.tlsServerName || config.host } : undefined,
|
||||||
connectTimeout: REDIS_CONNECT_TIMEOUT_MS,
|
connectTimeout: REDIS_CONNECT_TIMEOUT_MS,
|
||||||
retryStrategy: (times) => (times > 1 ? null : 200),
|
retryStrategy: (times) => (times > 1 ? null : 200),
|
||||||
maxRetriesPerRequest: 1,
|
maxRetriesPerRequest: 1,
|
||||||
@ -61,8 +62,10 @@ function getConfigSignature(config) {
|
|||||||
host: config.host,
|
host: config.host,
|
||||||
port: parseInt(config.port || '6379', 10),
|
port: parseInt(config.port || '6379', 10),
|
||||||
db: parseInt(config.db || '0', 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 || '',
|
password: config.password || '',
|
||||||
|
tls: Boolean(config.tls),
|
||||||
|
tlsServerName: config.tlsServerName || '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -54,8 +54,11 @@ const STORED_PROFILE_LABELS = {
|
|||||||
const ENV_MAPPING = {
|
const ENV_MAPPING = {
|
||||||
host: 'REDIS_HOST',
|
host: 'REDIS_HOST',
|
||||||
port: 'REDIS_PORT',
|
port: 'REDIS_PORT',
|
||||||
|
username: 'REDIS_USERNAME',
|
||||||
password: 'REDIS_PASSWORD',
|
password: 'REDIS_PASSWORD',
|
||||||
db: 'REDIS_DB',
|
db: 'REDIS_DB',
|
||||||
|
tls: 'REDIS_TLS',
|
||||||
|
tlsServerName: 'REDIS_TLS_SERVER_NAME',
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_CHANGZHOU_SERVER_IP = '192.168.150.234';
|
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;
|
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) {
|
function normalizeRuntimeProfile(value) {
|
||||||
const requestedProfile = String(value || '').trim();
|
const requestedProfile = String(value || '').trim();
|
||||||
if (!requestedProfile) {
|
if (!requestedProfile) {
|
||||||
@ -189,7 +198,10 @@ function mapConfig(config = {}, { includePassword = false, source = 'database' }
|
|||||||
profileName: config.profileName || config.profile_name || STORED_PROFILE_LABELS[profileKey] || profileKey,
|
profileName: config.profileName || config.profile_name || STORED_PROFILE_LABELS[profileKey] || profileKey,
|
||||||
host,
|
host,
|
||||||
port: normalizePort(config.port),
|
port: normalizePort(config.port),
|
||||||
|
username: String(config.username || '').trim(),
|
||||||
db: normalizeDb(config.db),
|
db: normalizeDb(config.db),
|
||||||
|
tls: normalizeBoolean(config.tls),
|
||||||
|
tlsServerName: String(config.tlsServerName || config.tls_server_name || '').trim(),
|
||||||
hasPassword: Boolean(password),
|
hasPassword: Boolean(password),
|
||||||
enabled,
|
enabled,
|
||||||
source,
|
source,
|
||||||
@ -203,8 +215,11 @@ function getEnvRedisConfig(options = {}) {
|
|||||||
profileName: '.env Redis',
|
profileName: '.env Redis',
|
||||||
host: process.env[ENV_MAPPING.host] || '',
|
host: process.env[ENV_MAPPING.host] || '',
|
||||||
port: process.env[ENV_MAPPING.port],
|
port: process.env[ENV_MAPPING.port],
|
||||||
|
username: process.env[ENV_MAPPING.username] || '',
|
||||||
db: process.env[ENV_MAPPING.db],
|
db: process.env[ENV_MAPPING.db],
|
||||||
password: process.env[ENV_MAPPING.password] || '',
|
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()),
|
enabled: Boolean(String(process.env[ENV_MAPPING.host] || '').trim()),
|
||||||
}, {
|
}, {
|
||||||
includePassword: Boolean(options.includePassword),
|
includePassword: Boolean(options.includePassword),
|
||||||
@ -354,8 +369,10 @@ async function probeRedisConnection(config) {
|
|||||||
const client = new Redis({
|
const client = new Redis({
|
||||||
host: config.host,
|
host: config.host,
|
||||||
port: normalizePort(config.port),
|
port: normalizePort(config.port),
|
||||||
|
username: config.username || undefined,
|
||||||
password: config.password || undefined,
|
password: config.password || undefined,
|
||||||
db: normalizeDb(config.db),
|
db: normalizeDb(config.db),
|
||||||
|
tls: config.tls ? { servername: config.tlsServerName || config.host } : undefined,
|
||||||
lazyConnect: true,
|
lazyConnect: true,
|
||||||
connectTimeout: REDIS_PROFILE_CONNECT_TIMEOUT_MS,
|
connectTimeout: REDIS_PROFILE_CONNECT_TIMEOUT_MS,
|
||||||
maxRetriesPerRequest: 1,
|
maxRetriesPerRequest: 1,
|
||||||
@ -390,8 +407,11 @@ async function getRuntimeRedisConfig() {
|
|||||||
return {
|
return {
|
||||||
host: candidate.host,
|
host: candidate.host,
|
||||||
port: candidate.port,
|
port: candidate.port,
|
||||||
|
username: candidate.username || undefined,
|
||||||
password: candidate.password || undefined,
|
password: candidate.password || undefined,
|
||||||
db: candidate.db,
|
db: candidate.db,
|
||||||
|
tls: candidate.tls,
|
||||||
|
tlsServerName: candidate.tlsServerName,
|
||||||
source: candidate.source,
|
source: candidate.source,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -400,8 +420,11 @@ async function getRuntimeRedisConfig() {
|
|||||||
return {
|
return {
|
||||||
host: candidate.host,
|
host: candidate.host,
|
||||||
port: candidate.port,
|
port: candidate.port,
|
||||||
|
username: candidate.username || undefined,
|
||||||
password: candidate.password || undefined,
|
password: candidate.password || undefined,
|
||||||
db: candidate.db,
|
db: candidate.db,
|
||||||
|
tls: candidate.tls,
|
||||||
|
tlsServerName: candidate.tlsServerName,
|
||||||
source: candidate.profileKey || candidate.source,
|
source: candidate.profileKey || candidate.source,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -411,8 +434,11 @@ async function getRuntimeRedisConfig() {
|
|||||||
return {
|
return {
|
||||||
host: envCandidate.host,
|
host: envCandidate.host,
|
||||||
port: envCandidate.port,
|
port: envCandidate.port,
|
||||||
|
username: envCandidate.username || undefined,
|
||||||
password: envCandidate.password || undefined,
|
password: envCandidate.password || undefined,
|
||||||
db: envCandidate.db,
|
db: envCandidate.db,
|
||||||
|
tls: envCandidate.tls,
|
||||||
|
tlsServerName: envCandidate.tlsServerName,
|
||||||
source: envCandidate.source,
|
source: envCandidate.source,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,6 +39,11 @@ async function enrollRemoteInstallation(input = {}) {
|
|||||||
port: connection.localDatabasePort,
|
port: connection.localDatabasePort,
|
||||||
database: enrollment.database.name,
|
database: enrollment.database.name,
|
||||||
},
|
},
|
||||||
|
redis: {
|
||||||
|
host: enrollment.redis.host,
|
||||||
|
port: Number(enrollment.redis.port),
|
||||||
|
tls: true,
|
||||||
|
},
|
||||||
adminUsername: enrollment.application_admin.username,
|
adminUsername: enrollment.application_admin.username,
|
||||||
adminPassword: enrollment.application_admin.password,
|
adminPassword: enrollment.application_admin.password,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,14 +1,16 @@
|
|||||||
# 生产部署(Docker)
|
# 生产部署(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/app`:仓库代码和前端构建结果
|
||||||
- `/opt/cangku/shared`:权限为 `0700` 的环境配置
|
- `/opt/cangku/shared`:权限为 `0700` 的环境配置
|
||||||
- `/opt/cangku/data`:MySQL 和部署注册服务持久数据
|
- `/opt/cangku/data`:MySQL、Redis 和部署注册服务持久数据
|
||||||
- `/www/wwwroot/cangku-acme`:Let's Encrypt HTTP-01 校验目录
|
- `/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 管理账号使用独立密钥,关闭密码登录。
|
SSH 隧道账号只接受由注册服务 CA 签发的短期证书,只允许转发到服务器 `127.0.0.1:3307`,并关闭终端、TTY、代理转发和密码认证。生产主机的 SSH 管理账号使用独立密钥,关闭密码登录。
|
||||||
|
|
||||||
|
Redis 原始端口不映射到宿主机。公网 `6380` 只接受 TLS,注册服务为每台设备创建独立 ACL 账号;停用安装会删除该账号。证书续期后需重新生成 `/opt/cangku/shared/redis-tls.pem` 并重启 TLS 网关。
|
||||||
|
|||||||
@ -25,6 +25,39 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- cangku-internal
|
- 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:
|
backend:
|
||||||
build:
|
build:
|
||||||
context: ../../backend
|
context: ../../backend
|
||||||
@ -40,6 +73,8 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
mysql:
|
mysql:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
healthcheck:
|
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))"]
|
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
|
interval: 10s
|
||||||
@ -65,6 +100,8 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
mysql:
|
mysql:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:4092/health', timeout=3)"]
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:4092/health', timeout=3)"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
|
|||||||
16
deploy/production/haproxy-redis.cfg
Normal file
16
deploy/production/haproxy-redis.cfg
Normal 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
|
||||||
@ -203,7 +203,7 @@ const Install = () => {
|
|||||||
});
|
});
|
||||||
remoteForm.setFieldValue('apiKey', '');
|
remoteForm.setFieldValue('apiKey', '');
|
||||||
setRemoteResult(result);
|
setRemoteResult(result);
|
||||||
message.success(result.message || '远程系统数据库配置完成');
|
message.success(result.message || '中央数据库和 Redis 配置完成');
|
||||||
startPollingForRestart();
|
startPollingForRestart();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!error?.errorFields) {
|
if (!error?.errorFields) {
|
||||||
@ -271,7 +271,7 @@ const Install = () => {
|
|||||||
closable={false}
|
closable={false}
|
||||||
maskClosable={false}
|
maskClosable={false}
|
||||||
keyboard={false}
|
keyboard={false}
|
||||||
title="连接中央系统数据库"
|
title="连接中央运行环境"
|
||||||
width={560}
|
width={560}
|
||||||
footer={remoteResult && !credentialsAcknowledged
|
footer={remoteResult && !credentialsAcknowledged
|
||||||
? [
|
? [
|
||||||
@ -326,8 +326,8 @@ const Install = () => {
|
|||||||
<Alert
|
<Alert
|
||||||
type="info"
|
type="info"
|
||||||
showIcon
|
showIcon
|
||||||
message="本机无需安装数据库"
|
message="本机无需安装数据库或 Redis"
|
||||||
description="保存后会通过 HTTPS 注册本机,自动配置到中央系统数据库,并从中读取第三方数据库连接配置。"
|
description="保存后会通过 HTTPS 注册本机,自动配置中央系统数据库、公网 TLS Redis 和第三方数据库连接。登录后可直接使用,无需再进入配置向导。"
|
||||||
/>
|
/>
|
||||||
<Form
|
<Form
|
||||||
form={remoteForm}
|
form={remoteForm}
|
||||||
|
|||||||
@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
首次执行 `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` 在终端中完成同一注册流程。
|
开发模式同时启动后端和前端。首次注册成功后,启动器会自动建立隧道并重启后端。没有浏览器时,可用 `npm run remote:setup` 在终端中完成同一注册流程。
|
||||||
|
|
||||||
@ -30,12 +30,13 @@ npm run remote:tunnel
|
|||||||
npm run remote:start
|
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 只用于设备注册,具有有效期和使用次数限制,不在仓库及本地配置中保存。
|
- 部署 Key 只用于设备注册,具有有效期和使用次数限制,不在仓库及本地配置中保存。
|
||||||
- 每台设备获得独立数据库用户名和密码,可单独停用。账号只能访问 `cangku_system`,不能访问服务器的其他数据库或管理数据库用户。
|
- 每台设备获得独立数据库用户名和密码,可单独停用。账号只能访问 `cangku_system`,不能访问服务器的其他数据库或管理数据库用户。
|
||||||
|
- 每台设备获得独立 Redis ACL 用户,只允许执行应用缓存所需命令;公网端口强制 TLS,密码不会提交到 Git。
|
||||||
- SSH 证书只允许登录 `cangku-tunnel`,该账号不能打开终端,只能把本地端口转发至服务器的 `127.0.0.1:3307`。
|
- SSH 证书只允许登录 `cangku-tunnel`,该账号不能打开终端,只能把本地端口转发至服务器的 `127.0.0.1:3307`。
|
||||||
- SSH 主机密钥由注册服务固定下发并校验;注册接口使用 `ck.yydd.ai:8443` 的公开可信 HTTPS 证书,证书由 Certbot 定时任务自动续期。
|
- SSH 主机密钥由注册服务固定下发并校验;注册接口使用 `ck.yydd.ai:8443` 的公开可信 HTTPS 证书,证书由 Certbot 定时任务自动续期。
|
||||||
- 本地实例设置 `CLOUD_DB_RUNTIME_PROFILE=public`,从中央系统数据库读取已配置的第三方公网数据库连接。
|
- 本地实例设置 `CLOUD_DB_RUNTIME_PROFILE=public`,从中央系统数据库读取已配置的第三方公网数据库连接。
|
||||||
@ -62,4 +63,4 @@ sudo cangku-bootstrapctl revoke-key <KEY_ID>
|
|||||||
sudo cangku-bootstrapctl revoke-installation <INSTALLATION_ID>
|
sudo cangku-bootstrapctl revoke-installation <INSTALLATION_ID>
|
||||||
```
|
```
|
||||||
|
|
||||||
`list` 不显示 Key 或数据库密码。Key 明文仅在创建时输出一次。停用设备会删除它的数据库账号;已签发的 SSH 证书在到期前仍可能建立受限隧道,但不能再登录系统数据库。
|
`list` 不显示 Key、数据库密码或 Redis 密码。Key 明文仅在创建时输出一次。停用设备会删除它的数据库账号与 Redis ACL 用户并禁用应用管理员;已签发的 SSH 证书在到期前仍可能建立受限隧道,但不能再登录系统数据库。
|
||||||
|
|||||||
@ -16,6 +16,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pymysql
|
import pymysql
|
||||||
import bcrypt
|
import bcrypt
|
||||||
|
import redis
|
||||||
|
|
||||||
UTC = dt.timezone.utc
|
UTC = dt.timezone.utc
|
||||||
PUBLIC_KEY_RE = re.compile(r'^ssh-ed25519 [A-Za-z0-9+/=]+(?: .{0,200})?$')
|
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_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_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.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'))
|
self.cert_days = int(os.environ.get('BOOTSTRAP_CERT_DAYS', '30'))
|
||||||
if not IDENTIFIER_RE.fullmatch(self.mysql_database):
|
if not IDENTIFIER_RE.fullmatch(self.mysql_database):
|
||||||
raise ValueError('BOOTSTRAP_MYSQL_DATABASE contains an invalid identifier')
|
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:
|
class Broker:
|
||||||
@ -100,6 +116,8 @@ class Broker:
|
|||||||
}
|
}
|
||||||
if 'admin_username' not in columns:
|
if 'admin_username' not in columns:
|
||||||
connection.execute('ALTER TABLE installations ADD COLUMN admin_username TEXT')
|
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):
|
def token_hash(self, token):
|
||||||
return hmac.new(self.config.token_pepper, token.encode(), hashlib.sha256).hexdigest()
|
return hmac.new(self.config.token_pepper, token.encode(), hashlib.sha256).hexdigest()
|
||||||
@ -157,6 +175,32 @@ class Broker:
|
|||||||
with connection.cursor() as cursor:
|
with connection.cursor() as cursor:
|
||||||
cursor.execute(f"DROP USER IF EXISTS '{username}'@'%'")
|
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):
|
def _create_application_admin(self, username, password, label):
|
||||||
password_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12)).decode()
|
password_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12)).decode()
|
||||||
email = f'{username}@remote.local'
|
email = f'{username}@remote.local'
|
||||||
@ -170,6 +214,23 @@ class Broker:
|
|||||||
(username, password_hash, display_name, email),
|
(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):
|
def _remove_application_admin(self, username):
|
||||||
with self._mysql_connection() as connection:
|
with self._mysql_connection() as connection:
|
||||||
with connection.cursor() as cursor:
|
with connection.cursor() as cursor:
|
||||||
@ -218,10 +279,13 @@ class Broker:
|
|||||||
db_password = secrets.token_urlsafe(32)
|
db_password = secrets.token_urlsafe(32)
|
||||||
admin_username = f'dev_{installation_id[:12]}'
|
admin_username = f'dev_{installation_id[:12]}'
|
||||||
admin_password = secrets.token_urlsafe(18)
|
admin_password = secrets.token_urlsafe(18)
|
||||||
|
redis_username = f'cangku_{secrets.token_hex(8)}'
|
||||||
|
redis_password = secrets.token_urlsafe(32)
|
||||||
created = now()
|
created = now()
|
||||||
expires = created + dt.timedelta(days=self.config.cert_days)
|
expires = created + dt.timedelta(days=self.config.cert_days)
|
||||||
db_created = False
|
db_created = False
|
||||||
admin_created = False
|
admin_created = False
|
||||||
|
redis_created = False
|
||||||
with self._connect_state() as connection:
|
with self._connect_state() as connection:
|
||||||
connection.execute('BEGIN IMMEDIATE')
|
connection.execute('BEGIN IMMEDIATE')
|
||||||
token_row = self._find_token(connection, token)
|
token_row = self._find_token(connection, token)
|
||||||
@ -232,11 +296,14 @@ class Broker:
|
|||||||
db_created = True
|
db_created = True
|
||||||
self._create_application_admin(admin_username, admin_password, label)
|
self._create_application_admin(admin_username, admin_password, label)
|
||||||
admin_created = True
|
admin_created = True
|
||||||
|
self._create_redis_user(redis_username, redis_password)
|
||||||
|
redis_created = True
|
||||||
|
self._mark_managed_setup_completed()
|
||||||
connection.execute(
|
connection.execute(
|
||||||
'''INSERT INTO installations
|
'''INSERT INTO installations
|
||||||
(id,token_id,label,db_username,certificate_serial,created_at,expires_at,admin_username)
|
(id,token_id,label,db_username,certificate_serial,created_at,expires_at,admin_username,redis_username)
|
||||||
VALUES(?,?,?,?,?,?,?,?)''',
|
VALUES(?,?,?,?,?,?,?,?,?)''',
|
||||||
(installation_id, token_row['id'], label, db_username, serial, iso(created), iso(expires), admin_username),
|
(installation_id, token_row['id'], label, db_username, serial, iso(created), iso(expires), admin_username, redis_username),
|
||||||
)
|
)
|
||||||
connection.execute(
|
connection.execute(
|
||||||
'UPDATE enrollment_tokens SET use_count=use_count+1 WHERE id=?', (token_row['id'],)
|
'UPDATE enrollment_tokens SET use_count=use_count+1 WHERE id=?', (token_row['id'],)
|
||||||
@ -246,6 +313,8 @@ class Broker:
|
|||||||
connection.rollback()
|
connection.rollback()
|
||||||
if admin_created:
|
if admin_created:
|
||||||
self._remove_application_admin(admin_username)
|
self._remove_application_admin(admin_username)
|
||||||
|
if redis_created:
|
||||||
|
self._drop_redis_user(redis_username)
|
||||||
if db_created:
|
if db_created:
|
||||||
self._drop_database_user(db_username)
|
self._drop_database_user(db_username)
|
||||||
raise
|
raise
|
||||||
@ -266,6 +335,15 @@ class Broker:
|
|||||||
'username': db_username,
|
'username': db_username,
|
||||||
'password': db_password,
|
'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': {
|
'application_admin': {
|
||||||
'username': admin_username,
|
'username': admin_username,
|
||||||
'password': admin_password,
|
'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'
|
'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(
|
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}
|
return {'tokens': tokens, 'installations': installations}
|
||||||
|
|
||||||
@ -289,6 +367,7 @@ class Broker:
|
|||||||
raise ValueError('未找到设备')
|
raise ValueError('未找到设备')
|
||||||
if not row['revoked_at']:
|
if not row['revoked_at']:
|
||||||
self._drop_database_user(row['db_username'])
|
self._drop_database_user(row['db_username'])
|
||||||
|
self._drop_redis_user(row['redis_username'])
|
||||||
self._deactivate_application_admin(row['admin_username'])
|
self._deactivate_application_admin(row['admin_username'])
|
||||||
connection.execute('UPDATE installations SET revoked_at=? WHERE id=?', (iso(now()), installation_id))
|
connection.execute('UPDATE installations SET revoked_at=? WHERE id=?', (iso(now()), installation_id))
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
PyMySQL==1.1.2
|
PyMySQL==1.1.2
|
||||||
bcrypt==5.0.0
|
bcrypt==5.0.0
|
||||||
cryptography==46.0.3
|
cryptography==46.0.3
|
||||||
|
redis==6.4.0
|
||||||
|
|||||||
@ -11,6 +11,13 @@ export const REQUIRED_ENROLLMENT_FIELDS = Object.freeze([
|
|||||||
'database.name',
|
'database.name',
|
||||||
'database.username',
|
'database.username',
|
||||||
'database.password',
|
'database.password',
|
||||||
|
'redis.host',
|
||||||
|
'redis.port',
|
||||||
|
'redis.username',
|
||||||
|
'redis.password',
|
||||||
|
'redis.db',
|
||||||
|
'redis.tls',
|
||||||
|
'redis.tls_server_name',
|
||||||
'application_admin.username',
|
'application_admin.username',
|
||||||
'application_admin.password',
|
'application_admin.password',
|
||||||
]);
|
]);
|
||||||
@ -37,6 +44,22 @@ export function validateEnrollmentResponse(value) {
|
|||||||
if (!/^[A-Za-z0-9_]+$/u.test(String(value.database.name))) {
|
if (!/^[A-Za-z0-9_]+$/u.test(String(value.database.name))) {
|
||||||
throw new Error('注册服务返回的数据库名无效');
|
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;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -139,7 +139,7 @@ function backupExistingAccessDirectory() {
|
|||||||
return backupPath;
|
return backupPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateBackendEnvironment(enrollment, localPort) {
|
function updateBackendEnvironment(enrollment, localDatabasePort) {
|
||||||
const examplePath = path.join(ROOT_DIR, 'backend', '.env.example');
|
const examplePath = path.join(ROOT_DIR, 'backend', '.env.example');
|
||||||
const envPath = path.join(ROOT_DIR, 'backend', '.env');
|
const envPath = path.join(ROOT_DIR, 'backend', '.env');
|
||||||
const original = fs.existsSync(envPath)
|
const original = fs.existsSync(envPath)
|
||||||
@ -148,10 +148,18 @@ function updateBackendEnvironment(enrollment, localPort) {
|
|||||||
const updates = {
|
const updates = {
|
||||||
LOCAL_DB_TYPE: 'mysql',
|
LOCAL_DB_TYPE: 'mysql',
|
||||||
LOCAL_DB_HOST: '127.0.0.1',
|
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_NAME: enrollment.database.name,
|
||||||
LOCAL_DB_USER: enrollment.database.username,
|
LOCAL_DB_USER: enrollment.database.username,
|
||||||
LOCAL_DB_PASSWORD: enrollment.database.password,
|
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',
|
CLOUD_DB_RUNTIME_PROFILE: 'public',
|
||||||
REMOTE_SYSTEM_DB: 'true',
|
REMOTE_SYSTEM_DB: 'true',
|
||||||
ENABLE_BACKGROUND_JOBS: 'false',
|
ENABLE_BACKGROUND_JOBS: 'false',
|
||||||
@ -190,7 +198,6 @@ export async function configureRemoteAccess({
|
|||||||
if (!Number.isInteger(localPort) || localPort < 1024 || localPort > 65535) {
|
if (!Number.isInteger(localPort) || localPort < 1024 || localPort > 65535) {
|
||||||
throw new Error('本地数据库端口必须是 1024 到 65535 之间的端口');
|
throw new Error('本地数据库端口必须是 1024 到 65535 之间的端口');
|
||||||
}
|
}
|
||||||
|
|
||||||
const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'cangku-enroll-'));
|
const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'cangku-enroll-'));
|
||||||
const temporaryPrivateKey = path.join(temporaryDirectory, 'id_ed25519');
|
const temporaryPrivateKey = path.join(temporaryDirectory, 'id_ed25519');
|
||||||
try {
|
try {
|
||||||
@ -220,6 +227,14 @@ export async function configureRemoteAccess({
|
|||||||
bootstrapEndpoint: endpointUrl.toString(),
|
bootstrapEndpoint: endpointUrl.toString(),
|
||||||
certificateValidUntil: enrollment.ssh.valid_until,
|
certificateValidUntil: enrollment.ssh.valid_until,
|
||||||
localDatabasePort: localPort,
|
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,
|
databaseName: enrollment.database.name,
|
||||||
ssh: {
|
ssh: {
|
||||||
host: enrollment.ssh.host,
|
host: enrollment.ssh.host,
|
||||||
@ -252,6 +267,7 @@ export async function main(args = process.argv.slice(2)) {
|
|||||||
const { enrollment, connection, backupPath } = result;
|
const { enrollment, connection, backupPath } = result;
|
||||||
console.log(`注册完成,设备编号:${enrollment.installation_id}`);
|
console.log(`注册完成,设备编号:${enrollment.installation_id}`);
|
||||||
console.log(`系统数据库:127.0.0.1:${connection.localDatabasePort}/${enrollment.database.name}(通过 SSH 隧道)`);
|
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(`SSH 证书有效期至:${enrollment.ssh.valid_until}`);
|
||||||
console.log('以下系统管理员凭据只显示一次,请立即保存:');
|
console.log('以下系统管理员凭据只显示一次,请立即保存:');
|
||||||
console.log(`管理员账号:${enrollment.application_admin.username}`);
|
console.log(`管理员账号:${enrollment.application_admin.username}`);
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { getKnownHostsLine, mergeEnv, validateEnrollmentResponse } from '../scripts/remote-access-common.mjs';
|
import { getKnownHostsLine, mergeEnv, validateEnrollmentResponse } from '../scripts/remote-access-common.mjs';
|
||||||
|
import { buildSshArgs } from '../scripts/start-remote.mjs';
|
||||||
|
|
||||||
const enrollment = {
|
const enrollment = {
|
||||||
installation_id: 'dev-123',
|
installation_id: 'dev-123',
|
||||||
@ -16,6 +17,15 @@ const enrollment = {
|
|||||||
username: 'client_123',
|
username: 'client_123',
|
||||||
password: 'a password with # symbols',
|
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: {
|
application_admin: {
|
||||||
username: 'dev_123',
|
username: 'dev_123',
|
||||||
password: 'one-time-password',
|
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', () => {
|
test('merges environment values without losing unrelated settings', () => {
|
||||||
const result = mergeEnv('KEEP=yes\nLOCAL_DB_HOST=old\n', {
|
const result = mergeEnv('KEEP=yes\nLOCAL_DB_HOST=old\n', {
|
||||||
LOCAL_DB_HOST: '127.0.0.1',
|
LOCAL_DB_HOST: '127.0.0.1',
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user