mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-16 11:39:38 +08:00
✨ feat(redis): 支持 Redis Sentinel 连接模式
This commit is contained in:
@@ -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)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 || '',
|
||||
},
|
||||
|
||||
@@ -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) !== ""
|
||||
);
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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"];
|
||||
}
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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) == "",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) != ""
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 表示一个查询结果集(行 + 列名),用于多结果集场景。
|
||||
|
||||
@@ -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) != ""
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user