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 = () => {
sudo cangku-bootstrapctl revoke-installation ``` -`list` 不显示 Key 或数据库密码。Key 明文仅在创建时输出一次。停用设备会删除它的数据库账号;已签发的 SSH 证书在到期前仍可能建立受限隧道,但不能再登录系统数据库。 +`list` 不显示 Key、数据库密码或 Redis 密码。Key 明文仅在创建时输出一次。停用设备会删除它的数据库账号与 Redis ACL 用户并禁用应用管理员;已签发的 SSH 证书在到期前仍可能建立受限隧道,但不能再登录系统数据库。 diff --git a/remote-access/server/bootstrap_service.py b/remote-access/server/bootstrap_service.py index 139f4bc..c90b6e5 100644 --- a/remote-access/server/bootstrap_service.py +++ b/remote-access/server/bootstrap_service.py @@ -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)) diff --git a/remote-access/server/requirements.txt b/remote-access/server/requirements.txt index 582b2ef..7461e52 100644 --- a/remote-access/server/requirements.txt +++ b/remote-access/server/requirements.txt @@ -1,3 +1,4 @@ PyMySQL==1.1.2 bcrypt==5.0.0 cryptography==46.0.3 +redis==6.4.0 diff --git a/scripts/remote-access-common.mjs b/scripts/remote-access-common.mjs index 7686bec..d3627a1 100644 --- a/scripts/remote-access-common.mjs +++ b/scripts/remote-access-common.mjs @@ -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; } diff --git a/scripts/setup-remote.mjs b/scripts/setup-remote.mjs index 4771392..427fc99 100644 --- a/scripts/setup-remote.mjs +++ b/scripts/setup-remote.mjs @@ -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}`); diff --git a/test/remote-access.test.mjs b/test/remote-access.test.mjs index 851c39f..380cde0 100644 --- a/test/remote-access.test.mjs +++ b/test/remote-access.test.mjs @@ -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',