diff --git a/src/sentinel.c b/src/sentinel.c index f6a1f75bd3..372e4b6406 100644 --- a/src/sentinel.c +++ b/src/sentinel.c @@ -458,6 +458,25 @@ const char *preMonitorCfgName[] = { "announce-hostnames" }; +/* Returns 1 if the string contains control characters (0x00-0x1F or 0x7F), + * which must be rejected to prevent config injection via newlines/etc. */ +int sentinelStringContainsControlChars(sds s) { + for (size_t i = 0; i < sdslen(s); i++) { + unsigned char c = (unsigned char)s[i]; + if (c < 0x20 || c == 0x7F) return 1; + } + return 0; +} + +/* Append an sds value to dest, quoting it with sdscatrepr only if the value + * contains characters that need escaping (spaces, quotes, control chars, etc.). + * Simple values are appended as-is, preserving the traditional config format. */ +static sds sentinelSdscatConfigArg(sds dest, sds value) { + if (sdsneedsrepr(value)) + return sdscatrepr(dest, value, sdslen(value)); + return sdscatsds(dest, value); +} + /* This function overwrites a few normal Redis config default with Sentinel * specific defaults. */ void initSentinelConfig(void) { @@ -2048,8 +2067,13 @@ void rewriteConfigSentinelOption(struct rewriteConfigState *state) { /* sentinel monitor */ master = dictGetVal(de); master_addr = sentinelGetCurrentMasterAddress(master); + + /* Pre-compute the safely-formatted master name for config serialization. + * Only quoted if it contains characters requiring escaping. */ + sds qname = sentinelSdscatConfigArg(sdsempty(), master->name); + line = sdscatprintf(sdsempty(),"sentinel monitor %s %s %d %d", - master->name, announceSentinelAddr(master_addr), master_addr->port, + qname, announceSentinelAddr(master_addr), master_addr->port, master->quorum); rewriteConfigRewriteLine(state,"sentinel monitor",line,1); /* rewriteConfigMarkAsProcessed is handled after the loop */ @@ -2058,7 +2082,7 @@ void rewriteConfigSentinelOption(struct rewriteConfigState *state) { if (master->down_after_period != sentinel_default_down_after) { line = sdscatprintf(sdsempty(), "sentinel down-after-milliseconds %s %ld", - master->name, (long) master->down_after_period); + qname, (long) master->down_after_period); rewriteConfigRewriteLine(state,"sentinel down-after-milliseconds",line,1); /* rewriteConfigMarkAsProcessed is handled after the loop */ } @@ -2067,7 +2091,7 @@ void rewriteConfigSentinelOption(struct rewriteConfigState *state) { if (master->failover_timeout != sentinel_default_failover_timeout) { line = sdscatprintf(sdsempty(), "sentinel failover-timeout %s %ld", - master->name, (long) master->failover_timeout); + qname, (long) master->failover_timeout); rewriteConfigRewriteLine(state,"sentinel failover-timeout",line,1); /* rewriteConfigMarkAsProcessed is handled after the loop */ @@ -2077,42 +2101,38 @@ void rewriteConfigSentinelOption(struct rewriteConfigState *state) { if (master->parallel_syncs != SENTINEL_DEFAULT_PARALLEL_SYNCS) { line = sdscatprintf(sdsempty(), "sentinel parallel-syncs %s %d", - master->name, master->parallel_syncs); + qname, master->parallel_syncs); rewriteConfigRewriteLine(state,"sentinel parallel-syncs",line,1); /* rewriteConfigMarkAsProcessed is handled after the loop */ } /* sentinel notification-script */ if (master->notification_script) { - line = sdscatprintf(sdsempty(), - "sentinel notification-script %s %s", - master->name, master->notification_script); + line = sdscatprintf(sdsempty(), "sentinel notification-script %s ", qname); + line = sentinelSdscatConfigArg(line, master->notification_script); rewriteConfigRewriteLine(state,"sentinel notification-script",line,1); /* rewriteConfigMarkAsProcessed is handled after the loop */ } /* sentinel client-reconfig-script */ if (master->client_reconfig_script) { - line = sdscatprintf(sdsempty(), - "sentinel client-reconfig-script %s %s", - master->name, master->client_reconfig_script); + line = sdscatprintf(sdsempty(), "sentinel client-reconfig-script %s ", qname); + line = sentinelSdscatConfigArg(line, master->client_reconfig_script); rewriteConfigRewriteLine(state,"sentinel client-reconfig-script",line,1); /* rewriteConfigMarkAsProcessed is handled after the loop */ } /* sentinel auth-pass & auth-user */ if (master->auth_pass) { - line = sdscatprintf(sdsempty(), - "sentinel auth-pass %s %s", - master->name, master->auth_pass); + line = sdscatprintf(sdsempty(), "sentinel auth-pass %s ", qname); + line = sentinelSdscatConfigArg(line, master->auth_pass); rewriteConfigRewriteLine(state,"sentinel auth-pass",line,1); /* rewriteConfigMarkAsProcessed is handled after the loop */ } if (master->auth_user) { - line = sdscatprintf(sdsempty(), - "sentinel auth-user %s %s", - master->name, master->auth_user); + line = sdscatprintf(sdsempty(), "sentinel auth-user %s ", qname); + line = sentinelSdscatConfigArg(line, master->auth_user); rewriteConfigRewriteLine(state,"sentinel auth-user",line,1); /* rewriteConfigMarkAsProcessed is handled after the loop */ } @@ -2121,7 +2141,7 @@ void rewriteConfigSentinelOption(struct rewriteConfigState *state) { if (master->master_reboot_down_after_period != 0) { line = sdscatprintf(sdsempty(), "sentinel master-reboot-down-after-period %s %ld", - master->name, (long) master->master_reboot_down_after_period); + qname, (long) master->master_reboot_down_after_period); rewriteConfigRewriteLine(state,"sentinel master-reboot-down-after-period",line,1); /* rewriteConfigMarkAsProcessed is handled after the loop */ } @@ -2129,7 +2149,7 @@ void rewriteConfigSentinelOption(struct rewriteConfigState *state) { /* sentinel config-epoch */ line = sdscatprintf(sdsempty(), "sentinel config-epoch %s %llu", - master->name, (unsigned long long) master->config_epoch); + qname, (unsigned long long) master->config_epoch); rewriteConfigRewriteLine(state,"sentinel config-epoch",line,1); /* rewriteConfigMarkAsProcessed is handled after the loop */ @@ -2137,7 +2157,7 @@ void rewriteConfigSentinelOption(struct rewriteConfigState *state) { /* sentinel leader-epoch */ line = sdscatprintf(sdsempty(), "sentinel leader-epoch %s %llu", - master->name, (unsigned long long) master->leader_epoch); + qname, (unsigned long long) master->leader_epoch); rewriteConfigRewriteLine(state,"sentinel leader-epoch",line,1); /* rewriteConfigMarkAsProcessed is handled after the loop */ @@ -2158,7 +2178,7 @@ void rewriteConfigSentinelOption(struct rewriteConfigState *state) { slave_addr = master->addr; line = sdscatprintf(sdsempty(), "sentinel known-replica %s %s %d", - master->name, announceSentinelAddr(slave_addr), slave_addr->port); + qname, announceSentinelAddr(slave_addr), slave_addr->port); /* try to replace any known-slave option first if found */ if (rewriteConfigRewriteLine(state, "sentinel known-slave", sdsdup(line), 0) == 0) { rewriteConfigRewriteLine(state, "sentinel known-replica", line, 1); @@ -2176,7 +2196,7 @@ void rewriteConfigSentinelOption(struct rewriteConfigState *state) { if (ri->runid == NULL) continue; line = sdscatprintf(sdsempty(), "sentinel known-sentinel %s %s %d %s", - master->name, announceSentinelAddr(ri->addr), ri->addr->port, ri->runid); + qname, announceSentinelAddr(ri->addr), ri->addr->port, ri->runid); rewriteConfigRewriteLine(state,"sentinel known-sentinel",line,1); /* rewriteConfigMarkAsProcessed is handled after the loop */ } @@ -2187,13 +2207,16 @@ void rewriteConfigSentinelOption(struct rewriteConfigState *state) { while((de = dictNext(&di2)) != NULL) { sds oldname = dictGetKey(de); sds newname = dictGetVal(de); - line = sdscatprintf(sdsempty(), - "sentinel rename-command %s %s %s", - master->name, oldname, newname); + line = sdscatprintf(sdsempty(), "sentinel rename-command %s ", qname); + line = sentinelSdscatConfigArg(line, oldname); + line = sdscatlen(line, " ", 1); + line = sentinelSdscatConfigArg(line, newname); rewriteConfigRewriteLine(state,"sentinel rename-command",line,1); /* rewriteConfigMarkAsProcessed is handled after the loop */ } dictResetIterator(&di2); + + sdsfree(qname); } /* sentinel current-epoch is a global state valid for all the masters. */ @@ -2221,7 +2244,8 @@ void rewriteConfigSentinelOption(struct rewriteConfigState *state) { /* sentinel sentinel-user. */ if (sentinel.sentinel_auth_user) { - line = sdscatprintf(sdsempty(), "sentinel sentinel-user %s", sentinel.sentinel_auth_user); + line = sdsnew("sentinel sentinel-user "); + line = sentinelSdscatConfigArg(line, sentinel.sentinel_auth_user); rewriteConfigRewriteLine(state,"sentinel sentinel-user",line,1); } else { rewriteConfigMarkAsProcessed(state,"sentinel sentinel-user"); @@ -2229,10 +2253,11 @@ void rewriteConfigSentinelOption(struct rewriteConfigState *state) { /* sentinel sentinel-pass. */ if (sentinel.sentinel_auth_pass) { - line = sdscatprintf(sdsempty(), "sentinel sentinel-pass %s", sentinel.sentinel_auth_pass); + line = sdsnew("sentinel sentinel-pass "); + line = sentinelSdscatConfigArg(line, sentinel.sentinel_auth_pass); rewriteConfigRewriteLine(state,"sentinel sentinel-pass",line,1); } else { - rewriteConfigMarkAsProcessed(state,"sentinel sentinel-pass"); + rewriteConfigMarkAsProcessed(state,"sentinel sentinel-pass"); } dictResetIterator(&di); @@ -3238,6 +3263,11 @@ void sentinelConfigSetCommand(client *c) { if (!(!strcasecmp(val->ptr, "debug") || !strcasecmp(val->ptr, "verbose") || !strcasecmp(val->ptr, "notice") || !strcasecmp(val->ptr, "warning") || !strcasecmp(val->ptr, "nothing"))) goto badfmt; + } else if (!strcasecmp(option, "announce-ip")) { + if (sentinelStringContainsControlChars(val->ptr)) { + addReplyErrorFormat(c, "'%s' must not contain control characters", option); + goto exit; + } } } @@ -4045,6 +4075,11 @@ NULL return; } + if (sentinelStringContainsControlChars(c->argv[2]->ptr)) { + addReplyError(c, "Master name must not contain control characters"); + return; + } + /* If resolve-hostnames is used, actual DNS resolution may take place. * Otherwise just validate address. */ @@ -4388,6 +4423,12 @@ void sentinelSetCommand(client *c) { goto seterr; } + if (sentinelStringContainsControlChars(value)) { + addReplyError(c, + "notification-script must not contain control characters"); + goto seterr; + } + if (strlen(value) && access(value,X_OK) == -1) { addReplyError(c, "Notification script seems non existing or non executable"); @@ -4407,6 +4448,12 @@ void sentinelSetCommand(client *c) { goto seterr; } + if (sentinelStringContainsControlChars(value)) { + addReplyError(c, + "client-reconfig-script must not contain control characters"); + goto seterr; + } + if (strlen(value) && access(value,X_OK) == -1) { addReplyError(c, "Client reconfiguration script seems non existing or " @@ -4450,6 +4497,13 @@ void sentinelSetCommand(client *c) { goto badfmt; } + if (sentinelStringContainsControlChars(oldname) || + sentinelStringContainsControlChars(newname)) { + addReplyError(c, + "rename-command arguments must not contain control characters"); + goto seterr; + } + /* Remove any older renaming for this command. */ dictDelete(ri->renamed_commands,oldname); diff --git a/tests/sentinel/tests/16-config-injection.tcl b/tests/sentinel/tests/16-config-injection.tcl new file mode 100644 index 0000000000..6aff07de98 --- /dev/null +++ b/tests/sentinel/tests/16-config-injection.tcl @@ -0,0 +1,312 @@ +# Test that control characters are rejected where appropriate, and that +# string values are safely quoted when persisted to disk. +# +# Config injection is prevented by sentinelSdscatConfigArg(), which escapes +# values containing special characters at persistence time. Fields like +# notification-script, rename-command, master name, and announce-ip also +# reject control characters at input time as an additional safeguard. + +source "../tests/includes/init-tests.tcl" + +# Helper: read the sentinel config file for a given sentinel id. +proc read_sentinel_config {id} { + set configfile [file join "sentinel_${id}" "sentinel.conf"] + set fp [open $configfile r] + set content [read $fp] + close $fp + return $content +} + +# Helper: count how many lines in the config match a pattern. +proc count_config_lines {content pattern} { + set count 0 + foreach line [split $content "\n"] { + if {[string match $pattern $line]} { + incr count + } + } + return $count +} + +# Helper: restart a (already stopped) sentinel and wait until it responds to PING. +proc start_sentinel_and_wait {sid} { + restart_instance sentinel $sid + wait_for_condition 200 50 { + [catch {S $sid PING}] == 0 + } else { + fail "Sentinel $sid did not restart in time" + } +} + +# Helper: kill sentinel, restart it, and wait until it responds to PING. +proc restart_sentinel_and_wait {sid} { + kill_instance sentinel $sid + start_sentinel_and_wait $sid +} + +# Helper: assert that the sentinel config file contains the expected substring. +proc assert_config_contains {sid expected} { + set content [read_sentinel_config $sid] + assert {[string first $expected $content] >= 0} +} + +# Helper: append lines to a sentinel's config file (sentinel must be stopped). +proc append_to_sentinel_config {sid lines} { + set configfile [file join "sentinel_${sid}" "sentinel.conf"] + set fp [open $configfile a] + foreach line $lines { + puts $fp $line + } + close $fp +} + +# Helper: create an executable script with spaces in its path. +# Returns the full path. Caller should "file delete -force" the directory. +proc create_script_with_spaces {sid} { + set script_dir [file join [pwd] "sentinel_${sid}" "script dir"] + file mkdir $script_dir + set script_path [file join $script_dir "my script.sh"] + set fp [open $script_path w] + puts $fp "#!/bin/sh" + close $fp + file attributes $script_path -permissions 0755 + return $script_path +} + +# -------------------------------------------------------------------------- +# Section 1: Control character rejection in SENTINEL SET +# -------------------------------------------------------------------------- + +test "SENTINEL SET notification-script rejects control characters" { + assert_error "*must not contain control characters*" { + S 0 SENTINEL SET mymaster notification-script "/tmp/ok\n/tmp/evil.sh" + } +} + +test "SENTINEL SET client-reconfig-script rejects control characters" { + assert_error "*must not contain control characters*" { + S 0 SENTINEL SET mymaster client-reconfig-script "/tmp/ok\n/tmp/evil.sh" + } +} + +test "SENTINEL SET rename-command rejects control characters" { + assert_error "*must not contain control characters*" { + S 0 SENTINEL SET mymaster rename-command "CONFIG\nEVIL" "NEWCONFIG" + } + assert_error "*must not contain control characters*" { + S 0 SENTINEL SET mymaster rename-command "CONFIG" "NEW\nCONFIG" + } +} + +# -------------------------------------------------------------------------- +# Section 2: Control character rejection in SENTINEL MONITOR +# -------------------------------------------------------------------------- + +test "SENTINEL MONITOR rejects master name with control characters" { + set port [get_instance_attrib redis 0 port] + assert_error "*must not contain control characters*" { + S 0 SENTINEL MONITOR "bad\nmaster" 127.0.0.1 $port 2 + } + assert_error "*must not contain control characters*" { + S 0 SENTINEL MONITOR "bad\rmaster" 127.0.0.1 $port 2 + } +} + +# -------------------------------------------------------------------------- +# Section 3: Control character rejection in SENTINEL CONFIG SET +# -------------------------------------------------------------------------- + +test "SENTINEL CONFIG SET announce-ip rejects control characters" { + catch {S 0 SENTINEL CONFIG SET announce-ip "1.2.3.4\nevil-directive"} e + assert_match "*must not contain control characters*" $e +} + +# -------------------------------------------------------------------------- +# Section 4: Config injection attempt does not pollute config file +# -------------------------------------------------------------------------- + +test "Newline injection in auth-pass does not pollute config file" { + # Auth-pass accepts control characters, but sentinelSdscatConfigArg + # escapes them at persistence time, preventing config injection. + S 0 SENTINEL SET mymaster auth-pass "x\nsentinel notification-script mymaster /tmp/evil.sh" + S 0 SENTINEL FLUSHCONFIG + set content [read_sentinel_config 0] + assert {[count_config_lines $content "sentinel notification-script mymaster /tmp/evil.sh"] == 0} + assert_config_contains 0 {sentinel auth-pass mymaster "x\nsentinel notification-script mymaster /tmp/evil.sh"} + S 0 SENTINEL SET mymaster auth-pass "" +} + +test "Newline injection in auth-user does not pollute config file" { + S 0 SENTINEL SET mymaster auth-user "x\nsentinel notification-script mymaster /tmp/evil.sh" + S 0 SENTINEL FLUSHCONFIG + set content [read_sentinel_config 0] + assert {[count_config_lines $content "sentinel notification-script mymaster /tmp/evil.sh"] == 0} + assert_config_contains 0 {sentinel auth-user mymaster "x\nsentinel notification-script mymaster /tmp/evil.sh"} + S 0 SENTINEL SET mymaster auth-user "" +} + +test "Newline injection in sentinel-pass does not pollute config file" { + S 0 SENTINEL CONFIG SET sentinel-pass "x\nsentinel notification-script mymaster /tmp/evil.sh" + S 0 SENTINEL FLUSHCONFIG + set content [read_sentinel_config 0] + assert {[count_config_lines $content "sentinel notification-script mymaster /tmp/evil.sh"] == 0} + assert_config_contains 0 {sentinel sentinel-pass "x\nsentinel notification-script mymaster /tmp/evil.sh"} + S 0 SENTINEL CONFIG SET sentinel-pass "" +} + +test "Newline injection in sentinel-user does not pollute config file" { + S 0 SENTINEL CONFIG SET sentinel-user "x\nsentinel notification-script mymaster /tmp/evil.sh" + S 0 SENTINEL FLUSHCONFIG + set content [read_sentinel_config 0] + assert {[count_config_lines $content "sentinel notification-script mymaster /tmp/evil.sh"] == 0} + assert_config_contains 0 {sentinel sentinel-user "x\nsentinel notification-script mymaster /tmp/evil.sh"} + S 0 SENTINEL CONFIG SET sentinel-user "" +} + +# -------------------------------------------------------------------------- +# Section 5: Values with special characters survive config round-trip +# -------------------------------------------------------------------------- + +test "auth-pass with special characters persists correctly through restart" { + S 0 SENTINEL SET mymaster auth-pass {my "comp#$&^`'!,lex pass} + set expected {sentinel auth-pass mymaster "my \"comp#$&^`'!,lex pass"} + S 0 SENTINEL FLUSHCONFIG + assert_config_contains 0 $expected + restart_sentinel_and_wait 0 + S 0 SENTINEL FLUSHCONFIG + assert_config_contains 0 $expected + S 0 SENTINEL SET mymaster auth-pass "" +} + +test "auth-user with spaces persists correctly through restart" { + S 0 SENTINEL SET mymaster auth-user {user with spaces} + set expected {sentinel auth-user mymaster "user with spaces"} + S 0 SENTINEL FLUSHCONFIG + assert_config_contains 0 $expected + restart_sentinel_and_wait 0 + S 0 SENTINEL FLUSHCONFIG + assert_config_contains 0 $expected + S 0 SENTINEL SET mymaster auth-user "" +} + +test "notification-script with spaces persists correctly through restart" { + set script_path [create_script_with_spaces 0] + S 0 SENTINEL SET mymaster notification-script $script_path + S 0 SENTINEL FLUSHCONFIG + set content [read_sentinel_config 0] + # The path must be quoted since it contains spaces. + assert {[string first "notification-script" $content] >= 0} + restart_sentinel_and_wait 0 + set info [S 0 SENTINEL MASTER mymaster] + set idx [lsearch $info "notification-script"] + assert {$idx >= 0} + assert_equal [lindex $info [expr {$idx+1}]] $script_path + S 0 SENTINEL SET mymaster notification-script "" + file delete -force [file dirname $script_path] +} + +test "client-reconfig-script with spaces persists correctly through restart" { + set script_path [create_script_with_spaces 0] + S 0 SENTINEL SET mymaster client-reconfig-script $script_path + S 0 SENTINEL FLUSHCONFIG + set content [read_sentinel_config 0] + # The path must be quoted since it contains spaces. + assert {[string first "client-reconfig-script" $content] >= 0} + restart_sentinel_and_wait 0 + set info [S 0 SENTINEL MASTER mymaster] + set idx [lsearch $info "client-reconfig-script"] + assert {$idx >= 0} + assert_equal [lindex $info [expr {$idx+1}]] $script_path + S 0 SENTINEL SET mymaster client-reconfig-script "" + file delete -force [file dirname $script_path] +} + +test "rename-command persists unquoted through restart" { + S 0 SENTINEL SET mymaster rename-command CONFIG CONF_RENAMED + set expected {sentinel rename-command mymaster CONFIG CONF_RENAMED} + S 0 SENTINEL FLUSHCONFIG + assert_config_contains 0 $expected + restart_sentinel_and_wait 0 + S 0 SENTINEL FLUSHCONFIG + assert_config_contains 0 $expected + S 0 SENTINEL SET mymaster rename-command CONFIG CONFIG +} + +# -------------------------------------------------------------------------- +# Section 6: Backward compatibility -- old unquoted config format still loads +# -------------------------------------------------------------------------- + +test "Old unquoted config format for auth-pass and auth-user loads correctly" { + kill_instance sentinel 0 + append_to_sentinel_config 0 { + "sentinel auth-pass mymaster oldformatpass" + "sentinel auth-user mymaster oldformatuser" + } + start_sentinel_and_wait 0 + S 0 SENTINEL FLUSHCONFIG + assert_config_contains 0 "sentinel auth-pass mymaster oldformatpass" + assert_config_contains 0 "sentinel auth-user mymaster oldformatuser" + S 0 SENTINEL SET mymaster auth-pass "" + S 0 SENTINEL SET mymaster auth-user "" +} + +test "Old unquoted config format for rename-command loads correctly" { + kill_instance sentinel 0 + append_to_sentinel_config 0 { + "sentinel rename-command mymaster CONFIG NEWCONFIGNAME" + } + start_sentinel_and_wait 0 + S 0 SENTINEL FLUSHCONFIG + assert_config_contains 0 "sentinel rename-command mymaster CONFIG NEWCONFIGNAME" + S 0 SENTINEL SET mymaster rename-command CONFIG CONFIG +} + +test "Old unquoted config format for sentinel-pass loads correctly" { + kill_instance sentinel 0 + append_to_sentinel_config 0 { + "sentinel sentinel-pass oldsentinelpass" + } + start_sentinel_and_wait 0 + set result [S 0 SENTINEL CONFIG GET sentinel-pass] + assert_equal [lindex $result 1] "oldsentinelpass" + S 0 SENTINEL CONFIG SET sentinel-pass "" +} + +test "Old unquoted config format for sentinel-user loads correctly" { + kill_instance sentinel 0 + append_to_sentinel_config 0 { + "sentinel sentinel-user oldsentineluser" + } + start_sentinel_and_wait 0 + set result [S 0 SENTINEL CONFIG GET sentinel-user] + assert_equal [lindex $result 1] "oldsentineluser" + S 0 SENTINEL CONFIG SET sentinel-user "" +} + +# -------------------------------------------------------------------------- +# Section 7: Values with special characters survive config round-trip +# -------------------------------------------------------------------------- + +test "sentinel-pass with special characters persists correctly through restart" { + set test_pass {sentinel pass word} + S 0 SENTINEL CONFIG SET sentinel-pass $test_pass + set expected {sentinel sentinel-pass "sentinel pass word"} + S 0 SENTINEL FLUSHCONFIG + assert_config_contains 0 $expected + restart_sentinel_and_wait 0 + set result [S 0 SENTINEL CONFIG GET sentinel-pass] + assert_equal [lindex $result 1] $test_pass + S 0 SENTINEL CONFIG SET sentinel-pass "" +} + +test "sentinel-user with special characters persists correctly through restart" { + set test_user {sentinel user name} + S 0 SENTINEL CONFIG SET sentinel-user $test_user + set expected {sentinel sentinel-user "sentinel user name"} + S 0 SENTINEL FLUSHCONFIG + assert_config_contains 0 $expected + restart_sentinel_and_wait 0 + set result [S 0 SENTINEL CONFIG GET sentinel-user] + assert_equal [lindex $result 1] $test_user + S 0 SENTINEL CONFIG SET sentinel-user "" +}