diff --git a/backend/.env.example b/backend/.env.example
index 765fb16..231f0eb 100644
--- a/backend/.env.example
+++ b/backend/.env.example
@@ -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
diff --git a/backend/src/config/redis.js b/backend/src/config/redis.js
index a0a5bc1..9ec90fc 100644
--- a/backend/src/config/redis.js
+++ b/backend/src/config/redis.js
@@ -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 || '',
});
}
diff --git a/backend/src/services/redisConfig.js b/backend/src/services/redisConfig.js
index 5113859..21c0b56 100644
--- a/backend/src/services/redisConfig.js
+++ b/backend/src/services/redisConfig.js
@@ -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,
};
}
diff --git a/backend/src/services/remoteEnrollmentService.js b/backend/src/services/remoteEnrollmentService.js
index d9aed7c..6826a86 100644
--- a/backend/src/services/remoteEnrollmentService.js
+++ b/backend/src/services/remoteEnrollmentService.js
@@ -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,
};
diff --git a/deploy/production/README.md b/deploy/production/README.md
index 3f46d5d..5d79b3e 100644
--- a/deploy/production/README.md
+++ b/deploy/production/README.md
@@ -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 网关。
diff --git a/deploy/production/docker-compose.yml b/deploy/production/docker-compose.yml
index 2377f18..f69f4e3 100644
--- a/deploy/production/docker-compose.yml
+++ b/deploy/production/docker-compose.yml
@@ -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
diff --git a/deploy/production/haproxy-redis.cfg b/deploy/production/haproxy-redis.cfg
new file mode 100644
index 0000000..a69f286
--- /dev/null
+++ b/deploy/production/haproxy-redis.cfg
@@ -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
diff --git a/frontend/src/pages/Install/index.jsx b/frontend/src/pages/Install/index.jsx
index 30e6fce..21a3320 100644
--- a/frontend/src/pages/Install/index.jsx
+++ b/frontend/src/pages/Install/index.jsx
@@ -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 = () => {