fix: 数据源编辑密码保留 + Redis ACL 认证支持
后端: - UpdateInstance: 空密码和'******'都不更新密码字段,防止编辑后丢失密码 - testRedis/getRedisClient/getRedisMetrics: 增加 Username 参数支持 Redis 6.0+ ACL 认证 - username 为 'default' 或空时走传统密码认证模式 前端: - EditInstanceDialog: Redis 用户名字段提示'6.0+ ACL 可选',密码 placeholder 改为'不修改请留空' - CreateInstanceForm: Redis 类型也显示用户名输入框(可选),创建/测试连接时 Redis username 为空则不传 - Redis 提示文案从'无需用户名'改为'6.0+ ACL 支持用户名+密码认证'
This commit is contained in:
1 parent
bfb64d4dce
commit
8af868b3ca
5 files changed
+59
-34
No files matched your search
@@ -42,7 +42,7 @@ func testDBConnection(instance *model.Instance) (bool, string) {
|
|||||||
}
|
}
|
||||||
return testPostgreSQL(ctx, instance.Host, instance.Port, instance.Username, password, dbname)
|
return testPostgreSQL(ctx, instance.Host, instance.Port, instance.Username, password, dbname)
|
||||||
case "redis":
|
case "redis":
|
||||||
ok, msg, _ := testRedis(ctx, instance.Host, instance.Port, password)
|
ok, msg, _ := testRedis(ctx, instance.Host, instance.Port, instance.Username, password)
|
||||||
return ok, msg
|
return ok, msg
|
||||||
default:
|
default:
|
||||||
return false, fmt.Sprintf("Unsupported database type: %s", instance.DBType)
|
return false, fmt.Sprintf("Unsupported database type: %s", instance.DBType)
|
||||||
@@ -71,7 +71,7 @@ func testDBConnectionFull(instance *model.Instance) *ConnectionResult {
|
|||||||
ok, msg := testPostgreSQL(ctx, instance.Host, instance.Port, instance.Username, password, dbname)
|
ok, msg := testPostgreSQL(ctx, instance.Host, instance.Port, instance.Username, password, dbname)
|
||||||
return &ConnectionResult{Success: ok, Message: msg, Version: extractVersion(msg, "version: ")}
|
return &ConnectionResult{Success: ok, Message: msg, Version: extractVersion(msg, "version: ")}
|
||||||
case "redis":
|
case "redis":
|
||||||
ok, msg, isCluster := testRedis(ctx, instance.Host, instance.Port, password)
|
ok, msg, isCluster := testRedis(ctx, instance.Host, instance.Port, instance.Username, password)
|
||||||
return &ConnectionResult{Success: ok, Message: msg, Version: extractVersion(msg, "version: "), ClusterMode: isCluster}
|
return &ConnectionResult{Success: ok, Message: msg, Version: extractVersion(msg, "version: "), ClusterMode: isCluster}
|
||||||
default:
|
default:
|
||||||
return &ConnectionResult{Success: false, Message: fmt.Sprintf("Unsupported database type: %s", instance.DBType)}
|
return &ConnectionResult{Success: false, Message: fmt.Sprintf("Unsupported database type: %s", instance.DBType)}
|
||||||
@@ -132,10 +132,16 @@ func testPostgreSQL(ctx context.Context, host string, port int, user, pass, dbna
|
|||||||
return true, fmt.Sprintf("PostgreSQL connection successful (version: %s)", ver)
|
return true, fmt.Sprintf("PostgreSQL connection successful (version: %s)", ver)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testRedis(ctx context.Context, host string, port int, pass string) (bool, string, bool) {
|
func testRedis(ctx context.Context, host string, port int, username, pass string) (bool, string, bool) {
|
||||||
addr := fmt.Sprintf("%s:%d", host, port)
|
addr := fmt.Sprintf("%s:%d", host, port)
|
||||||
|
// Redis 6.0+ ACL: empty username = traditional password-only auth
|
||||||
|
redisUsername := username
|
||||||
|
if redisUsername == "default" {
|
||||||
|
redisUsername = ""
|
||||||
|
}
|
||||||
rdb := redis.NewClient(&redis.Options{
|
rdb := redis.NewClient(&redis.Options{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
|
Username: redisUsername,
|
||||||
Password: pass,
|
Password: pass,
|
||||||
DB: 0,
|
DB: 0,
|
||||||
DialTimeout: 10 * time.Second,
|
DialTimeout: 10 * time.Second,
|
||||||
@@ -199,11 +205,17 @@ func getRedisMetrics(instance *model.Instance) map[string]interface{} {
|
|||||||
"cluster_mode": isCluster,
|
"cluster_mode": isCluster,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Redis 6.0+ ACL support
|
||||||
|
redisUsername := instance.Username
|
||||||
|
if redisUsername == "default" {
|
||||||
|
redisUsername = ""
|
||||||
|
}
|
||||||
var info string
|
var info string
|
||||||
// For metrics collection, always use single-node connection even for clusters.
|
// For metrics collection, always use single-node connection even for clusters.
|
||||||
// ClusterClient does topology discovery which can timeout if some nodes are unreachable.
|
// ClusterClient does topology discovery which can timeout if some nodes are unreachable.
|
||||||
rdb := redis.NewClient(&redis.Options{
|
rdb := redis.NewClient(&redis.Options{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
|
Username: redisUsername,
|
||||||
Password: password,
|
Password: password,
|
||||||
DB: 0,
|
DB: 0,
|
||||||
DialTimeout: 5 * time.Second,
|
DialTimeout: 5 * time.Second,
|
||||||
|
|||||||
@@ -108,15 +108,16 @@ func UpdateInstance(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// if password is masked, don't update it
|
// if password is empty or masked placeholder, don't update it
|
||||||
if pw, ok := input["password"].(string); ok && pw == "******" {
|
if pw, ok := input["password"].(string); ok {
|
||||||
delete(input, "password")
|
if pw == "" || pw == "******" {
|
||||||
}
|
delete(input, "password")
|
||||||
// Encrypt password if provided and not masked
|
} else {
|
||||||
if pw, ok := input["password"].(string); ok && pw != "" {
|
// Encrypt new password
|
||||||
encPw, err := encryptPassword(pw)
|
encPw, err := encryptPassword(pw)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
input["password"] = "enc:" + encPw
|
input["password"] = "enc:" + encPw
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -156,12 +156,19 @@ func getRedisClient(instance *model.Instance, db int) (*redisClientWrapper, erro
|
|||||||
}
|
}
|
||||||
addr := fmt.Sprintf("%s:%d", instance.Host, instance.Port)
|
addr := fmt.Sprintf("%s:%d", instance.Host, instance.Port)
|
||||||
|
|
||||||
|
// Redis 6.0+ ACL: username is optional (empty = use "default" traditional auth)
|
||||||
|
redisUsername := instance.Username
|
||||||
|
if redisUsername == "default" || redisUsername == "" {
|
||||||
|
redisUsername = "" // go-redis treats empty Username as no ACL auth
|
||||||
|
}
|
||||||
|
|
||||||
// Always use single-node connection for query execution.
|
// Always use single-node connection for query execution.
|
||||||
// ClusterClient does topology discovery (CLUSTER SLOTS) which fails when
|
// ClusterClient does topology discovery (CLUSTER SLOTS) which fails when
|
||||||
// internal cluster IPs are unreachable from the client (e.g., Docker networks).
|
// internal cluster IPs are unreachable from the client (e.g., Docker networks).
|
||||||
// Single-node mode works for all read-only commands on cluster nodes.
|
// Single-node mode works for all read-only commands on cluster nodes.
|
||||||
client := redis.NewClient(&redis.Options{
|
client := redis.NewClient(&redis.Options{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
|
Username: redisUsername,
|
||||||
Password: password,
|
Password: password,
|
||||||
DB: db,
|
DB: db,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -172,12 +172,15 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className={labelCls}>用户名</label>
|
<label className={labelCls}>
|
||||||
<input className={inputCls} value={username} onChange={e => setUsername(e.target.value)} />
|
用户名
|
||||||
|
{instance.db_type === 'redis' && <span className="font-normal text-[var(--dg-text-muted)]">(Redis 6.0+ ACL 可选)</span>}
|
||||||
|
</label>
|
||||||
|
<input className={inputCls} value={username} onChange={e => setUsername(e.target.value)} placeholder={instance.db_type === 'redis' ? '可选,留空则仅密码认证' : '用户名'} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className={labelCls}>{mode === 'clone' ? '密码' : '密码(留空不修改)'}</label>
|
<label className={labelCls}>{mode === 'clone' ? '密码' : '密码(留空不修改)'}</label>
|
||||||
<input className={inputCls} type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="••••••" />
|
<input className={inputCls} type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder={mode === 'edit' ? '不修改请留空' : '••••••'} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1008,7 +1008,7 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: {
|
|||||||
const [deleteConfirm, setDeleteConfirm] = useState<{msg: string; onConfirm: () => void} | null>(null);
|
const [deleteConfirm, setDeleteConfirm] = useState<{msg: string; onConfirm: () => void} | null>(null);
|
||||||
|
|
||||||
const isRedis = dbType === 'redis';
|
const isRedis = dbType === 'redis';
|
||||||
const needUsername = !isRedis;
|
const needUsername = !isRedis; // Redis username is optional (6.0+ ACL)
|
||||||
const needDatabase = !isRedis;
|
const needDatabase = !isRedis;
|
||||||
const finalEnv = env === '__custom__' ? customEnv.trim() : env;
|
const finalEnv = env === '__custom__' ? customEnv.trim() : env;
|
||||||
const canSubmit = testResult?.success === true;
|
const canSubmit = testResult?.success === true;
|
||||||
@@ -1017,7 +1017,7 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: {
|
|||||||
setDbType(t);
|
setDbType(t);
|
||||||
setPort(String(DB_DEFAULT_PORTS[t] || 3306));
|
setPort(String(DB_DEFAULT_PORTS[t] || 3306));
|
||||||
setTestResult(null);
|
setTestResult(null);
|
||||||
if (t === 'redis') { setUsername(''); setDatabase(''); }
|
if (t === 'redis') { setDatabase(''); }
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSubmit = useCallback(async () => {
|
const handleSubmit = useCallback(async () => {
|
||||||
@@ -1028,7 +1028,8 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: {
|
|||||||
try {
|
try {
|
||||||
await instanceApi.create({
|
await instanceApi.create({
|
||||||
name, db_type: dbType, host, port: Number(port),
|
name, db_type: dbType, host, port: Number(port),
|
||||||
username: username || 'default', password: password || 'default',
|
username: isRedis ? (username || undefined) : (username || 'default'),
|
||||||
|
password: password || 'default',
|
||||||
database: database || undefined,
|
database: database || undefined,
|
||||||
env: (finalEnv || 'development') as string,
|
env: (finalEnv || 'development') as string,
|
||||||
access_mode: accessMode,
|
access_mode: accessMode,
|
||||||
@@ -1054,7 +1055,8 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: {
|
|||||||
try {
|
try {
|
||||||
const res = await instanceApi.testNewConnection({
|
const res = await instanceApi.testNewConnection({
|
||||||
name: name || '__test__', db_type: dbType, host, port: Number(port),
|
name: name || '__test__', db_type: dbType, host, port: Number(port),
|
||||||
username: username || 'default', password: password || 'default',
|
username: isRedis ? (username || undefined) : (username || 'default'),
|
||||||
|
password: password || 'default',
|
||||||
database: database || undefined,
|
database: database || undefined,
|
||||||
env: (finalEnv || 'development') as string, access_mode: accessMode,
|
env: (finalEnv || 'development') as string, access_mode: accessMode,
|
||||||
});
|
});
|
||||||
@@ -1207,10 +1209,10 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isRedis && (
|
{isRedis && (
|
||||||
<div className="p-3 rounded-lg bg-yellow-500/5 border border-yellow-500/20">
|
<div className="p-3 rounded-lg bg-blue-500/5 border border-blue-500/20">
|
||||||
<p className="text-xs text-yellow-400 flex items-center gap-1.5">
|
<p className="text-xs text-blue-400 flex items-center gap-1.5">
|
||||||
<AlertTriangle className="w-3.5 h-3.5" />
|
<AlertTriangle className="w-3.5 h-3.5" />
|
||||||
Redis 实例无需用户名,密码可选
|
Redis 6.0+ ACL 支持用户名+密码认证;传统模式仅填密码即可
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1223,7 +1225,7 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: {
|
|||||||
<Card className="dg-card">
|
<Card className="dg-card">
|
||||||
<CardContent className="p-5 space-y-4">
|
<CardContent className="p-5 space-y-4">
|
||||||
<h3 className="text-sm font-semibold text-[var(--dg-text-secondary)]">连接信息</h3>
|
<h3 className="text-sm font-semibold text-[var(--dg-text-secondary)]">连接信息</h3>
|
||||||
<div className={isRedis ? 'grid grid-cols-2 gap-4' : 'grid grid-cols-3 gap-4'}>
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className={labelCls}>地址 <span className="text-red-400">*</span></label>
|
<label className={labelCls}>地址 <span className="text-red-400">*</span></label>
|
||||||
<input
|
<input
|
||||||
@@ -1242,17 +1244,17 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: {
|
|||||||
placeholder={String(DB_DEFAULT_PORTS[dbType] || 3306)}
|
placeholder={String(DB_DEFAULT_PORTS[dbType] || 3306)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{!isRedis && (
|
<div>
|
||||||
<div>
|
<label className={labelCls}>
|
||||||
<label className={labelCls}>用户名 <span className="text-red-400">*</span></label>
|
用户名 {isRedis ? <span className="text-[var(--dg-text-muted)]">(可选)</span> : <span className="text-red-400">*</span>}
|
||||||
<input
|
</label>
|
||||||
value={username}
|
<input
|
||||||
onChange={e => { setUsername(e.target.value); setTestResult(null); }}
|
value={username}
|
||||||
className={inputCls}
|
onChange={e => { setUsername(e.target.value); setTestResult(null); }}
|
||||||
placeholder="root"
|
className={inputCls}
|
||||||
/>
|
placeholder={isRedis ? 'ACL 用户名,留空则仅密码认证' : 'root'}
|
||||||
</div>
|
/>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
Reference in new issue
Block a user