Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,13 +175,13 @@ Runtime configuration is read from environment variables in `config/runtime.exs`
| `MN_REDIS_NAMESPACE` | `mirror_neuron` | Prefix/namespace for persisted runtime data. |
| `MN_REDIS_DB` | `0` | Redis database number. |
| `MN_REDIS_USERNAME` | Empty | Redis username. |
| `MN_REDIS_PASSWORD` | Empty | Redis password. |
| `MN_REDIS_PASSWORD` | Empty | Redis password. Required by `scripts/redis_ha.sh join` because HA autoconfiguration exposes Redis to cluster peers. |
| `MN_REDIS_HA_MODE` | `single` | Redis mode, currently `single` or Sentinel-related configuration. |
| `MN_REDIS_SENTINELS` | Empty | Comma-separated Sentinel endpoints. |
| `MN_REDIS_SENTINEL_MASTER` | `mirror-neuron` | Sentinel master name. |
| `MN_REDIS_SENTINEL_HOST_MAP` | Empty | Optional host mapping used when resolving Sentinel primary hosts. |
| `MN_REDIS_SENTINEL_USERNAME` | Empty | Sentinel username. |
| `MN_REDIS_SENTINEL_PASSWORD` | Empty | Sentinel password. |
| `MN_REDIS_SENTINEL_PASSWORD` | Empty | Sentinel password. The HA helper defaults it to `MN_REDIS_PASSWORD` when unset. |
| `MN_REDIS_WAIT_REPLICAS` | `0` | Redis write durability wait replica count. |
| `MN_REDIS_WAIT_TIMEOUT_MS` | `100` | Redis wait timeout in milliseconds. |
| `MN_REDIS_RECONNECT_ATTEMPTS` | `10` | Redis reconnect attempt count. |
Expand Down Expand Up @@ -281,7 +281,7 @@ MirrorNeuron.cancel("job-id")

### Cluster Helpers

Development and smoke-test scripts are available under `scripts/`.
Development and smoke-test scripts are available under `scripts/`. Redis HA autoconfiguration requires `MN_REDIS_PASSWORD` so Redis and Sentinel do not start as unauthenticated network listeners.

```bash
bash scripts/cluster_cli.sh --help
Expand Down
47 changes: 41 additions & 6 deletions scripts/redis_ha.sh
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ REDIS_CLI="${MN_REDIS_CLI_BIN:-redis-cli}"
REDIS_USERNAME="${MN_REDIS_USERNAME:-}"
REDIS_PASSWORD="${MN_REDIS_PASSWORD:-}"
SENTINEL_USERNAME="${MN_REDIS_SENTINEL_USERNAME:-}"
SENTINEL_PASSWORD="${MN_REDIS_SENTINEL_PASSWORD:-}"
SENTINEL_PASSWORD="${MN_REDIS_SENTINEL_PASSWORD:-$REDIS_PASSWORD}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Propagate the sentinel password fallback to runtime clients

When operators run scripts/redis_ha.sh join directly with only MN_REDIS_PASSWORD, this fallback makes the helper start Sentinel with requirepass using that password, but the Elixir Sentinel client still reads only MN_REDIS_SENTINEL_PASSWORD in lib/mirror_neuron/redis/sentinel.ex, and wrappers like scripts/cluster_cli.sh do not export the fallback added to start_cluster_node.sh. In that documented direct-helper/CLI path the runtime queries Sentinel unauthenticated and cannot resolve the primary, so the same fallback needs to live in shared runtime/client config or all Sentinel-mode wrappers.

Useful? React with 👍 / 👎.

PURGE_LOCAL="0"

usage() {
Expand All @@ -43,8 +43,11 @@ options:

Environment:
MN_REDIS_USERNAME / MN_REDIS_PASSWORD
MN_REDIS_SENTINEL_USERNAME / MN_REDIS_SENTINEL_PASSWORD
MN_REDIS_SENTINEL_USERNAME / MN_REDIS_SENTINEL_PASSWORD (defaults to MN_REDIS_PASSWORD)
MN_REDIS_SERVER_BIN / MN_REDIS_CLI_BIN

Security:
join requires MN_REDIS_PASSWORD so Redis and Sentinel are not exposed without authentication.
EOF
}

Expand Down Expand Up @@ -118,6 +121,32 @@ need_bins() {
fi
}

redis_conf_quote() {
local value="$1"
value="${value//\\/\\\\}"
value="${value//\"/\\\"}"
printf '"%s"' "$value"
}

require_join_credentials() {
if [ -z "$REDIS_PASSWORD" ]; then
cat >&2 <<EOF
MN_REDIS_PASSWORD is required for Redis HA autoconfiguration.

redis_ha.sh binds Redis and Sentinel for cluster peers, so it refuses to
start unauthenticated listeners. Set MN_REDIS_PASSWORD to a strong shared
secret before running join. Set MN_REDIS_SENTINEL_PASSWORD as well when
Sentinel should use a different client password.
EOF
exit 1
fi

if [ -z "$SENTINEL_PASSWORD" ]; then
echo "MN_REDIS_SENTINEL_PASSWORD must not be empty when running join" >&2
exit 1
fi
}

redis_auth_args() {
if [ -n "$REDIS_USERNAME" ]; then
printf '%s\0%s\0' --user "$REDIS_USERNAME"
Expand Down Expand Up @@ -188,9 +217,7 @@ ensure_local_redis() {
--appendonly yes
)

if [ -n "$REDIS_PASSWORD" ]; then
args+=(--requirepass "$REDIS_PASSWORD" --masterauth "$REDIS_PASSWORD")
fi
args+=(--requirepass "$REDIS_PASSWORD" --masterauth "$REDIS_PASSWORD")

"$REDIS_SERVER" "${args[@]}"
}
Expand All @@ -203,6 +230,9 @@ ensure_local_sentinel() {
mkdir -p "$DATA_DIR/sentinel"

local conf="$DATA_DIR/sentinel/sentinel.conf"
local sentinel_password_conf
sentinel_password_conf="$(redis_conf_quote "$SENTINEL_PASSWORD")"

cat >"$conf" <<EOF
port $SENTINEL_PORT
bind 0.0.0.0
Expand All @@ -212,6 +242,8 @@ sentinel resolve-hostnames yes
sentinel announce-hostnames no
sentinel announce-ip $SELF_HOST
sentinel announce-port $SENTINEL_PORT
requirepass $sentinel_password_conf
sentinel sentinel-pass $sentinel_password_conf
EOF

"$REDIS_SERVER" "$conf" --sentinel --daemonize yes
Expand Down Expand Up @@ -397,7 +429,10 @@ status() {
need_bins

case "$COMMAND" in
join) join_cluster ;;
join)
require_join_credentials
join_cluster
;;
leave) leave_cluster ;;
status) status ;;
esac
3 changes: 3 additions & 0 deletions scripts/start_cluster_node.sh
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,9 @@ export MN_REDIS_HA_MODE="$REDIS_HA_MODE"
export MN_REDIS_SENTINELS="$REDIS_SENTINELS"
export MN_REDIS_SENTINEL_MASTER="$REDIS_SENTINEL_MASTER"
export MN_REDIS_SENTINEL_PORT="$REDIS_SENTINEL_PORT"
if [ "$MN_REDIS_HA_MODE" = "sentinel" ] && [ -z "${MN_REDIS_SENTINEL_PASSWORD:-}" ] && [ -n "${MN_REDIS_PASSWORD:-}" ]; then
export MN_REDIS_SENTINEL_PASSWORD="$MN_REDIS_PASSWORD"
fi
export MN_REDIS_DB="${MN_REDIS_DB:-0}"
export MN_REDIS_WAIT_REPLICAS="$REDIS_WAIT_REPLICAS"
export MN_REDIS_WAIT_TIMEOUT_MS="$REDIS_WAIT_TIMEOUT_MS"
Expand Down
114 changes: 114 additions & 0 deletions tests/unit/redis_ha_scripts_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,118 @@ defmodule MirrorNeuron.RedisHAScriptsTest do
assert status != 0
assert output =~ "--remote-host is required"
end

test "redis HA join refuses to start without a Redis password" do
tmp_dir = tmp_path()
redis_server = fake_redis_server(tmp_dir)
redis_cli = fake_redis_cli(tmp_dir)

assert {output, status} =
System.cmd("bash", ["scripts/redis_ha.sh", "join", "--data-dir", tmp_dir],
env: [
{"MN_REDIS_SERVER_BIN", redis_server},
{"MN_REDIS_CLI_BIN", redis_cli},
{"MN_REDIS_PASSWORD", ""},
{"MN_REDIS_SENTINEL_PASSWORD", ""}
],
stderr_to_stdout: true
)

assert status != 0
assert output =~ "MN_REDIS_PASSWORD is required"
refute File.exists?(Path.join(tmp_dir, "redis-server.log"))
end

test "redis HA join configures Redis and Sentinel authentication" do
tmp_dir = tmp_path()
redis_server = fake_redis_server(tmp_dir)
redis_cli = fake_redis_cli(tmp_dir)

assert {_output, 0} =
System.cmd("bash", ["scripts/redis_ha.sh", "join", "--data-dir", tmp_dir],
env: [
{"MN_REDIS_SERVER_BIN", redis_server},
{"MN_REDIS_CLI_BIN", redis_cli},
{"MN_REDIS_PASSWORD", "shared-secret"}
],
stderr_to_stdout: true
)

redis_server_log = File.read!(Path.join(tmp_dir, "redis-server.log"))
sentinel_conf = File.read!(Path.join([tmp_dir, "sentinel", "sentinel.conf"]))
redis_cli_log = File.read!(Path.join(tmp_dir, "redis-cli.log"))

assert redis_server_log =~ "--requirepass shared-secret --masterauth shared-secret"
assert sentinel_conf =~ ~s(requirepass "shared-secret")
assert sentinel_conf =~ ~s(sentinel sentinel-pass "shared-secret")
assert redis_cli_log =~ "SENTINEL SET mirror-neuron auth-pass shared-secret"
end

defp tmp_path do
Path.join(
System.tmp_dir!(),
"mirror_neuron_redis_ha_test_#{System.unique_integer([:positive])}"
)
end

defp fake_redis_server(tmp_dir) do
File.mkdir_p!(tmp_dir)

path = Path.join(tmp_dir, "fake_redis_server.sh")

File.write!(path, """
#!/usr/bin/env bash
printf '%s\n' "$*" >> #{Path.join(tmp_dir, "redis-server.log")}
exit 0
""")

File.chmod!(path, 0o755)
path
end

defp fake_redis_cli(tmp_dir) do
File.mkdir_p!(tmp_dir)

path = Path.join(tmp_dir, "fake_redis_cli.sh")

File.write!(path, """
#!/usr/bin/env bash
args=("$@")
command=()
i=0
while [ "$i" -lt "${#args[@]}" ]; do
arg="${args[$i]}"
case "$arg" in
-h|-p|-a|--user)
i=$((i + 2))
;;
--no-auth-warning)
i=$((i + 1))
;;
*)
command=("${args[@]:$i}")
break
;;
esac
done

printf '%s\n' "${command[*]}" >> #{Path.join(tmp_dir, "redis-cli.log")}

case "${command[*]}" in
"PING")
exit 1
;;
"INFO replication")
printf 'role:master\n'
;;
"SENTINEL get-master-addr-by-name mirror-neuron")
;;
esac

exit 0
""")

File.chmod!(path, 0o755)
path
end
end
Loading