├── .gitignore ├── compression_test.go ├── crypto_test.go ├── compression.go ├── crypto.go ├── go.mod ├── caddyfile.go ├── README.md ├── storage_test.go ├── go.sum ├── LICENSE └── storage.go /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .idea 3 | .vscode 4 | caddy 5 | Caddyfile 6 | -------------------------------------------------------------------------------- /compression_test.go: -------------------------------------------------------------------------------- 1 | package storageredis 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestRedisStorage_ComressUncompress(t *testing.T) { 10 | 11 | rs := New() 12 | originalValue := []byte("Q2FkZHkgUmVkaXMgU3RvcmFnZQ==") 13 | 14 | compressedValue, err := rs.compress(originalValue) 15 | assert.NoError(t, err) 16 | 17 | uncompressedValue, err := rs.uncompress(compressedValue) 18 | assert.NoError(t, err) 19 | assert.Equal(t, originalValue, uncompressedValue) 20 | } 21 | -------------------------------------------------------------------------------- /crypto_test.go: -------------------------------------------------------------------------------- 1 | package storageredis 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestRedisStorage_EncryptDecrypt(t *testing.T) { 10 | 11 | rs := New() 12 | rs.EncryptionKey = "1aedfs5kcM8lOZO3BDDMuwC23croDwRr" 13 | originalValue := []byte("Q2FkZHkgUmVkaXMgU3RvcmFnZQ==") 14 | 15 | encryptedValue, err := rs.encrypt(originalValue) 16 | assert.NoError(t, err) 17 | 18 | decryptedValue, err := rs.decrypt(encryptedValue) 19 | assert.NoError(t, err) 20 | assert.Equal(t, originalValue, decryptedValue) 21 | } 22 | -------------------------------------------------------------------------------- /compression.go: -------------------------------------------------------------------------------- 1 | package storageredis 2 | 3 | import ( 4 | "bytes" 5 | "compress/flate" 6 | "io/ioutil" 7 | ) 8 | 9 | func (rs *RedisStorage) compress(input []byte) ([]byte, error) { 10 | 11 | var buf bytes.Buffer 12 | writer, err := flate.NewWriter(&buf, flate.DefaultCompression) 13 | if err != nil { 14 | return nil, err 15 | } 16 | _, err = writer.Write(input) 17 | if err != nil { 18 | return nil, err 19 | } 20 | 21 | if err = writer.Close(); err != nil { 22 | return nil, err 23 | } 24 | 25 | return buf.Bytes(), nil 26 | } 27 | 28 | func (rs *RedisStorage) uncompress(input []byte) ([]byte, error) { 29 | 30 | reader := flate.NewReader(bytes.NewReader(input)) 31 | 32 | output, err := ioutil.ReadAll(reader) 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | if err = reader.Close(); err != nil { 38 | return nil, err 39 | } 40 | 41 | return output, nil 42 | } 43 | -------------------------------------------------------------------------------- /crypto.go: -------------------------------------------------------------------------------- 1 | package storageredis 2 | 3 | import ( 4 | "crypto/aes" 5 | "crypto/cipher" 6 | "crypto/rand" 7 | "fmt" 8 | "io" 9 | ) 10 | 11 | func (rs *RedisStorage) encrypt(bytes []byte) ([]byte, error) { 12 | 13 | c, err := aes.NewCipher([]byte(rs.EncryptionKey)) 14 | if err != nil { 15 | return nil, fmt.Errorf("Unable to create AES cipher: %v", err) 16 | } 17 | 18 | gcm, err := cipher.NewGCM(c) 19 | if err != nil { 20 | return nil, fmt.Errorf("Unable to create GCM cipher: %v", err) 21 | } 22 | 23 | nonce := make([]byte, gcm.NonceSize()) 24 | _, err = io.ReadFull(rand.Reader, nonce) 25 | if err != nil { 26 | return nil, fmt.Errorf("Unable to generate nonce: %v", err) 27 | } 28 | 29 | return gcm.Seal(nonce, nonce, bytes, nil), nil 30 | } 31 | 32 | func (rs *RedisStorage) decrypt(bytes []byte) ([]byte, error) { 33 | 34 | if len(bytes) < aes.BlockSize { 35 | return nil, fmt.Errorf("Invalid encrypted data") 36 | } 37 | 38 | block, err := aes.NewCipher([]byte(rs.EncryptionKey)) 39 | if err != nil { 40 | return nil, fmt.Errorf("Unable to create AES cipher: %v", err) 41 | } 42 | 43 | gcm, err := cipher.NewGCM(block) 44 | if err != nil { 45 | return nil, fmt.Errorf("Unable to create GCM cipher: %v", err) 46 | } 47 | 48 | out, err := gcm.Open(nil, bytes[:gcm.NonceSize()], bytes[gcm.NonceSize():], nil) 49 | if err != nil { 50 | return nil, fmt.Errorf("Decryption failure: %v", err) 51 | } 52 | 53 | return out, nil 54 | } 55 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/pberkel/caddy-storage-redis 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/bsm/redislock v0.9.4 7 | github.com/caddyserver/caddy/v2 v2.7.5 8 | github.com/caddyserver/certmagic v0.19.2 9 | github.com/redis/go-redis/v9 v9.3.0 10 | github.com/spf13/cobra v1.7.0 11 | github.com/stretchr/testify v1.9.0 12 | go.uber.org/zap v1.25.0 13 | ) 14 | 15 | require ( 16 | github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b // indirect 17 | github.com/beorn7/perks v1.0.1 // indirect 18 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 19 | github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect 20 | github.com/davecgh/go-spew v1.1.1 // indirect 21 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 22 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect 23 | github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 // indirect 24 | github.com/google/uuid v1.3.1 // indirect 25 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 26 | github.com/klauspost/cpuid/v2 v2.2.5 // indirect 27 | github.com/libdns/libdns v0.2.1 // indirect 28 | github.com/mholt/acmez v1.2.0 // indirect 29 | github.com/miekg/dns v1.1.55 // indirect 30 | github.com/onsi/ginkgo/v2 v2.9.5 // indirect 31 | github.com/pmezard/go-difflib v1.0.0 // indirect 32 | github.com/prometheus/client_golang v1.19.1 // indirect 33 | github.com/prometheus/client_model v0.5.0 // indirect 34 | github.com/prometheus/common v0.48.0 // indirect 35 | github.com/prometheus/procfs v0.12.0 // indirect 36 | github.com/quic-go/qpack v0.5.1 // indirect 37 | github.com/quic-go/quic-go v0.49.1 // indirect 38 | github.com/rogpeppe/go-internal v1.11.0 // indirect 39 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 40 | github.com/spf13/pflag v1.0.5 // indirect 41 | github.com/zeebo/blake3 v0.2.3 // indirect 42 | go.uber.org/mock v0.5.0 // indirect 43 | go.uber.org/multierr v1.11.0 // indirect 44 | golang.org/x/crypto v0.45.0 // indirect 45 | golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect 46 | golang.org/x/mod v0.29.0 // indirect 47 | golang.org/x/net v0.47.0 // indirect 48 | golang.org/x/sync v0.18.0 // indirect 49 | golang.org/x/sys v0.38.0 // indirect 50 | golang.org/x/term v0.37.0 // indirect 51 | golang.org/x/text v0.31.0 // indirect 52 | golang.org/x/tools v0.38.0 // indirect 53 | google.golang.org/protobuf v1.33.0 // indirect 54 | gopkg.in/yaml.v3 v3.0.1 // indirect 55 | ) 56 | -------------------------------------------------------------------------------- /caddyfile.go: -------------------------------------------------------------------------------- 1 | package storageredis 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net" 8 | "reflect" 9 | "strconv" 10 | 11 | "github.com/spf13/cobra" 12 | 13 | "github.com/caddyserver/caddy/v2" 14 | "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" 15 | caddycmd "github.com/caddyserver/caddy/v2/cmd" 16 | "github.com/caddyserver/certmagic" 17 | ) 18 | 19 | func init() { 20 | caddy.RegisterModule(RedisStorage{}) 21 | caddycmd.RegisterCommand(caddycmd.Command{ 22 | Name: "redis", 23 | Short: "Commands for working with the Caddy Redis Storage module", 24 | CobraFunc: func(cmd *cobra.Command) { 25 | rebuildCmd := &cobra.Command{ 26 | Use: "repair --config ", 27 | Short: "Repair the Redis Storage directory index tree", 28 | RunE: caddycmd.WrapCommandFuncForCobra(cmdRedisStorageRepair), 29 | } 30 | rebuildCmd.Flags().StringP("config", "c", "", "Caddy configuration file (optional)") 31 | cmd.AddCommand(rebuildCmd) 32 | }, 33 | }) 34 | } 35 | 36 | func (RedisStorage) CaddyModule() caddy.ModuleInfo { 37 | return caddy.ModuleInfo{ 38 | ID: "caddy.storage.redis", 39 | New: func() caddy.Module { 40 | return New() 41 | }, 42 | } 43 | } 44 | 45 | func (rs *RedisStorage) CertMagicStorage() (certmagic.Storage, error) { 46 | return rs, nil 47 | } 48 | 49 | func (rs *RedisStorage) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { 50 | 51 | for d.Next() { 52 | 53 | // Optional Redis client type either "cluster" or "failover" 54 | if d.NextArg() { 55 | val := d.Val() 56 | if val == "cluster" || val == "failover" { 57 | rs.ClientType = val 58 | } else { 59 | return d.ArgErr() 60 | } 61 | } 62 | 63 | for nesting := d.Nesting(); d.NextBlock(nesting); { 64 | configKey := d.Val() 65 | var configVal []string 66 | 67 | if d.NextArg() { 68 | // configuration item with single parameter 69 | configVal = append(configVal, d.Val()) 70 | } else { 71 | // configuration item with nested parameter list 72 | for nesting := d.Nesting(); d.NextBlock(nesting); { 73 | configVal = append(configVal, d.Val()) 74 | } 75 | } 76 | // There are no valid configurations where configVal slice is empty 77 | if len(configVal) == 0 { 78 | return d.Errf("no value supplied for configuraton key '%s'", configKey) 79 | } 80 | 81 | switch configKey { 82 | case "address": 83 | rs.Address = configVal 84 | case "host": 85 | rs.Host = configVal 86 | case "port": 87 | rs.Port = configVal 88 | case "db": 89 | dbParse, err := strconv.Atoi(configVal[0]) 90 | if err != nil { 91 | return d.Errf("invalid db value: %s", configVal[0]) 92 | } 93 | rs.DB = dbParse 94 | case "timeout": 95 | rs.Timeout = configVal[0] 96 | case "username": 97 | if configVal[0] != "" { 98 | rs.Username = configVal[0] 99 | } 100 | case "password": 101 | if configVal[0] != "" { 102 | rs.Password = configVal[0] 103 | } 104 | case "sentinel_password": 105 | if configVal[0] != "" { 106 | rs.SentinelPassword = configVal[0] 107 | } 108 | case "master_name": 109 | if configVal[0] != "" { 110 | rs.MasterName = configVal[0] 111 | } 112 | case "key_prefix": 113 | if configVal[0] != "" { 114 | rs.KeyPrefix = configVal[0] 115 | } 116 | case "encryption_key", "aes_key": 117 | rs.EncryptionKey = configVal[0] 118 | case "compression": 119 | Compression, err := strconv.ParseBool(configVal[0]) 120 | if err != nil { 121 | return d.Errf("invalid boolean value for 'compression': %s", configVal[0]) 122 | } 123 | rs.Compression = Compression 124 | case "tls_enabled": 125 | TlsEnabledParse, err := strconv.ParseBool(configVal[0]) 126 | if err != nil { 127 | return d.Errf("invalid boolean value for 'tls_enabled': %s", configVal[0]) 128 | } 129 | rs.TlsEnabled = TlsEnabledParse 130 | case "tls_insecure": 131 | tlsInsecureParse, err := strconv.ParseBool(configVal[0]) 132 | if err != nil { 133 | return d.Errf("invalid boolean value for 'tls_insecure': %s", configVal[0]) 134 | } 135 | rs.TlsInsecure = tlsInsecureParse 136 | case "tls_server_certs_pem": 137 | if configVal[0] != "" { 138 | rs.TlsServerCertsPEM = configVal[0] 139 | } 140 | case "tls_server_certs_path": 141 | if configVal[0] != "" { 142 | rs.TlsServerCertsPath = configVal[0] 143 | } 144 | case "route_by_latency": 145 | routeByLatency, err := strconv.ParseBool(configVal[0]) 146 | if err != nil { 147 | return d.Errf("invalid boolean value for 'route_by_latency': %s", configVal[0]) 148 | } 149 | rs.RouteByLatency = routeByLatency 150 | case "route_randomly": 151 | routeRandomly, err := strconv.ParseBool(configVal[0]) 152 | if err != nil { 153 | return d.Errf("invalid boolean value for 'route_randomly': %s", configVal[0]) 154 | } 155 | rs.RouteRandomly = routeRandomly 156 | } 157 | } 158 | } 159 | return nil 160 | } 161 | 162 | // Provision module function called by Caddy Server 163 | func (rs *RedisStorage) Provision(ctx caddy.Context) error { 164 | 165 | rs.logger = ctx.Logger().Sugar() 166 | 167 | // Abstract this logic for testing purposes 168 | err := rs.finalizeConfiguration(ctx) 169 | if err == nil { 170 | rs.logger.Infof("Provision Redis %s storage using address %v", rs.ClientType, rs.Address) 171 | } 172 | 173 | return err 174 | } 175 | 176 | func (rs *RedisStorage) finalizeConfiguration(ctx context.Context) error { 177 | 178 | repl := caddy.NewReplacer() 179 | 180 | for idx, v := range rs.Address { 181 | v = repl.ReplaceAll(v, "") 182 | host, port, err := net.SplitHostPort(v) 183 | if err != nil { 184 | return fmt.Errorf("invalid address: %s", v) 185 | } 186 | rs.Address[idx] = net.JoinHostPort(host, port) 187 | } 188 | for idx, v := range rs.Host { 189 | v = repl.ReplaceAll(v, defaultHost) 190 | addr := net.ParseIP(v) 191 | _, err := net.LookupHost(v) 192 | if addr == nil && err != nil { 193 | return fmt.Errorf("invalid host value: %s", v) 194 | } 195 | rs.Host[idx] = v 196 | } 197 | for idx, v := range rs.Port { 198 | v = repl.ReplaceAll(v, defaultPort) 199 | _, err := strconv.Atoi(v) 200 | if err != nil { 201 | return fmt.Errorf("invalid port value: %s", v) 202 | } 203 | rs.Port[idx] = v 204 | } 205 | if rs.Timeout = repl.ReplaceAll(rs.Timeout, ""); rs.Timeout != "" { 206 | timeParse, err := strconv.Atoi(rs.Timeout) 207 | if err != nil || timeParse < 0 { 208 | return fmt.Errorf("invalid timeout value: %s", rs.Timeout) 209 | } 210 | } 211 | rs.MasterName = repl.ReplaceAll(rs.MasterName, "") 212 | rs.Username = repl.ReplaceAll(rs.Username, "") 213 | rs.Password = repl.ReplaceAll(rs.Password, "") 214 | rs.KeyPrefix = repl.ReplaceAll(rs.KeyPrefix, defaultKeyPrefix) 215 | 216 | if len(rs.EncryptionKey) > 0 { 217 | rs.EncryptionKey = repl.ReplaceAll(rs.EncryptionKey, "") 218 | // Encryption_key length must be at least 32 characters 219 | if len(rs.EncryptionKey) < 32 { 220 | return fmt.Errorf("invalid length for 'encryption_key', must contain at least 32 bytes: %s", rs.EncryptionKey) 221 | } 222 | // Truncate keys that are too long 223 | if len(rs.EncryptionKey) > 32 { 224 | rs.EncryptionKey = rs.EncryptionKey[:32] 225 | } 226 | } 227 | 228 | rs.TlsServerCertsPEM = repl.ReplaceAll(rs.TlsServerCertsPEM, "") 229 | rs.TlsServerCertsPath = repl.ReplaceAll(rs.TlsServerCertsPath, "") 230 | 231 | // TODO: these are non-string fields so they can't easily be substituted at runtime :( 232 | // rs.DB 233 | // rs.Compression 234 | // rs.TlsEnabled 235 | // rs.TlsInsecure 236 | // rs.RouteByLatency 237 | // rs.RouteRandomly 238 | 239 | // Construct Address from Host and Port if not explicitly provided 240 | if len(rs.Address) == 0 { 241 | 242 | var maxAddrs int 243 | var host, port string 244 | 245 | maxHosts := len(rs.Host) 246 | maxPorts := len(rs.Port) 247 | 248 | // Determine max number of addresses 249 | if maxHosts > maxPorts { 250 | maxAddrs = maxHosts 251 | } else { 252 | maxAddrs = maxPorts 253 | } 254 | 255 | for i := 0; i < maxAddrs; i++ { 256 | if i < maxHosts { 257 | host = rs.Host[i] 258 | } 259 | if i < maxPorts { 260 | port = rs.Port[i] 261 | } 262 | rs.Address = append(rs.Address, net.JoinHostPort(host, port)) 263 | } 264 | // Clear host and port values 265 | rs.Host = []string{} 266 | rs.Port = []string{} 267 | } 268 | 269 | return rs.initRedisClient(ctx) 270 | } 271 | 272 | func (rs *RedisStorage) Cleanup() error { 273 | // Close the Redis connection 274 | if rs.client != nil { 275 | rs.client.Close() 276 | } 277 | 278 | return nil 279 | } 280 | 281 | type storageConfig struct { 282 | StorageRaw json.RawMessage `json:"storage,omitempty" caddy:"namespace=caddy.storage inline_key=module"` 283 | } 284 | 285 | func cmdRedisStorageRepair(fl caddycmd.Flags) (int, error) { 286 | 287 | configFile := fl.String("config") 288 | 289 | // Load configuration file (if not specified, will look in usual locations) 290 | cfg, _, err := caddycmd.LoadConfig(configFile, "") 291 | if err != nil { 292 | return caddy.ExitCodeFailedStartup, fmt.Errorf("Unable to load config file: %v", err) 293 | } 294 | 295 | // Unmarshall the storage configuration into a temporary struct 296 | var storeCfg storageConfig 297 | if err := json.Unmarshal(cfg, &storeCfg); err != nil || storeCfg.StorageRaw == nil { 298 | return caddy.ExitCodeFailedStartup, fmt.Errorf("Unable to unmarshal configuration: %v", err) 299 | } 300 | 301 | ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()}) 302 | defer cancel() 303 | 304 | // Load module 305 | module, err := ctx.LoadModule(&storeCfg, "StorageRaw") 306 | if err != nil { 307 | return caddy.ExitCodeFailedStartup, err 308 | } 309 | // Ensure loaded module is the correct type 310 | if reflect.TypeOf(module) != reflect.TypeOf(&RedisStorage{}) { 311 | return caddy.ExitCodeFailedStartup, fmt.Errorf("Loaded storage module does not support Redis") 312 | } 313 | 314 | rs := module.(*RedisStorage) 315 | if err := rs.Repair(ctx, ""); err != nil { 316 | return caddy.ExitCodeFailedStartup, err 317 | } 318 | 319 | return caddy.ExitCodeSuccess, nil 320 | } 321 | 322 | // Interface guards 323 | var ( 324 | _ caddy.CleanerUpper = (*RedisStorage)(nil) 325 | _ caddy.Provisioner = (*RedisStorage)(nil) 326 | _ caddy.StorageConverter = (*RedisStorage)(nil) 327 | _ caddyfile.Unmarshaler = (*RedisStorage)(nil) 328 | ) 329 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Redis Storage module for Caddy / Certmagic 2 | 3 | This is comprehensive rewrite of the [gamalan/caddy-tlsredis](https://github.com/gamalan/caddy-tlsredis) Redis storage plugin for Caddy. Some highlights of this new version: 4 | 5 | * Fixes some logic issues with configuration parsing 6 | * Introduces a new storage compression option 7 | * Features a Sorted Set indexing algorithm for more efficient directory traversal 8 | * Implements support for Redis Cluster and Sentinal / Failover servers 9 | 10 | The plugin uses the latest version of the [go-redis/redis](https://github.com/go-redis/redis) client and [redislock](https://github.com/bsm/redislock) for the locking mechanism. See [distlock](https://redis.io/topics/distlock) for more information on the lock algorithm. 11 | 12 | ## Upgrading 13 | 14 | Previous configuration options are generally compatible except for the following: 15 | - `CADDY_CLUSTERING_REDIS_*` environment variables, which have been removed. To configure this Redis Storage module using environment variables, see the example configuration below. 16 | - When using the JSON config format directly, be aware of these differences between the old plugin and this plugin: 17 | - The `address`, `host`, and `port` config fields are now arrays of strings to allow for clustering. The old format was only a string. 18 | - The old plugin had a config field for `value_prefix`, which is not included in this plugin. 19 | - The config field `aes_key` is now `encryption_key`. 20 | - The `timeout` config field used to accept only an integer, now accepts only a string. 21 | 22 | Upgrading to this module from [gamalan/caddy-tlsredis](https://github.com/gamalan/caddy-tlsredis) will require an [export storage](https://caddyserver.com/docs/command-line#caddy-storage) from the previous installation then [import storage](https://caddyserver.com/docs/command-line#caddy-storage) into a new Caddy server instance running this module. The default `key_prefix` has been changed from `caddytls` to `caddy` to provide a simpler migration path so keys stored by the [gamalan/caddy-tlsredis](https://github.com/gamalan/caddy-tlsredis) plugin and this module can co-exist in the same Redis database. 23 | 24 | ## Configuration 25 | 26 | ### Simple mode (Standalone) 27 | 28 | Enable Redis storage for Caddy by specifying the module configuration in the Caddyfile: 29 | ``` 30 | { 31 | // All values are optional, below are the defaults 32 | storage redis { 33 | host 127.0.0.1 34 | port 6379 35 | address 127.0.0.1:6379 // derived from host and port values if not explicitly set 36 | username "" 37 | password "" 38 | db 0 39 | timeout 5 40 | key_prefix "caddy" 41 | encryption_key "" // default no encryption; enable by specifying a secret key containing 32 characters (longer keys will be truncated) 42 | compression false // default no compression; if set to true, stored values are compressed using "compress/flate" 43 | tls_enabled false 44 | tls_insecure true 45 | } 46 | } 47 | 48 | :443 { 49 | 50 | } 51 | ``` 52 | Note that `host` and `port` values can be configured (or accept the defaults) OR an `address` value can be specified, which will override the `host` and `port` values. 53 | 54 | Here's the same config as above, but in JSON format (which Caddy parses all configs into under the hood): 55 | ``` 56 | { 57 | "storage": { 58 | "address": [ 59 | "127.0.0.1:6379" 60 | ], 61 | "client_type": "simple", 62 | "compression": false, 63 | "db": 0, 64 | "encryption_key": "", 65 | "host": [ 66 | "127.0.0.1" 67 | ], 68 | "key_prefix": "caddy", 69 | "master_name": "", 70 | "module": "redis", 71 | "password": "", 72 | "port": [ 73 | "6379" 74 | ], 75 | "route_by_latency": false, 76 | "route_randomly": false, 77 | "timeout": "5", 78 | "tls_enabled": false, 79 | "tls_insecure": true, 80 | "tls_server_certs_path": "", 81 | "tls_server_certs_pem": "", 82 | "username": "" 83 | }, 84 | "apps": { 85 | "http": { 86 | "servers": { 87 | "srv0": { 88 | "listen": [ 89 | ":443" 90 | ] 91 | } 92 | } 93 | } 94 | } 95 | } 96 | ``` 97 | 98 | The module supports [environment variable substitution](https://caddyserver.com/docs/caddyfile/concepts#environment-variables) within Caddyfile parameters: 99 | ``` 100 | { 101 | storage redis { 102 | username "{$REDIS_USERNAME}" 103 | password "{$REDIS_PASSWORD}" 104 | encryption_key "{$REDIS_ENCRYPTION_KEY}" 105 | compression true 106 | } 107 | } 108 | ``` 109 | 110 | NOTE however the following configuration options do not (yet) support runtime substition: 111 | 112 | - db 113 | - compression 114 | - tls_enabled 115 | - tls_insecure 116 | - route_by_latency 117 | - route_randomly 118 | 119 | ### Cluster mode 120 | 121 | Connect to a Redis Cluster by specifying a flag before the main configuration block or by configuring more than one Redis host / address: 122 | ``` 123 | { 124 | storage redis cluster { 125 | address { 126 | redis-cluster-001.example.com:6379 127 | redis-cluster-002.example.com:6379 128 | redis-cluster-003.example.com:6379 129 | } 130 | } 131 | } 132 | ``` 133 | 134 | It is also possible to configure the cluster by specifying a single configuration endpoint: 135 | ``` 136 | { 137 | storage redis cluster { 138 | address clustercfg.redis-cluster.example.com:6379 139 | } 140 | } 141 | ``` 142 | 143 | Parameters `address`, `host`, and `port` all accept either single or multiple input values. A cluster of Redis servers all listening on the same port can be configured simply: 144 | ``` 145 | { 146 | storage redis cluster { 147 | host { 148 | redis-cluster-001.example.com 149 | redis-cluster-002.example.com 150 | redis-cluster-003.example.com 151 | } 152 | port 6379 153 | route_by_latency false 154 | route_randomly false 155 | } 156 | } 157 | ``` 158 | Two optional boolean cluster parameters `route_by_latency` and `route_randomly` are supported. Either option can be enabled by setting the value to `true` (Default is false) 159 | 160 | ### Failover mode (Sentinal) 161 | 162 | Connecting to Redis servers managed by Sentinal requires both the `failover` flag and `master_name` value to be set: 163 | ``` 164 | { 165 | storage redis failover { 166 | address { 167 | redis-sentinal-001.example.com:26379 168 | redis-sentinal-002.example.com:26379 169 | redis-sentinal-003.example.com:26379 170 | } 171 | master_name redis-master-server 172 | } 173 | } 174 | ``` 175 | Failover mode also supports the `route_by_latency` and `route_randomly` cluster configuration parameters. 176 | 177 | Optionally, if your Sentinel servers require authentication, you can specify the `sentinel_password` parameter. 178 | 179 | ### Enabling TLS 180 | 181 | TLS is disabled by default, and if enabled, accepts any server certificate by default. If TLS is and certificate verification are enabled as in the following example, then the system trust store will be used to validate the server certificate. 182 | ``` 183 | { 184 | storage redis { 185 | host 127.0.0.01 186 | port 6379 187 | tls_enabled true 188 | tls_insecure false 189 | } 190 | } 191 | ``` 192 | You can also use the `tls_server_certs_pem` option to provide one or more PEM encoded certificates to trust: 193 | ``` 194 | { 195 | storage redis { 196 | host 127.0.0.01 197 | port 6379 198 | tls_enabled true 199 | tls_insecure false 200 | tls_server_certs_pem < 225 | -----END CERTIFICATE----- 226 | CERTIFICATES 227 | } 228 | } 229 | ``` 230 | If you prefer not to put certificates in your Caddyfile, you can also put the series of PEM certificates into a file and use `tls_server_certs_path` to point Caddy at it. 231 | 232 | ## Maintenance 233 | 234 | This module has been architected to maintain a hierarchical index of storage items using Redis Sorted Sets to optimize directory listing operations typically used by Caddy. It is possible for this index structure to become corrupted in the event of an unexpected system crash or loss of power. If you suspect your Caddy storage has been corrupted, it is possible to repair this index structure from the command line by issuing the following command: 235 | 236 | ``` 237 | caddy redis repair --config /path/to/Caddyfile 238 | ``` 239 | 240 | Note that the config parameter is optional (but recommended); if not specified Caddy look for a configuration file named "Caddyfile" in the current working directory. 241 | -------------------------------------------------------------------------------- /storage_test.go: -------------------------------------------------------------------------------- 1 | package storageredis 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "io/fs" 8 | "strconv" 9 | "sync" 10 | "testing" 11 | "time" 12 | 13 | "github.com/stretchr/testify/assert" 14 | "go.uber.org/zap" 15 | ) 16 | 17 | const ( 18 | TestDB = 9 19 | TestKeyPrefix = "redistlstest" 20 | TestEncryptionKey = "1aedfs5kcM8lOZO3BDDMuwC23croDwRr" 21 | TestCompression = true 22 | 23 | TestKeyCertPath = "certificates" 24 | TestKeyAcmePath = TestKeyCertPath + "/acme-v02.api.letsencrypt.org-directory" 25 | TestKeyExamplePath = TestKeyAcmePath + "/example.com" 26 | TestKeyExampleCrt = TestKeyExamplePath + "/example.com.crt" 27 | TestKeyExampleKey = TestKeyExamplePath + "/example.com.key" 28 | TestKeyExampleJson = TestKeyExamplePath + "/example.com.json" 29 | TestKeyLock = "locks/issue_cert_example.com" 30 | TestKeyLockIterations = 250 31 | ) 32 | 33 | var ( 34 | TestValueCrt = []byte("TG9yZW0gaXBzdW0gZG9sb3Igc2l0IGFtZXQsIGNvbnNlY3RldHVyIGFkaXBpc2NpbmcgZWxpdCwgc2VkIGRvIGVpdXNtb2QgdGVtcG9yIGluY2lkaWR1bnQgdXQgbGFib3JlIGV0IGRvbG9yZSBtYWduYSBhbGlxdWEu") 35 | TestValueKey = []byte("RWdlc3RhcyBlZ2VzdGFzIGZyaW5naWxsYSBwaGFzZWxsdXMgZmF1Y2lidXMgc2NlbGVyaXNxdWUgZWxlaWZlbmQgZG9uZWMgcHJldGl1bSB2dWxwdXRhdGUuIFRpbmNpZHVudCBvcm5hcmUgbWFzc2EgZWdldC4=") 36 | TestValueJson = []byte("U2FnaXR0aXMgYWxpcXVhbSBtYWxlc3VhZGEgYmliZW5kdW0gYXJjdSB2aXRhZSBlbGVtZW50dW0uIEludGVnZXIgbWFsZXN1YWRhIG51bmMgdmVsIHJpc3VzIGNvbW1vZG8gdml2ZXJyYSBtYWVjZW5hcy4=") 37 | ) 38 | 39 | // Emulate the Provision() Caddy function 40 | func provisionRedisStorage(t *testing.T) (*RedisStorage, context.Context) { 41 | 42 | ctx := context.Background() 43 | rs := New() 44 | 45 | logger, _ := zap.NewProduction() 46 | rs.logger = logger.Sugar() 47 | 48 | rs.DB = TestDB 49 | rs.KeyPrefix = TestKeyPrefix 50 | rs.EncryptionKey = TestEncryptionKey 51 | rs.Compression = TestCompression 52 | 53 | err := rs.finalizeConfiguration(ctx) 54 | assert.NoError(t, err) 55 | 56 | // Skip test if unable to connect to Redis server 57 | if err != nil { 58 | t.Skip() 59 | return nil, nil 60 | } 61 | 62 | // Flush the current Redis database 63 | err = rs.client.FlushDB(ctx).Err() 64 | assert.NoError(t, err) 65 | 66 | return rs, ctx 67 | } 68 | 69 | func TestRedisStorage_Store(t *testing.T) { 70 | 71 | rs, ctx := provisionRedisStorage(t) 72 | 73 | err := rs.Store(ctx, TestKeyExampleCrt, TestValueCrt) 74 | assert.NoError(t, err) 75 | } 76 | 77 | func TestRedisStorage_Exists(t *testing.T) { 78 | 79 | rs, ctx := provisionRedisStorage(t) 80 | 81 | exists := rs.Exists(ctx, TestKeyExampleCrt) 82 | assert.False(t, exists) 83 | 84 | err := rs.Store(ctx, TestKeyExampleCrt, TestValueCrt) 85 | assert.NoError(t, err) 86 | 87 | exists = rs.Exists(ctx, TestKeyExampleCrt) 88 | assert.True(t, exists) 89 | } 90 | 91 | func TestRedisStorage_Load(t *testing.T) { 92 | 93 | rs, ctx := provisionRedisStorage(t) 94 | 95 | err := rs.Store(ctx, TestKeyExampleCrt, TestValueCrt) 96 | assert.NoError(t, err) 97 | 98 | loadedValue, err := rs.Load(ctx, TestKeyExampleCrt) 99 | assert.NoError(t, err) 100 | 101 | assert.Equal(t, TestValueCrt, loadedValue) 102 | } 103 | 104 | func TestRedisStorage_Delete(t *testing.T) { 105 | 106 | rs, ctx := provisionRedisStorage(t) 107 | 108 | err := rs.Store(ctx, TestKeyExampleCrt, TestValueCrt) 109 | assert.NoError(t, err) 110 | 111 | err = rs.Delete(ctx, TestKeyExampleCrt) 112 | assert.NoError(t, err) 113 | 114 | exists := rs.Exists(ctx, TestKeyExampleCrt) 115 | assert.False(t, exists) 116 | 117 | loadedValue, err := rs.Load(ctx, TestKeyExampleCrt) 118 | assert.Nil(t, loadedValue) 119 | 120 | notExist := errors.Is(err, fs.ErrNotExist) 121 | assert.True(t, notExist) 122 | } 123 | 124 | func TestRedisStorage_Stat(t *testing.T) { 125 | 126 | rs, ctx := provisionRedisStorage(t) 127 | size := int64(len(TestValueCrt)) 128 | 129 | startTime := time.Now() 130 | err := rs.Store(ctx, TestKeyExampleCrt, TestValueCrt) 131 | endTime := time.Now() 132 | assert.NoError(t, err) 133 | 134 | stat, err := rs.Stat(ctx, TestKeyExampleCrt) 135 | assert.NoError(t, err) 136 | 137 | assert.Equal(t, TestKeyExampleCrt, stat.Key) 138 | assert.WithinRange(t, stat.Modified, startTime, endTime) 139 | assert.Equal(t, size, stat.Size) 140 | } 141 | 142 | func TestRedisStorage_List(t *testing.T) { 143 | 144 | rs, ctx := provisionRedisStorage(t) 145 | 146 | // Store two key values 147 | err := rs.Store(ctx, TestKeyExampleCrt, TestValueCrt) 148 | assert.NoError(t, err) 149 | 150 | err = rs.Store(ctx, TestKeyExampleKey, TestValueKey) 151 | assert.NoError(t, err) 152 | 153 | // List recursively from root 154 | keys, err := rs.List(ctx, "", true) 155 | assert.NoError(t, err) 156 | assert.Len(t, keys, 2) 157 | assert.Contains(t, keys, TestKeyExampleCrt) 158 | assert.Contains(t, keys, TestKeyExampleKey) 159 | assert.NotContains(t, keys, TestKeyExampleJson) 160 | 161 | // List recursively from first directory 162 | keys, err = rs.List(ctx, TestKeyCertPath, true) 163 | assert.NoError(t, err) 164 | assert.Len(t, keys, 2) 165 | assert.Contains(t, keys, TestKeyExampleCrt) 166 | assert.Contains(t, keys, TestKeyExampleKey) 167 | assert.NotContains(t, keys, TestKeyExampleJson) 168 | 169 | // Store third key value 170 | err = rs.Store(ctx, TestKeyExampleJson, TestValueJson) 171 | assert.NoError(t, err) 172 | 173 | // List recursively from root 174 | keys, err = rs.List(ctx, "", true) 175 | assert.NoError(t, err) 176 | assert.Len(t, keys, 3) 177 | assert.Contains(t, keys, TestKeyExampleCrt) 178 | assert.Contains(t, keys, TestKeyExampleKey) 179 | assert.Contains(t, keys, TestKeyExampleJson) 180 | 181 | // List recursively from first directory 182 | keys, err = rs.List(ctx, TestKeyCertPath, true) 183 | assert.NoError(t, err) 184 | assert.Len(t, keys, 3) 185 | assert.Contains(t, keys, TestKeyExampleCrt) 186 | assert.Contains(t, keys, TestKeyExampleKey) 187 | assert.Contains(t, keys, TestKeyExampleJson) 188 | 189 | // Delete one key value 190 | err = rs.Delete(ctx, TestKeyExampleCrt) 191 | assert.NoError(t, err) 192 | 193 | // List recursively from root 194 | keys, err = rs.List(ctx, "", true) 195 | assert.NoError(t, err) 196 | assert.Len(t, keys, 2) 197 | assert.NotContains(t, keys, TestKeyExampleCrt) 198 | assert.Contains(t, keys, TestKeyExampleKey) 199 | assert.Contains(t, keys, TestKeyExampleJson) 200 | 201 | keys, err = rs.List(ctx, TestKeyCertPath, true) 202 | assert.NoError(t, err) 203 | assert.Len(t, keys, 2) 204 | assert.NotContains(t, keys, TestKeyExampleCrt) 205 | assert.Contains(t, keys, TestKeyExampleKey) 206 | assert.Contains(t, keys, TestKeyExampleJson) 207 | 208 | // Delete remaining two key values 209 | err = rs.Delete(ctx, TestKeyExampleKey) 210 | assert.NoError(t, err) 211 | 212 | err = rs.Delete(ctx, TestKeyExampleJson) 213 | assert.NoError(t, err) 214 | 215 | // List recursively from root 216 | keys, err = rs.List(ctx, "", true) 217 | assert.NoError(t, err) 218 | assert.Empty(t, keys) 219 | 220 | keys, err = rs.List(ctx, TestKeyCertPath, true) 221 | assert.NoError(t, err) 222 | assert.Empty(t, keys) 223 | } 224 | 225 | func TestRedisStorage_ListNonRecursive(t *testing.T) { 226 | 227 | rs, ctx := provisionRedisStorage(t) 228 | 229 | // Store three key values 230 | err := rs.Store(ctx, TestKeyExampleCrt, TestValueCrt) 231 | assert.NoError(t, err) 232 | 233 | err = rs.Store(ctx, TestKeyExampleKey, TestValueKey) 234 | assert.NoError(t, err) 235 | 236 | err = rs.Store(ctx, TestKeyExampleJson, TestValueJson) 237 | assert.NoError(t, err) 238 | 239 | // List non-recursively from root 240 | keys, err := rs.List(ctx, "", false) 241 | assert.NoError(t, err) 242 | assert.Len(t, keys, 1) 243 | assert.Contains(t, keys, TestKeyCertPath) 244 | 245 | // List non-recursively from first level 246 | keys, err = rs.List(ctx, TestKeyCertPath, false) 247 | assert.NoError(t, err) 248 | assert.Len(t, keys, 1) 249 | assert.Contains(t, keys, TestKeyAcmePath) 250 | 251 | // List non-recursively from second level 252 | keys, err = rs.List(ctx, TestKeyAcmePath, false) 253 | assert.NoError(t, err) 254 | assert.Len(t, keys, 1) 255 | assert.Contains(t, keys, TestKeyExamplePath) 256 | 257 | // List non-recursively from third level 258 | keys, err = rs.List(ctx, TestKeyExamplePath, false) 259 | assert.NoError(t, err) 260 | assert.Len(t, keys, 3) 261 | assert.Contains(t, keys, TestKeyExampleCrt) 262 | assert.Contains(t, keys, TestKeyExampleKey) 263 | assert.Contains(t, keys, TestKeyExampleJson) 264 | } 265 | 266 | func TestRedisStorage_LockUnlock(t *testing.T) { 267 | 268 | rs, ctx := provisionRedisStorage(t) 269 | 270 | err := rs.Lock(ctx, TestKeyLock) 271 | assert.NoError(t, err) 272 | 273 | err = rs.Unlock(ctx, TestKeyLock) 274 | assert.NoError(t, err) 275 | } 276 | 277 | func TestRedisStorage_MultipleLocks(t *testing.T) { 278 | 279 | var wg sync.WaitGroup 280 | var rsArray = make([]*RedisStorage, TestKeyLockIterations) 281 | 282 | for i := 0; i < len(rsArray); i++ { 283 | rsArray[i], _ = provisionRedisStorage(t) 284 | wg.Add(1) 285 | } 286 | 287 | for i := 0; i < len(rsArray); i++ { 288 | suffix := strconv.Itoa(i / 10) 289 | go lockAndUnlock(t, &wg, rsArray[i], TestKeyLock+"-"+suffix) 290 | } 291 | 292 | wg.Wait() 293 | } 294 | 295 | func lockAndUnlock(t *testing.T, wg *sync.WaitGroup, rs *RedisStorage, key string) { 296 | 297 | defer wg.Done() 298 | 299 | err := rs.Lock(context.Background(), key) 300 | assert.NoError(t, err) 301 | 302 | err = rs.Unlock(context.Background(), key) 303 | assert.NoError(t, err) 304 | } 305 | 306 | func TestRedisStorage_String(t *testing.T) { 307 | 308 | rs := New() 309 | redacted := `REDACTED` 310 | 311 | t.Run("Validate password", func(t *testing.T) { 312 | t.Run("is redacted when set", func(t *testing.T) { 313 | testrs := New() 314 | password := "iAmASuperSecurePassword" 315 | rs.Password = password 316 | err := json.Unmarshal([]byte(rs.String()), &testrs) 317 | assert.NoError(t, err) 318 | assert.Equal(t, redacted, testrs.Password) 319 | assert.Equal(t, password, rs.Password) 320 | }) 321 | rs.Password = "" 322 | t.Run("is empty if not set", func(t *testing.T) { 323 | err := json.Unmarshal([]byte(rs.String()), &rs) 324 | assert.NoError(t, err) 325 | assert.Empty(t, rs.Password) 326 | }) 327 | }) 328 | t.Run("Validate AES key", func(t *testing.T) { 329 | t.Run("is redacted when set", func(t *testing.T) { 330 | testrs := New() 331 | aeskey := "abcdefghijklmnopqrstuvwxyz123456" 332 | rs.EncryptionKey = aeskey 333 | err := json.Unmarshal([]byte(rs.String()), &testrs) 334 | assert.NoError(t, err) 335 | assert.Equal(t, redacted, testrs.EncryptionKey) 336 | assert.Equal(t, aeskey, rs.EncryptionKey) 337 | }) 338 | rs.EncryptionKey = "" 339 | t.Run("is empty if not set", func(t *testing.T) { 340 | err := json.Unmarshal([]byte(rs.String()), &rs) 341 | assert.NoError(t, err) 342 | assert.Empty(t, rs.EncryptionKey) 343 | }) 344 | }) 345 | } 346 | 347 | func TestRedisStorage_GetClient(t *testing.T) { 348 | rs, _ := provisionRedisStorage(t) 349 | assert.NotNil(t, rs.GetClient()) 350 | } 351 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b h1:uUXgbcPDK3KpW29o4iy7GtuappbWT0l5NaMo9H9pJDw= 2 | github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A= 3 | github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A= 4 | github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= 5 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 6 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 7 | github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= 8 | github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= 9 | github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= 10 | github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= 11 | github.com/bsm/redislock v0.9.4 h1:X/Wse1DPpiQgHbVYRE9zv6m070UcKoOGekgvpNhiSvw= 12 | github.com/bsm/redislock v0.9.4/go.mod h1:Epf7AJLiSFwLCiZcfi6pWFO/8eAYrYpQXFxEDPoDeAk= 13 | github.com/caddyserver/caddy/v2 v2.7.5 h1:HoysvZkLcN2xJExEepaFHK92Qgs7xAiCFydN5x5Hs6Q= 14 | github.com/caddyserver/caddy/v2 v2.7.5/go.mod h1:XswQdR/IFwTNsIx+GDze2jYy+7WbjrSe1GEI20/PZ84= 15 | github.com/caddyserver/certmagic v0.19.2 h1:HZd1AKLx4592MalEGQS39DKs2ZOAJCEM/xYPMQ2/ui0= 16 | github.com/caddyserver/certmagic v0.19.2/go.mod h1:fsL01NomQ6N+kE2j37ZCnig2MFosG+MIO4ztnmG/zz8= 17 | github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= 18 | github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 19 | github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 20 | github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 21 | github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= 22 | github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= 23 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 24 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 25 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 26 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 27 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= 28 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 29 | github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= 30 | github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 31 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= 32 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= 33 | github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= 34 | github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 35 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 36 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 37 | github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJYCmNdQXq6neHJOYx3V6jnqNEec= 38 | github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= 39 | github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= 40 | github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 41 | github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= 42 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 43 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 44 | github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= 45 | github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg= 46 | github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= 47 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 48 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 49 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 50 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 51 | github.com/libdns/libdns v0.2.1 h1:Wu59T7wSHRgtA0cfxC+n1c/e+O3upJGWytknkmFEDis= 52 | github.com/libdns/libdns v0.2.1/go.mod h1:yQCXzk1lEZmmCPa857bnk4TsOiqYasqpyOEeSObbb40= 53 | github.com/mholt/acmez v1.2.0 h1:1hhLxSgY5FvH5HCnGUuwbKY2VQVo8IU7rxXKSnZ7F30= 54 | github.com/mholt/acmez v1.2.0/go.mod h1:VT9YwH1xgNX1kmYY89gY8xPJC84BFAisjo8Egigt4kE= 55 | github.com/miekg/dns v1.1.55 h1:GoQ4hpsj0nFLYe+bWiCToyrBEJXkQfOOIvFGFy0lEgo= 56 | github.com/miekg/dns v1.1.55/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY= 57 | github.com/onsi/ginkgo/v2 v2.9.5 h1:+6Hr4uxzP4XIUyAkg61dWBw8lb/gc4/X5luuxN/EC+Q= 58 | github.com/onsi/ginkgo/v2 v2.9.5/go.mod h1:tvAoo1QUJwNEU2ITftXTpR7R1RbCzoZUOs3RonqW57k= 59 | github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE= 60 | github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg= 61 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 62 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 63 | github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= 64 | github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= 65 | github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= 66 | github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= 67 | github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE= 68 | github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc= 69 | github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= 70 | github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= 71 | github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= 72 | github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= 73 | github.com/quic-go/quic-go v0.49.1 h1:e5JXpUyF0f2uFjckQzD8jTghZrOUK1xxDqqZhlwixo0= 74 | github.com/quic-go/quic-go v0.49.1/go.mod h1:s2wDnmCdooUQBmQfpUSTCYBl1/D4FcqbULMMkASvR6s= 75 | github.com/redis/go-redis/v9 v9.3.0 h1:RiVDjmig62jIWp7Kk4XVLs0hzV6pI3PyTnnL0cnn0u0= 76 | github.com/redis/go-redis/v9 v9.3.0/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= 77 | github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= 78 | github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= 79 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 80 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 81 | github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= 82 | github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= 83 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 84 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 85 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 86 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 87 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 88 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 89 | github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY= 90 | github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= 91 | github.com/zeebo/blake3 v0.2.3 h1:TFoLXsjeXqRNFxSbk35Dk4YtszE/MQQGK10BH4ptoTg= 92 | github.com/zeebo/blake3 v0.2.3/go.mod h1:mjJjZpnsyIVtVgTOSpJ9vmRE4wgDeyt2HU3qXvvKCaQ= 93 | github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= 94 | github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= 95 | go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= 96 | go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= 97 | go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= 98 | go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= 99 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 100 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 101 | go.uber.org/zap v1.25.0 h1:4Hvk6GtkucQ790dqmj7l1eEnRdKm3k3ZUrUMS2d5+5c= 102 | go.uber.org/zap v1.25.0/go.mod h1:JIAUzQIH94IC4fOJQm7gMmBJP5k7wQfdcnYdPoEXJYk= 103 | golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= 104 | golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= 105 | golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= 106 | golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= 107 | golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= 108 | golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= 109 | golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= 110 | golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= 111 | golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= 112 | golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 113 | golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 114 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 115 | golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= 116 | golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 117 | golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= 118 | golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= 119 | golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= 120 | golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= 121 | golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= 122 | golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 123 | golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= 124 | golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= 125 | google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= 126 | google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 127 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 128 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 129 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 130 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 131 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 132 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 133 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /storage.go: -------------------------------------------------------------------------------- 1 | package storageredis 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "crypto/x509" 7 | "encoding/json" 8 | "errors" 9 | "fmt" 10 | "io/fs" 11 | "os" 12 | "path" 13 | "strconv" 14 | "strings" 15 | "sync" 16 | "time" 17 | 18 | "github.com/bsm/redislock" 19 | "github.com/caddyserver/certmagic" 20 | "github.com/redis/go-redis/v9" 21 | "go.uber.org/zap" 22 | ) 23 | 24 | const ( 25 | // Redis client type 26 | defaultClientType = "simple" 27 | 28 | // Redis server host 29 | defaultHost = "127.0.0.1" 30 | 31 | // Redis server port 32 | defaultPort = "6379" 33 | 34 | // Redis server database 35 | defaultDb = 0 36 | 37 | // Prepended to every Redis key 38 | defaultKeyPrefix = "caddy" 39 | 40 | // Compress values before storing 41 | defaultCompression = false 42 | 43 | // Connect to Redis via TLS 44 | defaultTLS = false 45 | 46 | // Do not verify TLS cerficate 47 | defaultTLSInsecure = true 48 | 49 | // Routing option for cluster client 50 | defaultRouteByLatency = false 51 | 52 | // Routing option for cluster client 53 | defaultRouteRandomly = false 54 | 55 | // Redis lock time-to-live 56 | lockTTL = 5 * time.Second 57 | 58 | // Delay between attempts to obtain Lock 59 | lockPollInterval = 1 * time.Second 60 | 61 | // How frequently the Lock's TTL should be updated 62 | lockRefreshInterval = 3 * time.Second 63 | ) 64 | 65 | // RedisStorage implements a Caddy storage backend for Redis 66 | // It supports Single (Standalone), Cluster, or Sentinal (Failover) Redis server configurations. 67 | type RedisStorage struct { 68 | // ClientType specifies the Redis client type. Valid values are "cluster" or "failover" 69 | ClientType string `json:"client_type"` 70 | // Address The full address of the Redis server. Example: "127.0.0.1:6379" 71 | // If not defined, will be generated from Host and Port parameters. 72 | Address []string `json:"address"` 73 | // Host The Redis server hostname or IP address. Default: "127.0.0.1" 74 | Host []string `json:"host"` 75 | // Host The Redis server port number. Default: "6379" 76 | Port []string `json:"port"` 77 | // DB The Redis server database number. Default: 0 78 | DB int `json:"db"` 79 | // Timeout The Redis server timeout in seconds. Default: 5 80 | Timeout string `json:"timeout"` 81 | // Username The username for authenticating with the Redis server. Default: "" (No authentication) 82 | Username string `json:"username"` 83 | // Password The password for authenticating with the Redis server. Default: "" (No authentication) 84 | Password string `json:"password"` 85 | // SentinelPassword Optional The Redis sentinel password if authentication is enabled. 86 | SentinelPassword string `json:"sentinel_password"` 87 | // MasterName Only required when connecting to Redis via Sentinal (Failover mode). Default "" 88 | MasterName string `json:"master_name"` 89 | // KeyPrefix A string prefix that is appended to Redis keys. Default: "caddy" 90 | // Useful when the Redis server is used by multiple applications. 91 | KeyPrefix string `json:"key_prefix"` 92 | // EncryptionKey A key string used to symmetrically encrypt and decrypt data stored in Redis. 93 | // The key must be exactly 32 characters, longer values will be truncated. Default: "" (No encryption) 94 | EncryptionKey string `json:"encryption_key"` 95 | // Compression Specifies whether values should be compressed before storing in Redis. Default: false 96 | Compression bool `json:"compression"` 97 | // TlsEnabled controls whether TLS will be used to connect to the Redis 98 | // server. False by default. 99 | TlsEnabled bool `json:"tls_enabled"` 100 | // TlsInsecure controls whether the client will verify the server 101 | // certificate. See `InsecureSkipVerify` in `tls.Config` for details. True 102 | // by default. 103 | // https://pkg.go.dev/crypto/tls#Config 104 | TlsInsecure bool `json:"tls_insecure"` 105 | // TlsServerCertsPEM is a series of PEM encoded certificates that will be 106 | // used by the client to validate trust in the Redis server's certificate 107 | // instead of the system trust store. May not be specified alongside 108 | // `TlsServerCertsPath`. See `x509.CertPool.AppendCertsFromPem` for details. 109 | // https://pkg.go.dev/crypto/x509#CertPool.AppendCertsFromPEM 110 | TlsServerCertsPEM string `json:"tls_server_certs_pem"` 111 | // TlsServerCertsPath is the path to a file containing a series of PEM 112 | // encoded certificates that will be used by the client to validate trust in 113 | // the Redis server's certificate instead of the system trust store. May not 114 | // be specified alongside `TlsServerCertsPem`. See 115 | // `x509.CertPool.AppendCertsFromPem` for details. 116 | // https://pkg.go.dev/crypto/x509#CertPool.AppendCertsFromPEM 117 | TlsServerCertsPath string `json:"tls_server_certs_path"` 118 | // RouteByLatency Route commands by latency, only used in Cluster mode. Default: false 119 | RouteByLatency bool `json:"route_by_latency"` 120 | // RouteRandomly Route commands randomly, only used in Cluster mode. Default: false 121 | RouteRandomly bool `json:"route_randomly"` 122 | 123 | client redis.UniversalClient 124 | locker *redislock.Client 125 | logger *zap.SugaredLogger 126 | locks *sync.Map 127 | } 128 | 129 | type StorageData struct { 130 | Value []byte `json:"value"` 131 | Modified time.Time `json:"modified"` 132 | Size int64 `json:"size"` 133 | Compression int `json:"compression"` 134 | Encryption int `json:"encryption"` 135 | } 136 | 137 | // create a new RedisStorage struct with default values 138 | func New() *RedisStorage { 139 | 140 | rs := RedisStorage{ 141 | ClientType: defaultClientType, 142 | Host: []string{defaultHost}, 143 | Port: []string{defaultPort}, 144 | DB: defaultDb, 145 | KeyPrefix: defaultKeyPrefix, 146 | Compression: defaultCompression, 147 | TlsEnabled: defaultTLS, 148 | TlsInsecure: defaultTLSInsecure, 149 | } 150 | return &rs 151 | } 152 | 153 | // Initilalize Redis client and locker 154 | func (rs *RedisStorage) initRedisClient(ctx context.Context) error { 155 | 156 | // Configure options for all client types 157 | clientOpts := redis.UniversalOptions{ 158 | Addrs: rs.Address, 159 | MasterName: rs.MasterName, 160 | Username: rs.Username, 161 | Password: rs.Password, 162 | DB: rs.DB, 163 | } 164 | 165 | // Configure timeout values if defined 166 | if rs.Timeout != "" { 167 | // Was already sanity-checked in UnmarshalCaddyfile 168 | timeout, _ := strconv.Atoi(rs.Timeout) 169 | clientOpts.DialTimeout = time.Duration(timeout) * time.Second 170 | clientOpts.ReadTimeout = time.Duration(timeout) * time.Second 171 | clientOpts.WriteTimeout = time.Duration(timeout) * time.Second 172 | } 173 | 174 | // Configure cluster routing options 175 | if rs.RouteByLatency || rs.RouteRandomly { 176 | clientOpts.RouteByLatency = rs.RouteByLatency 177 | clientOpts.RouteRandomly = rs.RouteRandomly 178 | } 179 | 180 | // Configure TLS support if enabled 181 | if rs.TlsEnabled { 182 | clientOpts.TLSConfig = &tls.Config{ 183 | InsecureSkipVerify: rs.TlsInsecure, 184 | } 185 | 186 | if len(rs.TlsServerCertsPEM) > 0 && len(rs.TlsServerCertsPath) > 0 { 187 | return fmt.Errorf("Cannot specify TlsServerCertsPEM alongside TlsServerCertsPath") 188 | } 189 | 190 | if len(rs.TlsServerCertsPEM) > 0 || len(rs.TlsServerCertsPath) > 0 { 191 | certPool := x509.NewCertPool() 192 | pem := []byte(rs.TlsServerCertsPEM) 193 | 194 | if len(rs.TlsServerCertsPath) > 0 { 195 | var err error 196 | pem, err = os.ReadFile(rs.TlsServerCertsPath) 197 | if err != nil { 198 | return fmt.Errorf("Failed to load PEM server certs from file %s: %v", rs.TlsServerCertsPath, err) 199 | } 200 | } 201 | 202 | if !certPool.AppendCertsFromPEM(pem) { 203 | return fmt.Errorf("Failed to load PEM server certs") 204 | } 205 | 206 | clientOpts.TLSConfig.RootCAs = certPool 207 | } 208 | } 209 | 210 | // Create appropriate Redis client type 211 | if rs.ClientType == "failover" && clientOpts.MasterName != "" { 212 | 213 | if rs.SentinelPassword != "" { 214 | clientOpts.SentinelPassword = rs.SentinelPassword 215 | } 216 | 217 | // Create new Redis Failover Cluster client 218 | clusterClient := redis.NewFailoverClusterClient(clientOpts.Failover()) 219 | 220 | // Test connection to the Redis cluster 221 | err := clusterClient.ForEachShard(ctx, func(ctx context.Context, shard *redis.Client) error { 222 | return shard.Ping(ctx).Err() 223 | }) 224 | if err != nil { 225 | return err 226 | } 227 | rs.client = clusterClient 228 | 229 | } else if rs.ClientType == "cluster" || len(clientOpts.Addrs) > 1 { 230 | 231 | // Create new Redis Cluster client 232 | clusterClient := redis.NewClusterClient(clientOpts.Cluster()) 233 | 234 | // Test connection to the Redis cluster 235 | err := clusterClient.ForEachShard(ctx, func(ctx context.Context, shard *redis.Client) error { 236 | return shard.Ping(ctx).Err() 237 | }) 238 | if err != nil { 239 | return err 240 | } 241 | rs.client = clusterClient 242 | 243 | } else { 244 | 245 | // Create new Redis simple standalone client 246 | rs.client = redis.NewClient(clientOpts.Simple()) 247 | 248 | // Test connection to the Redis server 249 | err := rs.client.Ping(ctx).Err() 250 | if err != nil { 251 | return err 252 | } 253 | } 254 | 255 | // Create new redislock client 256 | rs.locker = redislock.New(rs.client) 257 | rs.locks = &sync.Map{} 258 | return nil 259 | } 260 | 261 | func (rs RedisStorage) Store(ctx context.Context, key string, value []byte) error { 262 | 263 | var size = len(value) 264 | var compressionFlag = 0 265 | var encryptionFlag = 0 266 | 267 | // Compress value if compression enabled 268 | if rs.Compression { 269 | compressedValue, err := rs.compress(value) 270 | if err != nil { 271 | return fmt.Errorf("Unable to compress value for %s: %v", key, err) 272 | } 273 | // Check compression efficiency 274 | if size > len(compressedValue) { 275 | value = compressedValue 276 | compressionFlag = 1 277 | } 278 | } 279 | 280 | // Encrypt value if encryption enabled 281 | if rs.EncryptionKey != "" { 282 | encryptedValue, err := rs.encrypt(value) 283 | if err != nil { 284 | return fmt.Errorf("Unable to encrypt value for %s: %v", key, err) 285 | } 286 | value = encryptedValue 287 | encryptionFlag = 1 288 | } 289 | 290 | sd := &StorageData{ 291 | Value: value, 292 | Modified: time.Now(), 293 | Size: int64(size), 294 | Compression: compressionFlag, 295 | Encryption: encryptionFlag, 296 | } 297 | 298 | jsonValue, err := json.Marshal(sd) 299 | if err != nil { 300 | return fmt.Errorf("Unable to marshal value for %s: %v", key, err) 301 | } 302 | 303 | var prefixedKey = rs.prefixKey(key) 304 | 305 | // Create directory structure set for current key 306 | score := float64(sd.Modified.Unix()) 307 | if err := rs.storeDirectoryRecord(ctx, prefixedKey, score, false, false); err != nil { 308 | return fmt.Errorf("Unable to create directory for key %s: %v", key, err) 309 | } 310 | 311 | // Store the key value in the Redis database 312 | if err := rs.client.Set(ctx, prefixedKey, jsonValue, 0).Err(); err != nil { 313 | return fmt.Errorf("Unable to set value for %s: %v", key, err) 314 | } 315 | 316 | return nil 317 | } 318 | 319 | func (rs RedisStorage) Load(ctx context.Context, key string) ([]byte, error) { 320 | 321 | var sd *StorageData 322 | var value []byte 323 | var err error 324 | 325 | sd, err = rs.loadStorageData(ctx, key) 326 | if err != nil { 327 | return nil, err 328 | } 329 | value = sd.Value 330 | 331 | // Decrypt value if encrypted 332 | if sd.Encryption > 0 { 333 | value, err = rs.decrypt(value) 334 | if err != nil { 335 | return nil, fmt.Errorf("Unable to decrypt value for %s: %v", key, err) 336 | } 337 | } 338 | 339 | // Uncompress value if compressed 340 | if sd.Compression > 0 { 341 | value, err = rs.uncompress(value) 342 | if err != nil { 343 | return nil, fmt.Errorf("Unable to uncompress value for %s: %v", key, err) 344 | } 345 | } 346 | 347 | return value, nil 348 | } 349 | 350 | func (rs RedisStorage) Delete(ctx context.Context, key string) error { 351 | 352 | var prefixedKey = rs.prefixKey(key) 353 | 354 | // Remove current key from directory structure 355 | if err := rs.deleteDirectoryRecord(ctx, prefixedKey, false); err != nil { 356 | return fmt.Errorf("Unable to delete directory for key %s: %v", key, err) 357 | } 358 | 359 | if err := rs.client.Del(ctx, prefixedKey).Err(); err != nil { 360 | return fmt.Errorf("Unable to delete key %s: %v", key, err) 361 | } 362 | 363 | return nil 364 | } 365 | 366 | func (rs RedisStorage) Exists(ctx context.Context, key string) bool { 367 | 368 | // Redis returns a count of the number of keys found 369 | exists := rs.client.Exists(ctx, rs.prefixKey(key)).Val() 370 | 371 | return exists > 0 372 | } 373 | 374 | func (rs RedisStorage) List(ctx context.Context, dir string, recursive bool) ([]string, error) { 375 | 376 | var keyList []string 377 | var currKey = rs.prefixKey(dir) 378 | 379 | // Obtain range of all direct children stored in the Sorted Set 380 | keys, err := rs.client.ZRange(ctx, currKey, 0, -1).Result() 381 | if err != nil { 382 | return keyList, fmt.Errorf("Unable to get range on sorted set '%s': %v", currKey, err) 383 | } 384 | 385 | // Iterate over each child key 386 | for _, k := range keys { 387 | // Directory keys will have a "/" suffix 388 | trimmedKey := strings.TrimSuffix(k, "/") 389 | // Reconstruct the full path of child key 390 | fullPathKey := path.Join(dir, trimmedKey) 391 | // If current key is a directory 392 | if recursive && k != trimmedKey { 393 | // Recursively traverse all child directories 394 | childKeys, err := rs.List(ctx, fullPathKey, recursive) 395 | if err != nil { 396 | return keyList, err 397 | } 398 | keyList = append(keyList, childKeys...) 399 | } else { 400 | keyList = append(keyList, fullPathKey) 401 | } 402 | } 403 | 404 | return keyList, nil 405 | } 406 | 407 | func (rs RedisStorage) Stat(ctx context.Context, key string) (certmagic.KeyInfo, error) { 408 | 409 | sd, err := rs.loadStorageData(ctx, key) 410 | if err != nil { 411 | return certmagic.KeyInfo{}, err 412 | } 413 | 414 | return certmagic.KeyInfo{ 415 | Key: key, 416 | Modified: sd.Modified, 417 | Size: sd.Size, 418 | IsTerminal: true, 419 | }, nil 420 | } 421 | 422 | func (rs *RedisStorage) Lock(ctx context.Context, name string) error { 423 | 424 | key := rs.prefixLock(name) 425 | 426 | for { 427 | // try to obtain lock 428 | lock, err := rs.locker.Obtain(ctx, key, lockTTL, &redislock.Options{}) 429 | 430 | // lock successfully obtained 431 | if err == nil { 432 | // store the lock in sync.map, needed for unlocking 433 | rs.locks.Store(key, lock) 434 | // keep the lock fresh as long as we hold it 435 | go func(ctx context.Context, lock *redislock.Lock) { 436 | for { 437 | // refresh the Redis lock 438 | err := lock.Refresh(ctx, lockTTL, nil) 439 | if err != nil { 440 | return 441 | } 442 | 443 | select { 444 | case <-time.After(lockRefreshInterval): 445 | case <-ctx.Done(): 446 | return 447 | } 448 | } 449 | }(ctx, lock) 450 | 451 | return nil 452 | } 453 | 454 | // check for unexpected error 455 | if err != redislock.ErrNotObtained { 456 | return fmt.Errorf("Unable to obtain lock for %s: %v", key, err) 457 | } 458 | 459 | // lock already exists, wait and try again until cancelled 460 | select { 461 | case <-time.After(lockPollInterval): 462 | case <-ctx.Done(): 463 | return ctx.Err() 464 | } 465 | } 466 | } 467 | 468 | func (rs *RedisStorage) Unlock(ctx context.Context, name string) error { 469 | 470 | key := rs.prefixLock(name) 471 | 472 | // load and delete lock from sync.Map 473 | if syncMapLock, loaded := rs.locks.LoadAndDelete(key); loaded { 474 | 475 | // type assertion for Redis lock 476 | if lock, ok := syncMapLock.(*redislock.Lock); ok { 477 | 478 | // release the Redis lock 479 | if err := lock.Release(ctx); err != nil { 480 | return fmt.Errorf("Unable to release lock for %s: %v", key, err) 481 | } 482 | } 483 | } 484 | 485 | return nil 486 | } 487 | 488 | func (rs *RedisStorage) Repair(ctx context.Context, dir string) error { 489 | 490 | var currKey = rs.prefixKey(dir) 491 | 492 | // Perform recursive full key scan only from the root directory 493 | if dir == "" { 494 | 495 | var pointer uint64 = 0 496 | var scanCount int64 = 500 497 | 498 | for { 499 | // Scan for keys matching the search query and iterate until all found 500 | keys, nextPointer, err := rs.client.Scan(ctx, pointer, currKey+"*", scanCount).Result() 501 | if err != nil { 502 | return fmt.Errorf("Unable to scan path %s: %v", currKey, err) 503 | } 504 | 505 | // Iterate over returned keys 506 | for _, key := range keys { 507 | // Proceed only if key type is regular string value 508 | keyType := rs.client.Type(ctx, key).Val() 509 | if keyType != "string" { 510 | continue 511 | } 512 | 513 | // Load the Storage Data struct to obtain modified time 514 | trimmedKey := rs.trimKey(key) 515 | sd, err := rs.loadStorageData(ctx, trimmedKey) 516 | if err != nil { 517 | rs.logger.Infof("Unable to load storage data for key '%s'", trimmedKey) 518 | continue 519 | } 520 | 521 | // Repair directory structure set for current key 522 | score := float64(sd.Modified.Unix()) 523 | if err := rs.storeDirectoryRecord(ctx, key, score, true, false); err != nil { 524 | return fmt.Errorf("Unable to repair directory index for key '%s'", trimmedKey) 525 | } 526 | } 527 | 528 | // End of results reached 529 | if nextPointer == 0 { 530 | break 531 | } 532 | pointer = nextPointer 533 | } 534 | } 535 | 536 | // Obtain range of all direct children stored in the Sorted Set 537 | keys, err := rs.client.ZRange(ctx, currKey, 0, -1).Result() 538 | if err != nil { 539 | return fmt.Errorf("Unable to get range on sorted set '%s': %v", currKey, err) 540 | } 541 | 542 | // Iterate over each child key 543 | for _, k := range keys { 544 | // Directory keys will have a "/" suffix 545 | trimmedKey := strings.TrimSuffix(k, "/") 546 | 547 | // Reconstruct the full path of child key 548 | fullPathKey := path.Join(dir, trimmedKey) 549 | 550 | // Remove key from set if it does not exist 551 | if !rs.Exists(ctx, fullPathKey) { 552 | rs.client.ZRem(ctx, currKey, k) 553 | rs.logger.Infof("Removed non-existant record '%s' from directory '%s'", k, currKey) 554 | continue 555 | } 556 | 557 | // If current key is a directory 558 | if k != trimmedKey { 559 | // Recursively traverse all child directories 560 | if err := rs.Repair(ctx, fullPathKey); err != nil { 561 | return err 562 | } 563 | } 564 | } 565 | 566 | return nil 567 | } 568 | 569 | func (rs *RedisStorage) trimKey(key string) string { 570 | return strings.TrimPrefix(strings.TrimPrefix(key, rs.KeyPrefix), "/") 571 | } 572 | 573 | func (rs *RedisStorage) prefixKey(key string) string { 574 | return path.Join(rs.KeyPrefix, key) 575 | } 576 | 577 | func (rs *RedisStorage) prefixLock(key string) string { 578 | return rs.prefixKey(path.Join("locks", key)) 579 | } 580 | 581 | func (rs RedisStorage) loadStorageData(ctx context.Context, key string) (*StorageData, error) { 582 | 583 | data, err := rs.client.Get(ctx, rs.prefixKey(key)).Bytes() 584 | if data == nil || errors.Is(err, redis.Nil) { 585 | return nil, fs.ErrNotExist 586 | } else if err != nil { 587 | return nil, fmt.Errorf("Unable to get data for %s: %v", key, err) 588 | } 589 | 590 | sd := &StorageData{} 591 | if err := json.Unmarshal(data, sd); err != nil { 592 | return nil, fmt.Errorf("Unable to unmarshal value for %s: %v", key, err) 593 | } 594 | 595 | return sd, nil 596 | } 597 | 598 | // Store directory index in Redis ZSet structure for fast and efficient travseral in List() 599 | func (rs RedisStorage) storeDirectoryRecord(ctx context.Context, key string, score float64, repair, baseIsDir bool) error { 600 | 601 | // Extract parent directory and base (file) names from key 602 | dir, base := rs.splitDirectoryKey(key, baseIsDir) 603 | // Reached the top-level directory 604 | if dir == "." { 605 | return nil 606 | } 607 | 608 | // Insert "base" value into Set "dir" 609 | success, err := rs.client.ZAdd(ctx, dir, redis.Z{Score: score, Member: base}).Result() 610 | if err != nil { 611 | return fmt.Errorf("Unable to add %s to Set %s: %v", base, dir, err) 612 | } 613 | 614 | // Non-zero success means base was added to the set (not already there) 615 | if success > 0 || repair { 616 | if success > 0 && repair { 617 | rs.logger.Infof("Repaired index for record '%s' in directory '%s'", base, dir) 618 | } 619 | // recursively create parent directory until already 620 | // created (success == 0) or top level reached 621 | rs.storeDirectoryRecord(ctx, dir, score, repair, true) 622 | } 623 | 624 | return nil 625 | } 626 | 627 | // Delete record from directory index Redis ZSet structure 628 | func (rs RedisStorage) deleteDirectoryRecord(ctx context.Context, key string, baseIsDir bool) error { 629 | 630 | dir, base := rs.splitDirectoryKey(key, baseIsDir) 631 | // Reached the top-level directory 632 | if dir == "." { 633 | return nil 634 | } 635 | 636 | // Remove "base" value from Set "dir" 637 | if err := rs.client.ZRem(ctx, dir, base).Err(); err != nil { 638 | return fmt.Errorf("Unable to remove %s from Set %s: %v", base, dir, err) 639 | } 640 | 641 | // Check if Set "dir" still exists (removing the last item deletes the set) 642 | if exists := rs.client.Exists(ctx, dir).Val(); exists == 0 { 643 | // Recursively delete parent directory until parent 644 | // is not empty (exists > 0) or top level reached 645 | rs.deleteDirectoryRecord(ctx, dir, true) 646 | } 647 | 648 | return nil 649 | } 650 | 651 | func (rs RedisStorage) splitDirectoryKey(key string, baseIsDir bool) (string, string) { 652 | 653 | dir := path.Dir(key) 654 | base := path.Base(key) 655 | 656 | // Append slash to indicate directory 657 | if baseIsDir { 658 | base = base + "/" 659 | } 660 | 661 | return dir, base 662 | } 663 | 664 | func (rs RedisStorage) String() string { 665 | redacted := `REDACTED` 666 | if rs.Password != "" { 667 | rs.Password = redacted 668 | } 669 | if rs.EncryptionKey != "" { 670 | rs.EncryptionKey = redacted 671 | } 672 | strVal, _ := json.Marshal(rs) 673 | return string(strVal) 674 | } 675 | 676 | // GetClient returns the Redis client initialized by this storage. 677 | // 678 | // This is useful for other modules that need to interact with the same Redis instance. 679 | // The return type of GetClient is "any" for forward-compatibility new versions of go-redis. 680 | // The returned value must usually be cast to redis.UniversalClient. 681 | func (rs *RedisStorage) GetClient() any { 682 | return rs.client 683 | } 684 | --------------------------------------------------------------------------------