From 156fce531ce140497b9456132abc7396fa7cee08 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 12 Jun 2026 01:04:43 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(redis):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=20Redis=20Sentinel=20=E8=BF=9E=E6=8E=A5=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ConnectionModal.edit-password.test.tsx | 13 + frontend/src/components/ConnectionModal.tsx | 298 +++++++++++++++--- frontend/src/main.tsx | 5 + frontend/src/store.ts | 9 + frontend/src/types.ts | 6 +- frontend/wailsjs/go/models.ts | 10 + internal/app/app.go | 3 + internal/app/connection_package_appkey.go | 8 + .../app/connection_package_appkey_test.go | 35 +- .../app/connection_package_crypto_test.go | 17 +- internal/app/connection_package_transfer.go | 18 +- internal/app/connection_secret_resolution.go | 7 + .../app/connection_secret_resolution_test.go | 38 +++ internal/app/daily_secret_persistence.go | 52 +-- internal/app/methods_saved_connections.go | 1 + internal/app/saved_connections.go | 58 ++-- internal/app/saved_connections_test.go | 30 ++ internal/connection/saved_types.go | 64 ++-- internal/connection/types.go | 81 ++--- internal/dailysecret/store.go | 18 +- internal/redis/redis_impl.go | 75 ++++- internal/redis/redis_impl_test.go | 51 +++ 22 files changed, 697 insertions(+), 200 deletions(-) diff --git a/frontend/src/components/ConnectionModal.edit-password.test.tsx b/frontend/src/components/ConnectionModal.edit-password.test.tsx index 88fab3f..5bf1187 100644 --- a/frontend/src/components/ConnectionModal.edit-password.test.tsx +++ b/frontend/src/components/ConnectionModal.edit-password.test.tsx @@ -36,3 +36,16 @@ describe('ConnectionModal data source registry', () => { expect(source).toContain('label="显示数据库 (留空显示全部)"'); }); }); + +describe('ConnectionModal Redis Sentinel configuration', () => { + it('exposes Sentinel topology fields and safe defaults', () => { + expect(source).toContain('label: "哨兵模式"'); + expect(source).toContain('name="redisSentinelMaster"'); + expect(source).toContain('Sentinel master 名称'); + expect(source).toContain('name="redisSentinelPassword"'); + expect(source).toContain('hasRedisSentinelPassword'); + expect(source).toContain('clearKey: "redisSentinelPassword"'); + expect(source).toContain('form.setFieldValue("port", 26379)'); + expect(source).toContain('form.setFieldValue("port", 6379)'); + }); +}); diff --git a/frontend/src/components/ConnectionModal.tsx b/frontend/src/components/ConnectionModal.tsx index d3fce08..9b84791 100644 --- a/frontend/src/components/ConnectionModal.tsx +++ b/frontend/src/components/ConnectionModal.tsx @@ -176,6 +176,7 @@ type ConnectionSecretKey = | "httpTunnelPassword" | "mysqlReplicaPassword" | "mongoReplicaPassword" + | "redisSentinelPassword" | "opaqueURI" | "opaqueDSN"; @@ -189,6 +190,7 @@ const createEmptyConnectionSecretClearState = httpTunnelPassword: false, mysqlReplicaPassword: false, mongoReplicaPassword: false, + redisSentinelPassword: false, opaqueURI: false, opaqueDSN: false, }); @@ -215,6 +217,8 @@ const resolveInitialSecretFieldValue = ( return String(config.mysqlReplicaPassword || ""); case "mongoReplicaPassword": return String(config.mongoReplicaPassword || ""); + case "redisSentinelPassword": + return String(config.redisSentinelPassword || ""); case "uri": return String(config.uri || ""); case "dsn": @@ -915,6 +919,19 @@ const ConnectionModal: React.FC<{ setMongoMembers([]); } if (fieldName === "redisTopology") { + const nextRedisTopology = String(value || "single").toLowerCase(); + const currentRedisPort = Number(form.getFieldValue("port") || 0); + if ( + nextRedisTopology === "sentinel" && + (!currentRedisPort || currentRedisPort === 6379) + ) { + form.setFieldValue("port", 26379); + } else if ( + nextRedisTopology !== "sentinel" && + currentRedisPort === 26379 + ) { + form.setFieldValue("port", 6379); + } const supportedDbs = Array.from({ length: 16 }, (_, i) => i); setRedisDbList(supportedDbs); const selectedDbsRaw = form.getFieldValue("includeRedisDatabases"); @@ -1688,14 +1705,19 @@ const ConnectionModal: React.FC<{ if (parsed.hosts.some((entry) => !isValidUriHostEntry(entry))) { return null; } - const hostList = normalizeAddressList(parsed.hosts, 6379); - if (!hostList.length) { - return null; - } - const primary = parseHostPort(hostList[0] || "localhost:6379", 6379); const topologyParam = String( parsed.params.get("topology") || "", ).toLowerCase(); + const isSentinelTopology = topologyParam === "sentinel"; + const redisNodeDefaultPort = isSentinelTopology ? 26379 : 6379; + const hostList = normalizeAddressList(parsed.hosts, redisNodeDefaultPort); + if (!hostList.length) { + return null; + } + const primary = parseHostPort( + hostList[0] || `localhost:${redisNodeDefaultPort}`, + redisNodeDefaultPort, + ); const dbText = String(parsed.database || "") .trim() .replace(/^\//, ""); @@ -1711,7 +1733,7 @@ const ConnectionModal: React.FC<{ skipVerifyText === "on"; return { host: primary?.host || "localhost", - port: primary?.port || 6379, + port: primary?.port || redisNodeDefaultPort, user: parsed.username || "", password: parsed.password || "", useSSL: isRediss, @@ -1721,11 +1743,30 @@ const ConnectionModal: React.FC<{ : "required" : "disable", ...extractSSLPathValuesFromParams(parsed.params, type), - redisTopology: - hostList.length > 1 || topologyParam === "cluster" + redisTopology: isSentinelTopology + ? "sentinel" + : hostList.length > 1 || topologyParam === "cluster" ? "cluster" : "single", redisHosts: hostList.slice(1), + redisSentinelMaster: isSentinelTopology + ? String( + parsed.params.get("master") || + parsed.params.get("master_name") || + parsed.params.get("sentinel_master") || + "", + ).trim() + : "", + redisSentinelUser: isSentinelTopology + ? String( + parsed.params.get("sentinel_user") || + parsed.params.get("sentinel_username") || + "", + ).trim() + : "", + redisSentinelPassword: isSentinelTopology + ? String(parsed.params.get("sentinel_password") || "") + : "", redisDB: Number.isFinite(dbIndex) && dbIndex >= 0 && dbIndex <= 15 ? Math.trunc(dbIndex) @@ -2037,7 +2078,7 @@ const ConnectionModal: React.FC<{ return "clickhouse://default:pass@127.0.0.1:9000/default"; } if (dbType === "redis") { - return "redis://:pass@127.0.0.1:6379,127.0.0.2:6379/0?topology=cluster"; + return "redis://:pass@127.0.0.1:6379,127.0.0.2:6379/0?topology=cluster 或 redis://:pass@10.0.0.1:26379,10.0.0.2:26379/0?topology=sentinel&master=mymaster"; } if (dbType === "oracle") { return "oracle://user:pass@127.0.0.1:1521/ORCLPDB1"; @@ -2144,14 +2185,33 @@ const ConnectionModal: React.FC<{ } if (type === "redis") { - const primary = toAddress(host, port, 6379); - const clusterHosts = - values.redisTopology === "cluster" - ? normalizeAddressList(values.redisHosts, 6379) + const redisTopology = String(values.redisTopology || "single"); + const redisNodeDefaultPort = redisTopology === "sentinel" ? 26379 : 6379; + const primary = toAddress(host, port, redisNodeDefaultPort); + const extraRedisHosts = + redisTopology === "cluster" || redisTopology === "sentinel" + ? normalizeAddressList(values.redisHosts, redisNodeDefaultPort) : []; - const hosts = normalizeAddressList([primary, ...clusterHosts], 6379); + const hosts = normalizeAddressList( + [primary, ...extraRedisHosts], + redisNodeDefaultPort, + ); const params = new URLSearchParams(); - if (hosts.length > 1 || values.redisTopology === "cluster") { + if (redisTopology === "sentinel") { + params.set("topology", "sentinel"); + const sentinelMaster = String(values.redisSentinelMaster || "").trim(); + if (sentinelMaster) { + params.set("master", sentinelMaster); + } + const sentinelUser = String(values.redisSentinelUser || "").trim(); + if (sentinelUser) { + params.set("sentinel_user", sentinelUser); + } + const sentinelPassword = String(values.redisSentinelPassword || ""); + if (sentinelPassword) { + params.set("sentinel_password", sentinelPassword); + } + } else if (hosts.length > 1 || redisTopology === "cluster") { params.set("topology", "cluster"); } const redisUser = String(values.user || "").trim(); @@ -2540,9 +2600,11 @@ const ConnectionModal: React.FC<{ String(config.topology || "").toLowerCase() === "replica" || mongoHosts.length > 0 || !!config.replicaSet; + const redisTopologyValue = String(config.topology || "").toLowerCase(); + const redisIsSentinel = redisTopologyValue === "sentinel"; const redisIsCluster = - String(config.topology || "").toLowerCase() === "cluster" || - redisHosts.length > 0; + !redisIsSentinel && + (redisTopologyValue === "cluster" || redisHosts.length > 0); const { allowedModes: resolvedJvmAllowedModes, preferredMode: resolvedJvmPreferredMode, @@ -2610,8 +2672,15 @@ const ConnectionModal: React.FC<{ mysqlReplicaPassword: config.mysqlReplicaPassword || "", mongoTopology: mongoIsReplica ? "replica" : "single", mongoHosts: mongoHosts, - redisTopology: redisIsCluster ? "cluster" : "single", + redisTopology: redisIsSentinel + ? "sentinel" + : redisIsCluster + ? "cluster" + : "single", redisHosts: redisHosts, + redisSentinelMaster: config.redisSentinelMaster || "", + redisSentinelUser: config.redisSentinelUser || "", + redisSentinelPassword: config.redisSentinelPassword || "", mongoSrv: !!config.mongoSrv, mongoReplicaSet: config.replicaSet || "", mongoAuthSource: config.authSource || "", @@ -2808,6 +2877,16 @@ const ConnectionModal: React.FC<{ clearSecret: clearSecrets.mongoReplicaPassword, forceClear: !mongoReplicaEnabled, }); + const redisSentinelEnabled = + config.type === "redis" && + config.topology === "sentinel" && + values.savePassword !== false; + const redisSentinelDraft = resolveConnectionSecretDraft({ + hasSecret: initialValues?.hasRedisSentinelPassword, + valueInput: config.redisSentinelPassword, + clearSecret: clearSecrets.redisSentinelPassword, + forceClear: !redisSentinelEnabled, + }); const opaqueUriDraft = resolveConnectionSecretDraft({ hasSecret: initialValues?.hasOpaqueURI, valueInput: config.uri, @@ -2876,6 +2955,7 @@ const ConnectionModal: React.FC<{ dsn: opaqueDsnDraft.value, mysqlReplicaPassword: mysqlReplicaDraft.value, mongoReplicaPassword: mongoReplicaDraft.value, + redisSentinelPassword: redisSentinelDraft.value, }, includeDatabases: values.includeDatabases, includeRedisDatabases: isRedisType @@ -2889,6 +2969,7 @@ const ConnectionModal: React.FC<{ clearHttpTunnelPassword: httpTunnelDraft.clearStoredSecret, clearMySQLReplicaPassword: mysqlReplicaDraft.clearStoredSecret, clearMongoReplicaPassword: mongoReplicaDraft.clearStoredSecret, + clearRedisSentinelPassword: redisSentinelDraft.clearStoredSecret, clearOpaqueURI: opaqueUriDraft.clearStoredSecret, clearOpaqueDSN: opaqueDsnDraft.clearStoredSecret, }; @@ -3035,6 +3116,14 @@ const ConnectionModal: React.FC<{ ) { return "测试连接前请填写新的副本集密码,或取消清除已保存副本集密码"; } + if ( + clearSecrets.redisSentinelPassword && + values.type === "redis" && + values.redisTopology === "sentinel" && + String(values.redisSentinelPassword ?? "") === "" + ) { + return "测试连接前请填写新的 Sentinel 密码,或取消清除已保存 Sentinel 密码"; + } if ( values.type === "mongodb" && values.savePassword === false && @@ -3472,7 +3561,7 @@ const ConnectionModal: React.FC<{ } let hosts: string[] = []; - let topology: "single" | "replica" | "cluster" | undefined; + let topology: "single" | "replica" | "cluster" | "sentinel" | undefined; let replicaSet = ""; let authSource = ""; let readPreference = ""; @@ -3482,6 +3571,9 @@ const ConnectionModal: React.FC<{ let mongoAuthMechanism = ""; let mongoReplicaUser = ""; let mongoReplicaPassword = ""; + let redisSentinelMaster = ""; + let redisSentinelUser = ""; + let redisSentinelPassword = ""; const savePassword = type === "mongodb" ? mergedValues.savePassword !== false : true; @@ -3543,15 +3635,29 @@ const ConnectionModal: React.FC<{ } if (type === "redis") { - const clusterNodes = - mergedValues.redisTopology === "cluster" - ? normalizeAddressList(mergedValues.redisHosts, defaultPort) + const redisTopology = String(mergedValues.redisTopology || "single"); + const redisNodeDefaultPort = redisTopology === "sentinel" ? 26379 : defaultPort; + if ( + redisTopology === "sentinel" && + (!Number(mergedValues.port) || Number(mergedValues.port) === defaultPort) + ) { + primaryPort = redisNodeDefaultPort; + } + const extraRedisNodes = + redisTopology === "cluster" || redisTopology === "sentinel" + ? normalizeAddressList(mergedValues.redisHosts, redisNodeDefaultPort) : []; const allHosts = normalizeAddressList( - [`${primaryHost}:${primaryPort}`, ...clusterNodes], - defaultPort, + [`${primaryHost}:${primaryPort}`, ...extraRedisNodes], + redisNodeDefaultPort, ); - if (mergedValues.redisTopology === "cluster" || allHosts.length > 1) { + if (redisTopology === "sentinel") { + hosts = allHosts; + topology = "sentinel"; + redisSentinelMaster = String(mergedValues.redisSentinelMaster || "").trim(); + redisSentinelUser = String(mergedValues.redisSentinelUser || "").trim(); + redisSentinelPassword = String(mergedValues.redisSentinelPassword || ""); + } else if (redisTopology === "cluster" || allHosts.length > 1) { hosts = allHosts; topology = "cluster"; } else { @@ -3661,6 +3767,9 @@ const ConnectionModal: React.FC<{ redisDB: Number.isFinite(Number(mergedValues.redisDB)) ? Math.max(0, Math.min(15, Math.trunc(Number(mergedValues.redisDB)))) : 0, + redisSentinelMaster: redisSentinelMaster, + redisSentinelUser: redisSentinelUser, + redisSentinelPassword: keepPassword ? redisSentinelPassword : "", uri: String(mergedValues.uri || "").trim(), clickHouseProtocol: type === "clickhouse" @@ -3751,6 +3860,9 @@ const ConnectionModal: React.FC<{ savePassword: true, mysqlReplicaHosts: [], redisHosts: [], + redisSentinelMaster: "", + redisSentinelUser: "", + redisSentinelPassword: "", mongoHosts: [], mysqlReplicaUser: "", mysqlReplicaPassword: "", @@ -3810,6 +3922,9 @@ const ConnectionModal: React.FC<{ savePassword: true, mysqlReplicaHosts: [], redisHosts: [], + redisSentinelMaster: "", + redisSentinelUser: "", + redisSentinelPassword: "", mongoHosts: [], mysqlReplicaUser: "", mysqlReplicaPassword: "", @@ -3849,6 +3964,9 @@ const ConnectionModal: React.FC<{ savePassword: true, mysqlReplicaHosts: [], redisHosts: [], + redisSentinelMaster: "", + redisSentinelUser: "", + redisSentinelPassword: "", mongoHosts: [], mysqlReplicaUser: "", mysqlReplicaPassword: "", @@ -5539,21 +5657,59 @@ const ConnectionModal: React.FC<{ label: "集群模式", description: "Redis Cluster,配置多个种子节点。", }, + { + value: "sentinel", + label: "哨兵模式", + description: "通过 Sentinel 发现主节点,适合主从高可用。", + }, ], })} - {redisTopology === "cluster" && ( - - + + {redisTopology === "sentinel" && ( + + + + )} + )} ), @@ -5580,6 +5736,54 @@ const ConnectionModal: React.FC<{ })} /> + {redisTopology === "sentinel" && ( + <> +
+ + + + + + +
+ {renderStoredSecretControls({ + fieldName: "redisSentinelPassword", + clearKey: "redisSentinelPassword", + hasStoredSecret: + initialValues?.hasRedisSentinelPassword, + clearLabel: "清除已保存 Sentinel 密码", + description: + "当前已保存 Sentinel 密码。留空表示继续沿用,输入新值表示替换。", + })} + + )} ), })} @@ -6566,6 +6770,9 @@ const ConnectionModal: React.FC<{ savePassword: true, mysqlReplicaHosts: [], redisHosts: [], + redisSentinelMaster: "", + redisSentinelUser: "", + redisSentinelPassword: "", mongoHosts: [], mysqlReplicaUser: "", mysqlReplicaPassword: "", @@ -6685,6 +6892,21 @@ const ConnectionModal: React.FC<{ ); } if (changed.redisTopology !== undefined) { + const nextRedisTopology = String( + changed.redisTopology || "single", + ).toLowerCase(); + const currentRedisPort = Number(form.getFieldValue("port") || 0); + if ( + nextRedisTopology === "sentinel" && + (!currentRedisPort || currentRedisPort === 6379) + ) { + form.setFieldValue("port", 26379); + } else if ( + nextRedisTopology !== "sentinel" && + currentRedisPort === 26379 + ) { + form.setFieldValue("port", 6379); + } const supportedDbs = Array.from({ length: 16 }, (_, i) => i); setRedisDbList(supportedDbs); const selectedDbsRaw = form.getFieldValue("includeRedisDatabases"); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 2f04a9f..8a62451 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -106,6 +106,7 @@ if (typeof window !== 'undefined' && (!(window as any).go?.app?.App || !(window httpTunnelPassword: String(httpTunnel.password ?? existingSecrets.httpTunnelPassword ?? ''), mysqlReplicaPassword: String(config.mysqlReplicaPassword ?? existingSecrets.mysqlReplicaPassword ?? ''), mongoReplicaPassword: String(config.mongoReplicaPassword ?? existingSecrets.mongoReplicaPassword ?? ''), + redisSentinelPassword: String(config.redisSentinelPassword ?? existingSecrets.redisSentinelPassword ?? ''), uri: String(config.uri ?? existingSecrets.uri ?? ''), dsn: String(config.dsn ?? existingSecrets.dsn ?? ''), }; @@ -115,6 +116,7 @@ if (typeof window !== 'undefined' && (!(window as any).go?.app?.App || !(window if (input?.clearHttpTunnelPassword) nextSecrets.httpTunnelPassword = ''; if (input?.clearMySQLReplicaPassword) nextSecrets.mysqlReplicaPassword = ''; if (input?.clearMongoReplicaPassword) nextSecrets.mongoReplicaPassword = ''; + if (input?.clearRedisSentinelPassword) nextSecrets.redisSentinelPassword = ''; if (input?.clearOpaqueURI) nextSecrets.uri = ''; if (input?.clearOpaqueDSN) nextSecrets.dsn = ''; mockConnectionSecrets.set(nextId, nextSecrets); @@ -132,6 +134,7 @@ if (typeof window !== 'undefined' && (!(window as any).go?.app?.App || !(window dsn: '', mysqlReplicaPassword: '', mongoReplicaPassword: '', + redisSentinelPassword: '', }, includeDatabases: Array.isArray(input?.includeDatabases) ? [...input.includeDatabases] : existing?.includeDatabases, includeRedisDatabases: Array.isArray(input?.includeRedisDatabases) ? [...input.includeRedisDatabases] : existing?.includeRedisDatabases, @@ -143,6 +146,7 @@ if (typeof window !== 'undefined' && (!(window as any).go?.app?.App || !(window hasHttpTunnelPassword: resolveBrowserMockSecretFlag(httpTunnel.password, !!input?.clearHttpTunnelPassword, existing?.hasHttpTunnelPassword), hasMySQLReplicaPassword: resolveBrowserMockSecretFlag(config.mysqlReplicaPassword, !!input?.clearMySQLReplicaPassword, existing?.hasMySQLReplicaPassword), hasMongoReplicaPassword: resolveBrowserMockSecretFlag(config.mongoReplicaPassword, !!input?.clearMongoReplicaPassword, existing?.hasMongoReplicaPassword), + hasRedisSentinelPassword: resolveBrowserMockSecretFlag(config.redisSentinelPassword, !!input?.clearRedisSentinelPassword, existing?.hasRedisSentinelPassword), hasOpaqueURI: resolveBrowserMockSecretFlag(config.uri, !!input?.clearOpaqueURI, existing?.hasOpaqueURI), hasOpaqueDSN: resolveBrowserMockSecretFlag(config.dsn, !!input?.clearOpaqueDSN, existing?.hasOpaqueDSN), }; @@ -213,6 +217,7 @@ if (typeof window !== 'undefined' && (!(window as any).go?.app?.App || !(window httpTunnel: { ...(existing.config?.httpTunnel || {}), password: secrets.httpTunnelPassword || '' }, mysqlReplicaPassword: secrets.mysqlReplicaPassword || '', mongoReplicaPassword: secrets.mongoReplicaPassword || '', + redisSentinelPassword: secrets.redisSentinelPassword || '', uri: secrets.uri || '', dsn: secrets.dsn || '', }, diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 4645c45..1400ce4 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -756,6 +756,8 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => { ? "replica" : raw.topology === "cluster" ? "cluster" + : raw.topology === "sentinel" + ? "sentinel" : "single", mysqlReplicaUser: toTrimmedString(raw.mysqlReplicaUser), mysqlReplicaPassword: savePassword @@ -780,6 +782,11 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => { if (type === "redis") { safeConfig.redisDB = normalizeIntegerInRange(raw.redisDB, 0, 0, 15); + safeConfig.redisSentinelMaster = toTrimmedString(raw.redisSentinelMaster); + safeConfig.redisSentinelUser = toTrimmedString(raw.redisSentinelUser); + safeConfig.redisSentinelPassword = savePassword + ? toTrimmedString(raw.redisSentinelPassword) + : ""; } if (type === "clickhouse") { @@ -864,6 +871,7 @@ const sanitizeSavedConnection = ( hasHttpTunnelPassword: raw.hasHttpTunnelPassword === true, hasMySQLReplicaPassword: raw.hasMySQLReplicaPassword === true, hasMongoReplicaPassword: raw.hasMongoReplicaPassword === true, + hasRedisSentinelPassword: raw.hasRedisSentinelPassword === true, hasOpaqueURI: raw.hasOpaqueURI === true, hasOpaqueDSN: raw.hasOpaqueDSN === true, includeDatabases: @@ -1596,6 +1604,7 @@ const hasLegacyConnectionSecrets = ( toTrimmedString(httpTunnel.password) !== "" || toTrimmedString(config.mysqlReplicaPassword) !== "" || toTrimmedString(config.mongoReplicaPassword) !== "" || + toTrimmedString(config.redisSentinelPassword) !== "" || toTrimmedString(config.uri) !== "" || toTrimmedString(config.dsn) !== "" ); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 6e1ffce..8a7aa1b 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -302,7 +302,10 @@ export interface ConnectionConfig { clickHouseProtocol?: "auto" | "http" | "native"; // ClickHouse connection protocol override oceanBaseProtocol?: "mysql" | "oracle"; // OceanBase tenant compatibility protocol hosts?: string[]; // Multi-host addresses: host:port - topology?: "single" | "replica" | "cluster"; + topology?: "single" | "replica" | "cluster" | "sentinel"; + redisSentinelMaster?: string; + redisSentinelUser?: string; + redisSentinelPassword?: string; mysqlReplicaUser?: string; mysqlReplicaPassword?: string; replicaSet?: string; @@ -335,6 +338,7 @@ export interface SavedConnection { hasHttpTunnelPassword?: boolean; hasMySQLReplicaPassword?: boolean; hasMongoReplicaPassword?: boolean; + hasRedisSentinelPassword?: boolean; hasOpaqueURI?: boolean; hasOpaqueDSN?: boolean; includeDatabases?: string[]; diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 886fb3e..9c25a91 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -908,6 +908,9 @@ export namespace connection { connectionParams?: string; timeout?: number; redisDB?: number; + redisSentinelMaster?: string; + redisSentinelUser?: string; + redisSentinelPassword?: string; uri?: string; clickHouseProtocol?: string; oceanBaseProtocol?: string; @@ -954,6 +957,9 @@ export namespace connection { this.connectionParams = source["connectionParams"]; this.timeout = source["timeout"]; this.redisDB = source["redisDB"]; + this.redisSentinelMaster = source["redisSentinelMaster"]; + this.redisSentinelUser = source["redisSentinelUser"]; + this.redisSentinelPassword = source["redisSentinelPassword"]; this.uri = source["uri"]; this.clickHouseProtocol = source["clickHouseProtocol"]; this.oceanBaseProtocol = source["oceanBaseProtocol"]; @@ -1085,6 +1091,7 @@ export namespace connection { clearHttpTunnelPassword?: boolean; clearMySQLReplicaPassword?: boolean; clearMongoReplicaPassword?: boolean; + clearRedisSentinelPassword?: boolean; clearOpaqueURI?: boolean; clearOpaqueDSN?: boolean; @@ -1107,6 +1114,7 @@ export namespace connection { this.clearHttpTunnelPassword = source["clearHttpTunnelPassword"]; this.clearMySQLReplicaPassword = source["clearMySQLReplicaPassword"]; this.clearMongoReplicaPassword = source["clearMongoReplicaPassword"]; + this.clearRedisSentinelPassword = source["clearRedisSentinelPassword"]; this.clearOpaqueURI = source["clearOpaqueURI"]; this.clearOpaqueDSN = source["clearOpaqueDSN"]; } @@ -1144,6 +1152,7 @@ export namespace connection { hasHttpTunnelPassword?: boolean; hasMySQLReplicaPassword?: boolean; hasMongoReplicaPassword?: boolean; + hasRedisSentinelPassword?: boolean; hasOpaqueURI?: boolean; hasOpaqueDSN?: boolean; @@ -1167,6 +1176,7 @@ export namespace connection { this.hasHttpTunnelPassword = source["hasHttpTunnelPassword"]; this.hasMySQLReplicaPassword = source["hasMySQLReplicaPassword"]; this.hasMongoReplicaPassword = source["hasMongoReplicaPassword"]; + this.hasRedisSentinelPassword = source["hasRedisSentinelPassword"]; this.hasOpaqueURI = source["hasOpaqueURI"]; this.hasOpaqueDSN = source["hasOpaqueDSN"]; } diff --git a/internal/app/app.go b/internal/app/app.go index f351f71..e9e20e3 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -253,6 +253,9 @@ func normalizeCacheKeyConfig(config connection.ConnectionConfig) connection.Conn normalized.ConnectionParams = "" normalized.Hosts = nil normalized.Topology = "" + normalized.RedisSentinelMaster = "" + normalized.RedisSentinelUser = "" + normalized.RedisSentinelPassword = "" normalized.MySQLReplicaUser = "" normalized.MySQLReplicaPassword = "" normalized.ReplicaSet = "" diff --git a/internal/app/connection_package_appkey.go b/internal/app/connection_package_appkey.go index eb9e45f..0ba467d 100644 --- a/internal/app/connection_package_appkey.go +++ b/internal/app/connection_package_appkey.go @@ -143,6 +143,10 @@ func encryptSecretBundle(appKey []byte, bundle connectionSecretBundle, connectio if err != nil { return connectionSecretBundle{}, err } + encrypted.RedisSentinelPassword, err = encryptSecretField(appKey, bundle.RedisSentinelPassword, connectionID) + if err != nil { + return connectionSecretBundle{}, err + } encrypted.OpaqueURI, err = encryptSecretField(appKey, bundle.OpaqueURI, connectionID) if err != nil { return connectionSecretBundle{}, err @@ -183,6 +187,10 @@ func decryptSecretBundle(appKey []byte, bundle connectionSecretBundle, connectio if err != nil { return connectionSecretBundle{}, err } + decrypted.RedisSentinelPassword, err = decryptSecretField(appKey, bundle.RedisSentinelPassword, connectionID) + if err != nil { + return connectionSecretBundle{}, err + } decrypted.OpaqueURI, err = decryptSecretField(appKey, bundle.OpaqueURI, connectionID) if err != nil { return connectionSecretBundle{}, err diff --git a/internal/app/connection_package_appkey_test.go b/internal/app/connection_package_appkey_test.go index 619bb84..77f668d 100644 --- a/internal/app/connection_package_appkey_test.go +++ b/internal/app/connection_package_appkey_test.go @@ -89,14 +89,15 @@ func TestDecryptSecretFieldRejectsAADMismatch(t *testing.T) { func TestEncryptSecretBundleRoundTripAndAADBinding(t *testing.T) { appKey := []byte("0123456789abcdef0123456789abcdef") plain := connectionSecretBundle{ - Password: "primary-secret", - SSHPassword: "ssh-secret", - ProxyPassword: "proxy-secret", - HTTPTunnelPassword: "http-secret", - MySQLReplicaPassword: "mysql-secret", - MongoReplicaPassword: "mongo-secret", - OpaqueURI: "postgres://user:pass@db.local/app", - OpaqueDSN: "server=db.local;password=secret", + Password: "primary-secret", + SSHPassword: "ssh-secret", + ProxyPassword: "proxy-secret", + HTTPTunnelPassword: "http-secret", + MySQLReplicaPassword: "mysql-secret", + MongoReplicaPassword: "mongo-secret", + RedisSentinelPassword: "sentinel-secret", + OpaqueURI: "postgres://user:pass@db.local/app", + OpaqueDSN: "server=db.local;password=secret", } encrypted, err := encryptSecretBundle(appKey, plain, "conn-1") @@ -105,14 +106,15 @@ func TestEncryptSecretBundleRoundTripAndAADBinding(t *testing.T) { } for name, value := range map[string]string{ - "password": encrypted.Password, - "sshPassword": encrypted.SSHPassword, - "proxyPassword": encrypted.ProxyPassword, - "httpTunnelPassword": encrypted.HTTPTunnelPassword, - "mysqlReplicaPassword": encrypted.MySQLReplicaPassword, - "mongoReplicaPassword": encrypted.MongoReplicaPassword, - "opaqueURI": encrypted.OpaqueURI, - "opaqueDSN": encrypted.OpaqueDSN, + "password": encrypted.Password, + "sshPassword": encrypted.SSHPassword, + "proxyPassword": encrypted.ProxyPassword, + "httpTunnelPassword": encrypted.HTTPTunnelPassword, + "mysqlReplicaPassword": encrypted.MySQLReplicaPassword, + "mongoReplicaPassword": encrypted.MongoReplicaPassword, + "redisSentinelPassword": encrypted.RedisSentinelPassword, + "opaqueURI": encrypted.OpaqueURI, + "opaqueDSN": encrypted.OpaqueDSN, } { if value == "" { t.Fatalf("expected encrypted %s field to be populated", name) @@ -122,6 +124,7 @@ func TestEncryptSecretBundleRoundTripAndAADBinding(t *testing.T) { } if value == plain.Password || value == plain.SSHPassword || value == plain.ProxyPassword || value == plain.HTTPTunnelPassword || value == plain.MySQLReplicaPassword || value == plain.MongoReplicaPassword || + value == plain.RedisSentinelPassword || value == plain.OpaqueURI || value == plain.OpaqueDSN { t.Fatalf("expected encrypted %s field to differ from plaintext", name) } diff --git a/internal/app/connection_package_crypto_test.go b/internal/app/connection_package_crypto_test.go index 57d3748..3bfee32 100644 --- a/internal/app/connection_package_crypto_test.go +++ b/internal/app/connection_package_crypto_test.go @@ -150,14 +150,15 @@ func TestConnectionPackageV2ProtectedRoundTrip(t *testing.T) { Database: "app", }, Secrets: connectionSecretBundle{ - Password: "primary-secret", - SSHPassword: "ssh-secret", - ProxyPassword: "proxy-secret", - HTTPTunnelPassword: "http-secret", - MySQLReplicaPassword: "mysql-secret", - MongoReplicaPassword: "mongo-secret", - OpaqueURI: "mysql://root:primary-secret@tcp(db.local:3306)/app", - OpaqueDSN: "root:primary-secret@tcp(db.local:3306)/app", + Password: "primary-secret", + SSHPassword: "ssh-secret", + ProxyPassword: "proxy-secret", + HTTPTunnelPassword: "http-secret", + MySQLReplicaPassword: "mysql-secret", + MongoReplicaPassword: "mongo-secret", + RedisSentinelPassword: "sentinel-secret", + OpaqueURI: "mysql://root:primary-secret@tcp(db.local:3306)/app", + OpaqueDSN: "root:primary-secret@tcp(db.local:3306)/app", }, }, }, diff --git a/internal/app/connection_package_transfer.go b/internal/app/connection_package_transfer.go index 36e38e2..767116b 100644 --- a/internal/app/connection_package_transfer.go +++ b/internal/app/connection_package_transfer.go @@ -95,6 +95,7 @@ func newSavedConnectionInputFromPackageItem(item connectionPackageItem) connecti config.HTTPTunnel.Password = secrets.HTTPTunnelPassword config.MySQLReplicaPassword = secrets.MySQLReplicaPassword config.MongoReplicaPassword = secrets.MongoReplicaPassword + config.RedisSentinelPassword = secrets.RedisSentinelPassword config.URI = secrets.OpaqueURI config.DSN = secrets.OpaqueDSN @@ -107,14 +108,15 @@ func newSavedConnectionInputFromPackageItem(item connectionPackageItem) connecti IconType: item.IconType, IconColor: item.IconColor, // 连接恢复包以最新导入文件为准;载荷中缺失的密文字段需要显式清空旧值。 - ClearPrimaryPassword: strings.TrimSpace(secrets.Password) == "", - ClearSSHPassword: strings.TrimSpace(secrets.SSHPassword) == "", - ClearProxyPassword: strings.TrimSpace(secrets.ProxyPassword) == "", - ClearHTTPTunnelPassword: strings.TrimSpace(secrets.HTTPTunnelPassword) == "", - ClearMySQLReplicaPassword: strings.TrimSpace(secrets.MySQLReplicaPassword) == "", - ClearMongoReplicaPassword: strings.TrimSpace(secrets.MongoReplicaPassword) == "", - ClearOpaqueURI: strings.TrimSpace(secrets.OpaqueURI) == "", - ClearOpaqueDSN: strings.TrimSpace(secrets.OpaqueDSN) == "", + ClearPrimaryPassword: strings.TrimSpace(secrets.Password) == "", + ClearSSHPassword: strings.TrimSpace(secrets.SSHPassword) == "", + ClearProxyPassword: strings.TrimSpace(secrets.ProxyPassword) == "", + ClearHTTPTunnelPassword: strings.TrimSpace(secrets.HTTPTunnelPassword) == "", + ClearMySQLReplicaPassword: strings.TrimSpace(secrets.MySQLReplicaPassword) == "", + ClearMongoReplicaPassword: strings.TrimSpace(secrets.MongoReplicaPassword) == "", + ClearRedisSentinelPassword: strings.TrimSpace(secrets.RedisSentinelPassword) == "", + ClearOpaqueURI: strings.TrimSpace(secrets.OpaqueURI) == "", + ClearOpaqueDSN: strings.TrimSpace(secrets.OpaqueDSN) == "", } } diff --git a/internal/app/connection_secret_resolution.go b/internal/app/connection_secret_resolution.go index b923f11..e00f41f 100644 --- a/internal/app/connection_secret_resolution.go +++ b/internal/app/connection_secret_resolution.go @@ -59,6 +59,7 @@ func connectionConfigCarriesInlineSecrets(config connection.ConnectionConfig) bo strings.TrimSpace(config.HTTPTunnel.Password) != "" || strings.TrimSpace(config.MySQLReplicaPassword) != "" || strings.TrimSpace(config.MongoReplicaPassword) != "" || + strings.TrimSpace(config.RedisSentinelPassword) != "" || strings.TrimSpace(config.URI) != "" || strings.TrimSpace(config.DSN) != "" } @@ -83,6 +84,9 @@ func mergeInlineConnectionSecrets(base connection.ConnectionConfig, inline conne if strings.TrimSpace(inline.MongoReplicaPassword) != "" { merged.MongoReplicaPassword = inline.MongoReplicaPassword } + if strings.TrimSpace(inline.RedisSentinelPassword) != "" { + merged.RedisSentinelPassword = inline.RedisSentinelPassword + } if strings.TrimSpace(inline.URI) != "" { merged.URI = inline.URI } @@ -144,6 +148,9 @@ func mergeConnectionSecretBundleIntoConfig(config connection.ConnectionConfig, b if strings.TrimSpace(merged.MongoReplicaPassword) == "" { merged.MongoReplicaPassword = bundle.MongoReplicaPassword } + if strings.TrimSpace(merged.RedisSentinelPassword) == "" { + merged.RedisSentinelPassword = bundle.RedisSentinelPassword + } if strings.TrimSpace(merged.URI) == "" { merged.URI = bundle.OpaqueURI } diff --git a/internal/app/connection_secret_resolution_test.go b/internal/app/connection_secret_resolution_test.go index 0569722..d6ecaac 100644 --- a/internal/app/connection_secret_resolution_test.go +++ b/internal/app/connection_secret_resolution_test.go @@ -42,6 +42,44 @@ func TestResolveConnectionConfigByIDLoadsSecretsFromStore(t *testing.T) { } } +func TestResolveConnectionConfigByIDLoadsRedisSentinelPasswordFromStore(t *testing.T) { + store := newFakeAppSecretStore() + app := NewAppWithSecretStore(store) + app.configDir = t.TempDir() + + view, err := app.SaveConnection(connection.SavedConnectionInput{ + ID: "redis-sentinel", + Name: "Redis Sentinel", + Config: connection.ConnectionConfig{ + ID: "redis-sentinel", + Type: "redis", + Host: "sentinel.local", + Port: 26379, + Topology: "sentinel", + RedisSentinelMaster: "mymaster", + RedisSentinelUser: "sentinel-user", + RedisSentinelPassword: "sentinel-secret", + }, + }) + if err != nil { + t.Fatalf("SaveConnection returned error: %v", err) + } + if view.Config.RedisSentinelPassword != "" { + t.Fatal("saved metadata must not expose Redis Sentinel password") + } + if !view.HasRedisSentinelPassword { + t.Fatal("expected saved view to report Redis Sentinel password") + } + + resolved, err := app.resolveConnectionSecrets(view.Config) + if err != nil { + t.Fatalf("resolveConnectionSecrets returned error: %v", err) + } + if resolved.RedisSentinelPassword != "sentinel-secret" { + t.Fatalf("expected restored Redis Sentinel password, got %q", resolved.RedisSentinelPassword) + } +} + func TestResolveConnectionSecretsOnDarwinUsesInlineSavedSecrets(t *testing.T) { app := NewAppWithSecretStore(failOnUseSecretStore{}) app.configDir = t.TempDir() diff --git a/internal/app/daily_secret_persistence.go b/internal/app/daily_secret_persistence.go index ffcbb8a..dfb362e 100644 --- a/internal/app/daily_secret_persistence.go +++ b/internal/app/daily_secret_persistence.go @@ -13,40 +13,43 @@ var runtimeGOOS = func() string { func extractConnectionSecretBundle(config connection.ConnectionConfig) connectionSecretBundle { return connectionSecretBundle{ - Password: config.Password, - SSHPassword: config.SSH.Password, - ProxyPassword: config.Proxy.Password, - HTTPTunnelPassword: config.HTTPTunnel.Password, - MySQLReplicaPassword: config.MySQLReplicaPassword, - MongoReplicaPassword: config.MongoReplicaPassword, - OpaqueURI: config.URI, - OpaqueDSN: config.DSN, + Password: config.Password, + SSHPassword: config.SSH.Password, + ProxyPassword: config.Proxy.Password, + HTTPTunnelPassword: config.HTTPTunnel.Password, + MySQLReplicaPassword: config.MySQLReplicaPassword, + MongoReplicaPassword: config.MongoReplicaPassword, + RedisSentinelPassword: config.RedisSentinelPassword, + OpaqueURI: config.URI, + OpaqueDSN: config.DSN, } } func toDailyConnectionBundle(bundle connectionSecretBundle) dailysecret.ConnectionBundle { return dailysecret.ConnectionBundle{ - Password: bundle.Password, - SSHPassword: bundle.SSHPassword, - ProxyPassword: bundle.ProxyPassword, - HTTPTunnelPassword: bundle.HTTPTunnelPassword, - MySQLReplicaPassword: bundle.MySQLReplicaPassword, - MongoReplicaPassword: bundle.MongoReplicaPassword, - OpaqueURI: bundle.OpaqueURI, - OpaqueDSN: bundle.OpaqueDSN, + Password: bundle.Password, + SSHPassword: bundle.SSHPassword, + ProxyPassword: bundle.ProxyPassword, + HTTPTunnelPassword: bundle.HTTPTunnelPassword, + MySQLReplicaPassword: bundle.MySQLReplicaPassword, + MongoReplicaPassword: bundle.MongoReplicaPassword, + RedisSentinelPassword: bundle.RedisSentinelPassword, + OpaqueURI: bundle.OpaqueURI, + OpaqueDSN: bundle.OpaqueDSN, } } func fromDailyConnectionBundle(bundle dailysecret.ConnectionBundle) connectionSecretBundle { return connectionSecretBundle{ - Password: bundle.Password, - SSHPassword: bundle.SSHPassword, - ProxyPassword: bundle.ProxyPassword, - HTTPTunnelPassword: bundle.HTTPTunnelPassword, - MySQLReplicaPassword: bundle.MySQLReplicaPassword, - MongoReplicaPassword: bundle.MongoReplicaPassword, - OpaqueURI: bundle.OpaqueURI, - OpaqueDSN: bundle.OpaqueDSN, + Password: bundle.Password, + SSHPassword: bundle.SSHPassword, + ProxyPassword: bundle.ProxyPassword, + HTTPTunnelPassword: bundle.HTTPTunnelPassword, + MySQLReplicaPassword: bundle.MySQLReplicaPassword, + MongoReplicaPassword: bundle.MongoReplicaPassword, + RedisSentinelPassword: bundle.RedisSentinelPassword, + OpaqueURI: bundle.OpaqueURI, + OpaqueDSN: bundle.OpaqueDSN, } } @@ -58,6 +61,7 @@ func stripConnectionSecretFields(config connection.ConnectionConfig) connection. stripped.HTTPTunnel.Password = "" stripped.MySQLReplicaPassword = "" stripped.MongoReplicaPassword = "" + stripped.RedisSentinelPassword = "" stripped.URI = "" stripped.DSN = "" return stripped diff --git a/internal/app/methods_saved_connections.go b/internal/app/methods_saved_connections.go index 0e31ed4..2473de4 100644 --- a/internal/app/methods_saved_connections.go +++ b/internal/app/methods_saved_connections.go @@ -61,6 +61,7 @@ func (a *App) ImportLegacyConnections(items []connection.LegacySavedConnection) input.ClearHTTPTunnelPassword = strings.TrimSpace(item.Config.HTTPTunnel.Password) == "" input.ClearMySQLReplicaPassword = strings.TrimSpace(item.Config.MySQLReplicaPassword) == "" input.ClearMongoReplicaPassword = strings.TrimSpace(item.Config.MongoReplicaPassword) == "" + input.ClearRedisSentinelPassword = strings.TrimSpace(item.Config.RedisSentinelPassword) == "" input.ClearOpaqueURI = strings.TrimSpace(item.Config.URI) == "" input.ClearOpaqueDSN = strings.TrimSpace(item.Config.DSN) == "" inputs = append(inputs, input) diff --git a/internal/app/saved_connections.go b/internal/app/saved_connections.go index 7b89159..eaeaeb7 100644 --- a/internal/app/saved_connections.go +++ b/internal/app/saved_connections.go @@ -20,14 +20,15 @@ const ( ) type connectionSecretBundle struct { - Password string `json:"password,omitempty"` - SSHPassword string `json:"sshPassword,omitempty"` - ProxyPassword string `json:"proxyPassword,omitempty"` - HTTPTunnelPassword string `json:"httpTunnelPassword,omitempty"` - MySQLReplicaPassword string `json:"mysqlReplicaPassword,omitempty"` - MongoReplicaPassword string `json:"mongoReplicaPassword,omitempty"` - OpaqueURI string `json:"opaqueURI,omitempty"` - OpaqueDSN string `json:"opaqueDSN,omitempty"` + Password string `json:"password,omitempty"` + SSHPassword string `json:"sshPassword,omitempty"` + ProxyPassword string `json:"proxyPassword,omitempty"` + HTTPTunnelPassword string `json:"httpTunnelPassword,omitempty"` + MySQLReplicaPassword string `json:"mysqlReplicaPassword,omitempty"` + MongoReplicaPassword string `json:"mongoReplicaPassword,omitempty"` + RedisSentinelPassword string `json:"redisSentinelPassword,omitempty"` + OpaqueURI string `json:"opaqueURI,omitempty"` + OpaqueDSN string `json:"opaqueDSN,omitempty"` } type savedConnectionsFile struct { @@ -60,6 +61,7 @@ func (b connectionSecretBundle) hasAny() bool { strings.TrimSpace(b.HTTPTunnelPassword) != "" || strings.TrimSpace(b.MySQLReplicaPassword) != "" || strings.TrimSpace(b.MongoReplicaPassword) != "" || + strings.TrimSpace(b.RedisSentinelPassword) != "" || strings.TrimSpace(b.OpaqueURI) != "" || strings.TrimSpace(b.OpaqueDSN) != "" } @@ -84,6 +86,9 @@ func mergeConnectionSecretBundles(base, overlay connectionSecretBundle) connecti if strings.TrimSpace(overlay.MongoReplicaPassword) != "" { merged.MongoReplicaPassword = overlay.MongoReplicaPassword } + if strings.TrimSpace(overlay.RedisSentinelPassword) != "" { + merged.RedisSentinelPassword = overlay.RedisSentinelPassword + } if strings.TrimSpace(overlay.OpaqueURI) != "" { merged.OpaqueURI = overlay.OpaqueURI } @@ -113,6 +118,9 @@ func applyConnectionSecretClears(bundle connectionSecretBundle, input connection if input.ClearMongoReplicaPassword { cleared.MongoReplicaPassword = "" } + if input.ClearRedisSentinelPassword { + cleared.RedisSentinelPassword = "" + } if input.ClearOpaqueURI { cleared.OpaqueURI = "" } @@ -154,21 +162,22 @@ func splitConnectionSecrets(input connection.SavedConnectionInput) (connection.S meta = stripConnectionSecretFields(meta) view := connection.SavedConnectionView{ - ID: id, - Name: strings.TrimSpace(input.Name), - Config: meta, - IncludeDatabases: cloneStringSlice(input.IncludeDatabases), - IncludeRedisDatabases: cloneIntSlice(input.IncludeRedisDatabases), - IconType: strings.TrimSpace(input.IconType), - IconColor: strings.TrimSpace(input.IconColor), - HasPrimaryPassword: strings.TrimSpace(bundle.Password) != "", - HasSSHPassword: strings.TrimSpace(bundle.SSHPassword) != "", - HasProxyPassword: strings.TrimSpace(bundle.ProxyPassword) != "", - HasHTTPTunnelPassword: strings.TrimSpace(bundle.HTTPTunnelPassword) != "", - HasMySQLReplicaPassword: strings.TrimSpace(bundle.MySQLReplicaPassword) != "", - HasMongoReplicaPassword: strings.TrimSpace(bundle.MongoReplicaPassword) != "", - HasOpaqueURI: strings.TrimSpace(bundle.OpaqueURI) != "", - HasOpaqueDSN: strings.TrimSpace(bundle.OpaqueDSN) != "", + ID: id, + Name: strings.TrimSpace(input.Name), + Config: meta, + IncludeDatabases: cloneStringSlice(input.IncludeDatabases), + IncludeRedisDatabases: cloneIntSlice(input.IncludeRedisDatabases), + IconType: strings.TrimSpace(input.IconType), + IconColor: strings.TrimSpace(input.IconColor), + HasPrimaryPassword: strings.TrimSpace(bundle.Password) != "", + HasSSHPassword: strings.TrimSpace(bundle.SSHPassword) != "", + HasProxyPassword: strings.TrimSpace(bundle.ProxyPassword) != "", + HasHTTPTunnelPassword: strings.TrimSpace(bundle.HTTPTunnelPassword) != "", + HasMySQLReplicaPassword: strings.TrimSpace(bundle.MySQLReplicaPassword) != "", + HasMongoReplicaPassword: strings.TrimSpace(bundle.MongoReplicaPassword) != "", + HasRedisSentinelPassword: strings.TrimSpace(bundle.RedisSentinelPassword) != "", + HasOpaqueURI: strings.TrimSpace(bundle.OpaqueURI) != "", + HasOpaqueDSN: strings.TrimSpace(bundle.OpaqueDSN) != "", } return view, bundle } @@ -358,7 +367,7 @@ func (r *savedConnectionRepository) loadSecretBundleFromStore(view connection.Sa func savedConnectionViewHasSecrets(view connection.SavedConnectionView) bool { return view.HasPrimaryPassword || view.HasSSHPassword || view.HasProxyPassword || view.HasHTTPTunnelPassword || - view.HasMySQLReplicaPassword || view.HasMongoReplicaPassword || view.HasOpaqueURI || view.HasOpaqueDSN + view.HasMySQLReplicaPassword || view.HasMongoReplicaPassword || view.HasRedisSentinelPassword || view.HasOpaqueURI || view.HasOpaqueDSN } func applyConnectionBundleFlags(view *connection.SavedConnectionView, bundle connectionSecretBundle) { @@ -368,6 +377,7 @@ func applyConnectionBundleFlags(view *connection.SavedConnectionView, bundle con view.HasHTTPTunnelPassword = strings.TrimSpace(bundle.HTTPTunnelPassword) != "" view.HasMySQLReplicaPassword = strings.TrimSpace(bundle.MySQLReplicaPassword) != "" view.HasMongoReplicaPassword = strings.TrimSpace(bundle.MongoReplicaPassword) != "" + view.HasRedisSentinelPassword = strings.TrimSpace(bundle.RedisSentinelPassword) != "" view.HasOpaqueURI = strings.TrimSpace(bundle.OpaqueURI) != "" view.HasOpaqueDSN = strings.TrimSpace(bundle.OpaqueDSN) != "" } diff --git a/internal/app/saved_connections_test.go b/internal/app/saved_connections_test.go index 72fc153..35d812f 100644 --- a/internal/app/saved_connections_test.go +++ b/internal/app/saved_connections_test.go @@ -42,6 +42,36 @@ func TestSplitConnectionSecretsStripsPasswordsAndOpaqueDSN(t *testing.T) { } } +func TestSplitConnectionSecretsStripsRedisSentinelPassword(t *testing.T) { + withTestGOOS(t, "linux") + + input := connection.SavedConnectionInput{ + ID: "redis-sentinel", + Name: "Redis Sentinel", + Config: connection.ConnectionConfig{ + ID: "redis-sentinel", + Type: "redis", + Host: "sentinel.local", + Port: 26379, + Topology: "sentinel", + RedisSentinelMaster: "mymaster", + RedisSentinelUser: "sentinel-user", + RedisSentinelPassword: "sentinel-secret", + }, + } + + view, bundle := splitConnectionSecrets(input) + if view.Config.RedisSentinelPassword != "" { + t.Fatal("metadata must not keep Redis Sentinel password") + } + if bundle.RedisSentinelPassword != "sentinel-secret" { + t.Fatalf("bundle should keep Redis Sentinel password, got %q", bundle.RedisSentinelPassword) + } + if !view.HasRedisSentinelPassword { + t.Fatal("expected view to report Redis Sentinel password") + } +} + type fakeAppSecretStore struct { items map[string][]byte } diff --git a/internal/connection/saved_types.go b/internal/connection/saved_types.go index c99bf1a..2a5f875 100644 --- a/internal/connection/saved_types.go +++ b/internal/connection/saved_types.go @@ -1,40 +1,42 @@ package connection type SavedConnectionInput struct { - ID string `json:"id,omitempty"` - Name string `json:"name"` - Config ConnectionConfig `json:"config"` - IncludeDatabases []string `json:"includeDatabases,omitempty"` - IncludeRedisDatabases []int `json:"includeRedisDatabases,omitempty"` - IconType string `json:"iconType,omitempty"` - IconColor string `json:"iconColor,omitempty"` - ClearPrimaryPassword bool `json:"clearPrimaryPassword,omitempty"` - ClearSSHPassword bool `json:"clearSSHPassword,omitempty"` - ClearProxyPassword bool `json:"clearProxyPassword,omitempty"` - ClearHTTPTunnelPassword bool `json:"clearHttpTunnelPassword,omitempty"` - ClearMySQLReplicaPassword bool `json:"clearMySQLReplicaPassword,omitempty"` - ClearMongoReplicaPassword bool `json:"clearMongoReplicaPassword,omitempty"` - ClearOpaqueURI bool `json:"clearOpaqueURI,omitempty"` - ClearOpaqueDSN bool `json:"clearOpaqueDSN,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name"` + Config ConnectionConfig `json:"config"` + IncludeDatabases []string `json:"includeDatabases,omitempty"` + IncludeRedisDatabases []int `json:"includeRedisDatabases,omitempty"` + IconType string `json:"iconType,omitempty"` + IconColor string `json:"iconColor,omitempty"` + ClearPrimaryPassword bool `json:"clearPrimaryPassword,omitempty"` + ClearSSHPassword bool `json:"clearSSHPassword,omitempty"` + ClearProxyPassword bool `json:"clearProxyPassword,omitempty"` + ClearHTTPTunnelPassword bool `json:"clearHttpTunnelPassword,omitempty"` + ClearMySQLReplicaPassword bool `json:"clearMySQLReplicaPassword,omitempty"` + ClearMongoReplicaPassword bool `json:"clearMongoReplicaPassword,omitempty"` + ClearRedisSentinelPassword bool `json:"clearRedisSentinelPassword,omitempty"` + ClearOpaqueURI bool `json:"clearOpaqueURI,omitempty"` + ClearOpaqueDSN bool `json:"clearOpaqueDSN,omitempty"` } type SavedConnectionView struct { - ID string `json:"id"` - Name string `json:"name"` - Config ConnectionConfig `json:"config"` - IncludeDatabases []string `json:"includeDatabases,omitempty"` - IncludeRedisDatabases []int `json:"includeRedisDatabases,omitempty"` - IconType string `json:"iconType,omitempty"` - IconColor string `json:"iconColor,omitempty"` - SecretRef string `json:"secretRef,omitempty"` - HasPrimaryPassword bool `json:"hasPrimaryPassword,omitempty"` - HasSSHPassword bool `json:"hasSSHPassword,omitempty"` - HasProxyPassword bool `json:"hasProxyPassword,omitempty"` - HasHTTPTunnelPassword bool `json:"hasHttpTunnelPassword,omitempty"` - HasMySQLReplicaPassword bool `json:"hasMySQLReplicaPassword,omitempty"` - HasMongoReplicaPassword bool `json:"hasMongoReplicaPassword,omitempty"` - HasOpaqueURI bool `json:"hasOpaqueURI,omitempty"` - HasOpaqueDSN bool `json:"hasOpaqueDSN,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Config ConnectionConfig `json:"config"` + IncludeDatabases []string `json:"includeDatabases,omitempty"` + IncludeRedisDatabases []int `json:"includeRedisDatabases,omitempty"` + IconType string `json:"iconType,omitempty"` + IconColor string `json:"iconColor,omitempty"` + SecretRef string `json:"secretRef,omitempty"` + HasPrimaryPassword bool `json:"hasPrimaryPassword,omitempty"` + HasSSHPassword bool `json:"hasSSHPassword,omitempty"` + HasProxyPassword bool `json:"hasProxyPassword,omitempty"` + HasHTTPTunnelPassword bool `json:"hasHttpTunnelPassword,omitempty"` + HasMySQLReplicaPassword bool `json:"hasMySQLReplicaPassword,omitempty"` + HasMongoReplicaPassword bool `json:"hasMongoReplicaPassword,omitempty"` + HasRedisSentinelPassword bool `json:"hasRedisSentinelPassword,omitempty"` + HasOpaqueURI bool `json:"hasOpaqueURI,omitempty"` + HasOpaqueDSN bool `json:"hasOpaqueDSN,omitempty"` } type LegacySavedConnection = SavedConnectionInput diff --git a/internal/connection/types.go b/internal/connection/types.go index 31243fb..5968f0b 100644 --- a/internal/connection/types.go +++ b/internal/connection/types.go @@ -79,45 +79,48 @@ type JVMConfig struct { // ConnectionConfig 存储数据库连接的完整配置,包括 SSH、代理、SSL 等网络层设置。 type ConnectionConfig struct { - ID string `json:"id,omitempty"` - Type string `json:"type"` - Host string `json:"host"` - Port int `json:"port"` - User string `json:"user"` - Password string `json:"password"` - SavePassword bool `json:"savePassword,omitempty"` // Persist password in saved connection - Database string `json:"database"` - UseSSL bool `json:"useSSL,omitempty"` // MySQL-like SSL/TLS switch - SSLMode string `json:"sslMode,omitempty"` // preferred | required | skip-verify | disable - SSLCAPath string `json:"sslCAPath,omitempty"` // TLS root CA / server certificate path - SSLCertPath string `json:"sslCertPath,omitempty"` // TLS client certificate path (e.g., Dameng) - SSLKeyPath string `json:"sslKeyPath,omitempty"` // TLS client private key path (e.g., Dameng) - UseSSH bool `json:"useSSH"` - SSH SSHConfig `json:"ssh"` - UseProxy bool `json:"useProxy,omitempty"` - Proxy ProxyConfig `json:"proxy,omitempty"` - UseHTTPTunnel bool `json:"useHttpTunnel,omitempty"` - HTTPTunnel HTTPTunnelConfig `json:"httpTunnel,omitempty"` - Driver string `json:"driver,omitempty"` // For custom connection - DSN string `json:"dsn,omitempty"` // For custom connection - ConnectionParams string `json:"connectionParams,omitempty"` // Extra URI query parameters for built-in drivers - Timeout int `json:"timeout,omitempty"` // Connection timeout in seconds (default: 30) - RedisDB int `json:"redisDB,omitempty"` // Redis database index (0-15) - URI string `json:"uri,omitempty"` // Connection URI for copy/paste - ClickHouseProtocol string `json:"clickHouseProtocol,omitempty"` // auto | http | native - OceanBaseProtocol string `json:"oceanBaseProtocol,omitempty"` // OceanBase tenant compatibility protocol: mysql | oracle - Hosts []string `json:"hosts,omitempty"` // Multi-host addresses: host:port - Topology string `json:"topology,omitempty"` // single | replica | cluster - MySQLReplicaUser string `json:"mysqlReplicaUser,omitempty"` // MySQL replica auth user - MySQLReplicaPassword string `json:"mysqlReplicaPassword,omitempty"` // MySQL replica auth password - ReplicaSet string `json:"replicaSet,omitempty"` // MongoDB replica set name - AuthSource string `json:"authSource,omitempty"` // MongoDB authSource - ReadPreference string `json:"readPreference,omitempty"` // MongoDB readPreference - MongoSRV bool `json:"mongoSrv,omitempty"` // MongoDB use mongodb+srv URI scheme - MongoAuthMechanism string `json:"mongoAuthMechanism,omitempty"` // MongoDB authMechanism - MongoReplicaUser string `json:"mongoReplicaUser,omitempty"` // MongoDB replica auth user - MongoReplicaPassword string `json:"mongoReplicaPassword,omitempty"` // MongoDB replica auth password - JVM JVMConfig `json:"jvm,omitempty"` // JVM connector config + ID string `json:"id,omitempty"` + Type string `json:"type"` + Host string `json:"host"` + Port int `json:"port"` + User string `json:"user"` + Password string `json:"password"` + SavePassword bool `json:"savePassword,omitempty"` // Persist password in saved connection + Database string `json:"database"` + UseSSL bool `json:"useSSL,omitempty"` // MySQL-like SSL/TLS switch + SSLMode string `json:"sslMode,omitempty"` // preferred | required | skip-verify | disable + SSLCAPath string `json:"sslCAPath,omitempty"` // TLS root CA / server certificate path + SSLCertPath string `json:"sslCertPath,omitempty"` // TLS client certificate path (e.g., Dameng) + SSLKeyPath string `json:"sslKeyPath,omitempty"` // TLS client private key path (e.g., Dameng) + UseSSH bool `json:"useSSH"` + SSH SSHConfig `json:"ssh"` + UseProxy bool `json:"useProxy,omitempty"` + Proxy ProxyConfig `json:"proxy,omitempty"` + UseHTTPTunnel bool `json:"useHttpTunnel,omitempty"` + HTTPTunnel HTTPTunnelConfig `json:"httpTunnel,omitempty"` + Driver string `json:"driver,omitempty"` // For custom connection + DSN string `json:"dsn,omitempty"` // For custom connection + ConnectionParams string `json:"connectionParams,omitempty"` // Extra URI query parameters for built-in drivers + Timeout int `json:"timeout,omitempty"` // Connection timeout in seconds (default: 30) + RedisDB int `json:"redisDB,omitempty"` // Redis database index (0-15) + RedisSentinelMaster string `json:"redisSentinelMaster,omitempty"` // Redis Sentinel master name + RedisSentinelUser string `json:"redisSentinelUser,omitempty"` // Redis Sentinel auth user + RedisSentinelPassword string `json:"redisSentinelPassword,omitempty"` // Redis Sentinel auth password + URI string `json:"uri,omitempty"` // Connection URI for copy/paste + ClickHouseProtocol string `json:"clickHouseProtocol,omitempty"` // auto | http | native + OceanBaseProtocol string `json:"oceanBaseProtocol,omitempty"` // OceanBase tenant compatibility protocol: mysql | oracle + Hosts []string `json:"hosts,omitempty"` // Multi-host addresses: host:port + Topology string `json:"topology,omitempty"` // single | replica | cluster | sentinel + MySQLReplicaUser string `json:"mysqlReplicaUser,omitempty"` // MySQL replica auth user + MySQLReplicaPassword string `json:"mysqlReplicaPassword,omitempty"` // MySQL replica auth password + ReplicaSet string `json:"replicaSet,omitempty"` // MongoDB replica set name + AuthSource string `json:"authSource,omitempty"` // MongoDB authSource + ReadPreference string `json:"readPreference,omitempty"` // MongoDB readPreference + MongoSRV bool `json:"mongoSrv,omitempty"` // MongoDB use mongodb+srv URI scheme + MongoAuthMechanism string `json:"mongoAuthMechanism,omitempty"` // MongoDB authMechanism + MongoReplicaUser string `json:"mongoReplicaUser,omitempty"` // MongoDB replica auth user + MongoReplicaPassword string `json:"mongoReplicaPassword,omitempty"` // MongoDB replica auth password + JVM JVMConfig `json:"jvm,omitempty"` // JVM connector config } // ResultSetData 表示一个查询结果集(行 + 列名),用于多结果集场景。 diff --git a/internal/dailysecret/store.go b/internal/dailysecret/store.go index efb4037..8a8c47a 100644 --- a/internal/dailysecret/store.go +++ b/internal/dailysecret/store.go @@ -13,14 +13,15 @@ const ( ) type ConnectionBundle struct { - Password string `json:"password,omitempty"` - SSHPassword string `json:"sshPassword,omitempty"` - ProxyPassword string `json:"proxyPassword,omitempty"` - HTTPTunnelPassword string `json:"httpTunnelPassword,omitempty"` - MySQLReplicaPassword string `json:"mysqlReplicaPassword,omitempty"` - MongoReplicaPassword string `json:"mongoReplicaPassword,omitempty"` - OpaqueURI string `json:"opaqueURI,omitempty"` - OpaqueDSN string `json:"opaqueDSN,omitempty"` + Password string `json:"password,omitempty"` + SSHPassword string `json:"sshPassword,omitempty"` + ProxyPassword string `json:"proxyPassword,omitempty"` + HTTPTunnelPassword string `json:"httpTunnelPassword,omitempty"` + MySQLReplicaPassword string `json:"mysqlReplicaPassword,omitempty"` + MongoReplicaPassword string `json:"mongoReplicaPassword,omitempty"` + RedisSentinelPassword string `json:"redisSentinelPassword,omitempty"` + OpaqueURI string `json:"opaqueURI,omitempty"` + OpaqueDSN string `json:"opaqueDSN,omitempty"` } func (b ConnectionBundle) HasAny() bool { @@ -30,6 +31,7 @@ func (b ConnectionBundle) HasAny() bool { strings.TrimSpace(b.HTTPTunnelPassword) != "" || strings.TrimSpace(b.MySQLReplicaPassword) != "" || strings.TrimSpace(b.MongoReplicaPassword) != "" || + strings.TrimSpace(b.RedisSentinelPassword) != "" || strings.TrimSpace(b.OpaqueURI) != "" || strings.TrimSpace(b.OpaqueDSN) != "" } diff --git a/internal/redis/redis_impl.go b/internal/redis/redis_impl.go index d48e6c4..b34d065 100644 --- a/internal/redis/redis_impl.go +++ b/internal/redis/redis_impl.go @@ -122,6 +122,17 @@ func buildRedisSeedAddrs(config connection.ConnectionConfig) ([]string, error) { return addrs, nil } +func redisTopologyDisplayName(topology string) string { + switch strings.ToLower(strings.TrimSpace(topology)) { + case "sentinel": + return "Sentinel" + case "cluster": + return "集群" + default: + return "多节点" + } +} + func (r *RedisClientImpl) redisNamespacePrefixForDB(index int) string { if !r.isCluster || index <= 0 { return "" @@ -246,6 +257,7 @@ func sanitizeRedisPassword(password string) string { // Connect establishes a connection to Redis func (r *RedisClientImpl) Connect(config connection.ConnectionConfig) error { config.Password = sanitizeRedisPassword(config.Password) + config.RedisSentinelPassword = sanitizeRedisPassword(config.RedisSentinelPassword) r.config = config if r.config.RedisDB < 0 || r.config.RedisDB > 15 { r.config.RedisDB = 0 @@ -264,13 +276,70 @@ func (r *RedisClientImpl) Connect(config connection.ConnectionConfig) error { r.seedAddrs = append([]string(nil), seedAddrs...) topology := strings.ToLower(strings.TrimSpace(config.Topology)) - r.isCluster = topology == "cluster" || len(seedAddrs) > 1 + isSentinel := topology == "sentinel" + r.isCluster = !isSentinel && (topology == "cluster" || len(seedAddrs) > 1) - if r.isCluster && config.UseSSH { - return fmt.Errorf("Redis 集群模式暂不支持 SSH 隧道,请关闭 SSH 后重试") + if (r.isCluster || isSentinel) && config.UseSSH { + return fmt.Errorf("Redis %s模式暂不支持 SSH 隧道,请关闭 SSH 后重试", redisTopologyDisplayName(topology)) } timeout := normalizeRedisTimeout(config.Timeout) + if isSentinel { + masterName := strings.TrimSpace(config.RedisSentinelMaster) + if masterName == "" { + return fmt.Errorf("Redis Sentinel 模式需要填写 master 名称") + } + attempts := []connection.ConnectionConfig{config} + if shouldTryRedisSSLPreferredFallback(config) { + attempts = append(attempts, withRedisSSLDisabled(config)) + } + + var failures []string + for idx, attempt := range attempts { + var tlsConfig *tls.Config + if cfg, err := resolveRedisTLSConfig(attempt); err != nil { + failures = append(failures, fmt.Sprintf("第%d次 TLS 配置失败: %v", idx+1, err)) + continue + } else if cfg != nil { + if host, _, err := net.SplitHostPort(seedAddrs[0]); err == nil && host != "" { + cfg.ServerName = host + } + tlsConfig = cfg + } + opts := &redis.FailoverOptions{ + MasterName: masterName, + SentinelAddrs: seedAddrs, + Username: strings.TrimSpace(attempt.User), + Password: attempt.Password, + SentinelUsername: strings.TrimSpace(attempt.RedisSentinelUser), + SentinelPassword: attempt.RedisSentinelPassword, + DB: r.currentDB, + DialTimeout: timeout, + ReadTimeout: timeout, + WriteTimeout: timeout, + TLSConfig: tlsConfig, + } + sentinelClient := redis.NewFailoverClient(opts) + ctx, cancel := context.WithTimeout(context.Background(), timeout) + pingErr := sentinelClient.Ping(ctx).Err() + cancel() + if pingErr != nil { + sentinelClient.Close() + failures = append(failures, fmt.Sprintf("第%d次连接失败: %v", idx+1, pingErr)) + continue + } + r.client = sentinelClient + r.singleClient = sentinelClient + r.config = attempt + if idx > 0 { + logger.Warnf("Redis Sentinel SSL 优先连接失败,已回退至明文连接") + } + logger.Infof("Redis Sentinel 连接成功: sentinels=%s master=%s DB=%d", strings.Join(seedAddrs, ","), masterName, r.currentDB) + return nil + } + return fmt.Errorf("Redis Sentinel 连接失败: %s", strings.Join(failures, ";")) + } + if r.isCluster { attempts := []connection.ConnectionConfig{config} if shouldTryRedisSSLPreferredFallback(config) { diff --git a/internal/redis/redis_impl_test.go b/internal/redis/redis_impl_test.go index 2821be3..ecdcf58 100644 --- a/internal/redis/redis_impl_test.go +++ b/internal/redis/redis_impl_test.go @@ -1,11 +1,13 @@ package redis import ( + "GoNavi-Wails/internal/connection" "encoding/json" "errors" "math" "math/big" "sort" + "strings" "testing" ) @@ -224,6 +226,55 @@ func TestSanitizeRedisPassword(t *testing.T) { } } +func TestRedisSentinelRequiresMasterNameBeforeDial(t *testing.T) { + client := NewRedisClient() + err := client.Connect(connection.ConnectionConfig{ + Type: "redis", + Host: "127.0.0.1", + Port: 26379, + Topology: "sentinel", + }) + if err == nil || !strings.Contains(err.Error(), "master 名称") { + t.Fatalf("expected missing Sentinel master validation error, got %v", err) + } +} + +func TestRedisSentinelWithMultipleAddrsDoesNotUseClusterBranch(t *testing.T) { + client := NewRedisClient() + err := client.Connect(connection.ConnectionConfig{ + Type: "redis", + Host: "127.0.0.1", + Port: 26379, + Hosts: []string{"127.0.0.2:26379"}, + Topology: "sentinel", + RedisSentinelMaster: "mymaster", + UseSSH: true, + }) + if err == nil { + t.Fatal("expected Sentinel SSH validation error") + } + if !strings.Contains(err.Error(), "Sentinel模式暂不支持 SSH 隧道") { + t.Fatalf("expected Sentinel-specific SSH error, got %v", err) + } +} + +func TestRedisClusterKeepsSSHValidation(t *testing.T) { + client := NewRedisClient() + err := client.Connect(connection.ConnectionConfig{ + Type: "redis", + Host: "127.0.0.1", + Port: 6379, + Topology: "cluster", + UseSSH: true, + }) + if err == nil { + t.Fatal("expected cluster SSH validation error") + } + if !strings.Contains(err.Error(), "集群模式暂不支持 SSH 隧道") { + t.Fatalf("expected cluster SSH error, got %v", err) + } +} + func TestIsRedisKeyGone(t *testing.T) { tests := []struct { name string