diff --git a/deploy/cache-me-outside.env.example b/deploy/cache-me-outside.env.example index 384322f3d7b..608142e9887 100644 --- a/deploy/cache-me-outside.env.example +++ b/deploy/cache-me-outside.env.example @@ -112,3 +112,7 @@ CMO_CLUSTER_NODE_TIMEOUT=5000 # CMO_BACKUP_DIR=/mnt/backups/cache-me-outside # CMO_BACKUP_KEEP=7 # CMO_BACKUP_MAX_AGE_DAYS=30 + +# Optional. Comma-separated CIDRs allowed to hit the published port. +# Apply with deploy/ip-allowlist.sh (DOCKER-USER chain). +# CMO_ALLOWED_CIDRS=10.0.0.0/8,203.0.113.7/32 diff --git a/deploy/ip-allowlist-test.sh b/deploy/ip-allowlist-test.sh new file mode 100755 index 00000000000..4371832e83b --- /dev/null +++ b/deploy/ip-allowlist-test.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Offline test for ip-allowlist.sh: no Docker, no root, no iptables. +set -euo pipefail +here="$(cd "$(dirname "$0")" && pwd)" +tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT +export CMO_ENV_FILE="$tmp/t.env" +printf 'CMO_HOST_PORT=7000\n' >"$CMO_ENV_FILE" +fail() { echo "FAIL: $*"; exit 1; } +s="$here/ip-allowlist.sh" +"$s" show | grep -q "not restricted" || fail show-empty +"$s" set 10.0.0.0/8 203.0.113.7 >/dev/null || fail set +grep -q '^CMO_ALLOWED_CIDRS=10.0.0.0/8,203.0.113.7/32$' "$CMO_ENV_FILE" || fail saved +for bad in 0.0.0.0/0 300.1.1.1 1.2.3.4/33 abc; do "$s" set "$bad" 2>/dev/null && fail "accepted $bad"; done +grep -q '^CMO_ALLOWED_CIDRS=10.0.0.0/8,203.0.113.7/32$' "$CMO_ENV_FILE" || fail kept-after-bad +r="$("$s" rules)" +grep -q -- "--dport 7000 -j CMO-ALLOW" <<<"$r" || fail port +grep -q -- "-s 203.0.113.7/32 -j RETURN" <<<"$r" || fail allow +[[ "$(tail -1 <<<"$r")" == "iptables -A CMO-ALLOW -j DROP" ]] || fail drop-last +if [[ $EUID -ne 0 ]]; then "$s" apply 2>/dev/null && fail apply-nonroot; fi +"$s" clear >/dev/null; "$s" rules 2>/dev/null && fail rules-after-clear +echo "ip-allowlist tests passed" diff --git a/deploy/ip-allowlist.sh b/deploy/ip-allowlist.sh new file mode 100755 index 00000000000..18b5f7856fc --- /dev/null +++ b/deploy/ip-allowlist.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# IP allow-list for the published Valkey port (plan item 33). +# Valkey has no per-IP ACL, so this renders iptables rules for Docker's +# DOCKER-USER chain (the chain Docker leaves for operators). +# +# ./ip-allowlist.sh show the saved list +# ./ip-allowlist.sh set CIDR [CIDR...] validate and save CMO_ALLOWED_CIDRS +# ./ip-allowlist.sh clear remove the saved list (open again) +# ./ip-allowlist.sh rules print the iptables commands +# ./ip-allowlist.sh apply run them (root; nothing runs otherwise) +# Rules live in their own chain CMO-ALLOW, so re-applying replaces them cleanly. +set -euo pipefail +source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +cmo_valid_cidr() { + local c="$1" ip bits o + [[ "$c" == */* ]] || c="$c/32" + ip="${c%/*}"; bits="${c#*/}" + [[ "$bits" =~ ^[0-9]{1,2}$ ]] && (( bits <= 32 )) || return 1 + [[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || return 1 + IFS=. read -ra o <<<"$ip" + for x in "${o[@]}"; do (( 10#$x <= 255 )) || return 1; done + [[ "$c" != 0.0.0.0/0 ]] || return 1 + printf '%s\n' "$c" +} + +cmo_allow_save() { + local file="$1" value="$2" + touch "$file" + if grep -q '^CMO_ALLOWED_CIDRS=' "$file"; then + sed -i "s|^CMO_ALLOWED_CIDRS=.*|CMO_ALLOWED_CIDRS=${value}|" "$file" + else + printf 'CMO_ALLOWED_CIDRS=%s\n' "$value" >>"$file" + fi +} + +cmo_allow_rules() { + local port="$1" list="$2" c + echo "iptables -N CMO-ALLOW 2>/dev/null || iptables -F CMO-ALLOW" + echo "iptables -C DOCKER-USER -p tcp --dport ${port} -j CMO-ALLOW 2>/dev/null || iptables -I DOCKER-USER -p tcp --dport ${port} -j CMO-ALLOW" + echo "iptables -A CMO-ALLOW -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN" + for c in ${list//,/ }; do echo "iptables -A CMO-ALLOW -s ${c} -j RETURN"; done + echo "iptables -A CMO-ALLOW -j DROP" +} + +if [[ "${CMO_ALLOW_LIB:-}" == 1 ]]; then return 0 2>/dev/null || exit 0; fi + +file="$(cmo_env_path)" +saved="$( (grep '^CMO_ALLOWED_CIDRS=' "$file" 2>/dev/null || true) | tail -1 | cut -d= -f2-)" +port="$( (grep '^CMO_HOST_PORT=' "$file" 2>/dev/null || true) | tail -1 | cut -d= -f2-)" +port="${port:-6379}" + +case "${1:-show}" in + show) echo "${saved:-(empty: port ${port} is not restricted)}" ;; + set) + shift; [[ $# -gt 0 ]] || { echo "give at least one CIDR" >&2; exit 1; } + out=() + for c in "$@"; do v="$(cmo_valid_cidr "$c")" || { echo "bad CIDR: $c" >&2; exit 1; }; out+=("$v"); done + joined="$(IFS=,; echo "${out[*]}")" + cmo_allow_save "$file" "$joined" + echo "saved: $joined (run '$0 apply' as root to enforce)" ;; + clear) cmo_allow_save "$file" ""; echo "cleared (run 'iptables -F CMO-ALLOW' as root to open the port)" ;; + rules) [[ -n "$saved" ]] || { echo "no list saved" >&2; exit 1; }; cmo_allow_rules "$port" "$saved" ;; + apply) + [[ -n "$saved" ]] || { echo "no list saved" >&2; exit 1; } + [[ $EUID -eq 0 ]] || { echo "apply needs root" >&2; exit 1; } + cmo_allow_rules "$port" "$saved" | bash -e + echo "enforced on port ${port}: ${saved}" ;; + *) sed -n 2,11p "$0"; exit 1 ;; +esac diff --git a/docs/ci/cmo-ip-allowlist-test.patch b/docs/ci/cmo-ip-allowlist-test.patch new file mode 100644 index 00000000000..4f48441dcaf --- /dev/null +++ b/docs/ci/cmo-ip-allowlist-test.patch @@ -0,0 +1,12 @@ +--- a/.github/workflows/cmo-ci.yml ++++ b/.github/workflows/cmo-ci.yml +@@ -28,6 +28,9 @@ + - name: Build server + run: make -j"$(nproc)" SERVER_CFLAGS='-Werror' + ++ - name: IP allow-list offline test ++ run: deploy/ip-allowlist-test.sh ++ + - name: Run a fast Valkey test subset + run: | + # introspection is omitted: its forkless-bgsave case is timing-sensitive