Skip to content

Commit b518bd5

Browse files
author
Michael Mulvanny
committed
Remove command-line password usage
Signed-off-by: Michael Mulvanny <[email protected]>
1 parent 1cc6cf1 commit b518bd5

File tree

9 files changed

+185
-109
lines changed

9 files changed

+185
-109
lines changed

charts/redis-operator/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ kubectl create secret tls <webhook-server-cert> --key tls.key --cert tls.crt -n
9292
| certificate.secretName | string | `"webhook-server-cert"` | |
9393
| certmanager.apiVersion | string | `"cert-manager.io/v1"` | |
9494
| certmanager.enabled | bool | `false` | |
95+
| featureGates.AvoidCommandLinePassword | bool | `false` | |
9596
| featureGates.GenerateConfigInInitContainer | bool | `false` | |
9697
| issuer.create | bool | `true` | |
9798
| issuer.email | string | `"[email protected]"` | |

charts/redis-operator/values.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@ securityContext: {}
106106

107107
# Feature gates for alpha/experimental features
108108
featureGates:
109+
# Never execute redis-cli -a <password>, even if authentication cannot succeed without it
110+
AvoidCommandLinePassword: false
109111
# Enable generating Redis configuration using an init container instead of a regular container
110112
GenerateConfigInInitContainer: false
111113

docs/content/en/docs/Advance Configuration/Feature Gates/_index.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,34 @@ Feature gates can be configured in the Helm chart values:
1717
featureGates:
1818
# Enable generating Redis configuration using an init container instead of a regular container
1919
GenerateConfigInInitContainer: false
20+
# Never execute redis-cli -a <password>, even if authentication cannot succeed without it
21+
AvoidCommandLinePassword: false
2022
```
2123
2224
## Available Feature Gates
2325
26+
### AvoidCommandLinePassword
27+
28+
When enabled, Redis Operator will never execute `redis-cli -a <password>`, which can leak passwords. The Operator sets the
29+
`REDISCLI_AUTH` variable on all Redis pods, so the password does not need to be provided on the command line and it is normally
30+
safe to turn this on unless you are simultaneously upgrading the operator. This is an alpha feature and may change in future releases.
31+
32+
However, if you upgrade from a version that does not add `REDISCLI_AUTH` to the pods (a behavior introduced in the same version that
33+
added `AvoidCommandLinePassword`), simultaneously enabling `AvoidCommandLinePassword` will make Redis Operator unable to manage
34+
your current pods, since `-a <password>` is still needed on them. Hence, to guarantee that the Redis password will never be included
35+
on a command line, you must either risk an operator downtime or upgrade in two steps:
36+
37+
1. Upgrade to a version that adds `REDISCLI_AUTH` to the pods (which was introduced at the same time as `AvoidCommandLinePassword`).
38+
2. Turn on `AvoidCommandLinePassword`.
39+
40+
**Default**: `false`
41+
42+
**Usage**:
43+
```yaml
44+
featureGates:
45+
AvoidCommandLinePassword: true
46+
```
47+
2448
### GenerateConfigInInitContainer
2549

2650
When enabled, Redis configuration will be generated using an init container instead of a regular container. This is an alpha feature and may change in future releases.

internal/cmd/manager/cmd.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,9 @@ func addFlags(cmd *cobra.Command, opts *managerOptions) {
9797
cmd.Flags().BoolVar(&opts.enableLeaderElection, "leader-elect", false, "Enable leader election for controller manager. Enabling this will ensure there is only one active controller manager.")
9898
cmd.Flags().BoolVar(&opts.enableWebhooks, "enable-webhooks", internalenv.IsWebhookEnabled(), "Enable webhooks")
9999
cmd.Flags().IntVar(&opts.maxConcurrentReconciles, "max-concurrent-reconciles", 1, "Max concurrent reconciles")
100-
cmd.Flags().StringVar(&opts.featureGatesString, "feature-gates", internalenv.GetFeatureGates(), "A set of key=value pairs that describe feature gates for alpha/experimental features. "+
101-
"Options are:\n GenerateConfigInInitContainer=true|false: enables using init container for config generation")
100+
cmd.Flags().StringVar(&opts.featureGatesString, "feature-gates", internalenv.GetFeatureGates(), "A set of key=value pairs that describe feature gates for alpha/experimental features. Options are:"+
101+
"\n GenerateConfigInInitContainer=true|false: enables using init container for config generation"+
102+
"\n AvoidCommandLinePassword=true|false: prevents using -a <password> in redis-cli commands")
102103
cmd.Flags().Duration(
103104
operator.KubeClientTimeoutMGRFlag,
104105
60*time.Second,

internal/features/features.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@ const (
99
// GenerateConfigInInitContainer enables generating Redis configuration using an init container
1010
// instead of a regular container
1111
GenerateConfigInInitContainer featuregate.Feature = "GenerateConfigInInitContainer"
12+
AvoidCommandLinePassword featuregate.Feature = "AvoidCommandLinePassword"
1213
)
1314

1415
// DefaultRedisOperatorFeatureGates consists of all known Redis operator feature gates.
1516
// To add a new feature, define a key for it above and add it here.
1617
var DefaultRedisOperatorFeatureGates = map[featuregate.Feature]featuregate.FeatureSpec{
1718
GenerateConfigInInitContainer: {Default: false, PreRelease: featuregate.Alpha},
19+
AvoidCommandLinePassword: {Default: false, PreRelease: featuregate.Alpha},
1820
}
1921

2022
// MutableFeatureGate is a feature gate that can be dynamically set

internal/k8sutils/cluster-scaling.go

Lines changed: 31 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -35,15 +35,11 @@ func ReshardRedisCluster(ctx context.Context, client kubernetes.Interface, cr *r
3535
}
3636
cmd = []string{"redis-cli", "--cluster", "reshard"}
3737
cmd = append(cmd, getEndpoint(ctx, client, cr, transferPOD))
38-
if cr.Spec.KubernetesConfig.ExistingPasswordSecret != nil {
39-
pass, err := getRedisPassword(ctx, client, cr.Namespace, *cr.Spec.KubernetesConfig.ExistingPasswordSecret.Name, *cr.Spec.KubernetesConfig.ExistingPasswordSecret.Key)
40-
if err != nil {
41-
log.FromContext(ctx).Error(err, "error in getting redis password")
42-
return
43-
}
44-
cmd = append(cmd, "-a")
45-
cmd = append(cmd, pass)
38+
authArgs, err := getRedisClusterAuthArgs(ctx, client, cr, transferNodeName)
39+
if err != nil {
40+
log.FromContext(ctx).Error(err, "Failed to get password authentication arguments")
4641
}
42+
cmd = append(cmd, authArgs...)
4743

4844
cmd = append(cmd, getRedisTLSArgs(cr.Spec.TLS, transferNodeName)...)
4945

@@ -129,7 +125,7 @@ func getRedisNodeID(ctx context.Context, client kubernetes.Interface, cr *rcvb2.
129125

130126
// Rebalance the Redis CLuster using the Empty Master Nodes
131127
func RebalanceRedisClusterEmptyMasters(ctx context.Context, client kubernetes.Interface, cr *rcvb2.RedisCluster) {
132-
// cmd = redis-cli --cluster rebalance <redis>:<port> --cluster-use-empty-masters -a <pass>
128+
// cmd = redis-cli --cluster rebalance <redis>:<port> --cluster-use-empty-masters
133129
var cmd []string
134130
pod := RedisDetails{
135131
PodName: cr.Name + "-leader-1",
@@ -138,14 +134,11 @@ func RebalanceRedisClusterEmptyMasters(ctx context.Context, client kubernetes.In
138134
cmd = []string{"redis-cli", "--cluster", "rebalance"}
139135
cmd = append(cmd, getEndpoint(ctx, client, cr, pod))
140136
cmd = append(cmd, "--cluster-use-empty-masters")
141-
if cr.Spec.KubernetesConfig.ExistingPasswordSecret != nil {
142-
pass, err := getRedisPassword(ctx, client, cr.Namespace, *cr.Spec.KubernetesConfig.ExistingPasswordSecret.Name, *cr.Spec.KubernetesConfig.ExistingPasswordSecret.Key)
143-
if err != nil {
144-
log.FromContext(ctx).Error(err, "Error in getting redis password")
145-
}
146-
cmd = append(cmd, "-a")
147-
cmd = append(cmd, pass)
137+
authArgs, err := getRedisClusterAuthArgs(ctx, client, cr, cr.Name+"-leader-0")
138+
if err != nil {
139+
log.FromContext(ctx).Error(err, "Failed to get password authentication arguments")
148140
}
141+
cmd = append(cmd, authArgs...)
149142

150143
cmd = append(cmd, getRedisTLSArgs(cr.Spec.TLS, cr.Name+"-leader-0")...)
151144

@@ -175,22 +168,19 @@ func CheckIfEmptyMasters(ctx context.Context, client kubernetes.Interface, cr *r
175168

176169
// Rebalance Redis Cluster Would Rebalance the Redis Cluster without using the empty masters
177170
func RebalanceRedisCluster(ctx context.Context, client kubernetes.Interface, cr *rcvb2.RedisCluster) {
178-
// cmd = redis-cli --cluster rebalance <redis>:<port> -a <pass>
171+
// cmd = redis-cli --cluster rebalance <redis>:<port>
179172
var cmd []string
180173
pod := RedisDetails{
181174
PodName: cr.Name + "-leader-1",
182175
Namespace: cr.Namespace,
183176
}
184177
cmd = []string{"redis-cli", "--cluster", "rebalance"}
185178
cmd = append(cmd, getEndpoint(ctx, client, cr, pod))
186-
if cr.Spec.KubernetesConfig.ExistingPasswordSecret != nil {
187-
pass, err := getRedisPassword(ctx, client, cr.Namespace, *cr.Spec.KubernetesConfig.ExistingPasswordSecret.Name, *cr.Spec.KubernetesConfig.ExistingPasswordSecret.Key)
188-
if err != nil {
189-
log.FromContext(ctx).Error(err, "Error in getting redis password")
190-
}
191-
cmd = append(cmd, "-a")
192-
cmd = append(cmd, pass)
179+
authArgs, err := getRedisClusterAuthArgs(ctx, client, cr, cr.Name+"-leader-0")
180+
if err != nil {
181+
log.FromContext(ctx).Error(err, "Failed to get password authentication arguments")
193182
}
183+
cmd = append(cmd, authArgs...)
194184

195185
cmd = append(cmd, getRedisTLSArgs(cr.Spec.TLS, cr.Name+"-leader-0")...)
196186

@@ -211,14 +201,11 @@ func AddRedisNodeToCluster(ctx context.Context, client kubernetes.Interface, cr
211201
}
212202
cmd = append(cmd, getEndpoint(ctx, client, cr, newPod))
213203
cmd = append(cmd, getEndpoint(ctx, client, cr, existingPod))
214-
if cr.Spec.KubernetesConfig.ExistingPasswordSecret != nil {
215-
pass, err := getRedisPassword(ctx, client, cr.Namespace, *cr.Spec.KubernetesConfig.ExistingPasswordSecret.Name, *cr.Spec.KubernetesConfig.ExistingPasswordSecret.Key)
216-
if err != nil {
217-
log.FromContext(ctx).Error(err, "Error in getting redis password")
218-
}
219-
cmd = append(cmd, "-a")
220-
cmd = append(cmd, pass)
204+
authArgs, err := getRedisClusterAuthArgs(ctx, client, cr, cr.Name+"-leader-0")
205+
if err != nil {
206+
log.FromContext(ctx).Error(err, "Failed to get password authentication arguments")
221207
}
208+
cmd = append(cmd, authArgs...)
222209

223210
cmd = append(cmd, getRedisTLSArgs(cr.Spec.TLS, cr.Name+"-leader-0")...)
224211

@@ -259,14 +246,11 @@ func RemoveRedisFollowerNodesFromCluster(ctx context.Context, client kubernetes.
259246

260247
cmd = []string{"redis-cli"}
261248

262-
if cr.Spec.KubernetesConfig.ExistingPasswordSecret != nil {
263-
pass, err := getRedisPassword(ctx, client, cr.Namespace, *cr.Spec.KubernetesConfig.ExistingPasswordSecret.Name, *cr.Spec.KubernetesConfig.ExistingPasswordSecret.Key)
264-
if err != nil {
265-
log.FromContext(ctx).Error(err, "Error in getting redis password")
266-
}
267-
cmd = append(cmd, "-a")
268-
cmd = append(cmd, pass)
249+
authArgs, err := getRedisClusterAuthArgs(ctx, client, cr, cr.Name+"-leader-0")
250+
if err != nil {
251+
log.FromContext(ctx).Error(err, "Failed to get password authentication arguments")
269252
}
253+
cmd = append(cmd, authArgs...)
270254
cmd = append(cmd, getRedisTLSArgs(cr.Spec.TLS, cr.Name+"-leader-0")...)
271255

272256
lastLeaderPodNodeID := getRedisNodeID(ctx, client, cr, lastLeaderPod)
@@ -292,14 +276,11 @@ func RemoveRedisNodeFromCluster(ctx context.Context, client kubernetes.Interface
292276
cmd := []string{"redis-cli", "--cluster", "del-node"}
293277
cmd = append(cmd, getEndpoint(ctx, client, cr, existingPod))
294278
cmd = append(cmd, getRedisNodeID(ctx, client, cr, removePod))
295-
if cr.Spec.KubernetesConfig.ExistingPasswordSecret != nil {
296-
pass, err := getRedisPassword(ctx, client, cr.Namespace, *cr.Spec.KubernetesConfig.ExistingPasswordSecret.Name, *cr.Spec.KubernetesConfig.ExistingPasswordSecret.Key)
297-
if err != nil {
298-
log.FromContext(ctx).Error(err, "Error in getting redis password")
299-
}
300-
cmd = append(cmd, "-a")
301-
cmd = append(cmd, pass)
279+
authArgs, err := getRedisClusterAuthArgs(ctx, client, cr, cr.Name+"-leader-0")
280+
if err != nil {
281+
log.FromContext(ctx).Error(err, "Failed to get password authentication arguments")
302282
}
283+
cmd = append(cmd, authArgs...)
303284
cmd = append(cmd, getRedisTLSArgs(cr.Spec.TLS, cr.Name+"-leader-0")...)
304285
executeCommand(ctx, client, cr, cmd, cr.Name+"-leader-0")
305286
}
@@ -333,7 +314,7 @@ func verifyLeaderPodInfo(ctx context.Context, redisClient *redis.Client, podName
333314

334315
func ClusterFailover(ctx context.Context, client kubernetes.Interface, cr *rcvb2.RedisCluster, shardIdx int32) error {
335316
slavePodName := cr.Name + "-leader-" + strconv.Itoa(int(shardIdx))
336-
// cmd = redis-cli cluster failover -a <pass>
317+
// cmd = redis-cli cluster failover
337318
var cmd []string
338319
pod := RedisDetails{
339320
PodName: slavePodName,
@@ -344,14 +325,11 @@ func ClusterFailover(ctx context.Context, client kubernetes.Interface, cr *rcvb2
344325
return err
345326
}
346327
cmd = []string{"redis-cli", "-h", host, "-p", port}
347-
if cr.Spec.KubernetesConfig.ExistingPasswordSecret != nil {
348-
pass, err := getRedisPassword(ctx, client, cr.Namespace, *cr.Spec.KubernetesConfig.ExistingPasswordSecret.Name, *cr.Spec.KubernetesConfig.ExistingPasswordSecret.Key)
349-
if err != nil {
350-
log.FromContext(ctx).Error(err, "Error in getting redis password")
351-
}
352-
cmd = append(cmd, "-a")
353-
cmd = append(cmd, pass)
328+
authArgs, err := getRedisClusterAuthArgs(ctx, client, cr, slavePodName)
329+
if err != nil {
330+
log.FromContext(ctx).Error(err, "Failed to get password authentication arguments")
354331
}
332+
cmd = append(cmd, authArgs...)
355333

356334
cmd = append(cmd, getRedisTLSArgs(cr.Spec.TLS, slavePodName)...)
357335
cmd = append(cmd, "cluster", "failover")

internal/k8sutils/redis.go

Lines changed: 75 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
rcvb2 "github.com/OT-CONTAINER-KIT/redis-operator/api/rediscluster/v1beta2"
1515
rrvb2 "github.com/OT-CONTAINER-KIT/redis-operator/api/redisreplication/v1beta2"
1616
common "github.com/OT-CONTAINER-KIT/redis-operator/internal/controller/common"
17+
"github.com/OT-CONTAINER-KIT/redis-operator/internal/features"
1718
redis "github.com/redis/go-redis/v9"
1819
"github.com/samber/lo"
1920
corev1 "k8s.io/api/core/v1"
@@ -122,6 +123,66 @@ func getEndpoint(ctx context.Context, client kubernetes.Interface, cr *rcvb2.Red
122123
return host + ":" + strconv.Itoa(port)
123124
}
124125

126+
// checkRedisCLIAuthInEnv returns true if we can use the pod's REDISCLI_AUTH variable instead of sending redis-cli -a <password>.
127+
// It checks only variables specified via env[].valueFrom since this is what the operator sets; it does not look at envFrom.
128+
func checkRedisCLIAuthInEnv(ctx context.Context, client kubernetes.Interface, cr *rcvb2.RedisCluster, podName, secretName, secretKey string) (bool, error) {
129+
redisPod, err := client.CoreV1().Pods(cr.Namespace).Get(context.TODO(), podName, metav1.GetOptions{})
130+
if err != nil {
131+
log.FromContext(ctx).Error(err, "Error checking Redis pod's REDISCLI_AUTH variable", "namespace", cr.Namespace, "podName", podName)
132+
return false, err
133+
}
134+
135+
for _, tr := range redisPod.Spec.Containers {
136+
if tr.Name == cr.Name+"-leader" {
137+
for _, e := range tr.Env {
138+
if e.Name != "REDISCLI_AUTH" {
139+
continue
140+
}
141+
142+
if e.ValueFrom == nil || e.ValueFrom.SecretKeyRef == nil {
143+
continue
144+
}
145+
146+
if e.ValueFrom.SecretKeyRef.Name != secretName || e.ValueFrom.SecretKeyRef.Key != secretKey {
147+
return false, nil
148+
}
149+
150+
return true, nil
151+
}
152+
153+
log.FromContext(ctx).V(1).Info("Leader container not configured with REDISCLI_AUTH", "podName", podName)
154+
return false, nil
155+
}
156+
}
157+
158+
log.FromContext(ctx).V(1).Info("Leader container not found in pod", "podName", podName)
159+
return false, nil
160+
}
161+
162+
func getRedisClusterAuthArgs(ctx context.Context, client kubernetes.Interface, cr *rcvb2.RedisCluster, podName string) ([]string, error) {
163+
if cr.Spec.KubernetesConfig.ExistingPasswordSecret != nil {
164+
passwordInEnv, err := checkRedisCLIAuthInEnv(ctx, client, cr, podName, *cr.Spec.KubernetesConfig.ExistingPasswordSecret.Name, *cr.Spec.KubernetesConfig.ExistingPasswordSecret.Key)
165+
if err != nil {
166+
return []string{}, fmt.Errorf("error checking pod authentication config: %w", err)
167+
}
168+
169+
if !passwordInEnv {
170+
if features.Enabled(features.AvoidCommandLinePassword) {
171+
return []string{}, errors.New("refusing to use command-line authentication because AvoidCommandLinePassword is set")
172+
}
173+
174+
pass, err := getRedisPassword(ctx, client, cr.Namespace, *cr.Spec.KubernetesConfig.ExistingPasswordSecret.Name, *cr.Spec.KubernetesConfig.ExistingPasswordSecret.Key)
175+
if err != nil {
176+
return []string{}, fmt.Errorf("error getting Redis password: %w", err)
177+
}
178+
179+
return []string{"-a", pass}, nil
180+
}
181+
}
182+
183+
return []string{}, nil
184+
}
185+
125186
// CreateSingleLeaderRedisCommand will create command for single leader cluster creation
126187
func CreateSingleLeaderRedisCommand(ctx context.Context, cr *rcvb2.RedisCluster) RedisInvocation {
127188
cmd := RedisInvocation{
@@ -245,13 +306,12 @@ func ExecuteRedisClusterCommand(ctx context.Context, client kubernetes.Interface
245306
cmd = CreateMultipleLeaderRedisCommand(ctx, client, cr)
246307
}
247308

248-
if cr.Spec.KubernetesConfig.ExistingPasswordSecret != nil {
249-
pass, err := getRedisPassword(ctx, client, cr.Namespace, *cr.Spec.KubernetesConfig.ExistingPasswordSecret.Name, *cr.Spec.KubernetesConfig.ExistingPasswordSecret.Key)
250-
if err != nil {
251-
log.FromContext(ctx).Error(err, "Error in getting redis password")
252-
}
253-
cmd.AddFlag("-a")
254-
cmd.AddFlag(pass)
309+
authArgs, err := getRedisClusterAuthArgs(ctx, client, cr, cr.Name+"-leader-0")
310+
if err != nil {
311+
log.FromContext(ctx).Error(err, "Failed to get password authentication arguments")
312+
}
313+
for _, arg := range authArgs {
314+
cmd.AddFlag(arg)
255315
}
256316
cmd.AddFlag(getRedisTLSArgs(cr.Spec.TLS, cr.Name+"-leader-0")...)
257317
executeCommand(ctx, client, cr, cmd.Args(), cr.Name+"-leader-0")
@@ -274,14 +334,11 @@ func createRedisReplicationCommand(ctx context.Context, client kubernetes.Interf
274334
cmd = append(cmd, getEndpoint(ctx, client, cr, followerPod))
275335
cmd = append(cmd, getEndpoint(ctx, client, cr, leaderPod))
276336
cmd = append(cmd, "--cluster-slave")
277-
if cr.Spec.KubernetesConfig.ExistingPasswordSecret != nil {
278-
pass, err := getRedisPassword(ctx, client, cr.Namespace, *cr.Spec.KubernetesConfig.ExistingPasswordSecret.Name, *cr.Spec.KubernetesConfig.ExistingPasswordSecret.Key)
279-
if err != nil {
280-
log.FromContext(ctx).Error(err, "Failed to retrieve Redis password", "Secret", *cr.Spec.KubernetesConfig.ExistingPasswordSecret.Name)
281-
} else {
282-
cmd = append(cmd, "-a", pass)
283-
}
337+
authArgs, err := getRedisClusterAuthArgs(ctx, client, cr, leaderPod.PodName)
338+
if err != nil {
339+
log.FromContext(ctx).Error(err, "Failed to get password authentication arguments")
284340
}
341+
cmd = append(cmd, authArgs...)
285342
cmd = append(cmd, getRedisTLSArgs(cr.Spec.TLS, leaderPod.PodName)...)
286343
return cmd
287344
}
@@ -444,14 +501,11 @@ func RedisClusterStatusHealth(ctx context.Context, client kubernetes.Interface,
444501
defer redisClient.Close()
445502

446503
cmd := []string{"redis-cli", "--cluster", "check", fmt.Sprintf("127.0.0.1:%d", *cr.Spec.Port)}
447-
if cr.Spec.KubernetesConfig.ExistingPasswordSecret != nil {
448-
pass, err := getRedisPassword(ctx, client, cr.Namespace, *cr.Spec.KubernetesConfig.ExistingPasswordSecret.Name, *cr.Spec.KubernetesConfig.ExistingPasswordSecret.Key)
449-
if err != nil {
450-
log.FromContext(ctx).Error(err, "Error in getting redis password")
451-
}
452-
cmd = append(cmd, "-a")
453-
cmd = append(cmd, pass)
504+
authArgs, err := getRedisClusterAuthArgs(ctx, client, cr, cr.Name+"-leader-0")
505+
if err != nil {
506+
log.FromContext(ctx).Error(err, "Failed to get password authentication arguments")
454507
}
508+
cmd = append(cmd, authArgs...)
455509
cmd = append(cmd, getRedisTLSArgs(cr.Spec.TLS, cr.Name+"-leader-0")...)
456510
out, err := executeCommand1(ctx, client, cr, cmd, cr.Name+"-leader-0")
457511
if err != nil {

0 commit comments

Comments
 (0)