Files
openhands 90b65c92a2
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 30s
CI / tests-integration (push) Successful in 1m51s
CI / tests-unit (push) Successful in 1m54s
CI / tests-ui (push) Successful in 2m44s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 20s
feat(proxy): sync Cloudflare ranges at nginx+Traefik, block IP spoofing
- cloudflare-ips.conf (new): geo $cms_trusted_edge + set_real_ip_from from
  live CF IPv4/IPv6 ranges plus Traefik bridge and loopback
- nginx-cms.conf: forward real client IP only from trusted peers, strip
  incoming CF-Connecting-IP, 403 any other peer that presents one
  (spoof gate); direct game clients on :9443 stay unaffected
- cf-ips-sync.sh (new): fetch cloudflare.com/ips-v4/-v6, regenerate the
  nginx snippet and Traefik websecure.forwardedHeaders.trustedIPs
- nginx-sync.sh: install the cloudflare-ips.conf snippet
- cms_upstream_servers.conf: point default at the live green slot 3003
2026-09-28 23:35:00 +02:00

186 lines
7.3 KiB
Bash
Executable File

#!/usr/bin/env bash
# Keep the Cloudflare IP ranges in sync for BOTH proxy layers:
# 1) deployment/proxy/cloudflare-ips.conf (nginx: geo + set_real_ip_from)
# 2) entryPoints.websecure.forwardedHeaders.trustedIPs in /docker/proxyserver/traefik.yml
#
# The repo file is the source of truth for nginx (installed by nginx-sync.sh);
# Traefik's static config lives outside the repo and is regenerated in place.
# Traefik only picks it up after a container restart (static config), which
# --install performs automatically when the list actually changed.
#
# Usage:
# scripts/cf-ips-sync.sh # regen repo + traefik files if ranges changed
# scripts/cf-ips-sync.sh --check # report what would change (exit 1 if any)
# sudo scripts/cf-ips-sync.sh --install # + run nginx-sync.sh and restart Traefik
#
# The Traefik bridge subnet is auto-detected (env TRUSTED_SUBNET overrides),
# because nginx trusts it as a TLS-terminating peer.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROXY_DIR="$SCRIPT_DIR/../deployment/proxy"
TARGET="$PROXY_DIR/cloudflare-ips.conf"
TRAEFIK_CONFIG="${TRAEFIK_CONFIG:-/docker/proxyserver/traefik.yml}"
IPV4_URL="https://www.cloudflare.com/ips-v4"
IPV6_URL="https://www.cloudflare.com/ips-v6"
MODE="status"
for arg in "$@"; do
case "$arg" in
--check) MODE="check" ;;
--install) MODE="install" ;;
--no-traefik) TRAEFIK_CONFIG="" ;;
esac
done
TRUSTED_SUBNET="${TRUSTED_SUBNET:-}"
if [[ -z "$TRUSTED_SUBNET" ]]; then
TRUSTED_SUBNET="$(docker network inspect proxyserver_traefik-proxy --format '{{range .IPAM.Config}}{{.Subnet}}{{end}}' 2>/dev/null || true)"
fi
if [[ -z "$TRUSTED_SUBNET" ]]; then
TRUSTED_SUBNET="172.22.0.0/16"
echo "warning: could not auto-detect Traefik bridge subnet, using $TRUSTED_SUBNET" >&2
fi
ipv4="$(mktemp)"
ipv6="$(mktemp)"
trap 'rm -f "$ipv4" "$ipv6"' EXIT
if ! curl -sf "$IPV4_URL" -o "$ipv4" || ! curl -sf "$IPV6_URL" -o "$ipv6"; then
echo "error: could not fetch Cloudflare ranges" >&2
if [[ -f "$TARGET" ]]; then
echo "keeping existing $TARGET (ranges not refreshed)" >&2
exit 0
fi
exit 1
fi
gen_nginx_conf() {
{
printf '%s\n' "# Trusted edge networks + live Cloudflare CDN ranges."
printf '%s\n' "# Managed/regenerated by scripts/cf-ips-sync.sh - do not hand-edit the ranges."
printf '%s\n' ""
printf '%s\n' "# Topology: Cloudflare -> Traefik (:443, docker bridge proxy_traefik-proxy) ->"
printf '%s\n' "# nginx (:9443) -> CMS. nginx ALSO receives direct connections on :9443 from"
printf '%s\n' "# Cloudflare edges and from the game client (ws.epicnabbo.nl is not proxied)."
printf '%s\n' ""
printf '%s\n' "# nginx only trusts the peers listed here as a source of \$remote_addr"
printf '%s\n' "# (via CF-Connecting-IP). Anyone else presenting a CF-Connecting-IP or"
printf '%s\n' "# CF-ray header is spoofing and is rejected in nginx-cms.conf."
printf '%s\n' ""
printf '%s\n' "# 1 = peer is a trusted edge or internal network (keyed on the raw peer,"
printf '%s\n' "# unaffected by real_ip rewrites)."
printf '%s\n' "geo \$realip_remote_addr \$cms_trusted_edge {"
printf '%s\n' " default 0;"
printf '%s\n' " 127.0.0.0/8 1; # localhost (health checks, admin)"
printf '%s\n' " ::1 1; # localhost v6"
printf '%s\n' " $TRUSTED_SUBNET 1; # Traefik (proxyserver_traefik-proxy)"
printf '%s\n' " # --- Cloudflare IPv4 ranges (live from cloudflare.com/ips-v4) ---"
awk '{print " "$0" 1;"}' "$ipv4"
printf '%s\n' " # --- Cloudflare IPv6 ranges (live from cloudflare.com/ips-v6) ---"
awk '{print " "$0" 1;"}' "$ipv6"
printf '%s\n' "}"
printf '%s\n' ""
printf '%s\n' "# 1 when an UNTRUSTED peer still presents a CF-Connecting-IP header: that is a"
printf '%s\n' "# spoof attempt (only real Cloudflare edges or Traefik may do that lawfully)."
printf '%s\n' "map \"\$cms_trusted_edge:\$http_cf_connecting_ip\" \$cms_disallow_forwarding {"
printf '%s\n' " default 0;"
printf '%s\n' " \"~^0:.+\" 1;"
printf '%s\n' "}"
printf '%s\n' ""
printf '%s\n' "# Rewrite \$remote_addr from CF-Connecting-IP but ONLY for the trusted peers"
printf '%s\n' "# above. Direct game clients (untrusted) keep their real peer address."
printf '%s\n' "set_real_ip_from 127.0.0.0/8;"
printf '%s\n' "set_real_ip_from ::1;"
printf '%s\n' "set_real_ip_from $TRUSTED_SUBNET;"
awk '{print "set_real_ip_from "$0";"}' "$ipv4"
awk '{print "set_real_ip_from "$0";"}' "$ipv6"
printf '%s\n' ""
printf '%s\n' "real_ip_header CF-Connecting-IP;"
printf '%s\n' "real_ip_recursive off;"
} > "$TARGET.tmp"
}
gen_nginx_conf
changed=0
if cmp -s "$TARGET" "$TARGET.tmp"; then
rm -f "$TARGET.tmp"
echo "= $TARGET up to date (Cloudflare ranges unchanged)"
else
changed=1
if [[ "$MODE" == "check" ]]; then
rm -f "$TARGET.tmp"
echo "- Cloudflare ranges DIFFER; $TARGET would be regenerated"
else
mv "$TARGET.tmp" "$TARGET"
echo "+ regenerated $TARGET"
fi
fi
traefik_changed=0
if [[ -n "$TRAEFIK_CONFIG" ]]; then
if [[ ! -f "$TRAEFIK_CONFIG" ]]; then
echo "warning: $TRAEFIK_CONFIG not found, skipping Traefik sync" >&2
else
new_traefik="$(CF_V4="$ipv4" CF_V6="$ipv6" python3 - "$TRAEFIK_CONFIG" <<'PY'
import os, sys
path = sys.argv[1]
v4 = sorted(x.rstrip("\n") for x in open(os.environ["CF_V4"]) if x.strip())
v6 = sorted(x.rstrip("\n") for x in open(os.environ["CF_V6"]) if x.strip())
lines = open(path).read().split("\n")
out, i, n = [], 0, len(lines)
while i < n:
line = lines[i]
if line.startswith(" trustedIPs:"):
out.append(line)
i += 1
while i < n:
s = lines[i]
if not s.strip() or s.startswith(" - ") or s.startswith(" #"):
i += 1
else:
break
out.append(" # Cloudflare IPv4 reeksen (gesynct door cf-ips-sync.sh)")
out += [" - " + c for c in v4]
out.append(" # Cloudflare IPv6 reeksen")
out += [" - " + c for c in v6]
continue
out.append(line)
i += 1
sys.stdout.write("\n".join(out))
PY
)"
if grep -q 'trustedIPs:' <<< "$new_traefik" \
&& grep -cq '^ - ' <<< "$new_traefik"; then
if [[ "$new_traefik" == "$(cat "$TRAEFIK_CONFIG")" ]]; then
echo "= $TRAEFIK_CONFIG up to date (Cloudflare ranges unchanged)"
else
traefik_changed=1
if [[ "$MODE" == "check" ]]; then
echo "- $TRAEFIK_CONFIG differs from live Cloudflare ranges"
else
cp -a "$TRAEFIK_CONFIG" "$TRAEFIK_CONFIG.bak-$(date +%Y%m%d-%H%M%S)"
printf '%s\n' "$new_traefik" > "$TRAEFIK_CONFIG"
echo "+ updated $TRAEFIK_CONFIG"
fi
fi
else
echo "error: generated Traefik config is missing its trustedIPs block; NOT writing" >&2
exit 1
fi
fi
fi
[[ "$MODE" == "check" ]] && exit $((changed || traefik_changed))
if [[ "$MODE" == "install" ]]; then
"$SCRIPT_DIR/nginx-sync.sh"
if [[ "$traefik_changed" -eq 1 ]]; then
if docker inspect traefik >/dev/null 2>&1; then
echo "--- restarting traefik (static config changed) ---"
docker restart traefik
else
echo "warning: traefik container not found; restart it manually" >&2
fi
fi
fi