套了 Cloudflare 之后 IP 全变成 CF 的:Nginx real_ip 取回真实访客 IP 实战

给网站套上 Cloudflare 之后,好处是显而易见的:免费 CDN 加速、隐藏源站 IP、自带一定程度的 DDoS 防护。但紧接着你会遇到一个很别扭的问题:Nginx 的 access.log 里,所有访客的 IP 都变成了 Cloudflare 的节点 IP,127.0.0.1 或者 172.6x.x.x 这类内网地址,根本分不清谁是谁。

这个问题的影响比想象中大。一是日志失去分析价值,你没法知道真实用户在哪个地区、哪个网段;二是基于 IP 的功能全部失效——限流会误伤(把所有 CF 节点当成一个 IP 限速)、封禁会封错(封掉 CF 节点等于封掉半个互联网)、后台登录白名单会拦不住人。这篇文章把恢复真实 IP 的完整做法讲清楚,包括 Cloudflare 官方 IP 段的正确获取方式,以及几个容易漏掉的配合配置。

一、先搞清楚 X-Forwarded-For 是怎么来的

Cloudflare 作为反向代理,在转发请求时会在 HTTP 头里加上访客的真实信息。最关键的一个头是 CF-Connecting-IP,它直接就是访客的真实 IP,Cloudflare 保证这个头只有一个值、不会被伪造。另一个是标准的 X-Forwarded-For,格式是一串逗号分隔的 IP,最左边是原始访客,后面依次是各级代理。

这里的坑在于:X-Forwarded-For 是可以被客户端伪造的。如果你的 Nginx 直接信任它,攻击者只要在请求里自己加一个 X-Forwarded-For: 1.2.3.4,日志里就会显示成 1.2.3.4,绕过了所有基于 IP 的防护。所以正确的做法不是简单取 XFF,而是先验证请求确实来自 Cloudflare 的节点,再信任这些头。

验证的手段就是 ngx_http_realip_module 模块——它做的事情是:当请求来源 IP 在可信列表里时,就用头里的 IP 替换 $remote_addr。来源不在可信列表里,就保持原样,伪造的头不起作用。这个"可信列表"是整个配置的核心。

二、确认 Nginx 编译了 realip 模块

先检查模块有没有:

nginx -V 2>&1 | tr ' ' '\n' | grep realip

如果输出里有 --with-http_realip_module,说明支持。Debian、Ubuntu、CentOS 官方仓库的 nginx 包默认都带这个模块,一般不用重编译。如果没有,要么换成发行版自带的包,要么重编译 Nginx 加上该参数。

三、获取并维护 Cloudflare 的 IP 段

这是整个配置里最容易被做错的地方。Cloudflare 的节点 IP 段不是固定不变的,官方提供了两个接口随时可查:

curl https://www.cloudflare.com/ips-v4
curl https://www.cloudflare.com/ips-v6

返回的就是当前的 IP 段列表。千万不要凭记忆硬编码几个段就不管了——Cloudflare 会新增网段,硬编码的结果是这些新节点来的请求又变回显示 CF 的 IP。正确做法是写一个自动更新脚本,定期拉取并重载 Nginx。

新建一个存储可信 IP 的文件,注意 Nginx 的 real_ip 配置需要的是 set_real_ip_from 指令,不是像 allow 那样的 CIDR 列表,所以要用脚本把拉取到的段转换成指令:

#!/bin/bash
# /usr/local/bin/update-cf-ips.sh
CF_FILE=/etc/nginx/conf.d/cloudflare-ips.conf
TMP=$(mktemp)
echo "# Auto-generated $(date '+%F %T')" > "$TMP"
curl -s https://www.cloudflare.com/ips-v4 | while read ip; do
  [ -n "$ip" ] && echo "set_real_ip_from $ip;" >> "$TMP"
done
curl -s https://www.cloudflare.com/ips-v6 | while read ip; do
  [ -n "$ip" ] && echo "set_real_ip_from $ip;" >> "$TMP"
done
echo "real_ip_header CF-Connecting-IP;" >> "$TMP"
echo "real_ip_recursive on;" >> "$TMP"
mv "$TMP" "$CF_FILE"
nginx -t && systemctl reload nginx

给脚本加执行权限,然后用 cron 每周跑一次:

chmod +x /usr/local/bin/update-cf-ips.sh
crontab -e
# 加入:0 4 * * 1 /usr/local/bin/update-cf-ips.sh >/dev/null 2>&1

脚本里最后两行指令很重要。real_ip_header CF-Connecting-IP 是选择用哪个头来取真实 IP——用 CF-Connecting-IP 比 X-Forwarded-For 更可靠,因为它不可伪造。real_ip_recursive on 的含意是:如果取到的 IP 本身也在可信列表里,就继续往上一层找,直到找到第一个不信的 IP。这个选项能正确处理"套了多层 Cloudflare 的场景",建议打开。

四、在主配置里引入并测试

把上面生成的文件引入 Nginx。它应该在 http 或 server 块里生效,最简单的方式是放在 conf.d 目录下让它自动加载:

# /etc/nginx/nginx.conf 里确保有这行
include /etc/nginx/conf.d/*.conf;

Debian 的 nginx 默认 include /etc/nginx/conf.d/*.conf 在 http 块里,我们的 cloudflare-ips.conf 放在那里就会被加载。改完先测试语法:

nginx -t
systemctl reload nginx

验证真实 IP 是否生效,最快的办法是打开日志实时看,然后自己用手机流量或换个网络访问一次网站:

tail -f /var/log/nginx/access.log

如果看到的 IP 是 CF-Connecting-IP 对应的真实地址,说明配置生效。为了双重确认,还可以临时加一个测试接口输出 $remote_addr:

location = /whoami {
    default_type text/plain;
    return 200 "$remote_addr\n";
}

访问 https://你的域名/whoami,返回应该是你的真实公网 IP 而不是 CF 的 IP。注意调试时如果本身就在 Cloudflare 的 CDN 缓存里,可能需要加参数绕过缓存,或者临时关掉 CF 的缓存。

五、配合配置:日志格式和 IP 透传

真实 IP 取回来之后,还得让后续环节也能用上。首先是日志格式,确保 log_format 里记录的是 $remote_addr(realip 模块会把它替换成真实 IP),如果你还想要完整链路,可以额外记一个头:

log_format main '$remote_addr - $remote_user [$time_local] "$request" '
                '$status $body_bytes_sent "$http_referer" '
                '"$http_user_agent" "xff=$http_x_forwarded_for"';

其次是后端应用。如果你是 Nginx 反代 PHP-FPM 或者 Node,应用拿到的 remote_addr 还是 Nginx 的地址,需要把真实 IP 透传过去。对 PHP-FPM 来说,是在 fastcgi_params 里加上:

fastcgi_param HTTP_X_REAL_IP $remote_addr;

然后 PHP 里读 $_SERVER['HTTP_X_REAL_IP']。对反代到后端的场景,则要主动设置转发头:

proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

注意 $proxy_add_x_forwarded_for 是"在已有的 XFF 后面追加当前 remote_addr",如果客户端本身伪造了 XFF,它伪造的部分会被原样记进去。所以后端解析时应该取 XFF 的最后一个值(也就是 Nginx 追加的那个真实 IP),而不是第一个——这和 Cloudflare 场景下取第一个值的规则正好相反,是很容易搞混的一点。最稳妥的做法是只信任你自己设置的那个头(比如 X-Real-IP),别去解析用户可控的 XFF。

六、限流和封禁终于能正常工作了

真实 IP 到位之后,前面提到的那些基于 IP 的功能才能正确运转。限流用 $binary_remote_addr 作为 key,realip 生效后这个变量就是真实 IP:

limit_req_zone $binary_remote_addr zone=login:10m rate=3r/m;
location = /admin/login {
    limit_req zone=login burst=5 nodelay;
}

封禁也可以用真实 IP 来做。配合 fail2ban 时,只需要保证它读的日志里记录的是真实 IP——如果你已经按上面的配置改好了 log_format,fail2ban 的默认过滤器就能正常工作,不需要额外改规则。

还有一个关于源站安全的提醒:套了 Cloudflare 之后,源站的真实 IP 理论上被隐藏了,但如果服务器防火墙允许任意 IP 直连 80/443,攻击者一旦通过 DNS 历史记录或者别的途径拿到你的源站 IP,就能绕过 CF 直接打你的源站,同时 CF 的防护全部失效。加固办法是在服务器防火墙上只放行 Cloudflare 的 IP 段访问 80/443,其他一律拒绝。这些 IP 段和上面 set_real_ip_from 用的是同一份列表,可以让脚本一并生成防火墙规则,做到一次维护两处生效。

用 iptables 实现的话,思路是这样的(注意 iptables 规则顺序敏感,必须放在最后一条 DROP 之前):

# 先允许已建立的连接,避免把正在跑的请求切断
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
# 逐个放行 Cloudflare 网段
for ip in $(curl -s https://www.cloudflare.com/ips-v4); do
  iptables -A INPUT -p tcp -s "$ip" -m multiport --dports 80,443 -j ACCEPT
done
# 其余访问 80/443 的全部丢弃
iptables -A INPUT -p tcp -m multiport --dports 80,443 -j DROP

这样即便有人拿到了你的源站 IP,直接访问也连不上。测试时务必先确认自己的 SSH 端口有单独的放行规则,否则可能把自己关在服务器外面——这是防火墙操作的第一铁律,任何批量规则变更前都应该先确认管理通道是通的。

七、常见问题排查

第一种情况:配置改完 IP 还是没变。先确认 set_real_ip_from 的段是否和实际来的 CF 节点匹配,用 tail -1 access.log 看请求来源,然后对照 CF 的 IP 列表检查。最常见的错误是只更新了 IPv4 的段,但 Cloudflare 有时候会用 IPv6 回源,导致部分请求取不到真实 IP。IPv4 和 IPv6 两套都要配上。

第二种情况:报错 "real_ip_header" is duplicate。说明配置被 include 了两次,检查是不是既在 conf.d 里放了一份,又在 server 块里手写了一遍。同一个 http 上下文里 set_real_ip_from 可以有多条(一个网段一条),但 real_ip_header 只能出现一次,重复会直接导致 nginx -t 失败。

第三种情况:日志里出现了两种格式的 IP,一部分是真实 IP,一部分还是 CF 的。这通常是因为用了 real_ip_recursive on 但可信列表不全,或者请求走的是没被 include 到的 server 块。检查你的 Nginx 配置结构,确认 include 的位置在 http 层级而不是某个特定 server 里——放错位置的话,只有那一个站生效,其他站还是老样子。

第四种情况:真实 IP 取到了,但后台的登录失败次数统计或者防刷功能还是不准。这多半是后端应用没拿到透传的头。回到第五节的配合配置,检查 fastcgi_param 或 proxy_set_header 有没有加,以及应用代码读的是哪个变量。PHP 里 $_SERVER['REMOTE_ADDR'] 永远是 Nginx 的地址,必须显式读你设置的那个头才行。

总结一下这套配置的核心逻辑:先用 set_real_ip_from 声明"我信任哪些代理",再用 real_ip_header 声明"从哪个头取真实 IP",最后用 real_ip_recursive 处理多层代理的情况。三者合起来,既恢复了真实 IP,又保住了防伪造的能力——只信任来自可信代理的头,是这个方案和"直接取 XFF"最大的区别。整套配置下来大概十几分钟,值得每个套了 CDN 的站长做一次。

Last modification:September 27th, 2026 at 01:28 pm

Leave a Comment