feat: improve datasource query audit workflows

This commit is contained in:
Logan committed 2026-07-06 23:01:04 +08:00
1 parent 74748e4914
commit 644981eef4
32 files changed
+780 -3061

No files matched your search

+166 -28
View File
@@ -13,6 +13,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
)
// Helper: get Redis client from instance (auto-detect standalone vs cluster)
type redisClientWrapper struct {
Client *redis.Client
@@ -21,7 +22,6 @@ type redisClientWrapper struct {
}
func (w *redisClientWrapper) Close() {
// Always close the single-node Client (cluster uses single-node now)
if w.Client != nil {
w.Client.Close()
}
@@ -30,19 +30,24 @@ func (w *redisClientWrapper) Close() {
}
}
// Ping, Info, DBSize, Scan, Type, TTL, Get, LRange, SMembers, ZRangeWithScores, HGetAll,
// SlowLogGet, ClientList, Do, Set, RPush, SAdd, HSet, Expire, Del — unified interface via wrapper methods
// NOTE: All methods use Client (single-node). isCluster flag is only for ScanAllKeys behavior.
func (w *redisClientWrapper) Ping(ctx context.Context) *redis.StatusCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.Ping(ctx)
}
return w.Client.Ping(ctx)
}
func (w *redisClientWrapper) Info(ctx context.Context, sections ...string) *redis.StringCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.Info(ctx, sections...)
}
return w.Client.Info(ctx, sections...)
}
func (w *redisClientWrapper) DBSize(ctx context.Context) *redis.IntCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.DBSize(ctx)
}
return w.Client.DBSize(ctx)
}
@@ -50,9 +55,27 @@ func (w *redisClientWrapper) Scan(ctx context.Context, cursor uint64, match stri
return w.Client.Scan(ctx, cursor, match, count)
}
// ScanAllKeys scans keys on the connected node.
// For clusters, this only scans keys on the single connected node (not all masters).
func (w *redisClientWrapper) ScanAllKeys(ctx context.Context, match string, count int) ([]string, error) {
if w.isCluster && w.ClusterClient != nil {
var allKeys []string
err := w.ClusterClient.ForEachMaster(ctx, func(ctx context.Context, shard *redis.Client) error {
cursor := uint64(0)
for {
keys, nextCursor, err := shard.Scan(ctx, cursor, match, int64(count)).Result()
if err != nil {
return err
}
allKeys = append(allKeys, keys...)
cursor = nextCursor
if cursor == 0 {
break
}
}
return nil
})
return allKeys, err
}
var allKeys []string
cursor := uint64(0)
for {
@@ -70,30 +93,51 @@ func (w *redisClientWrapper) ScanAllKeys(ctx context.Context, match string, coun
}
func (w *redisClientWrapper) Type(ctx context.Context, key string) *redis.StatusCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.Type(ctx, key)
}
return w.Client.Type(ctx, key)
}
func (w *redisClientWrapper) TTL(ctx context.Context, key string) *redis.DurationCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.TTL(ctx, key)
}
return w.Client.TTL(ctx, key)
}
func (w *redisClientWrapper) Get(ctx context.Context, key string) *redis.StringCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.Get(ctx, key)
}
return w.Client.Get(ctx, key)
}
func (w *redisClientWrapper) LRange(ctx context.Context, key string, start, stop int64) *redis.StringSliceCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.LRange(ctx, key, start, stop)
}
return w.Client.LRange(ctx, key, start, stop)
}
func (w *redisClientWrapper) SMembers(ctx context.Context, key string) *redis.StringSliceCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.SMembers(ctx, key)
}
return w.Client.SMembers(ctx, key)
}
func (w *redisClientWrapper) ZRangeWithScores(ctx context.Context, key string, start, stop int64) *redis.ZSliceCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.ZRangeWithScores(ctx, key, start, stop)
}
return w.Client.ZRangeWithScores(ctx, key, start, stop)
}
func (w *redisClientWrapper) HGetAll(ctx context.Context, key string) *redis.MapStringStringCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.HGetAll(ctx, key)
}
return w.Client.HGetAll(ctx, key)
}
@@ -106,42 +150,72 @@ func (w *redisClientWrapper) ClientList(ctx context.Context) *redis.StringCmd {
}
func (w *redisClientWrapper) Do(ctx context.Context, args ...interface{}) *redis.Cmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.Do(ctx, args...)
}
return w.Client.Do(ctx, args...)
}
func (w *redisClientWrapper) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) *redis.StatusCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.Set(ctx, key, value, expiration)
}
return w.Client.Set(ctx, key, value, expiration)
}
func (w *redisClientWrapper) RPush(ctx context.Context, key string, values ...interface{}) *redis.IntCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.RPush(ctx, key, values...)
}
return w.Client.RPush(ctx, key, values...)
}
func (w *redisClientWrapper) SAdd(ctx context.Context, key string, members ...interface{}) *redis.IntCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.SAdd(ctx, key, members...)
}
return w.Client.SAdd(ctx, key, members...)
}
func (w *redisClientWrapper) HSet(ctx context.Context, key string, values ...interface{}) *redis.IntCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.HSet(ctx, key, values...)
}
return w.Client.HSet(ctx, key, values...)
}
func (w *redisClientWrapper) Expire(ctx context.Context, key string, expiration time.Duration) *redis.BoolCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.Expire(ctx, key, expiration)
}
return w.Client.Expire(ctx, key, expiration)
}
func (w *redisClientWrapper) Del(ctx context.Context, keys ...string) *redis.IntCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.Del(ctx, keys...)
}
return w.Client.Del(ctx, keys...)
}
func (w *redisClientWrapper) LLen(ctx context.Context, key string) *redis.IntCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.LLen(ctx, key)
}
return w.Client.LLen(ctx, key)
}
func (w *redisClientWrapper) SCard(ctx context.Context, key string) *redis.IntCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.SCard(ctx, key)
}
return w.Client.SCard(ctx, key)
}
func (w *redisClientWrapper) SScan(ctx context.Context, key string, cursor uint64, match string, count int64) *redis.ScanCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.SScan(ctx, key, cursor, match, count)
}
return w.Client.SScan(ctx, key, cursor, match, count)
}
@@ -162,10 +236,6 @@ func getRedisClient(instance *model.Instance, db int) (*redisClientWrapper, erro
redisUsername = "" // go-redis treats empty Username as no ACL auth
}
// Always use single-node connection for query execution.
// ClusterClient does topology discovery (CLUSTER SLOTS) which fails when
// internal cluster IPs are unreachable from the client (e.g., Docker networks).
// Single-node mode works for all read-only commands on cluster nodes.
client := redis.NewClient(&redis.Options{
Addr: addr,
Username: redisUsername,
@@ -180,7 +250,48 @@ func getRedisClient(instance *model.Instance, db int) (*redisClientWrapper, erro
// Check cluster mode flag for wrapper behavior (ScanAllKeys needs to know)
isCluster := instance.ClusterMode != nil && *instance.ClusterMode
return &redisClientWrapper{Client: client, isCluster: isCluster}, nil
wrapper := &redisClientWrapper{Client: client, isCluster: isCluster}
if !isCluster {
return wrapper, nil
}
// Redis Cluster routes key commands by slot. Build the address list from
// CLUSTER SLOTS, but replace advertised internal node IPs with the configured
// host so Docker/NAT deployments can still reach the mapped node ports.
addrs := []string{addr}
if slots, err := client.ClusterSlots(ctx).Result(); err == nil && len(slots) > 0 {
seen := map[string]bool{}
addrs = addrs[:0]
for _, slot := range slots {
if len(slot.Nodes) == 0 {
continue
}
nodeAddr := slot.Nodes[0].Addr
if idx := strings.LastIndex(nodeAddr, ":"); idx >= 0 {
nodeAddr = instance.Host + nodeAddr[idx:]
}
if !seen[nodeAddr] {
seen[nodeAddr] = true
addrs = append(addrs, nodeAddr)
}
}
if len(addrs) == 0 {
addrs = []string{addr}
}
}
clusterClient := redis.NewClusterClient(&redis.ClusterOptions{
Addrs: addrs,
Username: redisUsername,
Password: password,
})
if err := clusterClient.Ping(ctx).Err(); err != nil {
clusterClient.Close()
client.Close()
return nil, fmt.Errorf("Redis cluster connection failed: %v", err)
}
wrapper.ClusterClient = clusterClient
return wrapper, nil
}
// GET /api/v1/redis/:id/info
@@ -265,11 +376,12 @@ func RedisDbsize(c *gin.Context) {
func RedisScanKeys(c *gin.Context) {
id := c.Param("id")
dbParam := c.Query("db")
pattern := c.DefaultQuery("pattern", "*")
// Auto-append * if pattern has no wildcard (user-friendly fuzzy search)
if !strings.Contains(pattern, "*") && !strings.Contains(pattern, "?") && !strings.Contains(pattern, "[") {
pattern = pattern + "*"
rawPattern := strings.TrimSpace(c.DefaultQuery("pattern", "*"))
if rawPattern == "" {
rawPattern = "*"
}
pattern := rawPattern
hasWildcard := strings.ContainsAny(pattern, "*?[")
count, _ := strconv.Atoi(c.DefaultQuery("count", "100"))
db := 0
@@ -291,11 +403,6 @@ func RedisScanKeys(c *gin.Context) {
defer client.Close()
ctx := context.Background()
allKeys, err := client.ScanAllKeys(ctx, pattern, count)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return
}
// Get types for each key
type KeyInfo struct {
@@ -303,6 +410,37 @@ func RedisScanKeys(c *gin.Context) {
Type string `json:"type"`
TTL int64 `json:"ttl"`
}
// Redis Cluster 精确 key 查询应优先走槽位路由直查。全量 SCAN 需要访问各 master,
// 在 NAT/代理暴露单入口时容易受节点通告地址影响。
if !hasWildcard && pattern != "*" {
keyType, err := client.Type(ctx, pattern).Result()
if err == nil && keyType != "none" {
ttl, _ := client.TTL(ctx, pattern).Result()
ttlMs := int64(-1)
if ttl > 0 {
ttlMs = ttl.Milliseconds()
}
c.JSON(http.StatusOK, gin.H{
"keys": []KeyInfo{{Key: pattern, Type: keyType, TTL: ttlMs}},
"cursor": 0,
"total": 1,
})
return
}
}
// Auto-append * if pattern has no wildcard (user-friendly fuzzy search)
if !hasWildcard {
pattern = pattern + "*"
}
allKeys, err := client.ScanAllKeys(ctx, pattern, count)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return
}
var keyInfos []KeyInfo
for _, key := range allKeys {
keyType, _ := client.Type(ctx, key).Result()
@@ -498,7 +636,7 @@ func RedisExecuteCommand(c *gin.Context) {
ctx := context.Background()
cmd := strings.ToUpper(input.Command)
// Safe read-only commands
readOnly := map[string]bool{
"GET": true, "MGET": true, "HGET": true, "HGETALL": true,
@@ -508,7 +646,7 @@ func RedisExecuteCommand(c *gin.Context) {
"STRLEN": true, "LLEN": true, "SCARD": true, "ZCARD": true,
"HLEN": true, "GETRANGE": true,
}
if !readOnly[cmd] {
c.JSON(http.StatusForbidden, gin.H{"detail": "Only read-only commands allowed via query interface"})
return
@@ -547,10 +685,10 @@ func RedisCreateKey(c *gin.Context) {
}
var input struct {
Key string `json:"key" binding:"required"`
Type string `json:"type" binding:"required"`
Value interface{} `json:"value"`
TTL int `json:"ttl"`
Key string `json:"key" binding:"required"`
Type string `json:"type" binding:"required"`
Value interface{} `json:"value"`
TTL int `json:"ttl"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
@@ -571,7 +709,7 @@ func RedisCreateKey(c *gin.Context) {
defer client.Close()
ctx := context.Background()
switch input.Type {
case "string":
client.Set(ctx, input.Key, input.Value, 0)