一、背景

1. 业务上云带来性能收益

公司从去年全面推动业务上云,而以往 IDC 架构部署上,接入层采用典型的 4 层 LVS 多机房容灾架构,在业务高峰时期,扩容困难(受限于物理机资源和 LVS 内网网段的网络规划),且抵挡不住 HTTPS 卸载引发的高 CPU 占用。

而经过压力测试发现,使用腾讯云 7 层 CLB 负载均衡进行 HTTPS 卸载,性能得到极大提升。测试数据也表明,IDC 旧架构中,启用 HTTPS 会带来 90% 以上的性能损耗。

2. 架构调整引发多次故障

引入腾讯云 7 层 CLB 负载均衡产品,带了了巨大的性能提升,却也给业务带来了痛苦,主要核心问题是获取客户端的真实 IP 上。

当前现状是业务语言异构(PHP + Go),多数业务已经历服务化改造,但缺乏服务发现机制,服务与服务之间的调用依赖域名和 DNS 解析,大部分都是 HTTP 服务。

在架构调整后,由于未能 100% 覆盖测试,导致漏测的服务经常拿到错误的客户端 IP 地址,造成的后果是损失大量的用户。这些用户会因为短信验证码发送限制、IP 登录频次过高而无法登录、充值,给公司带来巨大损失。

3. 未来的路应该怎么走?

更进一步讲,当前业务如何抵挡外界的 DDoS 攻击、请求机器人、SQL 注入等等,最简单的是接入高防 IP、WAF 应用防火墙,而请求经过多轮转发,同样也有获取客户端真实 IP 的问题。

再者,业务也在逐步容器化,享受 Kubernetes 弹性扩容的便利,怎么平滑迁移也是非常值得深思的。

假设有一天某个同学,不小心配置有误——应用层拿到的,很有可能是高防 IP 或者 WAF 的 IP,业务绝对无法忍受。

显然,确定一个业务无感知的方案并成功落地迫在眉睫。

然而翻遍整个互联网,几乎没有文章能把这些看起来很简单的事情捋清楚、讲明白,更不用说最佳实践。

大多数人都是抄抄配置,潦潦草草上线,方案并没有普适性。

这篇文章也是我在这段时间的研究中总结出来的宝贵经验,希望对读者能有些许帮助。文章篇幅较长,难免有错误之处,还请各位看官斧正,感激不尽:)

二、名词释义

1. REMOTE-ADDR

  • Nginx + PHP 模式下,REMOTE-ADDR 为远端的 IP 地址,可通过 $_SERVER['REMOTE-ADDR'] 获取;
  • 它代表与上一层建立 TCP 连接的 IP 地址;
  • 网站无代理时(客户端->服务端),WEB服务器(Nginx,Apache等)会设置该值为客户端 IP;
  • 网站存在代理时(客户端->代理->服务端),该值为代理的 IP。
proxy_set_header REMOTE-ADDR $remote_addr;

2. X-Forwarded-For

X-Forwarded-For 是一个 HTTP 扩展头部。HTTP/1.1(RFC 2616)协议并没有对它的定义,它最开始是由 Squid 这个缓存代理软件引入,用来表示 HTTP 请求端真实 IP。如今它已经成为事实上的标准,被各大 HTTP 代理、负载均衡等转发服务广泛使用,并被写入 RFC 7239(Forwarded HTTP Extension)标准之中。

  • 格式为英文逗号 + 空格隔开,例如:X-Forwarded-For: IP0(client), IP1(proxy), IP2(proxy);

  • 中间经过的代理,会逐层追加至末尾;

  • IP0 离服务端最远,然后是每一级代理设备的 IP,IP2 直连服务端。

  • 如果客户端伪造 IP 地址,格式为:X-Forwarded-For: 伪造的 IP 地址 1, [伪造的 IP 地址 2...], IP0(client), IP1(proxy), IP2(proxy)。

3. X-Real-IP

注:CLB <=> SLB,为腾讯云和阿里云不同产品的称呼,均为负载均衡。

典型的调用链路:

client --> ① [CLB-7]gateway --域名--> ② [CLB-7]server(③ nginx + ④ go/php)
  • X-Real-IP 为建立 TCP 连接的上一跳的 IP 地址;
  • 对于 ④ 而言,X-Real-IP 为 ① 网关的 NAT 公网出口 IP 地址,或 gateway 的内网 IP 地址,该结论通过生产环境 tcpdump 抓包验证得到;
  • 公网调用下,① 网关 调用 ② 7 层 CLB,再到应用层 ③④,此时 ④ 拿到的 X-Real-IP 为 ① 的 NAT 公网出口地址(7 层 CLB 会重写 X-Real-IP 头部,并追加 X-Forwarded-For 头部);
  • 内网环境中,原理相似,只不过拿到的是 gateway 的内网 IP 地址;
  • 中间可能被 ③ nginx 重写,此时等同于 REMOTE-ADDR。

比如以下最常见的 nginx 配置:

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

REMOTE-ADDR 和 X-Real-IP 都是 nginx 的 $remote_addr 变量,再传递给下游。

三、面临困境

1. 运维侧

  • 业务线配置五花八门,没有统一。具体表现在 nginx.conf 和 vhost 配置在不同的业务线有很大区别;
  • vhost 成千上万,nginx 内部存在多重转发,外部也有网关转发过来的流量,且网关不止一套,捋不清链路容易导致线上故障;
  • 缺乏完善的 QA 验证流程,变更没办法 100% 覆盖测试,最终结果就是尽可能少变更,但这不是长久之计;
  • 存在开发自行维护信任 IP 的情况,所以运维不敢随便变更,因为变更前需要通知开发整改,开发有自己的时间排期,处理起来效率极其低下;
  • 为了尽可能少修改原先的配置,部分机器组接入了腾讯云的 TOA 模块,用来获取客户端真实 IP 地址,而阿里云没有相似的产品,如果没有统一的方案,没办法上线阿里云,实现不了双云双活的目标等等。

2. 开发侧

各个业务线使用的技术栈不统一,存在多种获取客户端 IP 的方案,需要找到一种尽可能少修改代码,或者一点都不需要修改代码的方案。

PHP 以 Laravel 框架为例(底层是 Symfony 框架),发现内部取了 $_SERVER['REMOTE_ADDR'] 变量:

public function getClientIp()
{
$ipAddresses = $this->getClientIps();
return $ipAddresses[0]; // 1. 取第一个 IP 地址。
}
public function getClientIps()
{
$ip = $this->server->get('REMOTE_ADDR');
if (!$this->isFromTrustedProxy()) {
// 2. 程序在这里返回了 REMOTE_ADDR 头部的值。
return [$ip];
}
// 3. 永远到不了这个分支。
return $this->getTrustedValues(self::HEADER_X_FORWARDED_FOR, $ip) ?: [$ip];
}
public function isFromTrustedProxy()
{
// 4. 因为生产环境中,$trustedProxies 没有配置。
return self::$trustedProxies && IpUtils::checkIp($this->server->get('REMOTE_ADDR', ''), self::$trustedProxies);
}

公司内部有些业务自己实现函数,依赖的是 X-Forwarded-For 头部。

Go 以 Gin 框架为例,准确的说是 Gin@v1.6. 版本,它先取 X-Forwarded-For 的第一个 IP,取不到就取 X-Real-IP 头部:*

func (c *Context) ClientIP() string {
// 1. ForwardedByClientIP 默认为 true
if c.engine.ForwardedByClientIP {
// 2. 优先获取 X-Forwarded-For 头部
clientIP := c.requestHeader("X-Forwarded-For")
// 3. 取 X-Forwarded-For 的第一个 IP 地址
clientIP = strings.TrimSpace(strings.Split(clientIP, ",")[0])
// 4. 取不到就取 X-Real-Ip 字段
if clientIP == "" {
clientIP = strings.TrimSpace(c.requestHeader("X-Real-Ip"))
}
// 5. 拿到了就直接返回(正常的逻辑)
if clientIP != "" {
return clientIP
}
}
// 6. 忽略,该值为 false,除非 build tags 包含 appengine 为 true
if c.engine.AppEngine {
if addr := c.requestHeader("X-Appengine-Remote-Addr"); addr != "" {
return addr
}
}
// 7. 以上都取不到的话,取 RemoteAddr 字段,走到这个逻辑,程序肯定不正常。
// 参考 Go 标准库,该值为 TCP 建立连接的远端 IP 地址
// go1.17.1/src/net/http/server.go:1003
// req.RemoteAddr = *conn.remoteAddr
if ip, _, err := net.SplitHostPort(strings.TrimSpace(c.Request.RemoteAddr)); err == nil {
return ip
}
return ""
}

经过调研发现,业务取的是 X-Real-IP 字段,具体原因就不展开了。

至于 Gin@1.7.* 版本,由于 Gin@1.6.* 的实现存在伪造客户端 IP 的问题,被爆 CVE-2020-28483 漏洞,官方为了修复这个问题,换了一种实现修复该漏洞:

func (c *Context) ClientIP() string {
// 1. 自定义 Header 的情况,可以忽略
if c.engine.TrustedPlatform != "" {
if addr := c.requestHeader(c.engine.TrustedPlatform); addr != "" {
return addr
}
}
if c.engine.AppEngine {
if addr := c.requestHeader("X-Appengine-Remote-Addr"); addr != "" {
return addr
}
}
// 2. 获取 IP 地址,并返回是否可以信任
remoteIP, trusted := c.RemoteIP()
if remoteIP == nil {
return ""
}
// 3. 如果信任,检查 IP 地址的合法性,合法就返回
// 默认值:ForwardedByClientIP=true,RemoteIPHeaders=[X-Forwarded-For(优先), X-Real-IP]
if trusted && c.engine.ForwardedByClientIP && c.engine.RemoteIPHeaders != nil {
for _, headerName := range c.engine.RemoteIPHeaders {
// c.requestHeader 在头部有效的情况下,也是返回第一个 IP 地址。
ip, valid := validateHeader(c.requestHeader(headerName))
if valid {
return ip
}
}
}
// 4. 不能信任,那就用 TCP 连接远端 IP 兜底。
return remoteIP.String()
}
func (c *Context) RemoteIP() (net.IP, bool) {
ip, _, err := net.SplitHostPort(strings.TrimSpace(c.Request.RemoteAddr))
if err != nil {
return nil, false
}
remoteIP := net.ParseIP(ip)
if remoteIP == nil {
return nil, false
}
// remoteIP = TCP 连接远端 IP 地址
// 由于业务没有配置 engine.TrustedProxies,所以是不可信任的。
return remoteIP, c.engine.isTrustedProxy(remoteIP)
}
func (e *Engine) isTrustedProxy(ip net.IP) bool {
if e.trustedCIDRs != nil {
for _, cidr := range e.trustedCIDRs {
if cidr.Contains(ip) {
return true
}
}
}
// 业务将会走到这里!
return false
}
func (e *Engine) validateHeader(header string) (clientIP string, valid bool) {
if header == "" {
return "", false
}
items := strings.Split(header, ",")
for i := len(items) - 1; i >= 0; i-- {
ipStr := strings.TrimSpace(items[i])
ip := net.ParseIP(ipStr)
if ip == nil {
return "", false
}
// X-Forwarded-For is appended by proxy
// Check IPs in reverse order and stop when find untrusted proxy
if (i == 0) || (!e.isTrustedProxy(ip)) {
return ipStr, true
}
}
return
}

官方的手法也是简单粗暴,以前是将错就错,这次一下子修复好了,搞得很多人翻车了(https://github.com/gin-gonic/gin/issues/2697)。

原因是新的实现没有兼容 1.6 版本,导致升级框架后获取不到客户端的真实 IP,1.7.7 才解决该问题。

四、三大原则

分析完整个事情的来龙去脉,想必读者们对现状有一定的了解。

我把这套方案,抽象为三大原则,只要理解它,获取客户端真实 IP 的问题,就跟喝水一样简单!

1. 代理必须向下传递客户端 IP 地址

原因:从入口流量开始,经过 N 层代理,如果代理中间不传递客户端的 IP 地址,底层业务必然获取不到客户端的真实 IP 地址

2. 统一使用 nginx 的 realip 模块获取客户端 IP 地址

# nginx.conf
# ...
set_real_ip_from 腾讯云/阿里云 NAT 出口网段;
set_real_ip_from 腾讯云/阿里云高防 IP 网段;
set_real_ip_from 腾讯云/阿里云 WAF 网段;
set_real_ip_from CDN 网段;
set_real_ip_from 内网地址网段; # 按需配置,对于网关进来的请求通过内网到业务机器,需要配置上这个网段。
set_real_ip_from 127.0.0.1; # 按需配置,主要作用在 nginx 的内部转发。
real_ip_header X-Forwarded-For;
real_ip_recursive on; # 必须打开该选项,原因见下面分析。
access_by_lua '
ngx.req.set_header("X-REAL-IP", ngx.var.remote_addr)
ngx.req.set_header("X-FORWARDED-FOR", ngx.var.remote_addr)
'; # vhost/*.conf
location ^~ /foo {
access_log logs/api_foo.access.log main;
proxy_pass http://api_foo;
proxy_redirect off;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-NginX-Proxy true;
proxy_set_header Connection "";
}

此时,X-Real-IP、REMOTE-ADDR、X-Forwarded-For 均统一为 realip 模块重写后的 $remote_addr 变量,业务就可以取到真实的客户端 IP 地址,无需考虑 PHP、Go 等不同语言、同种语言不同框架下的差异。

那问题来了,客户端 IP 是否会被伪造?答案是不会的。

按照 X-Forwarded-For 的定义,该头部每经过一层就追加一个 IP 地址:

X-Forwarded-For: 客户端伪造 IP 地址, IP0(client), IP1(proxy), IP2(proxy)

那么,我们只需启用 realip 模块的 real_ip_recursive 递归模式,将从右往左逐步剔除 IP2,IP1 等信任代理,最后会获取到真实的客户端 IP 地址。

问题二:网上有一种边缘节点的方案,为什么不采用?

边缘节点,指的就是接入层,直接连接客户端的那一层。经过边缘节点转发到下游的,统称为非边缘节点。

按照这个思路,如果边缘节点拿到了客户端 IP,重置 X-FORWARDED-FOR 头部为客户端 IP 地址,并转发到下游,业务只获取第一个 IP 地址,理论上也不会被伪造,业务也简单,为什么不采用?

因为边缘节点方案最大的缺点在于失去了灵活性,譬如你想接入高防 IP 或者 WAF 防火墙,此时它已不再是边缘节点,而是接收高防服务器或 WAF 防火墙清洗的流量,将会拿到错误的 IP 地址。

3. 运维维护信任 IP 列表,开发代码不做处理

由 2 可知,三个头部均为统一的值,对开发可以保证最大的兼容性。原因是不同的语言,同个语言的不同开发框架,同个框架的不同版本,获取客户端 IP 的方式也就这几种。

对开发而言,确实没必要关心自己的代码需要引入 NAT 网关 IP 配置、高防 IP 配置等,并且每个工程可能都要修改,这是不现实的。

本质上,这也是运维的工作。举个例子,如果真的遇到 DDoS 攻击,切换高防 IP 抵御 DDoS 攻击的操作人是运维,开发这个时候去将所有工程配置上高防 IP 地址是一件极其痛苦的事情。一旦加漏、加错将直接引发故障。

五、最佳实践

(1) 虚拟机部署

  1. SRE 维护信任的 IP 池,X-Real-IP、REMOTE-ADDR、X-Forwarded-For 均统一为 realip 模块重写后的 $remote_addr 变量,开发不感知;
  2. 开发无需修改代码,因为上述三个变量读取出来的值是一致的,无任何风险。

(2) 容器化部署

a. PHP 无需改动,可以平滑切换上容器。因为 PHP 容器上层依然有 nginx.conf,平移该配置即可;

b. GO 容器化,有 2 种方案:

注:最终采用方案 2,去除了 Pod 内部的 nginx 转发,Pod 的上层使用了 nginx-ingress,做到了业务无感知容器上云。

  1. 如果保留虚拟机架构,即 Go 服务上层有 nginx,也是平移就可以了,跟 PHP 一样;

  2. 如果 Go 服务上游去除 nginx 转发:

    流量入口使用 7 层腾讯云 CLB / 阿里云 SLB 进行 HTTPS 卸载后转发到容器集群的 nginx-ingress,业务代码无感知。实现原理和虚拟机方案相似,均为配置 realip 模块和统一 X-Real-IP、REMOTE-ADDR、X-Forwarded-For 头部,详情可以参考以下资料:

还有个容易忽略的点——ingress 选型。

如果使用 Pod 直连,也就是不使用 nginx-ingress:

PHP / Go 上层都需要有一层 nginx 并配置好 nginx.conf,配置 realip 模块和统一 X-Real-IP、REMOTE-ADDR、X-Forwarded-For 头部。

此时 PHP / Go 架构统一,但对 Go 容器来说多了一层 nginx,会造成资源浪费(每个 Pod 都需要部署一个 nginx,再转发到 Go)。

具体用哪个 ingress,就要看怎么取舍了。

nginx 存在的意义在于阻止业务直接感知到信任代理 IP 列表的存在,如果对于你的业务而言,各个业务线去维护这个配置列表成本极低,那 nginx 确实是没有存在的必要性。


总之,我个人认为:

  1. 业务完全不需要关心如何获取客户端的真实 IP,这是最好的选择;
  2. 千万不要封装各种函数去获取客户端真实 IP,这种问题最好交给上层 SRE 基础架构的同学负责,不然真的非常容易出问题;
  3. 理解好三大原则,获取客户端真实 IP 的问题,就跟喝水一样简单!

OK,文章终于写完了,花费了好多天的时间整理,憋出来了。感谢你读到这里,是时候吃晚饭了:)


文章来源于本人博客,发布于 2021-12-19,原文链接:https://imlht.com/archives/248/

获取客户端真实 IP 地址的最佳实践的更多相关文章

  1. Java 获取客户端真实IP地址

    本文基于方法 HttpServletRequest.getHeader 和 HttpServletRequest.getRemoteAddr 介绍如何在服务器端获取客户端真实IP地址. 业务背景 服务 ...

  2. 伪造IP及获取客户端真实IP地址

    Fiddler支持自定义规则,可以实现对HTTP请求数据发送给Server前或HTTP应答数据发送给浏览器前进行修改.下面的例子将演示如何向所有HTTP请求数据中增加一个头.1)打开Fiddler,点 ...

  3. Java获取客户端真实IP地址的两种方法

    在JSP里,获取客户端的IP地址的方法是:request.getRemoteAddr(),这种方法在大部分情况下都是有效的.但是在通过了Apache,Squid等反向代理软件就不能获取到客户端的真实I ...

  4. 获取客户端真实IP地址

    Java-Web获取客户端真实IP: 发生的场景:服务器端接收客户端请求的时候,一般需要进行签名验证,客户端IP限定等情况,在进行客户端IP限定的时候,需要首先获取该真实的IP. 一般分为两种情况: ...

  5. 【整理】PHP获取客户端真实IP地址详解

    php获取客户端IP地址有四种方法,这五种方法分别为REMOTE_ADDR.HTTP_CLIENT_IP.HTTP_X_FORWARDED_FOR.HTTP_VIA. REMOTE_ADDR 是你的客 ...

  6. Java获取客户端真实IP地址

    Java代码 import javax.servlet.http.HttpServletRequest; /** * 获取对象的IP地址等信息 */ public class IPUtil { /** ...

  7. 关于socket.io获取客户端真实IP地址

    1 前言 由于使用了CDN加速,导致了socket.handshake.address拿到值都是服务器的,而没有使用CDN加速时,可以拿到客户端真实IP. 2 代码 if(socket.handsha ...

  8. Nginx反向代理后,java获取客户端真实IP地址

    一般情况下,java获取客户端IP地址的方法为request.getRemoteAddr();但这只是在没有网关或者代理的情况下,如果客户端将请求发送到nginx,再由nginx进行反向代理到目标服务 ...

  9. 获取客户端真实ip地址(无视代理)

    /// <summary> /// 获取客户端IP地址(无视代理) /// </summary> /// <returns>若失败则返回回送地址</retur ...

  10. 【Nginx】如何获取客户端真实IP、域名、协议、端口?看这一篇就够了!

    写在前面 Nginx最为最受欢迎的反向代理和负载均衡服务器,被广泛的应用于互联网项目中.这不仅仅是因为Nginx本身比较轻量,更多的是得益于Nginx的高性能特性,以及支持插件化开发,为此,很多开发者 ...

随机推荐

  1. UE中根据场景模型,导出缩略图

    在实际使用中,我们有了很多模型,但是有时候我们需要这些模型对应的缩略图,比如我有很多物品,我想弄个仓库,有2种方式,要么,弄个仓库场景,一个物体一个格子摆放第二种,就是为每个物体制作一个缩略图 如果一 ...

  2. Unix shell开头的#!

    1:位于脚本文件最开始 2:#!告诉系统内核应有哪个shell来执行所指定的shell脚本. 3:如#! /bin/bash ,#!与shell文件名之间可以有空格,没有限定. 4:指定的shell可 ...

  3. MQTT-会话

    MQTT会话 为什么需要会话 ​ 假如有以下场景,客户端A发送消息到服务端,服务端转发给客户端B,如果这个时候服务端和客户端B的网络连接断开,那么就无法保证消息到达,并且客户端A不知道B连接断开,还会 ...

  4. 【Xpath】 xpath语法总结

    节点选取 表达式 描述 用法 说明 nodename 选取此节点的所有子节点 div 选取div下的所有标签 // 从全局节点中选择节点,任意位置均可 //div 选取整个HTML页面的所有div标签 ...

  5. 揭秘Karmada百倍集群规模多云基础设施体系

    摘要:本文结合Karmada社区对大规模场景的思考,揭示Karmada稳定支持100个大规模集群.管理超过50万个节点和200万个Pod背后的原理 本文分享自华为云社区<Karmada百倍集群规 ...

  6. 2021-02-03:手写代码:KMP算法。

    福哥答案2021-02-03: Knuth-Morris-Pratt 字符串查找算法,简称为 KMP算法,常用于在一个文本串 S 内查找一个模式串 P 的出现位置.这个算法由 Donald Knuth ...

  7. 2021-02-22:一个象棋的棋盘,然后把整个棋盘放入第一象限,棋盘的最左下角是(0,0)位置,那么整个棋盘就是横坐标上9条线、纵坐标上10条线的区域。给你三个 参数 x,y,k。返回“马”从(0,0)位置出发,必须走k步。最后落在(x,y)上的方法数有多少种?

    2021-02-22:一个象棋的棋盘,然后把整个棋盘放入第一象限,棋盘的最左下角是(0,0)位置,那么整个棋盘就是横坐标上9条线.纵坐标上10条线的区域.给你三个 参数 x,y,k.返回"马 ...

  8. ChatGPT 推出 iOS 应用,支持语音输入,使用体验如何?

    最近,OpenAI 宣布推出官方 iOS 应用,允许用户随时随地访问其高人气 AI 聊天机器人,此举也打破了近几个月内苹果 App Store 上充斥似是而非的山寨服务的窘境. 该应用程序是 Chat ...

  9. MAUI Android 关联文件类型

    实现效果 打开某个文件,后缀是自己想要的类型,在弹出的窗口(用其它应用打开)的列表中显示自己的应用图标 点击后可以获得文件信息以便于后续的操作 实现步骤 以注册.bin后缀为例,新建一个MAUI项目 ...

  10. OneForAll下载安装以及环境配置

    python-3.9.7-amd64 OneForAll-master python安装以及插件安装 首先下载python解压到电脑c盘在c盘中创建一个工具文件夹,然后下载OneForAll-mast ...