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 === "cluster" ||
+ redisTopology === "sentinel") && (
+ <>
+
+
+
+ {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