feat(redis): 支持 Redis Sentinel 连接模式

This commit is contained in:
Syngnat
2026-06-12 01:04:43 +08:00
parent 480edbe501
commit 156fce531c
22 changed files with 697 additions and 200 deletions

View File

@@ -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)');
});
});

View File

@@ -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" && (
<Form.Item
name="redisHosts"
label="集群附加节点地址"
help="主节点使用上方主机地址这里填写其他种子节点格式host:port"
style={{ marginTop: 16, marginBottom: 0 }}
>
<Select
mode="tags"
placeholder="例如10.10.0.12:6379、10.10.0.13:6379"
tokenSeparators={[",", ";", " "]}
/>
</Form.Item>
{(redisTopology === "cluster" ||
redisTopology === "sentinel") && (
<>
<Form.Item
name="redisHosts"
label={
redisTopology === "sentinel"
? "Sentinel 附加节点地址"
: "集群附加节点地址"
}
help={
redisTopology === "sentinel"
? "上方主机地址作为第一个 Sentinel这里填写其他 Sentinel 节点格式host:port"
: "主节点使用上方主机地址这里填写其他种子节点格式host:port"
}
style={{ marginTop: 16, marginBottom: 0 }}
>
<Select
mode="tags"
placeholder={
redisTopology === "sentinel"
? "例如10.10.0.12:26379、10.10.0.13:26379"
: "例如10.10.0.12:6379、10.10.0.13:6379"
}
tokenSeparators={[",", ";", " "]}
/>
</Form.Item>
{redisTopology === "sentinel" && (
<Form.Item
name="redisSentinelMaster"
label="Sentinel master 名称"
help="填写 Sentinel 配置中的 monitor 名称,例如 mymaster。"
rules={[
createUriAwareRequiredRule(
"请输入 Sentinel master 名称",
),
]}
style={{ marginTop: 16, marginBottom: 0 }}
>
<Input
{...noAutoCapInputProps}
placeholder="例如mymaster"
/>
</Form.Item>
)}
</>
)}
</>
),
@@ -5580,6 +5736,54 @@ const ConnectionModal: React.FC<{
})}
/>
</Form.Item>
{redisTopology === "sentinel" && (
<>
<div
style={{
display: "grid",
gridTemplateColumns:
"repeat(2, minmax(0, 1fr))",
gap: 16,
}}
>
<Form.Item
name="redisSentinelUser"
label="Sentinel 用户名(可选)"
style={{ marginBottom: 0 }}
>
<Input
{...noAutoCapInputProps}
placeholder="留空表示 Sentinel 不使用 ACL 用户名"
/>
</Form.Item>
<Form.Item
name="redisSentinelPassword"
label="Sentinel 密码(可选)"
style={{ marginBottom: 0 }}
>
<Input.Password
{...noAutoCapInputProps}
placeholder={getStoredSecretPlaceholder({
hasStoredSecret:
initialValues?.hasRedisSentinelPassword,
emptyPlaceholder:
"Sentinel 自身认证密码,留空则不发送",
retainedLabel: "已保存 Sentinel 密码",
})}
/>
</Form.Item>
</div>
{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");

View File

@@ -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 || '',
},

View File

@@ -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) !== ""
);

View File

@@ -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[];

View File

@@ -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"];
}

View File

@@ -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 = ""

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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",
},
},
},

View File

@@ -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) == "",
}
}

View File

@@ -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
}

View File

@@ -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()

View File

@@ -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

View File

@@ -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)

View File

@@ -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) != ""
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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 表示一个查询结果集(行 + 列名),用于多结果集场景。

View File

@@ -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) != ""
}

View File

@@ -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) {

View File

@@ -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