深入理解 web 協議:DNS、WebSocket

本文系統性的講述了 DNS 協議與 WebSocket 協議的重要細節。

一、DNS 

1、Linux dig 命令

我們首先通過 Linux 下的 dig 命令來了解一下 DNS 是怎麼做域名解析的。我們首先輸入命令:

dig www.baidu.com

圖片

看下標註的紅框,從左到右依次代表:

這裏可能有人會問了,這個域名的後面爲啥還有個 “.”?我們輸入的明明是 www.baidu.com 不是 www.baidu.com. 啊 。 

這裏要提一下:

末尾的**.** 代表的就是**根域名**,每個域名都有根域名,所以通常我們會**省略它**。

根域名的下一級叫頂級域名,比如我們熟知的. com 與. net。

再下一級就是次級域名了,比如例子中的. baidu。這個次級域名只要你有錢是可以隨便註冊的。

最後這個 www,這個代表三級域名。一般是用戶在自己的域裏面爲服務器分配的名稱。用戶可以隨便分他。 

所以可以看出來這裏的域名是分級別的。能弄明白這點就能搞清楚爲什麼 DNS 的查詢過程是分級查詢了。

我們可以利用 dig+trace 命令來完整的還原一次分級查詢的過程:

圖片

你看通過命令的方式就能一目瞭然的理解 DNS 查詢的過程了。這遠比你在網上搜一些什麼 DNS 是遞歸查詢啊之類的要來的直觀。這裏有眼尖的小夥伴可能會問,這個 CNAME 是用來幹嘛的?大家只要理解 CNAME 主要用來做 CDN 加速的即可。詳細的可以去維基百科查詢,那裏說的很清楚,本文受限於篇幅就不在這個知識點上展開了。

2、WireShark 學習理解 DNS 報文

這裏注意因爲 Wireshark 的捕獲過濾器無法設置 DNS 協議,又因爲 DNS 是基於 UDP 協議的,所以這裏捕獲過濾器我們就設置爲 UDP 就好。

圖片

然後就可以在一堆 UDP 報文中找到我們想看的 DNS 報文了,我們在瀏覽器中輸入 www.airbnb.com:

圖片

這裏要注意左邊有兩個箭頭, 向右的箭頭代表 “請求”,向左的箭頭就代表該“請求” 的回覆了。

圖片

圖片

這些 DNS 報文經過 Wireshark 的解析以後,格式已經幫我們分析好了,所以看起來很清晰。也很簡單。這裏我們不再詳細分析 DNS 的二進制報文格式,有興趣的可以自行查找相關資料。在我們上述展示的 DNS 報文抓包截圖的時候,細心的同學已經發現了,我們 DNS 報文的查詢地址是 172.22.3.102。一般而言,大部分公司內部網絡都會提供一個統一的 DNS 服務器,這個地址就是內部的 DNS 服務器地址了,有圖爲證:

圖片

我們當然也可以使用其他 DNS 查詢,比如使用著名的谷歌 DNS

圖片

3、傳統 DNS 服務查詢的缺點

經過上述的分析看起來 DNS 的查詢過程好像比較簡單,但實際上 DNS 帶來的性能或者安全問題很多很多。我們首先來還原一下完整的 DNS 查詢過程(假設我們想訪問 csdn 的網站):

  1. 瀏覽器輸入一個域名地址,如果操作系統的 DNS 緩存中有這個域名的 Ip 地址 那麼直接返回,沒有的話 就去第二步。

  2. 操作系統會向 本系統設置的 tcp/ip 參數中的 DNS 服務器地址 發出 DNS 查詢報文。注意這個服務器我們通常叫他 **本地 DNS 服務器。**也就是上述我們截圖中的 172.22.3.102

  3. 如果本地 DNS 服務器的緩存中有這個域名對應的 ip 地址,那就直接返回,如果沒有,繼續下一步。

  4. 首先看 DNS 服務器的架構圖:

    圖片

  5. 也就是說當我們的本地 DNS 服務器緩存中沒有該域名的 ip 地址的時候,本地 DNS 服務器就會直接向 根 DNS(全世界只有 13 臺)服務器去查詢,然後根 DNS 服務器就會分析這個域名,告訴我們的本地 DNS 服務器 你應該去. net 這個 DNS 服務器去查詢。然後. net 這個 DNS 服務器又告訴本地 DNS 服務器 你應該去 csdn.net 這個 DNS 服務器 去查詢 DNS 地址。然後最終 csdn 的 DNS 服務器就將正確的 ip 地址返回給我們的本地 DNS,本地 DNS 再將這個值返回給我們的瀏覽器(這個過程其實你用前面的 dig+trace 命令可以更直觀的體會到)。

通過上述的一次完整的 DNS 交互過程,我們可以至少得出三個結論:

  1. DNS 服務器是可以做負載均衡的。當然前提條件是你這個域名得自己建一個 DNS 服務器。一般大廠都會自建。

  2. DNS 的查詢是一個遞歸的過程,弱網的情況,這個時間會變的很漫長。且 DNS 使用的是 UDP 傳輸協議,弱網有直接查詢失敗的可能

  3. DNS 的查詢過程不可控,比如說本地 DNS 服務器完全可以返回一個錯誤的 ip 地址。比如你訪問了一個京東的鏈接,然後返回給你的 ip 地址是拼多多的。

這還只是表面上看出來的傳統 DNS 查詢的缺點,實際上現在我們每天大部分的流量都來自於**移動網絡。**移動網絡中,傳統 DNS 服務暴露出來的問題更多:

  1. 前文我們說過本地 DNS 服務器會緩存域名的 ip,但這個緩存時效我們控制不了,全靠運營商的操守。有可能發生我們 ip 地址已經變化,但是本地 DNS 服務器返回的還是老 ip 的情況。

  2. 有些運營商爲了節省運營商和運營商之間的流量計算成本,會偷偷的將一些靜態頁面緩存。當用戶訪問這些頁面的時候 往往訪問的是這些靜態頁面的緩存服務器的地址。此時不管我們的頁面更新了多少內容,對於用戶來說都是老的頁面。

  3. 運營商在某些場景,例如人口集中的地鐵站,演唱會,足球場附近等等,一旦發現自己的用戶太多,本地 DNS 服務器壓力巨大的時候,就會手動設置將本地 DNS 服務器向根域名服務器
    查詢 然後遞歸查詢 DNS 的過程 修改成:直接向另外一個運營商(假設這個運營商名字爲 B)的 DNS 服務器地址進行查詢,B 的 DNS 服務器就會返回一個 B 的地址,此時運營商 A 的用戶訪問的 ip 地址就是運營商 B 的 ip 了,這種跨運營商訪問的場景速度會非常慢。

  4. 某些寬帶提供方的 NAT 服務非常不穩定,大家都知道我們在家上網的時候 本機地址其實就是一個內網地址,我們之所以能訪問外部的網絡是因爲這些寬帶提供方提供了一層網關來負責 NAT, 這個 NAT 會將我們的內網地址轉換成一個外網的地址,NAT 之後的 ip,某些權威 DNS 服務器就無法判斷這個 ip 到底屬於哪個運營商。也會帶來跨運營商訪問的問題。

  5. 除了自己的 DNS 服務器,其他公共 DNS 服務器的緩存時效都不可控,這對雙機房部署,異地多活,多域名等策略都會有影響。

  6. 弱網環境下,因爲 DNS 使用的傳輸協議是不可靠的 UDP,又因爲 DNS 查詢的過程是一個遞歸的過程,所以 DNS 查詢在弱網環境下是有概率失敗的

4、HTTPDNS

基於上述缺點,越來越多的大廠使用了 HTTPDNS 的這種技術(據騰訊的公開資料顯示,15 年騰訊每天的 localDNS 失敗次數就達到了 80w 次,接入 HTTPDNS 以後,用戶平均訪問延遲下降超過 10%,訪問失敗率下降了超過五分之一,用戶訪問體驗的效果提升非常顯著):

圖片

這種技術的原理其實挺簡單的,無非就是讓我們的手機 App 發起一個 HTTP 請求 (這個請求地址多數使用 ip 直連,如果使用域名那麼依然針對此請求依然有傳統 DNS 的問題),這個請求可以攜帶用戶所在的運營商,地理位置,精確到省市,然後服務器根據這些信息 返回一個最佳的 ip 地址給 App,然後 App 將這個域名 - ip 的映射關係設置到我們的 okhttp 中。這樣手機中的大部分請求就會直接使用我們 HTTP 服務器返回的 ip 地址而不是運營商的地址了。

**注意這裏我說的是大部分請求而不是全部請求的原因是,對於 Android 系統來說,webview 的 DNS 查詢過程代碼全部在 c 層,且版本和版本之間有一定差異,這部分的 hook 過程極爲艱難,截止到這篇文章編寫的時候,筆者依舊沒有查詢到公開的能夠 hook webview DNS 的源碼,而 iOS 這點做的顯然就比 Android 好一些,對於 iOS 來說 webview 的 HTTP 就是一個正常的 HTTP request 與原生的代碼並沒有任何區別。**對於 Android 客戶端來說,接入 HTTPDNS 也不是一件特別容易的事。即使現在擁有了 okhttp。

方案一:

通過 okhttp 的攔截器,在發出請求之前將我們的 url 中的域名直接替換成 ip,再手動往 header 中添加 host 頭部信息。缺點:如果 url 是 https 的,ip 直連會出現證書校驗的問題。另外因爲請求的時候 我們直接用的 ip 但是 服務端返回的 set cookie 頭部信息卻帶上的域名,這裏也要額外處理。優點:因爲是攔截器的實現機制,所以很容易做開關進行降級處理。

方案二:

通過 okhttp 的 DNS 直接接管。

public class HttpDNS implements DNS {
    private static final DNS SYSTEM = DNS.SYSTEM;
    @Override
    public List<InetAddress> lookup(String hostname) throws UnknownHostException {
        //假設這個DNShelper可以返回我們httpDNS查詢的結果
        String ip = DNSHelper.getIpByHost(hostname);
        if (ip != null && !ip.equals("")) {
            List<InetAddress> inetAddresses = Arrays.asList(InetAddress.getAllByName(ip));
            return inetAddresses;
        }
        return SYSTEM.lookup(hostname);
    }
}
 //然後讓okhttp使用我們的DNS實現就好
 OkHttpClient client = new OkHttpClient.Builder()
                .DNS(new HttpDNS())
                .build();

這種方案就不存在攔截器哪種缺點,因爲本質上這種方案和系統的 DNS 查詢方案並無二致,無非系統的是 UDP 去 localDNS 找,我們的是用 HTTP 去 HTTP 服務器上找。這種方案可以解決方案一的所有缺點,但是有一個問題就是一旦這個 HTTPDNS 返回的結果有問題,那麼很難降級。且 okhttp 的 DNS 查詢也是有一層緩存的,一旦我們的 HTTP DNS 服務器返回的地址有誤,那麼在一定時間範圍內後續針對這個域名的訪問都會有問題。

前面我們說過 Android 自身 webview 的機制導致 HTTPDNS 很難在 webview 中起到作用,但是仍舊有一些方法可以儘量規避掉 webview 中 loacalDNS 速度慢的缺點。例如我們可以在 html 中設置預加載靜態資源的 DNS 請求,而不用等到真正請求這些資源的時候纔會查找 DNS。

<!--域名預解析-->
<meta http-equiv="x-DNS-prefetch-control" content="on" >
<link rel="DNS-prefetch" href="//vivo.com.cn" >

考慮到實際上 webview 和 App 自身代碼使用的 DNS 緩存都是操作系統中的同一塊存儲區域,我們也可以統計出我們常用 web 頁面中頻繁請求的 url 的域名,在 App 一啓動的時機,就提前訪問這些域名,這樣等到熱點 web 頁面在加載的時候,如果操作系統 DNS 緩存已經有了對應的 ip,則可以省略一次 DNS 的查詢。

5、DNS 真的是基於 UDP 協議的嗎?

其實 DNS 協議真的不是完全基於 UDP 協議的,DNS 的協議裏面其實有主 DNS 服務器和輔 DNS 服務器的概念,輔 DNS 服務器在啓動的時候會主動去主 DNS 服務器上拉取最新的該區域 DNS 信息。這個拉取的過程採用的就是 TCP 協議,而不是 UDP 協議。也就是協議文檔中說的 zone transfer。

這裏有人可能會想到,爲什麼不用 UDP 協議來完成這個過程,因爲 UDP 協議最大隻能傳送 512 個 byte 的數據,而輔 DNS 要拉取該區域的 DNS 信息很容易就超過這個最大報文數量的限制,所以這裏採用的就是 TCP 協議來完成拉取數據的操作。

二、WebSocket

1、有 HTTP 輪詢爲什麼還需要 WebSocket 技術?

很多人不明白爲什麼一定要用 WebSocket,明明我輪詢 HTTP 請求一樣可以完成需求。這句話本身並不錯,可以用 WebSocket 的地方確實全部都可以用輪詢 HTTP 請求來替代。但是其背後的效率卻天差地別。

我們可以把 WebSocket 看成是 HTTP 協議爲了支持長連接所打的一個大補丁,它和 HTTP 有一些共性,是爲了解決 HTTP 本身無法解決的某些問題而做出的一個改良設計。在以前 HTTP 協議中所謂的 keep-alive 長連接是指在一次 TCP 連接中完成多個 HTTP 請求,但是對每個請求仍然要單獨發 header;所謂的輪詢是指從客戶端不斷主動的向服務器發 HTTP 請求查詢是否有新數據。這種模式有三個缺點:

當然輪詢也有優點就是實現成本極低,幾乎不需要客戶端和服務端有額外的開發成本。WebSocket 在首次使用的時候還是需要做一些基礎設施改造的(例如 nginx 相應的配置)。WebSocket 的實現成本:雖然說現代服務器編程中默認都提供了 WebSocket 的實現,但是我們知道考慮到擴展性等因素,**我們通常都不會直接和源服務器打交道,而是和代理服務器打交道。**對 WebSocket 來說同樣如此,所以這裏對於首次實現 WebSocket 的團隊是有一定技術成本。

圖片

上圖是一個簡單的服務器架構圖,客戶端發出去的請求經過一臺專門做負載均衡的代理服務器以後將這些請求逐一轉發到對應的源服務器上。而對於 WebSocket 來說 情況則變的稍微有點複雜:

圖片

相比純 HTTP 來說,WebSocket 通常會增加一層專門的消息分發系統提高消息的處理效率。通常是 Kafka 或者是 RabbitMQ。

2、Wireshark 解析 WebSocket 報文

首先 來看一下 WebSocket 的幀格式。我們首先設置一下 Wireshark 的捕獲器:

圖片

設置一下我們想要捕獲的域名和端口號。注意 WebSocket 是可以複用 HTTP 端口號的。http://demos.kaazing.com 這個網址是一個專門用來體驗 WebSocket 技術的網址。我們以這網站爲例。

圖片

可以看出來這裏我們操作步驟一共是 connect,然後發消息,服務器返回我們發送的消息,最後我們主動斷開連接。

WebSocket 是一個基於幀的協議,所以這裏我們着重分析一下 WebSocket 的幀格式,每個幀頭部的 第 4 - 第 7 個 bit 位,這 4 個 bit 代表的就是 Opcode,比較重要的就是幾個值:

圖片

圖片

圖片

3、WebSocket 連接的建立過程

這裏有人就要問了,既然 WebSocket 是能保證長連接(tcp)的,那麼這條長連接是由誰發起的?看下圖:

圖片

這個抓包結果顯示的說明了 WebSocket 下面使用的 tcp 連接是交給 HTTP1.1 來發起的。來詳細看一下,這裏我用箭頭標註的都是必須要設置的 HTTP 頭部信息,否則是無法完成 WebSocket 連接的建立的。

圖片

此外我們還需要注意 Sec-WebSocket-Accept,和 Sec-WebSocket-Key 這 2 個頭部信息。

客戶端生成一個隨機數以後用 base64 加密以後放到 Sec-WebSocket-Key 頭部信息中,然後服務器接受到這個信息,用這個值與 rfc 中規定的一個魔法字符串:“258EAFA5-E914-47DA-95CA-C5AB0DC85B11” 拼起來,然後再使用 sha-1 加密 再經過 base64 以後計算出來的值 放到 Sec-WebSocket-Accept 頭部中返回給客戶端。

這麼做的原因是帶來一些基礎的保障,前面我們說過 WebSocket 連接的建立是依託 HTTP 消息的,爲了防止這個 WebSocket 連接的建立是調用者無心誤觸發或者其他異常情況,所以這裏有一次額外的數據校驗的過程。

4、WebSocket 連接的斷開過程

看完連接,我們再看一下斷開連接,與 WebSocket 的連接不同,WebSocket 的斷開連接是有明確步驟的,需要先斷開 WebSocket 的連接,然後纔是 tcp 的斷開連接。

圖片

可以看出來,斷開連接的步驟是客戶端先發了一個斷開連接的幀,然後服務端再給客戶端發一個確認斷開 WebSocket 連接的幀。最後就是 tcp 的四次揮手了,保證了 tcp 連接的徹底斷開。

另外 HTTP 1.1 中保持長連接的方法其實就是一個定時器,定時器大概時間爲 60s,如果 60s 都沒有 HTTP 消息,那麼這個 tcp 連接就斷掉了。WebSocket 雖然也是利用了 HTTP 1.1 的消息來保證 tcp 的連接,但是保證這條 tcp 連接不被斷開的方法卻又不是定時器了,與 mqtt xmpp 等協議類似,WebSocket 保持長連接的方法也是利用了心跳包。

在 RFC 協議中,規定了 opcode 爲 0x9 0xA 的幀爲心跳幀,但是往往 這個關於心跳包的協議卻很少有人遵守,很多時候人們選擇間隔一段時間發送一個任意幀(當然這個任意幀的內容需要客戶端和服務端提前約定好)來保證心跳包的建立。比如前文中我們拿來做例子的 http://demos.kaazing.com 網站,他的心跳包就沒有遵守協議 而是:

圖片

圖中可以看出來這個心跳包大概是 30s 發送一次,而且並沒有使用 rfc 中約定好的 0x9 或者 0xA 的所謂 ping pong 的心跳幀,而是就用了最簡單的文本幀來表示。

圖片

圖片

上圖所示,左邊的就是 WebSocket 服務端發起的心跳包,opcode 的值還是 text 文本幀的意思,只不過文本的內容很特殊。右邊就是 WebSocket 客戶端回覆的心跳包。

5、WebSocket 的代理緩存污染

這裏要注意的是 Wireshark 抓包的時候,最右邊有一個 masked 的標識,這通常代表這一個 WebSocket 的幀是由客戶端發送給服務端的,這是一個掩碼的標識。**在 WebSocket 協議中只要是客戶端發起的消息,都必須經過這個隨機的 masking-key 的掩碼計算之後才能傳輸。**這是爲了解決代理緩存污染的問題。

圖片

注意這裏問題的核心是實現不當的代理服務器,所謂實現不當的代理服務器就是指沒有完整實現好 WebSocket 協議的代理服務器。而不是真正意義上惡意的代理服務器,惡意的代理服務器,用 mask 幀的技術是無法避免的。

所謂 mask 掩碼技術就是指瀏覽器在發送 WebSocket 幀的時候必須生成一個隨機的 mask-key,在幀的二進制中將傳輸的內容與這個 mask-key 做異或操作。得出來的值纔可以在網絡中傳輸。

當我們的服務器接收到這個 WebSocket 幀以後就可以用這個 mask-key 來反異或,從而就可以得出真正的內容了,這是最低成本實現檢測 WebSocket 幀是否遭到篡改的方案。例如:我們用 WebSocket 傳輸一個 文本幀,內容爲字符串 vivo,vivo 的 ascii 碼的 16 進製爲:76 69 76 6f。而這個消息,這次瀏覽器生成的 mask-key 爲 23 68 c0 a3。

圖片

我們將這 2 個值進行異或操作:

圖片

可以得到值爲 55 01 b6 cc。然後看一下抓包的幀內容裏面是不是這個值:

圖片

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/AkbAN4UZLDf841g1ZLFPBQ