公司從去年全面推動業務上雲,而以往 IDC 架構部署上,接入層採用典型的 4 層 LVS 多機房容災架構,在業務高峰時期,擴容困難(受限於物理機資源和 LVS 內網網段的網路規劃),且抵擋不住 HTTPS 解除安裝引發的高 CPU 佔用。
而經過壓力測試發現,使用騰訊雲 7 層 CLB 負載均衡進行 HTTPS 解除安裝,效能得到極大提升。測試資料也表明,IDC 舊架構中,啟用 HTTPS 會帶來 90% 以上的效能損耗。
引入騰訊雲 7 層 CLB 負載均衡產品,帶了了巨大的效能提升,卻也給業務帶來了痛苦,主要核心問題是獲取使用者端的真實 IP 上。
當前現狀是業務語言異構(PHP + Go),多數業務已經歷服務化改造,但缺乏服務發現機制,服務與服務之間的呼叫依賴域名和 DNS 解析,大部分都是 HTTP 服務。
在架構調整後,由於未能 100% 覆蓋測試,導致漏測的服務經常拿到錯誤的使用者端 IP 地址,造成的後果是損失大量的使用者。這些使用者會因為簡訊驗證碼傳送限制、IP 登入頻次過高而無法登入、充值,給公司帶來巨大損失。
更進一步講,當前業務如何抵擋外界的 DDoS 攻擊、請求機器人、SQL 注入等等,最簡單的是接入高防 IP、WAF 應用防火牆,而請求經過多輪轉發,同樣也有獲取使用者端真實 IP 的問題。
再者,業務也在逐步容器化,享受 Kubernetes 彈性擴容的便利,怎麼平滑遷移也是非常值得深思的。
假設有一天某個同學,不小心設定有誤——應用層拿到的,很有可能是高防 IP 或者 WAF 的 IP,業務絕對無法忍受。
顯然,確定一個業務無感知的方案併成功落地迫在眉睫。
然而翻遍整個網際網路,幾乎沒有文章能把這些看起來很簡單的事情捋清楚、講明白,更不用說最佳實踐。
大多數人都是抄抄設定,潦潦草草上線,方案並沒有普適性。
這篇文章也是我在這段時間的研究中總結出來的寶貴經驗,希望對讀者能有些許幫助。文章篇幅較長,難免有錯誤之處,還請各位看官斧正,感激不盡:)
$_SERVER['REMOTE-ADDR']
獲取;proxy_set_header REMOTE-ADDR $remote_addr;
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)。
注:CLB <=> SLB,為騰訊雲和阿里雲不同產品的稱呼,均為負載均衡。
典型的呼叫鏈路:
client --> ① [CLB-7]gateway --域名--> ② [CLB-7]server(③ nginx + ④ go/php)
比如以下最常見的 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 變數,再傳遞給下游。
各個業務線使用的技術棧不統一,存在多種獲取使用者端 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 框架為例,準確的說是 [email protected]. 版本,它先取 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 欄位,具體原因就不展開了。
至於 [email protected].* 版本,由於 [email protected].* 的實現存在偽造使用者端 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 的問題,就跟喝水一樣簡單!
原因:從入口流量開始,經過 N 層代理,如果代理中間不傳遞使用者端的 IP 地址,底層業務必然獲取不到使用者端的真實 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 地址。
由 2 可知,三個頭部均為統一的值,對開發可以保證最大的相容性。原因是不同的語言,同個語言的不同開發框架,同個框架的不同版本,獲取使用者端 IP 的方式也就這幾種。
對開發而言,確實沒必要關心自己的程式碼需要引入 NAT 閘道器 IP 設定、高防 IP 設定等,並且每個工程可能都要修改,這是不現實的。
本質上,這也是運維的工作。舉個例子,如果真的遇到 DDoS 攻擊,切換高防 IP 抵禦 DDoS 攻擊的操作人是運維,開發這個時候去將所有工程設定上高防 IP 地址是一件極其痛苦的事情。一旦加漏、加錯將直接引發故障。
a. PHP 無需改動,可以平滑切換上容器。因為 PHP 容器上層依然有 nginx.conf,平移該設定即可;
b. GO 容器化,有 2 種方案:
注:最終採用方案 2,去除了 Pod 內部的 nginx 轉發,Pod 的上層使用了 nginx-ingress,做到了業務無感知容器上雲。
如果保留虛擬機器器架構,即 Go 服務上層有 nginx,也是平移就可以了,跟 PHP 一樣;
如果 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 確實是沒有存在的必要性。
總之,我個人認為:
OK,文章終於寫完了,花費了好多天的時間整理,憋出來了。感謝你讀到這裡,是時候吃晚飯了:)
文章來源於本人部落格,釋出於 2021-12-19,原文連結:https://imlht.com/archives/248/