Kademlia、DHT、KRPC、BitTorrent 協議、DHT Sniffer

Andrew.Hann發表於2017-04-18

catalogue

0. 引言
1. Kademlia協議
2. KRPC 協議 KRPC Protocol
3. DHT 公網嗅探器實現(DHT 爬蟲)
4. BitTorrent協議
5. uTP協議
6. Peer Wire協議 
7. BitTorrent協議擴充套件與ut_metadata和ut_pex(Extension for Peers to Send Metadata Files)
8. 用P2P對等網路思想改造C/S、B/S架構的思考

 

0. 引言

平常我們高階使用者都會用到BT工具來分享一些好玩的資源,例如ubuntu 13.04的ISO安裝盤,一些好聽的音樂等。這個時候我們會進入一個叫做P2P的網路,大家都在這個網路裡互相傳遞資料,這種分散式的資料傳輸解決了HTTP、FTP等單一伺服器的頻寬壓力。以往的BT工具(包括現在也有)在加入這個P2P網路的時候都需要藉助一個叫Tracker的中心伺服器,這個伺服器是用來登記有哪些使用者在請求哪些資源,然後讓請求同一個資源的使用者都集中在一起互相分享資料,形成的一個叢集叫做Swarm。
這種工作方式有一個弊端就是一旦Tracker伺服器出現故障或者線路遭到遮蔽,BT工具就無法正常工作了。所以聰明的人類後來發明了一種叫做DHT(Distributed Hash Table)的去中心化網路。每個加入這個DHT網路的人都要負責儲存這個網路裡的資源資訊和其他成員的聯絡資訊,相當於所有人一起構成了一個龐大的分散式儲存資料庫。在DHT裡定位一個使用者和定位一個資源的方法是一樣的,他們都使用SHA-1產生的雜湊值來作標識

0x1: Kademlia/DHT/KRPC/BitTorrent之間的關係

Kademlia是一個最初提出的框架和理論基礎,P2P對等資源共享的思想從這裡開始衍生,DHT和KRPC是在Kademlia的基礎上進行了包裝和發展,BitTorrent是在這三者之上的檔案共享分發協議

0x2: Magnet URI格式

magnet:?xt=urn:btih:<info-hash>&dn=<name>&tr=<tracker-url>

1. <info-hash>: Infohash的16進位制編碼,共40字元。為了與其它的編碼相容,客戶端應當也支援32字元的infohash base32編碼 
2. Xt是唯一強制的引數
3. dn是在等待metadata時可能供客戶端顯示的名字
4. 如果只有一個欄位,Tr是tracker的url,如果有很多的tracker,那麼多個tr欄位會被包含進去 
# dn和tr都是可選的 

如果沒有指定tracker,客戶端應使用DHT來獲取peers

0x3:P2P的含義

從第一個P2P應用系統Napster的出現開始,P2P技術掀起的風暴為網際網路帶來了一場空前的變革。P2P不是一個全新的概念,P2P理念的起源可以追溯到20世紀80年代。目前,在學術界、工業界對於P2P沒有一個統一的定義。Peer在英語裡有“(地位、能力等)同等者”、“同事”和“夥伴”等意義,這樣一來,P2P也就可以理解為“夥伴對夥伴”的意思,或稱為對等網
嚴格地定義純粹的P2P網路,它是指完全分佈的系統,每一個節點都是在功能上和任務上完全相同的。但是這樣的定義就會排除掉一些使用“超級節點”的系統或者一些使用中央伺服器做一些非核心任務的系統。廣義的定義裡面指出P2P是一種能善於利用網際網路上的儲存、CPU週期、內容和使用者活動等各種資源的一類應用程式[3],包括了一些依賴中央伺服器才能工作的系統
P2P這個定義並不是從系統的結構或者內部的操作特徵出發考慮的,而是從人們外在的感知角度出發,如果一個系統從直觀上看是各個計算機之間直接互相聯絡的就可以被叫做P2P。當前,技術上比較權威的定義為,P2P系統是一個由直接相連的節點們所構成的分散式的系統[4],這些節點能夠為了共享內容、CPU 時間、儲存或者頻寬等資源而自我形成一定的網路拓撲結構,能夠在適應節點數目的變化和失效的同時維持可以接受的連結能力和效能,並且不需要一個全域性伺服器或者權威的中介的支援。本文從人們感知的角度出發,採用P2P的廣義定義

Relevant Link:

http://blog.csdn.net/xxxxxx91116/article/details/8549454

 

1. Kademlia協議

0x1: Kademlia

Kademlia是一種通過分散式雜湊表實現的協議演算法,它是由Petar和David為非集中式P2P計算機網路而設計的

1. Kademlia規定了網路的結構,也規定了通過節點查詢進行資訊交換的方式
2. Kademlia網路節點之間使用UDP進行通訊
3. 參與通訊的所有節點形成一張虛擬網(或者叫做覆蓋網)。這些節點通過一組數字(或稱為節點ID)來進行身份標識
4. 節點ID不僅可以用來做身份標識,還可以用來進行值定位(值通常是檔案的雜湊或者關鍵詞)
5. 其實,節點ID與檔案雜湊直接對應,它所表示的那個節點儲存著哪兒能夠獲取檔案和資源的相關資訊
6. 當我們在網路中搜尋某些值(即通常搜尋儲存檔案雜湊或關鍵詞的節點)的時候,Kademlia演算法需要知道與這些值相關的鍵,然後分步在網路中開始搜尋。每一步都會找到一些節點,這些節點的ID與鍵更為接近,如果有節點直接返回搜尋的值或者再也無法找到與鍵更為接近的節點ID的時候搜尋便會停止。這種搜尋值的方法是非常高效的
7. 與其他的分散式雜湊表的實現類似,在一個包含n個節點的系統的值的搜尋中,Kademlia僅訪問O(log(n))個節點。非集中式網路結構還有更大的優勢,那就是它能夠顯著增強抵禦拒絕服務攻擊的能力。即使網路中的一整批節點遭受泛洪攻擊,也不會對網路的可用性造成很大的影響,通過繞過這些漏洞(被攻擊的節點)來重新編織一張網路,網路的可用性就可以得到恢復 

0x2: p2p網路架構演進

1. 第一代P2P檔案分享網路,像Napster,依賴於中央資料庫來協調網路中的查詢
2. 第二代P2P網路,像Gnutella,使用氾濫式查詢(query flooding)來查詢檔案,它會搜尋網路中的所有節點
3. 第三代p2p網路使用分散式雜湊表來查詢網路中的檔案,分散式雜湊表在整個網路中儲存資源的位置

這些協議追求的主要目標就是快速定位期望的節點。Kademlia基於兩個節點之間的距離計算,該距離是"兩個網路節點ID號的異或",計算的結果最終作為整型數值返回。關鍵字和節點ID有同樣的格式和長度,因此,可以使用同樣的方法計算關鍵字和節點ID之間的距離。節點ID一般是一個大的隨機數,選擇該數的時候所追求的一個目標就是它的唯一性(希望在整個網路中該節點ID是唯一的)。異或距離跟實際上的地理位置沒有任何關係,只與ID相關。因此很可能來自德國和澳大利亞的節點由於選擇了相似的隨機ID而成為鄰居。選擇異或是因為通過它計算的距離享有幾何距離公式的一些特徵,尤其體現在以下幾點

1. 節點和它本身之間的異或距離是0
2. 異或距離是對稱的:即從A到B的異或距離與從B到A的異或距離是等同的
3. 異或距離符合三角不等式: 三個頂點A B C,AC異或距離小於或等於AB異或距離和BC異或距離之和,這種幾何數學特徵,可以很好的支撐演算法進行尋路路由

由於以上的這些屬性,在實際的節點距離的度量過程中計算量將大大降低。Kademlia搜尋的每一次迭代將距目標至少更近1 bit(每次根據XOR結果,往前選擇1bit更近的節點)。一個基本的具有2的n次方個節點的Kademlia網路在最壞的情況下只需花n步就可找到被搜尋的節點或值

因為Kademlia是根據bit位XOR計算得到"相對距離"的,對於越低bit位,XOR可能得到的結果越小,對於越高位的bit位,XOR可能得到的值就越大,並且是呈現2的指數方式增長的,所以,從數學上來說,一個DHT網路中的所有節點,通過這種方式(XOR距離)進行定址,每次前進一個bit,最大隻需要log2N次即可到達目標節點(log2逼近的思路,即bit 2可以表示世界上任何數字)

0x3: 路由表

Kademlia路由表由多個列表組成,每個列表對應節點ID的一位(例如: 假如節點ID共有6位,則節點的路由表將包含6個列表),一個列表中包含多個條目,條目中包含定位其他節點所必要的一些資料。列表條目中的這些資料通常是由其他節點的IP地址,埠和節點ID組成。這裡仔細思考一下

1. 節點ID的一位就是1bit,假設我們的節點ID是: 111000
2. 對第一個K桶來說,它的列表中的條目必須第一bit不能是1,因為第一個K桶的含義是和該節點的距離是最遠的一個分組,第一位不為1,它背後的含義是該分組裡的節點和該節點的距離至少在2^6以上,它代表了整個網路中和該節點邏輯距離最遠的一些節點 它的列表條目是這樣的: 0 00000 ~ 0 111111
3. 對第二個K桶來說,它的列表中的條目的第一位必須是1,表示和當前節點的第一bit相同,第二bit不能是1,這樣代表的意思是第二個K桶裡的節點和該節點的距離是介於MAX(2bit)和MIN(1bit)之間的距離,它的列表條目是這樣的: 10 0000 ~ 10 1111
4. 第三個K桶的情況和前2個相同
5. 對第四個K桶的來說,它的列表中的條目前三位都是1,第四位不是0,它的列表條目是這樣的: 1111 00 ~ 1111 11
6. 後面的bit位情況類推,可以看出,越低bit位的K桶的MAX(XOR)就越小,它的可變範圍就越小了。這代表了越低bit位的K桶裡儲存的都是距離當前節點越近的Nod節點

而條目列表以節點ID的一位(即1bit)來分組是有道理的:我們使用log2N的指數分級方法把除當前節點的全網所有節點都進行了分組,當別的節點來向當前節點請求某個資源HASH的時候,將待搜尋定址的"目標節點ID"和路由表進行異或,會有2種情況

1. 找到某個條目和目標節點XOR為0,即已經定址成功,則直接返回這個條目給requester即可
2. 如果沒找到XOR結果為0的條目,則選取那個XOR值最小的條目對應的K桶中的K個條目返回給requester,因為這些條目是最有可能儲存了目標節點ID條目的

每個列表對應於與節點相距"特定範圍距離"的一些節點,節點的第n個列表中所找到的節點的第n位與該節點的第n位肯定不同,而前n-1位相同

1. 這就意味著很容易使用網路中遠離該節點的一半節點來填充第一個列表(第一位不同的節點最多有一半)
2. 而用網路中四分之一的節點來填充第二個列表(比第一個列表中的那些節點離該節點更近一位)
3. 依次類推。如果ID有128個二進位制位,則網路中的每個節點按照不同的異或距離把其他所有的節點分成了128類,ID的每一位對應於其中的一類

隨著網路中的節點被某節點發現,它們被逐步加入到該節點的相應的列表中,這個過程中包括

1. 向節點列表中存資訊: 錄入別的節點發布的宣告
2. 和從節點列表中取資訊的操作
3. 甚至還包括當時協助其他節點尋找相應鍵對應值的操作: 轉發其他節點的定址請求

這個過程中發現的所有節點都將被加入到節點的列表之中,因此節點對整個網路的感知是動態的,這使得網路一直保持著頻繁地更新,增強了抵禦錯誤和攻擊的能力
在Kademlia相關的論文中,列表也稱為K桶,其中K是一個系統變數,如20,每一個K桶是一個最多包含K個條目的列表,也就是說,網路中所有節點的一個列表(對應於某一位,與該節點相距一個特定的距離)最多包含20個節點。隨著對應的bit位變低(即對應的異或距離越來越短)(bit位越小,可能的距離MAX值就越小了,即距離目標節點的距離越近),K桶包含的可能節點數迅速下降(K定義的是該bit對應的列表最多能儲存K個條目,但不一定都是K存滿,當到最低幾個bit位的時候,K桶裡可能就只有幾個個位數的條目了)。由於網路中節點的實際數量遠遠小於可能ID號的數量,所以對應那些短距離的某些K桶可能一直是空的(如果異或距離只有1,可能的數量就最大隻能為1,這個異或距離為1的節點如果沒有發現,則對應於異或距離為1的K桶則是空的)

從這個邏輯圖中可以看出

1. 節點的HASH值決定了它們的邏輯距離,即Kademlia網路中的下一跳定址是根據HASH XOR的值範圍(數值大小範圍)結果決定的
2. 該網路最大可有2^3,即8個關鍵字和節點,目前共有7個節點加入,每個節點用一個小圈表示(在樹的底部)
3. 考慮那個用黑圈標註的節點6,它共有3個K桶(即3bit位)

節點0,1和2(二進位制表示為000,001和010)是第一個K桶的候選節點
000 -> 110: 6
001 -> 110: 5
010 -> 110: 4

節點3目前(二進位制表示為011)還沒有加入網路

節點4和節點5(二進位制表示分別為100和101)是第二個K桶的候選節點
100 -> 110: 2
101 -> 110: 1 

節點7(二進位制表示為111)是第3個K桶的候選節點
111 -> 110: 1

圖中3個K桶都用灰色圈表示,假如K桶的大小(即K值)是2,那麼第一個K桶只能包含3個節點中的2個。眾所周知,那些長時間線上連線的節點未來長時間線上的可能性更大,基於這種靜態統計分佈的規律,Kademlia選擇把那些長時間線上的節點存入K桶,這一方法增長了未來某一時刻有效節點的數量(hot hint),同時也提供了更為穩定的網路。當某個K桶已滿,而又發現了相應於該桶的新節點的時候,那麼,就首先檢查K桶中最早訪問的節點,假如該節點仍然存活,那麼新節點就被安排到一個附屬列表中(作為一個替代快取). 只有當K桶中的某個節點停止響應的時候,替代cache才被使用。換句話說,新發現的節點只有在老的節點消失(失效)後才被使用

0x4: 協議訊息

Kademlia協議共有四種訊息

1. PING訊息: 用來測試節點是否仍然線上
2. STORE訊息: 在某個節點中儲存一個鍵值對
3. FIND_NODE訊息: 訊息請求的接收者將返回自己桶中離請求鍵值最近的K個節點: 將請求者請求的節點HASH和自己的HASH進行XOR計算,將計算結果
4. FIND_VALUE訊息: 與FIND_NODE一樣,不過當請求的接收者存有請求者所請求的鍵的時候,它將返回相應鍵的值

每一個RPC訊息中都包含一個發起者加入的隨機值,這一點確保響應訊息在收到的時候能夠與前面傳送的請求訊息匹配

0x5: 定位節點

節點查詢可以非同步進行,也可以同時進行,同時查詢的數量由α表示,一般是3

1. 在節點查詢的時候,它先得到它K桶中離所查詢的鍵值最近的K個節點(XOR值最小的那個條目所在的分組),然後向這K個節點發起FIND_NODE訊息請求(因為這個K桶內的節點最有可能定址成功)
2. 訊息接收者收到這些請求訊息後將在他們的K桶中進行查詢,如果他們知道離被查鍵更近的節點,他們就返回這些節點(最多K個)
    1) 找到某個條目和目標節點XOR為0,即已經定址成功,則直接返回這個條目給requester即可
    2) 如果沒找到XOR結果為0的條目,則選取那個XOR值最小的條目對應的K桶中的K個條目返回給requester,因為這些條目是最有可能儲存了目標節點ID條目的
3. 訊息的請求者在收到響應後將使用它所收到的響應結果來更新它的結果列表,返回的結果也應該插入到剛才發起請求的那個K桶裡,這個結果列表總是保持K個響應FIND_NODE訊息請求的最優節點(即離被搜尋鍵更近的K個節點) 
4. 然後訊息發起者將向這K個最優節點發起查詢,因為剛開始的查詢很可能K桶裡存的不全是目標節點,而是潛在地離目標節點較近的節點
5. 不斷地迭代執行上述查詢過程。因為每一個節點比其他節點對它周邊的節點有更好的感知能力(水波擴散式的節點定址方式),因此響應結果將是一次一次離被搜尋鍵值越來越近的某節點。如果本次響應結果中的節點沒有比前次響應結果中的節點離被搜尋鍵值更近了(即發現這輪查詢的結果未發生diff變化了),這個查詢迭代也就終止了
6. 當這個迭代終止的時候,響應結果集中的K個最優節點就是整個網路中離被搜尋鍵值最近的K個節點(從以上過程看,這顯然是區域性的,而非整個網路,因為這本質和最優解搜尋演算法一樣,可能陷入區域性最優解而無法獲得全域性最優解) 
7. 節點資訊中可以增加一個往返時間,或者叫做RTT的引數,這個引數可以被用來定義一個針對每個被查詢節點的超時設定,即當向某個節點發起的查詢超時的時候,另一個查詢才會發起,當然,針對某個節點的查詢在同一時刻從來不超過α個

0x6: 定位和冗餘拷貝資源

通過把資源資訊與鍵進行對映,資源即可進行定位,雜湊表是典型的用來對映的手段。由於以前的STORE訊息,儲存節點將會有對應STORE所儲存的相關資源的資訊。定位資源時,如果一個節點存有相應的資源的值的時候,它就返回該資源,搜尋便結束了,除了該點以外,定位資源與定位離鍵最近的節點的過程相似

1. 考慮到節點未必都線上的情況,資源的值被存在多個節點上(節點中的K個),並且,為了提供冗餘,還有可能在更多的節點上儲存值
2. 儲存值的節點將定期搜尋網路中與儲存值所對應的鍵接近的K個節點並且把值複製到這些節點上,這些節點可作為那些下線的節點的補充
3. 另外,對於那些普遍流行的內容,可能有更多的請求需求,通過讓那些訪問值的節點把值儲存在附近的一些節點上(不在K個最近節點的範圍之類)來減少儲存值的那些節點的負載,這種新的儲存技術就是快取技術,通過這種技術,依賴於請求的數量,資源的值被儲存在離鍵越來越遠的那些節點上(資源熱度越高,快取cache就越廣泛),這使得那些流行的搜尋可以更快地找到資源的儲存者
5. 由於返回值的節點的NODE_ID遠離值所對應的關鍵字,網路中的"熱點"區域存在的可能性也降低了。依據與鍵的距離,快取的那些節點在一段時間以後將會刪除所儲存的快取值。DHT的某些實現(如Kad)即不提供冗餘(複製)節點也不提供快取,這主要是為了能夠快速減少系統中的陳舊資訊。在這種網路中,提供檔案的那些節點將會週期性地更新網路上的資訊(通過NODE_LOOKUP訊息和STORE訊息)。當存有某個檔案的所有節點都下線了,關於該檔案的相關的值(源和關鍵字)的更新也就停止了,該檔案的相關資訊也就從網路上完全消失了 

0x7: 加入網路

1. 想要加入網路的節點首先要經歷一個引導過程。在引導過程中,節點需要知道其他已加入該網路的某個節點的IP地址和埠號(可從使用者或者儲存的列表中獲得)。假如正在引導的那個節點還未加入網路,它會計算一個目前為止還未分配給其他節點的隨機ID號,直到離開網路,該節點會一直使用該ID號 
2. 正在加入Kademlia網路的節點在它的某個K桶中插入引導節點(加入該網路的介紹人)(負責加入節點的初始化工作),然後向它的唯一鄰居(引導節點)發起NODE_LOOKUP操作請求來定位自己,這種"自我定位"將使得Kademlia的其他節點(收到請求的節點)能夠使用新加入節點的Node Id填充他們的K桶(鄰居互相認識)
3. 同時也能夠使用那些查詢過程的中間節點(位於新加入節點和引導節點的查詢路徑上的其他節點)來填充新加入節點的K桶(相當於完成一個DNS遞迴查詢後,沿途路徑上的DNS IP都被記錄了)。想象一下這個過程
    1) 新加入的節點可能和"引導節點"距離很遠,它一上來就向離自己幾何距離最遠的引導節點問話: "誰知道我自己這個節點在哪?",引導節點會盡力去回答這個問題,即引導節點會把自己K桶內最有可能知道該節點位置(即離該幾點XOR幾何距離最近的K個點返回給新加入的請求節點)
    2) 新加入的請求方收到了K個節點後,把這K個節點儲存進自己的K桶,然後繼續向這些節點去"詢問(發起find_node請求)"自己的節點在哪,這些節點會收到這些請求,同時也把新加入節點儲存進自己的K桶內
    3) 整個過程和向DNS根域名伺服器請求解析某個域名的遞迴過程類似
4. 這一自查詢過程使得新加入節點自引導節點所在的那個K桶開始,由遠及近,對沿途的所有節點逐步得到重新整理,整條鏈路上的鄰居都認識了這個新鄰居
5. 最初的時候,節點僅有一個K桶(覆蓋所有的ID範圍),當有新節點需要插入該K桶時,如果K桶已滿,K桶就開始分裂,分裂發生在節點的K桶的覆蓋範圍(表現為二叉樹某部分從左至右的所有值)包含了該節點本身的ID的時候。對於節點內距離節點最近的那個K桶,Kademlia可以放鬆限制(即可以到達K時不發生分裂),因為桶內的所有節點離該節點距離最近,這些節點個數很可能超過K個,而且節點希望知道所有的這些最近的節點。因此,在路由樹中,該節點附近很可能出現高度不平衡的二叉子樹。假如K是20,新加入網路的節點ID為"xxx000011001",則字首為"xxx0011..."的節點可能有21個,甚至更多,新的節點可能包含多個含有21個以上節點的K桶(位於節點附近的k桶)。這點保證使得該節點能夠感知網路中附近區域的所有節點 

0x8: 查詢加速

1. Kademlia使用異或來定義距離。兩個節點ID的異或(或者節點ID和關鍵字的異或)的結果就是兩者之間的距離。對於每一個二進位制位來說,如果相同,異或返回0,否則,異或返回1。異或距離滿足三角形不等式: 任何一邊的距離小於(或等於)其它兩邊距離之和
2. 異或距離使得Kademlia的路由表可以建在單個bit之上,即可使用位組(多個位聯合)來構建路由表。位組可以用來表示相應的K桶,它有個專業術語叫做字首,對一個m位的字首來說,可對應2^m-1個K桶(m位的字首本來可以對應2^m個K桶)另外的那個K桶可以進一步擴充套件為包含該節點本身ID的路由樹
3. 一個b位的字首可以把查詢的最大次數從logn減少到logn/b。這只是查詢次數的最大值,因為自己K桶可能比字首有更多的位與目標鍵相同,這會增加在自己K桶中找到節點的機會,假設字首有m位,很可能查詢一個節點就能匹配2m甚至更多的位組,所以其實平均的查詢次數要少的多 
4. 節點可以在他們的路由表中使用混合字首,就像eMule中的Kad網路。如果以增加查詢的複雜性為代價,Kademlia網路在路由表的具體實現上甚至可以是有異構的

0x9: 在檔案分享網路中的應用

Kademlia可在檔案分享網路中使用,通過製作Kademlia關鍵字搜尋,我們能夠在檔案分享網路中找到我們需要的檔案以供我們下載。由於沒有中央伺服器儲存檔案的索引,這部分工作就被平均地分配到所有的客戶端中去

1. 假如一個節點希望分享某個檔案,它先根據檔案的內容來處理該檔案,通過運算,把檔案的內容雜湊成一組數字,該數字在檔案分享網路中可被用來標識檔案
2. 這組雜湊數字必須和節點ID有同樣的長度,然後,該節點便在網路中搜尋ID值與檔案的雜湊值相近的節點,然後向這些被搜尋到的節點廣播自己(即把它自己的IP地址儲存在那些搜尋到的節點上),本質意思是說: "你如果要搜尋這個檔案,就去找那些節點ID就好了,那些節點ID會告訴搜尋者應該到自己這裡來(檔案釋出者)來建立TCP連線,下載檔案",也就是說,它把自己作為檔案的源進行了釋出(檔案共享方式)。正在進行檔案搜尋的客戶端將使用Kademlia協議來尋找網路上ID值與希望尋找的檔案的雜湊值最近的那個節點(尋找檔案的過程和尋找節點的機制形成了統一,因為檔案和節點的ID的HASH格式是一樣的),然後取得儲存在那個節點上的檔案源列表 
3. 由於一個鍵(HASH)可以對應很多值,即同一個檔案(通過一個對應的HASH公佈到P2P網路中)可以有多個源(因為可能有多個節點都會有這個檔案的拷貝),每一個儲存源列表的節點可能有不同的檔案的源的資訊,這樣的話,源列表可以從與鍵值相近的K個節點獲得。 檔案的雜湊值通常可以從其他的一些特別的Internet連結的地方獲得,或者被包含在從其他某處獲得的索引檔案中(即種子檔案)
4. 檔名的搜尋可以使用關鍵詞來實現,檔名可以分割成連續的幾個關鍵詞,這些關鍵詞都可以雜湊並且可以和相應的檔名和檔案雜湊儲存在網路中。搜尋者可以使用其中的某個關鍵詞,聯絡ID值與關鍵詞雜湊最近的那個節點,取得包含該關鍵詞的檔案列表。由於在檔案列表中的檔案都有相關的雜湊值,通過該雜湊值就可利用上述通常取檔案的方法獲得要搜尋的檔案 

Relevant Link:

https://zh.wikipedia.org/wiki/Kademlia
http://file.scirp.org/pdf/3-4.3.pdf

 

2. KRPC 協議 KRPC Protocol

KRPC是BitTorrent在Kademlia理論基礎之上定義的一個通訊訊息格式協議,主要用來支援peer節點的獲取(get_peer)和peer節點的宣告(announce_peer),以及判活心跳(ping)、節點定址(find_node),它在find_node的原理上和DHT是一樣的,同時增加了get_peer/announce_peer/ping協議的支援

KRPC協議是由B編碼組成的一個簡單的RPC結構,有4種請求:ping、find_node、get_peers 和 announce_peer

0x0: bencode編碼

bencode 有 4 種資料型別: string, integer, list 和 dictionary

1. string: 字元是以這種方式編碼的: <字串長度>:<字串> 
如 hell: 4:hell

2. integer: 整數是一這種方式編碼的: i<整數>e 
如 1999: i1999e

3. list: 列表是一這種方式編碼的: l[資料1][資料2][資料3][…]e 
如列表 [hello, world, 101]:l5:hello5:worldi101ee

4. dictionary: 字典是一這種方式編碼的: d[key1][value1][key2][value2][…]e,其中 key 必須是 string 而且按照字母順序排序 
如字典 {aa:100, bb:bb, cc:200}: d2:aai100e2:bb2:bb2:cci200ee

KRPC 協議是由 bencode 編碼組成的一個簡單的 RPC 結構,他使用 UDP 報文傳送。一個獨立的請求包被髮出去然後一個獨立的包被回覆。這個協議沒有重發(UDP是無連線協議)

0x1: KRPC字典基本組成元素

一條 KRPC 訊息即可能是request,也可能是response,由一個獨立的字典組成

1. t關鍵字: 每條訊息都包含 t 關鍵字,它是一個代表了 transaction ID 的字串。transaction ID 由請求節點產生,並且回覆中要包含回顯該欄位(挑戰-響應模型),所以回覆可能對應一個節點的多個請求。transaction ID 應當被編碼為一個短的二進位制字串,比如 2 個位元組,這樣就可以對應 2^16 個請求
2. y關鍵字: 它由一個位元組組成,表明這個訊息的型別。y 對應的值有三種情況
    1) q 表示請求(請求Queries): q型別的訊息它包含 2 個附加的關鍵字 q 和 a
        1.1) 關鍵字 q: 是字串型別,包含了請求的方法名字(get_peers/announce_peer/ping/find_node)
        1.2) 關鍵字 a: 一個字典型別包含了請求所附加的引數(info_hash/id..)
    2) r 表示回覆(回覆 Responses): 包含了返回的值。傳送回覆訊息是在正確解析了請求訊息的基礎上完成的,包含了一個附加的關鍵字 r。關鍵字 r 是字典型別
        2.1) id: peer節點id號或者下一跳DHT節點
                2.2) nodes": "" 
                2.3) token: token
    3) e 表示錯誤(錯誤 Errors): 包含一個附加的關鍵字 e,關鍵字 e 是列表型別
        3.1) 第一個元素是數字型別,表明了錯誤碼,當一個請求不能解析或出錯時,錯誤包將被髮送。下表描述了可能出現的錯誤碼
        201: 一般錯誤
        202: 服務錯誤
        203: 協議錯誤,比如不規範的包,無效的引數,或者錯誤的 toke
        204: 未知方法 
        3.2) 第二個元素是字串型別,表明了錯誤資訊

以上是整個KRPC的協議框架結構,具體到請求Query/回覆Response/錯誤Error還有具體的協議實現

0x2: 請求Query具體協議

所有的請求都包含一個關鍵字 id,它包含了請求節點的節點 ID。所有的回覆也包含關鍵字id,它包含了回覆節點的節點 ID

1. ping: 檢測節點是否可達,請求包含一個引數id,代表該節點的nodeID。對應的回覆也應該包含回覆者的nodeID

ping Query = {"t":"aa", "y":"q", "q":"ping", "a":{"id":"abcdefghij0123456789"}}
bencoded = d1:ad2:id20:abcdefghij0123456789e1:q4:ping1:t2:aa1:y1:qe

Response = {"t":"aa", "y":"r", "r": {"id":"mnopqrstuvwxyz123456"}}
bencoded = d1:rd2:id20:mnopqrstuvwxyz123456e1:t2:aa1:y1:re

2. find_node: find_node 被用來查詢給定 ID 的DHT節點的聯絡資訊,該請求包含兩個引數id(代表該節點的nodeID)和target。回覆中應該包含被請求節點的路由表中距離target最接近的K個nodeID以及對應的nodeINFO

find_node Query = {"t":"aa", "y":"q", "q":"find_node", "a": {"id":"abcdefghij0123456789", "target":"mnopqrstuvwxyz123456"}}
# "id" containing the node ID of the querying node, and "target" containing the ID of the node sought by the queryer. 
bencoded = d1:ad2:id20:abcdefghij01234567896:target20:mnopqrstuvwxyz123456e1:q9:find_node1:t2:aa1:y1:qe

Response = {"t":"aa", "y":"r", "r": {"id":"0123456789abcdefghij", "nodes": "def456..."}}
bencoded = d1:rd2:id20:0123456789abcdefghij5:nodes9:def456...e1:t2:aa1:y1:re

find_node 請求包含 2 個引數,第一個引數是 id,包含了請求節點的ID。第二個引數是 target,包含了請求者正在查詢的節點的 ID

當一個節點接收到了 find_node 的請求,他應該給出對應的回覆,回覆中包含 2 個關鍵字 id(被請求節點的id) 和 nodes,nodes 是字串型別,包含了被請求節點的路由表中最接近目標節點的 K(8) 個最接近的節點的聯絡資訊(被請求方每次都統一返回最靠近目標節點的節點列表K捅)

引數: {"id" : "<querying nodes id>", "target" : "<id of target node>"}
回覆: {"id" : "<queried nodes id>", "nodes" : "<compact node info>"}

這裡要明確3個概念

1. 請求方的id: 發起這個DHT節點定址的節點自身的ID,可以類比DNS查詢中的客戶端
2. 目標target id: 需要查詢的目標ID號,可以類比於DNS查詢中的URL,這個ID在整個遞迴查詢中是一直不變的
3. 被請求節點的id: 在節點的遞迴查詢中,請求方由遠及近不斷詢問整個鏈路上的節點,沿途的每個節點在返回時都要帶上自己的id號

3. get_peers

1. get_peers 請求包含 2 個引數(id請求節點ID,info_hash代表torrent檔案的infohash,infohash為種子檔案的SHA1雜湊值,也就是磁力連結的btih值)
2. response get_peer: 
    1) 如果被請求的節點有對應 info_hash 的 peers,他將返回一個關鍵字 values,這是一個列表型別的字串。每一個字串包含了 "CompactIP-address/portinfo" 格式的 peers 資訊(即對應的機器ip/port資訊)(peer的info資訊和DHT節點的info資訊是一樣的)
    2) 如果被請求的節點沒有這個 infohash 的 peers,那麼他將返回關鍵字 nodes(需要注意的是,如果該節點沒有對應的infohash資訊,而只是返回了nodes,則請求方會認為該節點是一個"可疑節點",則會從自己的路由表K捅中刪除該節點),這個關鍵字包含了被請求節點的路由表中離 info_hash 最近的 K 個節點(我這裡沒有該節點,去別的節點試試運氣),使用 "Compactnodeinfo" 格式回覆。在這兩種情況下,關鍵字 token 都將被返回。token 關鍵字在今後的 annouce_peer 請求中必須要攜帶。token 是一個短的二進位制字串

 

infohash = 1619ecc9373c3639f4ee3e261638f29b33a6cbd6,正是磁力連結magnet:?xt=urn:btih:1619ecc9373c3639f4ee3e261638f29b33a6cbd6&dn;=ubuntu-14.10-desktop-i386.iso中的btih值

get_peers Query = {"t":"aa", "y":"q", "q":"get_peers", "a": {"id":"abcdefghij0123456789", "info_hash":"mnopqrstuvwxyz123456"}}
bencoded = d1:ad2:id20:abcdefghij01234567899:info_hash20:mnopqrstuvwxyz123456e1:q9:get_peers1:t2:aa1:y1:qe

Response with peers = {"t":"aa", "y":"r", "r": {"id":"abcdefghij0123456789", "token":"aoeusnth", "values": ["axje.u", "idhtnm"]}}
bencoded = d1:rd2:id20:abcdefghij01234567895:token8:aoeusnth6:valuesl6:axje.u6:idhtnmee1:t2:aa1:y1:re

Response with closest nodes = {"t":"aa", "y":"r", "r": {"id":"abcdefghij0123456789", "token":"aoeusnth", "nodes": "def456..."}}
bencoded = d1:rd2:id20:abcdefghij01234567895:nodes9:def456...5:token8:aoeusnthe1:t2:aa1:y1:re

框架格式

引數: {"id" : "<querying nodes id>", "info_hash" : "<20-byte infohash of target torrent>"}
回覆: 
{"id" : "<queried nodes id>", "token" :"<opaque write token>", "values" : ["<peer 1 info string>", "<peer 2 info string>"]}
或: 
{"id" : "<queried nodes id>", "token" :"<opaque write token>", "nodes" : "<compact node info>"}

get_peers請求的回覆。回覆報文中包含了8個node,以及100個peer。可見包含該種子檔案的peer非常多

4. announce_peer: 這個請求用來表明發出 announce_peer 請求的節點,正在某個埠下載 torrent 檔案

announce_peer 包含 4 個引數

1. 第一個引數是 id: 包含了請求節點的 ID
2. 第二個引數是 info_hash: 包含了 torrent 檔案的 infohash
3. 第三個引數是 port: 包含了整型的埠號,表明 peer 在哪個埠下載
4. 第四個引數數是 token: 這是在之前的 get_peers 請求中收到的回覆中包含的。收到 announce_peer 請求的節點必須檢查這個 token 與之前我們回覆給這個節點 get_peers 的 token 是否相同(也就說,所有下載者/釋出者都要參與檢測新加入的釋出者是否偽造了該資源,但是這個機制有一個問題,如果最開始的那個釋出者就偽造,則整條鏈路都是一個偽造的錯的資源infohash資訊了)
如果相同,那麼被請求的節點將記錄傳送 announce_peer 節點的 IP 和請求中包含的 port 埠號在 peer 聯絡資訊中對應的 infohash 下,這意味著一個一個事實: 當前這個資源有一個新的peer提供者了,下一次有其他節點希望或者這個資源的時候,會把這個新的(前一次請求下載資源的節點)也當作一個peer返回給請求者,這樣,資源的提供者就越來越多,資源共享速度就越來越快

一個peer正在下載某個資源,意味著該peer有能夠訪問到該資源的渠道,且該peer本地是有這份資源的全部或部分拷貝的,它需要向DHT網路廣播announce訊息,告訴其他節點這個資源的下載地址

arguments:  {"id" : "<querying nodes id>",
"implied_port": <0 or 1>,
"info_hash" : "<20-byte infohash of target torrent>",
"port" : <port number>,
"token" : "<opaque token>"}

response: {"id" : "<queried nodes id>"}

報文包例子 Example Packets 

announce_peers Query = {"t":"aa", "y":"q", "q":"announce_peer", "a": {"id":"abcdefghij0123456789", "implied_port": 1, "info_hash":"mnopqrstuvwxyz123456", "port": 6881, "token": "aoeusnth"}}
bencoded = d1:ad2:id20:abcdefghij01234567899:info_hash20:<br />
mnopqrstuvwxyz1234564:porti6881e5:token8:aoeusnthe1:q13:announce_peer1:t2:aa1:y1:qe

Response = {"t":"aa", "y":"r", "r": {"id":"mnopqrstuvwxyz123456"}}
bencoded = d1:rd2:id20:mnopqrstuvwxyz123456e1:t2:aa1:y1:re

0x3: 回覆 Responses

回覆 Responses的包已經在上面的Query裡說明了

0x4: 錯誤 Errors

錯誤包例子 Example Error Packets

generic error = {"t":"aa", "y":"e", "e":[201, "A Generic Error Ocurred"]}
bencoded = d1:eli201e23:A Generic Error Ocurrede1:t2:aa1:y1:ee

Relevant Link:

http://www.bittorrent.org/beps/bep_0005.html
https://segmentfault.com/a/1190000002528378
https://github.com/wuzhenda/simDHT
https://wenku.baidu.com/view/9a7b447aa26925c52cc5bfc9.html
http://www.bittorrent.org/beps/bep_0005.html

 

3. DHT 公網嗅探器實現(DHT 爬蟲)

在開始研究BitTorrent協議之前,我們先來了解下DHT協議上存在的一個缺點導致的一個攻擊面: 嗅探攻擊

0x1: DHT嗅探器的原理

DHT這種對等分散式網路在帶來抗DDOS的優點的同時,也帶來了一些缺點

1. 偽造攻擊: 有些不聽話的使用者可能會在DHT網路裡搗亂,譬如說撒謊,明明自己不是奧巴馬,卻偏說自己是奧巴馬,這樣會誤導其他人無法正常獲取想要的資源
2. 嗅探攻擊: 另外,使用者在DHT網路裡的隱私可能會被竊聽,因為在DHT網路裡跟其他使用者交換資源的時候,難免會暴露自己的IP地址,所以別人就會知道你有什麼資源,你在請求什麼資源了。這也是目前DHT網路裡一直存在的一個弱點

利用第二個特點,我們可以根據DHT協議用Python寫了一段程式,加入了這個DHT網路。在這個網路裡,我會認識很多人,越多越好(不斷遞迴find_node),並且觀察這些人的舉動(監聽get_peer的query包),比如說A想要ubuntu的安裝盤,那麼我會把A的這個行為記下來,同時我會把ubuntu安裝盤這個資源的資訊也記下來,儲存到資料庫中,統計請求ubuntu這個資源的人有多少

0x2: 示例程式碼: DHT HASH嗅探

from gevent import monkey
monkey.patch_all()

from gevent.server import DatagramServer
import gevent
import socket
from hashlib import sha1
from random import randint
from struct import unpack
from socket import inet_ntoa
from threading import Timer, Thread
from gevent import sleep
from collections import deque
from bencode import bencode, bdecode



BOOTSTRAP_NODES = (
    ("router.bittorrent.com", 6881),
    ("dht.transmissionbt.com", 6881),
    ("router.utorrent.com", 6881)
)
TID_LENGTH = 2
RE_JOIN_DHT_INTERVAL = 3
MONITOR_INTERVAL = 10
TOKEN_LENGTH = 2

def entropy(length):
    return "".join(chr(randint(0, 255)) for _ in xrange(length))


def random_id():
    h = sha1()
    h.update(entropy(20))
    return h.digest()


def decode_nodes(nodes):
    n = []
    length = len(nodes)
    if (length % 26) != 0:
        return n

    for i in range(0, length, 26):
        nid = nodes[i:i+20]
        ip = inet_ntoa(nodes[i+20:i+24])
        port = unpack("!H", nodes[i+24:i+26])[0]
        n.append((nid, ip, port))

    return n

def get_neighbor(target, nid, end=10):
    return target[:end]+nid[end:]



class KNode(object):

    def __init__(self, nid, ip, port):
        self.nid = nid
        self.ip = ip
        self.port = port


class DHTServer(DatagramServer):
    def __init__(self, max_node_qsize, bind_ip):
        s = ':' + str(bind_ip)
        self.bind_ip = bind_ip
        DatagramServer.__init__(self, s)

        self.process_request_actions = {
            "get_peers": self.on_get_peers_request,
            "announce_peer": self.on_announce_peer_request,
        }
        self.max_node_qsize = max_node_qsize
        self.nid = random_id()
        self.nodes = deque(maxlen=max_node_qsize)

    def handle(self, data, address): #
        try:
            msg = bdecode(data)
            self.on_message(msg, address)
        except Exception:
            pass

    def monitor(self):
        while True:
            # print 'len: ', len(self.nodes)
            sleep(MONITOR_INTERVAL)

    def send_krpc(self, msg, address):
        try:
            self.socket.sendto(bencode(msg), address)
        except Exception:
            pass

    def send_find_node(self, address, nid=None):
        nid = get_neighbor(nid, self.nid) if nid else self.nid
        tid = entropy(TID_LENGTH)
        msg = {
            "t": tid,
            "y": "q",
            "q": "find_node",
            "a": {
                "id": nid,
                "target": random_id()
            }
        }
        self.send_krpc(msg, address)

    def join_DHT(self):
        for address in BOOTSTRAP_NODES:
            self.send_find_node(address)

    def re_join_DHT(self):

        while True:
            if len(self.nodes) == 0:
                self.join_DHT()
            sleep(RE_JOIN_DHT_INTERVAL)


    def auto_send_find_node(self):

        wait = 1.0 / self.max_node_qsize / 5.0
        while True:
            try:
                node = self.nodes.popleft()
                self.send_find_node((node.ip, node.port), node.nid)
            except IndexError:
                pass
            sleep(wait)

    def process_find_node_response(self, msg, address):
        # print 'find node' + str(msg)
        nodes = decode_nodes(msg["r"]["nodes"])
        for node in nodes:
            (nid, ip, port) = node
            if len(nid) != 20: continue
            if ip == self.bind_ip: continue
            if port < 1 or port > 65535: continue
            n = KNode(nid, ip, port)
            self.nodes.append(n)


    def on_message(self, msg, address):
        try:
            if msg["y"] == "r":
                if msg["r"].has_key("nodes"):
                    self.process_find_node_response(msg, address)
            elif msg["y"] == "q":
                try:
                    self.process_request_actions[msg["q"]](msg, address)
                except KeyError:
                    self.play_dead(msg, address)
        except KeyError:
            pass

    def on_get_peers_request(self, msg, address):
        try:
            infohash = msg["a"]["info_hash"]
            tid = msg["t"]
            nid = msg["a"]["id"]
            token = infohash[:TOKEN_LENGTH]
            info = infohash.encode("hex").upper() + '|' + address[0]
            print  info + "\n",
            msg = {
                "t": tid,
                "y": "r",
                "r": {
                    "id": get_neighbor(infohash, self.nid),
                    "nodes": "",
                    "token": token
                }
            }
            self.send_krpc(msg, address)
        except KeyError:
            pass

    def on_announce_peer_request(self, msg, address):
        try:
            # print 'announce peer'
            infohash = msg["a"]["info_hash"]
            token = msg["a"]["token"]
            nid = msg["a"]["id"]
            tid = msg["t"]

            if infohash[:TOKEN_LENGTH] == token:
                if msg["a"].has_key("implied_port") and msg["a"]["implied_port"] != 0:
                    port = address[1]
                else:
                    port = msg["a"]["port"]
                    if port < 1 or port > 65535: return
                info = infohash.encode("hex").upper()
                print  info + "\n",
        except Exception as e:
            print e
            pass
        finally:
            self.ok(msg, address)

    def play_dead(self, msg, address):
        try:
            tid = msg["t"]
            msg = {
                "t": tid,
                "y": "e",
                "e": [202, "Server Error"]
            }
            self.send_krpc(msg, address)
        except KeyError:
            print 'error'
            pass

    def ok(self, msg, address):
        try:
            tid = msg["t"]
            nid = msg["a"]["id"]
            msg = {
                "t": tid,
                "y": "r",
                "r": {
                    "id": get_neighbor(nid, self.nid)
                }
            }
            self.send_krpc(msg, address)
        except KeyError:
            pass

if __name__ == '__main__':
    sniffer = DHTServer(50, 8080)
    gevent.spawn(sniffer.auto_send_find_node)
    gevent.spawn(sniffer.re_join_DHT)
    gevent.spawn(sniffer.monitor)

    print('Receiving datagrams on :6882')
    sniffer.serve_forever()

0x3: 示例程式碼: DHT HASH/BitTorrent infohash嗅探

在DHT的基礎 上,增加umetainfo資訊的獲取(即種子資訊的獲取)

#!/usr/bin/env python
# encoding: utf-8
# apt-get install python-dev
# pip install bencode
# pip install mysql-python
# apt-get install libmysqlclient-dev
import math
import socket
import threading
from time import sleep, time
from hashlib import sha1
from random import randint
from struct import pack, unpack
from socket import inet_ntoa
from threading import Timer, Thread
from time import sleep
from collections import deque
from bencode import bencode, bdecode
from Queue import Queue
import base64
import json
import urllib2
import traceback
import gc

BOOTSTRAP_NODES = (
    ("router.bittorrent.com", 6881),
    ("dht.transmissionbt.com", 6881),
    ("router.utorrent.com", 6881)
)
TID_LENGTH = 2
RE_JOIN_DHT_INTERVAL = 3
TOKEN_LENGTH = 2

BT_PROTOCOL = "BitTorrent protocol"
BT_MSG_ID = 20
EXT_HANDSHAKE_ID = 0


def entropy(length):
    return "".join(chr(randint(0, 255)) for _ in xrange(length))


def random_id():
    h = sha1()
    h.update(entropy(20))
    return h.digest()


def decode_nodes(nodes):
    n = []
    length = len(nodes)
    if (length % 26) != 0:
        return n

    for i in range(0, length, 26):
        nid = nodes[i:i + 20]
        ip = inet_ntoa(nodes[i + 20:i + 24])
        port = unpack("!H", nodes[i + 24:i + 26])[0]
        n.append((nid, ip, port))

    return n


def timer(t, f):
    Timer(t, f).start()


def get_neighbor(target, nid, end=10):
    return target[:end] + nid[end:]


def send_packet(the_socket, msg):
    the_socket.send(msg)


def send_message(the_socket, msg):
    msg_len = pack(">I", len(msg))
    send_packet(the_socket, msg_len + msg)


def send_handshake(the_socket, infohash):
    bt_header = chr(len(BT_PROTOCOL)) + BT_PROTOCOL
    ext_bytes = "\x00\x00\x00\x00\x00\x10\x00\x00"
    peer_id = random_id()
    packet = bt_header + ext_bytes + infohash + peer_id

    send_packet(the_socket, packet)


def check_handshake(packet, self_infohash):
    try:
        bt_header_len, packet = ord(packet[:1]), packet[1:]
        if bt_header_len != len(BT_PROTOCOL):
            return False
    except TypeError:
        return False

    bt_header, packet = packet[:bt_header_len], packet[bt_header_len:]
    if bt_header != BT_PROTOCOL:
        return False

    packet = packet[8:]
    infohash = packet[:20]
    if infohash != self_infohash:
        return False

    return True


def send_ext_handshake(the_socket):
    msg = chr(BT_MSG_ID) + chr(EXT_HANDSHAKE_ID) + bencode({"m": {"ut_metadata": 1}})
    send_message(the_socket, msg)


def request_metadata(the_socket, ut_metadata, piece):
    """bep_0009"""
    msg = chr(BT_MSG_ID) + chr(ut_metadata) + bencode({"msg_type": 0, "piece": piece})
    send_message(the_socket, msg)


def get_ut_metadata(data):
    try:
        ut_metadata = "_metadata"
        index = data.index(ut_metadata) + len(ut_metadata) + 1
        return int(data[index])
    except Exception as e:
        pass


def get_metadata_size(data):
    metadata_size = "metadata_size"
    start = data.index(metadata_size) + len(metadata_size) + 1
    data = data[start:]
    return int(data[:data.index("e")])


def recvall(the_socket, timeout=15):
    the_socket.setblocking(0)
    total_data = []
    data = ""
    begin = time()

    while True:
        sleep(0.05)
        if total_data and time() - begin > timeout:
            break
        elif time() - begin > timeout * 2:
            break
        try:
            data = the_socket.recv(1024)
            if data:
                total_data.append(data)
                begin = time()
        except Exception:
            pass
    return "".join(total_data)


def ip_black_list(ipaddress):
    black_lists = ['45.32.5.150', '45.63.4.233']
    if ipaddress in black_lists:
        return True
    return False


def download_metadata(address, infohash, timeout=15):
    if ip_black_list(address[0]):
        return
    try:
        the_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        the_socket.settimeout(15)
        the_socket.connect(address)

        # handshake
        send_handshake(the_socket, infohash)
        packet = the_socket.recv(4096)

        # handshake error
        if not check_handshake(packet, infohash):
            try:
                the_socket.close()
            except:
                return
            return

        # ext handshake
        send_ext_handshake(the_socket)
        packet = the_socket.recv(4096)

        # get ut_metadata and metadata_size
        ut_metadata, metadata_size = get_ut_metadata(packet), get_metadata_size(packet)
        # print 'ut_metadata_size: ', metadata_size

        # request each piece of metadata
        metadata = []
        for piece in range(int(math.ceil(metadata_size / (16.0 * 1024)))):
            request_metadata(the_socket, ut_metadata, piece)
            packet = recvall(the_socket, timeout)  # the_socket.recv(1024*17) #
            metadata.append(packet[packet.index("ee") + 2:])

        metadata = "".join(metadata)
        info = {}
        meta_data = bdecode(metadata)
        del metadata
        info['hash_id'] = infohash.encode("hex").upper()

        if meta_data.has_key('name'):
            info["hash_name"] = meta_data["name"].strip()
        else:
            info["hash_name"] = ''

        if meta_data.has_key('length'):
            info['hash_size'] = meta_data['length']
        else:
            info['hash_size'] = 0

        if meta_data.has_key('files'):
            info['files'] = meta_data['files']
            for item in info['files']:
                # print item
                if item.has_key('length'):
                    info['hash_size'] += item['length']
            info['files'] = json.dumps(info['files'], ensure_ascii=False)
            info['files'] = info['files'].replace("\"path\"", "\"p\"").replace("\"length\"", "\"l\"")
        else:
            info['files'] = ''

        info['a_ip'] = address[0]
        info['hash_size'] = str(info['hash_size'])
        print info, "\r\n\r\n"
        del info
        gc.collect()

    except socket.timeout:
        try:
            the_socket.close()
        except:
            return
    except socket.error:
        try:
            the_socket.close()
        except:
            return
    except Exception, e:
        try:
            # print e
            # traceback.print_exc() 
            the_socket.close()
        except:
            return
    finally:
        the_socket.close()
        return



class KNode(object):
    def __init__(self, nid, ip, port):
        self.nid = nid
        self.ip = ip
        self.port = port


class DHTClient(Thread):
    def __init__(self, max_node_qsize):
        Thread.__init__(self)
        self.setDaemon(True)
        self.max_node_qsize = max_node_qsize
        self.nid = random_id()
        self.nodes = deque(maxlen=max_node_qsize)

    def send_krpc(self, msg, address):
        try:
            self.ufd.sendto(bencode(msg), address)
        except Exception:
            pass

    def send_find_node(self, address, nid=None):
        nid = get_neighbor(nid, self.nid) if nid else self.nid
        tid = entropy(TID_LENGTH)
        msg = {
            "t": tid,
            "y": "q",
            "q": "find_node",
            "a": {
                "id": nid,
                "target": random_id()
            }
        }
        self.send_krpc(msg, address)

    def join_DHT(self):
        for address in BOOTSTRAP_NODES:
            self.send_find_node(address)

    def re_join_DHT(self):
        if len(self.nodes) == 0:
            self.join_DHT()
        timer(RE_JOIN_DHT_INTERVAL, self.re_join_DHT)

    def auto_send_find_node(self):
        wait = 1.0 / self.max_node_qsize
        while True:
            try:
                node = self.nodes.popleft()
                self.send_find_node((node.ip, node.port), node.nid)
            except IndexError:
                pass
            sleep(wait)

    def process_find_node_response(self, msg, address):
        nodes = decode_nodes(msg["r"]["nodes"])
        for node in nodes:
            (nid, ip, port) = node
            if len(nid) != 20: continue
            if ip == self.bind_ip: continue
            n = KNode(nid, ip, port)
            self.nodes.append(n)


class DHTServer(DHTClient):
    def __init__(self, master, bind_ip, bind_port, max_node_qsize):
        DHTClient.__init__(self, max_node_qsize)

        self.master = master
        self.bind_ip = bind_ip
        self.bind_port = bind_port

        self.process_request_actions = {
            "get_peers": self.on_get_peers_request,
            "announce_peer": self.on_announce_peer_request,
        }

        self.ufd = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
        self.ufd.bind((self.bind_ip, self.bind_port))

        timer(RE_JOIN_DHT_INTERVAL, self.re_join_DHT)

    def run(self):
        self.re_join_DHT()
        while True:
            try:
                (data, address) = self.ufd.recvfrom(65536)
                msg = bdecode(data)
                self.on_message(msg, address)
            except Exception:
                pass

    def on_message(self, msg, address):
        try:
            if msg["y"] == "r":
                if msg["r"].has_key("nodes"):
                    self.process_find_node_response(msg, address)
            elif msg["y"] == "q":
                try:
                    self.process_request_actions[msg["q"]](msg, address)
                except KeyError:
                    self.play_dead(msg, address)
        except KeyError:
            pass

    def on_get_peers_request(self, msg, address):
        try:
            infohash = msg["a"]["info_hash"]
            tid = msg["t"]
            nid = msg["a"]["id"]
            token = infohash[:TOKEN_LENGTH]
            msg = {
                "t": tid,
                "y": "r",
                "r": {
                    "id": get_neighbor(infohash, self.nid),
                    "nodes": "",
                    "token": token
                }
            }
            self.send_krpc(msg, address)
        except KeyError:
            pass

    def on_announce_peer_request(self, msg, address):
        try:
            infohash = msg["a"]["info_hash"]
            token = msg["a"]["token"]
            nid = msg["a"]["id"]
            tid = msg["t"]

            if infohash[:TOKEN_LENGTH] == token:
                if msg["a"].has_key("implied_port ") and msg["a"]["implied_port "] != 0:
                    port = address[1]
                else:
                    port = msg["a"]["port"]
                self.master.log(infohash, (address[0], port))
        except Exception:
            print 'error'
            pass
        finally:
            self.ok(msg, address)

    def play_dead(self, msg, address):
        try:
            tid = msg["t"]
            msg = {
                "t": tid,
                "y": "e",
                "e": [202, "Server Error"]
            }
            self.send_krpc(msg, address)
        except KeyError:
            pass

    def ok(self, msg, address):
        try:
            tid = msg["t"]
            nid = msg["a"]["id"]
            msg = {
                "t": tid,
                "y": "r",
                "r": {
                    "id": get_neighbor(nid, self.nid)
                }
            }
            self.send_krpc(msg, address)
        except KeyError:
            pass


class Master(Thread):
    def __init__(self):
        Thread.__init__(self)
        self.setDaemon(True)
        self.queue = Queue()

    def run(self):
        while True:
            self.downloadMetadata()

    def log(self, infohash, address=None):
        self.queue.put([address, infohash])

    def downloadMetadata(self):
        # 100 threads for download metadata
        for i in xrange(0, 100):
            if self.queue.qsize() == 0:
                sleep(1)
                continue
            announce = self.queue.get()
            t = threading.Thread(target=download_metadata, args=(announce[0], announce[1]))
            t.setDaemon(True)
            t.start()


trans_queue = Queue()

if __name__ == "__main__":
    # max_node_qsize bigger, bandwith bigger, spped higher
    master = Master()
    master.start()

    print('Receiving datagrams on :6882')
    dht = DHTServer(master, "0.0.0.0", 6881, max_node_qsize=10)
    dht.start()
    dht.auto_send_find_node()

Relevant Link:

http://www.lyyyuna.com/2016/03/26/dht01/

 

4. BitTorrent協議

BitTorrent 使用"分散式雜湊表"(DHT)來為無 tracker 的種子(torrents)儲存 peer 之間的聯絡資訊。這樣每個 peer 都成了 tracker。這個協議基於 Kademila 網路並且在 UDP 上實現

1. "peer" 是在一個 TCP 埠上監聽的客戶端/伺服器,它實現了 BitTorrent 協議 
2. "節點" 是在一個 UDP 埠上監聽的客戶端/伺服器,它實現了 DHT(分散式雜湊表) 協議 
DHT 由節點組成,它儲存了 peer 的位置。BitTorrent 客戶端包含一個 DHT 節點,這個節點用來聯絡 DHT 中其他節點,從而得到 peer 的位置,進而通過 BitTorrent 協議下載 

每個節點有一個全域性唯一的識別符號,作為 "node ID"。節點 ID 是一個隨機選擇的 160bit(20位元組) 空間,BitTorrent infohash 也使用這樣的 160bit 空間。"距離"用來比較兩個節點 ID 之間或者節點 ID 和 infohash 之間的"遠近"(節點和節點、節點和檔案之間的距離)。節點必須維護一個路由表,路由表中含有一部分其它節點的聯絡資訊。其它節點距離自己越近時,路由表資訊越詳細。因此每個節點都知道 DHT 中離自己很"近"的節點的聯絡資訊,而離自己非常遠的 ID 的聯絡資訊卻知道的很少
在 Kademlia 網路中,距離是通過異或(XOR)計算的,結果為無符號整數。distance(A, B) = |A xor B|,值越小表示越近

1. 當節點要為 torrent(種子檔案) 尋找 peer(儲存了目標資源的IP) 時,它將自己路由表中的節點 ID 和 torrent 的 infohash(資源HASH) 進行"距離對比"(節點和目標檔案的距離),然後向路由表中離 infohash 最近的節點傳送請求,問它們正在下載這個 torrent 的 peer 的聯絡資訊
2. 因為資源HASH和節點HASH都共用一套20bytes的名稱空間,所以DHT節點充當了peer節點的"代理"的工作,我們不能直接向peer節點發起資源獲取請求(即使這個peer節點確實儲存了我們的目標資源),因為peer節點本身不具備處理P2P request/response能力的,我們需要藉助DHT的能力,讓DHT告訴我們哪個peer節點儲存了我們想要的資源或者哪個DHT節點可能知道從而遞迴地繼續去問那個DHT網路
3. 如果一個被聯絡的節點知道下載這個 torrent 的 peer 資訊,那個 peer 的聯絡資訊將被回覆給當前節點。否則,那個被聯絡的節點則必須回覆在它的路由表中離該 torrent 的 infohash 最近的節點的聯絡資訊,
4. 最初的節點重複地請求比目標 infohash 更近的節點,直到不能再找到更近的節點為止
5. 查詢完了之後,客戶端把自己作為一個 peer 插入到所有回覆節點中離種子最近的那個節點中,這一步背後的含義是: 我之前是請求這個資源的人,我們現在獲取到資源了,我在下載這個檔案的同時,我也要充當一個新的peer來向其他的客戶端貢獻自己的檔案共享,這樣,當另外的其他客戶端在發起新的請求的時候,DHT節點就有可能把當前客戶端對應的peer返回給新的請求方,這樣不斷髮展下去,這個資源的熱度就越來越熱,下載速度也越來越快
6. 請求 peer 的返回值包含一個不透明的值,稱之為"令牌(token)"
7. 如果一個節點宣佈它所控制的 peer 正在下載一個種子(即該節點擁有該檔案資源),它必須在回覆請求節點的同時,附加上對方向我們傳送的最近的"令牌(token)"。這樣當一個節點試圖"宣佈"正在下載一個種子時,被請求的節點核對令牌和發出請求的節點的 IP 地址。這是為了防止惡意的主機登記其它主機的種子。由於令牌僅僅由請求節點返回給收到令牌的同一個節點,所以沒有規定他的具體實現。但是令牌必須在一個規定的時間內被接受,超時後令牌則失效。在 BitTorrent 的實現中,token 是在 IP 地址後面連線一個 secret(通常是一個隨機數),這個 secret 每五分鐘改變一次,其中 token 在十分鐘以內是可接受的

這種握手驗證的原理是

請求方生成一個隨機值,跟著我的請求發給被請求方,被請求方回覆的時候要帶上這個隨機值,那請求方就知道,你是我剛才想請求的那個人

0x1: 路由表 Routing Table

1. 每個節點維護一個路由表儲存已知的好節點。路由表中的節點是用來作為在 DHT 中請求的起始點。路由表中的節點是在不斷的向其他節點請求過程中,對方節點回復的。即DHT中的K桶中的節點,當我們請求一個目標資源的時候,我們根據HASH XOR從自己的K桶中選擇最有可能知道該資源的節點發起請求,而被請求的節點也不一定知道目標資源所在的peer,這個時候被請求方會返回一個新的"它認為可能知道這個peer的節點",請求方收到這個新的節點後,會把這個節點儲存進自己的K桶內,然後繼續發起請求,直到找到目標資源所在的peer為止
2. 並不是我們在請求過程中收到的節點都是平等的,有的節點是好的,而另一些則不是。許多使用 DHT 協議的節點都可以傳送請求並接收回復,但是不能主動回覆其他節點的請求,這種節點被稱之為"壞節點"
3. 節點的路由表只包含已知的好節點,這很重要。好節點是指在過去的 15 分鐘以內,曾經對我們的某一個請求給出過回覆的節點(存活好節點),或者曾經對我們的請求給出過一個回覆(不用在15分鐘以內),並且在過去的 15 分鐘給我們傳送過請求。上述兩種情況都可將節點視為好節點。在 15 分鐘之後,對方沒有上述 2 種情況發生,這個節點將變為可疑的。當節點不能給我們的一系列請求給出回覆時,這個節點將變為壞的。相比那些未知狀態的節點,已知的好節點會被給於更高的優先順序。
# 這就反過來告訴我們,如果我們要做DHT嗅探,我們的嗅探器除了要能夠發出FIND_NODE請求及接收返回之外,還需要能夠響應其他節點發來的請求(get_peers/announce_peer),這樣才不會被其他節點列入"可疑"甚至"壞節點"列表中
4. 路由表覆蓋從 02^160 全部的節點 ID 空間。路由表又被劃分為桶(bucket),每個桶包含一部分的 ID 空間。空的路由表只有一個桶,它的 ID 範圍從 min=0 到 max=2^160。當 ID 為 N 的節點插入到表中時,它將被放到 ID 範圍在 min <= N < max 的 桶 中
5. 空的路由表只有一個桶,所以所有的節點都將被放到這個桶中。每個桶最多隻能儲存 K 個節點,當前 K=8。當一個桶放滿了好節點之後,將不再允許新的節點加入,除非我們自身的節點 ID 在這個桶的範圍內。在這樣的情況下,這個桶將被分裂為 2 個新的桶,每個新桶的範圍都是原來舊桶的一半。原來舊桶中的節點將被重新分配到這兩個新的桶中。如果一個新表只有一個桶,這個包含整個範圍的桶將總被分裂為 2 個新的桶,每個桶的覆蓋範圍從 0..2^1592^159..2^160 
# 以log2N的方式不斷分裂,類似於Kademlia中的K桶機制
6. 當桶裝滿了好節點,新的節點會被丟棄。一旦桶中的某個節點變為了壞的節點,那麼我們就用新的節點來替換這個壞的節點。如果桶中有在 15 分鐘內都沒有活躍過的節點,我們將這樣的節點視為可疑的節點,這時我們向最久沒有聯絡的節點傳送 ping。如果被 ping 的節點給出了回覆,那麼我們向下一個可疑的節點傳送 ping,不斷這樣迴圈下去,直到有某一個節點沒有給出 ping 的回覆,或者當前桶中的所有節點都是好的(也就是所有節點都不是可疑節點,他們在過去 15 分鐘內都有活動)。如果桶中的某個節點沒有對我們的 ping 給出回覆,我們最好再試一次(再傳送一次 ping,因為這個節點也許仍然是活躍的,但由於網路擁塞,所以發生了丟包現象,注意 DHT 的包都是 UDP 的),而不是立即丟棄這個節點或者直接用新節點來替代它。這樣,我們得路由表將充滿穩定的長時間線上的節點 
7. 每個桶都應該維持一個 lastchange 欄位來表明桶中節點的"新鮮"度。當桶中的節點被 ping 並給出了回覆,或者一個節點被加入到了桶,或者一個節點被新的節點所替代,桶的 lastchange 欄位都應當被更新。如果一個桶的 lastchange 在過去的 15 分鐘內都沒有變化,那麼我們將更新它。這個更新桶操作是這樣完成的
    1) 從這個桶所覆蓋的範圍中隨機選擇一個 ID,並對這個 ID 執行 find_nodes 查詢操作。常常收到請求的節點通常不需要常常更新自己的桶
    2) 反之,不常常收到請求的節點常常需要週期性的執行更新所有桶的操作,這樣才能保證當我們用到 DHT 的時候,裡面有足夠多的好的節點 
8. 在插入第一個節點到路由表並啟動服務後,這個節點應試著查詢 DHT 中離自己更近的節點,這個查詢工作是通過不斷的發出 find_node 訊息給越來越近的節點來完成的,當不能找到更近的節點時,這個擴散工作就結束了
9. 路由表應當被啟動工作和客戶端軟體儲存(也就是啟動的時候從客戶端中讀取路由表資訊,結束的時候客戶端軟體記錄到檔案中)

0x2: BitTorrent 協議擴充套件 BitTorrent Protocol Extension

BitTorrent 協議已經被擴充套件為可以在通過 tracker 得到的 peer 之間互相交換節點的 UDP 埠號(也就是告訴對方我們的 DHT 服務埠號),在這樣的方式下,客戶端可以通過下載普通的種子檔案來自動擴充套件 DHT 路由表(我直接知道某個節點有某一個資源)。新安裝的客戶端第一次試著下載一個無 tracker 的種子時,它的路由表中將沒有任何節點,這是它需要在 torrent 檔案中找到聯絡資訊

1. peers 如果支援 DHT 協議就將 BitTorrent 協議握手訊息的保留位的第 8 位元組的最後一位置為 1
2. 這時如果 peer 收到一個 handshake 表明對方支援 DHT 協議,就應該傳送 PORT 訊息。它由位元組 0x09 開始,payload 的長度是 2 個位元組,包含了這個 peer 的 DHT 服務使用的網路位元組序的 UDP 埠號
3. 當 peer 收到這樣的訊息時應當向對方的 IP 和訊息中指定的埠號的節點傳送 ping
4. 如果收到了 ping 的回覆,那麼應當使用上述的方法將新節點的聯絡資訊加入到路由表中 

0x3: Torrent 檔案擴充套件 Torrent File Extensions(種子檔案)

一個無 tracker 的 torrent 檔案字典不包含 announce 關鍵字,而使用 nodes 關鍵字來替代。這個關鍵字對應的內容應該設定為 torrent 建立者的路由表中 K 個最接近的節點(可供選擇的),這個關鍵字也可以設定為一個已知的可用節點(這意味著接收到這個種子檔案的客戶端能夠向這些節點發出解析請求,詢問資源的所在位置),比如這個 torrent 檔案的建立者

請不要自動加入 router.bittorrent.com 到 torrent 檔案中或者自動加入這個節點到客戶端路由表中。這裡可以仔細思考一下,這麼做還有另一個好處,這個對等網路可以保持無中心化,對於外部新加入的新節點來說,它可以不用通過"中心引導節點"來加入網路,隱藏了"中心引導節點"的存在,增強了對等網路的隱蔽性

bt 種子檔案是使用 bencode 編碼的,整個檔案就 dictionary,包含以下鍵

1. info(dictinary): 必選, 表示該bt種子檔案的檔案資訊 
    1) 檔案資訊包括檔案的公共部分
        1.1) piece length(integer): 必選, 每一資料塊的長度
        1.2) pieces(string): 必選, 所有資料塊的 SHA1 校驗值
        1.3) publisher(string):    可選, 釋出者
        1.4) publisher.utf-8(string): 可選, 釋出者的 UTF-8 編碼
        1.5) publisher-url(string): 可選, 釋出者的 URL
        1.6) publisher-url.utf-8(string): 可選, 釋出者的 URL 的 UTF-8 編碼
    2) 如果 bt 種子包含的是單個檔案,包含以下內容
        2.1) name(string): 必選, 推薦的檔名稱
        2.2) name.utf-8(string): 可選, 推薦的檔名稱的 UTF-8 編碼
        2.3) length(int): 必選,檔案的長度單位是位元組
    3) 如果是多檔案,則包含以下部分:
        3.1) name(string): 必選, 推薦的資料夾名稱
        3.2) name.utf-8(string): 可選, 推薦的檔名稱的 UTF-8 編碼
        3.3) files(list): 必選, 檔案列表,每個檔案列表下面是包括每一個檔案的資訊,檔案資訊是個字典 
    4) 檔案字典
        4.1) length(int): 必選,檔案的長度單位是位元組
        4.2) path(string): 必選,檔名稱,包含資料夾在內
        4.3) path.utf-8(string): 必選,檔名稱 UTF-8 表示,包含資料夾在內
        4.4) filehas(string): 可選,檔案hash
        4.5) ed2k(string): 可選, ed2k 資訊 

2. announce(string): 必選, tracker 伺服器的地址
3. announce-list(list): 可選, 可選的 tracker 伺服器地址
4. creation date(interger): 必選, 檔案建立時間
5. comment(string): 可選, bt 檔案註釋
6. created by(string): 可選,檔案建立者

以人民的名義的BT檔案為例

d8:announce33:udp://mgtracker.org:2710/announce13:announce-listll33:udp://mgtracker.org:2710/announceel35:http://share.camoe.cn:8080/announceel29:udp://11.rarbg.me:80/announceel32:http://tracker.tfile.me/announceel40:http://open.acgtracker.com:1096/announceel34:http://mgtracker.org:2710/announceel35:udp://tracker.openbittorrent.com:80el44:udp://tracker.openbittorrent.com:80/announceel38:udp://torrent.gresille.org:80/announceel34:udp://glotorrents.pw:6969/announceel33:udp://208.67.16.113:8000/announceel31:udp://9.rarbg.com:2710/announceel30:udp://9.rarbg.me:2710/announceel31:udp://tracker.ex.ua:80/announceee10:created by13:uTorrent/204013:creation datei1491018889e8:encoding5:UTF-84:infod5:filesld6:lengthi2168824586e4:pathl6:01.mp4eed6:lengthi2175155313e4:pathl6:02.mp4eed6:lengthi2165600175e4:pathl6:03.mp4eed6:lengthi2172491370e4:pathl6:04.mp4eed6:lengthi2168274148e4:pathl6:05.mp4eed6:lengthi2174880177e4:pathl6:06.mp4eed6:lengthi373e4:pathl38:HQC@水嫩的大白菜_2017.3.31.txteee4:name60:In.the.Name.of.People.EP01-06.2017.1080p.WEB-DL.x264.AAC-HQC12:piece lengthi4194304e6:pieces62120:XXXXX(SHA1雜湊值)

表示瞭如下資訊

Tracker地址: udp://mgtracker.org:2710/announce

Tracker伺服器地址列表: 
udp://mgtracker.org:2710/announce
http://share.camoe.cn:8080/announce
udp://11.rarbg.me:80/announce
http://tracker.tfile.me/announce
http://open.acgtracker.com:1096/announce
http://mgtracker.org:2710/announce
udp://tracker.openbittorrent.com:80
udp://tracker.openbittorrent.com:80/announce
udp://torrent.gresille.org:80/announce
udp://glotorrents.pw:6969/announce
udp://208.67.16.113:8000/announce
udp://9.rarbg.com:2710/announce
udp://9.rarbg.me:2710/announce
udp://tracker.ex.ua:80/announce

建立者: uTorrent/204013
建立時間: datei1491018889e8
encoding: UTF-8
info: 檔案列表字典(包含了一批檔案)
    length: 2168824586
    path: 01.mp4
    ..
雜湊SHA1內容: 按照每個檔案塊(pieces)的方式分別計算SHA1雜湊值9

這裡要特別注意一點:磁力連結的infohash也是根據info欄位來計算的,info欄位的pieces為每個資料塊的校驗值,其作用是驗證下載下來的檔案是否正確,如果下載下來的檔案塊計算出來的SHA1值和pieces中的SHA1校驗值不一致,該資料塊要重新下載。 所以,我們可以看出根據磁力連結下載檔案是分成兩個步驟的

1. 先根據infohash下載種子檔案的info欄位,種子檔案並不是必須的,但是info欄位卻必不可少
2. 然後根據infohash下載原始檔,將下載的每一個資料塊和info中的對應的SHA1校驗碼進行比較,不一致重新下載該資料塊

需要注意的是

1. 一般的種子檔案會包含announce,也就是tracker伺服器的地址(trackerless是BTTorrent的趨勢)
2. 如果沒有tracker伺服器,檔案中可能會包含nodes,nodes是存有種子資訊的peer節點,這樣的種子檔案就是trackerless torrent。如果有nodes客戶端直接從nodes獲取種子資訊
3. 而從DHT網路中下載下來的種子檔案既沒有annouce也沒有nodes,客戶端只能通過info欄位計算出hashinfo,再從bootstrap node節點開始在DHT網路中尋找種子資訊 

BT原生依靠Tracker,後來才加入dht

Relevant Link:

http://xiaoxia.org/2013/05/11/magnet-search-engine/
https://segmentfault.com/a/1190000000681331
http://www.360doc.com/content/15/0507/16/3242454_468754791.shtml

 

5. uTP協議 

uTP協議是一個基於UDP的開放的BT點對點檔案共享協議。在uTP協議出現之前,BT下載會佔用網路中大量的連結,直接導致其它網路應用服務質量下載和網路的擁堵,因此有很多ISP都開始限制BT的下載。uTP減輕了網路延遲並解決了傳統的基於TCP的BT協議所遇到的擁塞控制問題,提供可靠的有序的傳送。一個有效的uTP資料包包含下面格式的報頭

1. type(包型別):
    1) ST_DATA = 0: 最重要的資料包,uTP就是使用該型別的包傳送資料
    2) ST_FIN = 1: 關閉連線,這是uTP連線的最後一個包,類似於TCP中的FIN
    3) ST_STATE = 2: 簡單的應答包,表明已從對方收到了資料包,該包不包含任何資料,seq_nr值不變
    4) ST_RESET = 3: 終止連線,類似於TCP中的RST
    5) ST_SYN = 4: 初始化連線,類似於TCP中的SYN,這是uTP連線的第一個包

2. ver: This is the protocol version. The current version is 1.
3. extension: The type of the first extension in a linked list of extension headers. 
    1) 0 means no extension.
    2) Selective acks: There is currently one extension:

4. connection_id: This is a random, unique, number identifying all the packets that belong to the same connection. Each socket has one connection ID for sending packets and a different connection ID for receiving packets. The endpoint initiating the connection decides which ID to use, and the return path has the same ID + 1.    
uTP的一個很重要的特點是使用connection id來標識一次連線,而不是每個包算一次連線。所以在分析ST_DATA時,需要注意找所有connection id相同的資料包,然後按seq_nr排序,seq_nr應該是依次遞增的(注意ST_STATE包不會增加seq_nr值),如果發現兩個ST_DA他的seq_nr值相同則說明後面那個報文是重複報文需要忽略掉,如果發現兩個ST_DA他的seq_nr值不是連續的,中間差了一個或多個,則可能是由於網路原因發生了丟包現象,資料包將不可用

5. timestamp_microseconds: This is the 'microseconds' parts of the timestamp of when this packet was sent. This is set using gettimeofday() on posix and QueryPerformanceTimer() on windows. The higher resolution this timestamp has, the better. The closer to the actual transmit time it is set, the better.

6. timestamp_difference_microseconds: This is the difference between the local time and the timestamp in the last received packet, at the time the last packet was received. This is the latest one-way delay measurement of the link from the remote peer to the local machine. 
When a socket is newly opened and doesn't have any delay samples yet, this must be set to 0.

7. wnd_size: Advertised receive window. This is 32 bits wide and specified in bytes. The window size is the number of bytes currently in-flight, i.e. sent but not acked. The advertised receive window lets the other end cap the window size if it cannot receive any faster, if its receive buffer is filling up. When sending packets, this should be set to the number of bytes left in the socket's receive buffer.

8. seq_nr
9. ack_nr

下面是一個簡單的ST_SYN報文,從192.168.18.33傳送到112.208.162.161,seq_nr = 31445,ack_nr = 0, connection id = 14487

112.208.162.161收到ST_SYN報文後,會向192.168.18.33傳送一個ST_STATE報文,表示已收到。如果沒有回覆,則連線不能建立。如下圖所示,seq_nr = 9690,ack_nr = 31445,connection id = 14487

uTP連線建立之後,就開始傳送需要的資料了。peer和peer之間傳送資料也是遵循著一定的規範,這就是下面要講的Peer Wire協議

Relevant Link:

http://www.bittorrent.org/beps/bep_0029.html

 

6. Peer Wire協議 

在BitTorrent中,節點的定址是通過DHT實現的,而實際的資源共享和傳輸則需要通過uTP以及Peer Wire協議來配合完成

0x1: 握手

Peer Wire協議是Peer之間的通訊協議,通常由一個握手訊息開始。握手訊息的格式是這樣的

<pstrlen><pstr><reserved><info_hash><peer_id> 

在BitTorrent協議的v1.0版本, pstrlen = 19, pstr = "BitTorrent protocol",info_hash是上文中提到的磁力連結中的btih,peer_id每個客戶端都不一樣,但是有著一定的規則,根據前面幾個字元可以推斷出客戶端的型別

'AG' - Ares
'A~' - Ares
'AR' - Arctic
'AV' - Avicora
'AX' - BitPump
'AZ' - Azureus
'BB' - BitBuddy
'BC' - BitComet
'BF' - Bitflu
'BG' - BTG (uses Rasterbar libtorrent)
'BR' - BitRocket
'BS' - BTSlave
'BX' - ~Bittorrent X
'CD' - Enhanced CTorrent
'CT' - CTorrent
'DE' - DelugeTorrent
'DP' - Propagate Data Client
'EB' - EBit
'ES' - electric sheep
'FT' - FoxTorrent
'FX' - Freebox BitTorrent
'GS' - GSTorrent
'HL' - Halite
'HN' - Hydranode
'KG' - KGet
'KT' - KTorrent
'LH' - LH-ABC
'LP' - Lphant
'LT' - libtorrent
'lt' - libTorrent
'LW' - LimeWire
'MO' - MonoTorrent
'MP' - MooPolice
'MR' - Miro
'MT' - MoonlightTorrent
'NX' - Net Transport
'PD' - Pando
'qB' - qBittorrent
'QD' - QQDownload
'QT' - Qt 4 Torrent example
'RT' - Retriever
'S~' - Shareaza alpha/beta
'SB' - ~Swiftbit
'SS' - SwarmScope
'ST' - SymTorrent
'st' - sharktorrent
'SZ' - Shareaza
'TN' - TorrentDotNET
'TR' - Transmission
'TS' - Torrentstorm
'TT' - TuoTu
'UL' - uLeecher!
'UT' - µTorrent
'VG' - Vagaa
'WD' - WebTorrent Desktop
'WT' - BitLet
'WW' - WebTorrent
'WY' - FireTorrent
'XL' - Xunlei
'XT' - XanTorrent
'XX' - Xtorrent
'ZT' - ZipTorrent

可以看到,Peer Wire協議是在uTP協議基礎上裡層應用態協議。收到握手訊息後,對方也會回覆一個握手訊息,並且開始協商一些基本的資訊。如下圖是握手報文的回覆

Relevant Link:

http://www.bittorrent.org/beps/bep_0020.html
https://wiki.theory.org/BitTorrentSpecification#peer_id

 

7. BitTorrent協議擴充套件與ut_metadata和ut_pex(Extension for Peers to Send Metadata Files)

藉助於DHT/KRPC完成了的Node節點定址,資源對應的Peer獲取,以及uTP以及Peer Wire完成握手之後,接下要就要"動真格"了,我們需要獲取到目標資源的"種子資訊(infohash/filename/pieces分塊sha1)"了,這個擴充套件的目的是為了在最初沒有.torrent檔案的情況仍然能夠加入swarm並能夠完成下載。這個擴充套件能讓客戶端從peer哪裡下載metadata。這讓支援magnet link成為了可能,magnet link是一個web頁上的連結,僅僅包含了足夠加入swarm的足夠資訊(info hash)

0x1: Metadata

這個擴充套件僅僅傳輸.torrent檔案的info-字典欄位,這個部分可以由infohash來驗證。在這篇文件中,.torrent的這個部分被稱為metadata。
Metadata被分塊,每個塊有16KB(16384位元組),Metadata塊從0開始索引,所有快的大小都是16KB,除了最後一個塊可能比16KB小

0x2: Extension頭部

Metadata擴充套件使用extension協議(BEP0010)來聲稱它的存在。它在extension握手訊息的頭部m字典加入ut_metadata項。它標識了這個訊息可以使用這個訊息碼,同時也可以在握手訊息中加入metadata_size這個整型欄位(不是在m字典中)來指定metadata的位元組數

{'m': {'ut_metadata', 3}, 'metadata_size': 31235}

0x3: Extension訊息

Extension訊息都是bencode編碼,這裡有3類不同的訊息

0. request(請求): 
請求訊息並不在字典中附加任何關鍵字,這個訊息的回覆應當來自支援這個擴充套件的peer,是一個reject或者data訊息,回覆必須和請求所指出的片相同
Peer必須保證它所傳送的每個片都通過了infohash的檢測。即直到peer獲得了整個metadata並通過了infohash的驗證,才能夠傳送片(即一個peer應該保證自己已經完整從其他peer中拷貝了一份相同的資原始檔後,才能繼續響應其他節點的拷貝請求)。Peers沒有獲得整個metadata時,對收到的所有metadata請求都必須直接回復reject訊息
# exampel
{'msg_type': 0, 'piece': 0}
d8:msg_typei0e5:piecei0ee
# 這代表請求訊息在請求metadata的第一片

1. data
這個data訊息需要在字典中新增一個新的欄位,"total_size".這個關鍵欄位和extension頭的"metadata_size"有相同的含義,這是一個整型
Metadata片被新增到bencode字典後面,他不是字典的一部分,但是是訊息的一部分(必須包括長度字首)。
如果這個片是metadata的最後一個片,他可能小於16KB。如果它不是metadata的最後一片,那大小必須是16KB
# example
{'msg_type': 1, 'piece': 0, 'total_size': 3425}
d8:msg_typei1e5:piecei0e10:total_sizei34256eexxxxxxxx...
# x表示二進位制資料(metadata) 

2. reject
Reject訊息沒有附件的關鍵字。它的意思是peer沒有請求的這個metadata片資訊 
在客戶端收到收到一定數目的訊息後,可以通過拒絕請求訊息來進行洪泛攻擊保護。尤其在metadata的數目乘上一個因子時 
# 
{'msg_type': 2, 'piece': 0}
d8:msg_typei1e5:piecei0ee

0x4: request訊息: Metadat資訊獲取過程

1. 擴充套件支援互動(互相詢問對方支援哪些擴充套件)

根據BEP-010我們知道,擴充套件訊息一般在Peer Wire握手之後立即發出,是一個B編碼的字典

{
    e: 0,
    ipv4: xxx,
    ipv6: xxx,
    complete_ago: 1,
    m:
    {
        upload_only: 3,
        lt_donthave: 7,
        ut_holepunch: 4,
        ut_metadata: 2,
        ut_pex: 1,
        ut_comment: 6
    },
    matadata_size: 45377,
    p: 33733,
    reqq: 255,
    v: BitTorrent 7.9.3
    yp: 19616,
    yourip: xxx
}

1. m: 是一個字典,表示客戶端支援的所有擴充套件以及每個擴充套件的編號
    1) ut_pex: 表示該客戶端支援PEX(Peer Exchange)
    2) ut_metadata表示支援BEP-009(也就是交換種子檔案的metadata)

2. 握手handshake

我們在完成雙方握手之後,並且得到了對方支援的擴充套件資訊。接著我們發出下面的請求

資源請求方也通知被請求方本機支援的擴充套件情況,然後後面接著一個擴充套件訊息(從上面的m字典可以看到可能會有多種不同的擴充套件訊息),具體是哪個型別的擴充套件訊息由message ID後面那個數字決定,這個數字對應著m字典中的編號。譬如我們這裡的訊息是

00 00 00 1b 14 02 ... 00 00 00 1b 
1. 訊息長度為 0x1b (27 bytes) 
2. 14 表示是 擴充套件訊息(0x14 = 20)
3. 02 對應上面m字典中的 ut_metadata,所以我們這個訊息是ut_metadata訊息

再次看上圖的截圖,我們這裡的圖顯示的是[msg_type: 0, piece: 2]正是request訊息,意思是向物件請求第二個piece的資料,piece的意思是分塊的意思,根據BEP-009我們知道,種子檔案的metadata(也就是info部分)會按16KB分成若干塊,除最後一塊每一塊的大小都是16KB,每一塊從0開始按順序進行編號。所以這個請求的意思就是向物件請求第三塊的metadata

3. 回覆data資訊

從圖中形象的表示可以看到torrent檔案整個info的長度為45377,這個值正是上面握手報文後的擴充套件訊息中的metadata_size的值。在傳送request訊息之後,接下來對方應該回復data訊息(如果對方有資料)或reject訊息(如果對方沒有資料)。下圖是針對上面的request訊息的回覆

msg_type為1表示是回覆就是我所需要的資料,但是注意這裡的資料並沒完,由於uTP協議的緣故,我們可以根據connection id找到這個連線後續的所有資料。 這裡其實一共收到了三個訊息,我們分別來看一下

00 00 00 03 09 83 c5 --> message ID為9,port訊息,表示埠號為0x83c5 = 33733
00 00 00 03 14 03 01 --> message ID為20(0x14),extend訊息,編號03為upload_only,表示設定upload_only = 1
00 00 31 70 14 02 xx --> message ID為20(0x14),extend訊息,編號02為ut_metadata,後面的xx表示[msg_type: 1, piece: 2, total_size: 45377]和相應塊的metadata資料

看第三個訊息可以知道訊息長度為0x3170,這個長度包括了[msg_type...]這一串字串的長度,共0x2f個位元組,我們將其減去就得到了piece2的長度:0x3170 - 0x2f = 0x3141 我們上面說過每個塊的大小應該是16KB,也就是0x4000,這裡的大小為0x3141,只可能是最後一塊。我們稍微計算驗證下,將整個info的長度45377(0xb141)按16KB分塊

piece 0: 0x0001 ~ 0x4000 長度0x4000
piece 1: 0x4001 ~ 0x8000 長度0x4000
piece 2: 0x8001 ~ 0xb141 長度0x3141

可以看到piece2正是最後一塊,大小為0x3141。至此我們得到了第二塊的metadata,然後通過request訊息獲取piece0和piece1獲取第一和第二塊的metadata,將三塊的訊息合併成torrent檔案info欄位,然後再加上create datecreate bycomment等資訊,種子檔案就算完成下載了。可見要在BT網路中完成實際的資源下載,就必須完整獲取到種子檔案,因為種子檔案中不單有infohash值,還有piece sha1校驗碼,分塊下載時需要進行校驗,而磁力連線magnet只是一個最小化入口,最終還是需要通過磁力連線在DHT網路中獲取種子檔案的完整資訊

0x5: 校驗info_hash

我們將從DHT網路中下載的種子檔案和原始的種子檔案進行比較,可以看到annouce和annouce-list欄位都丟掉了(引入了DHT網路後,BT可以實現Trackerless),create date發生了變化,info欄位不變

磁力鏈是為了簡化BT種子檔案的分發,封裝了一個簡化版的magnet url,客戶端解析這個magnet磁力鏈之後,需要在DHT網路中尋找infohash對應的peer節點,獲取節點成功後,向目標peer節點獲取真正的BitTorrent種子(.torrent檔案)資訊(包含了完整的pieces SHA1雜湊資訊),另一個渠道就是傳統的Bt種子論壇會分發.BT種子檔案

Relevant Link:

http://www.bittorrent.org/beps/bep_0010.html
http://www.aneasystone.com/archives/2015/05/analyze-magnet-protocol-using-wireshark.html

 

8. 用P2P對等網路思想改造C/S、B/S架構的思考

以下這個章節是我在谷歌時遇到的文章和片段,我感覺我的思路很混亂,原因是我當今網際網路的網路架構和具體的業務場景有密切的關係,是重內容還是重連線,是長連還是短連線,需要共享的是meta資訊是資源本身,要共享的訊息是否需要實時地廣播到全網,很多問題都在腦子裡雜糅在一起,一時都梳理不清我到底在思考什麼問題99

0x1: IPTV採用客戶機/伺服器模式的侷限性

IPTV一般泛指通過IP網路傳輸音視訊內容並用電視機收看的業務。目前電信運營商提供的IPTV運營在支援組播的可管理的IP網上,其主要業務為直播電視(轉播電視廣播)、時移電視、視訊點播(VoD)以及互動資訊服務等。
目前中國的IPTV系統採用客戶機/伺服器模式提供單播和點播(包括VoD和時移電視)業務。由於伺服器輸入/輸出(I/O)“瓶頸”的限制,一臺伺服器只能支援有限的併發流(千數量級的併發流)。要解決十萬、百萬使用者同時收看的問題,不僅需要大量伺服器,還需要極寬的網路頻寬。目前的解決方法一是採用組播來提供廣播,二是採用內容傳送網路(CDN)技術將伺服器儘量放到離客戶近的地方以減輕網路負荷。現有網路要支援組播,需要進行改造,這不僅導致成本增加還將損失網際網路無所不在的通達能力。因此,IPTV只能在經過改造的區域性網路內提供廣播業務。對於IPTV進一步向網路新媒體演化趨勢,目前的客戶機/伺服器模式也不能很好地提供支援。
客戶機/伺服器已經成為制約IPTV發展的“瓶頸”,解決方法是體系結構向對等連線(P2P)模式演化

0x2: P2P內容分發技術的演化

計算機網路發展演化過程不斷在集中和分佈之間擺動。早期計算機的使用模式是眾多使用者共享大型計算機,以後發展了個人計算機,從集中走向分佈。在網際網路上存在類似情況,開始採用客戶機/伺服器方式,使用網站上集中的伺服器。進一步發展將走向分散式,集中的伺服器變成分散式的。
P2P技術將許多使用者結合成一個網路,共享其中的頻寬,共同處理其中的資訊。與傳統的客戶機/伺服器模式不同,P2P工作方式中,每一個客戶終端既是客戶機又是伺服器。以共享下載檔案為例,下載同一個檔案的眾多使用者中的每一個使用者只需要下載檔案的一個片段,然後互相交換,最終每個使用者都得到完整的檔案

1. 實現P2P的第一步是在網際網路上進行檢索,找到擁有所需內容和計算能力的結點地址
2. 第二步是通過網際網路實現對等連線

為了充分發揮網際網路無所不在的優勢,不能對網際網路協議進行任何修改。解決的方法是在基礎的網際網路上架設一個P2P重疊網,P2P重疊網分為以下幾類

1. 無組織的P2P重疊網: 目前在網際網路上廣泛使用的大多是無組織的P2P重疊網,如BitTorrent(BT)下載。無組織的P2P重疊網已經演進了幾代
    1) 第一代P2P網路採用中央控制網路體系結構。早期的軟體Napster就採用這種結構
    2) 第二代P2P採用分散分佈網路體系結構,適合在自組織(Ad hoc)網上應用,如即時通訊等
    3) 第三代P2P採用混合網路體系結構,這種模式綜合了第一代和第二代的優點,用分佈的超級結點取代中央檢索伺服器,並進一步利用有組織網的分散式雜湊表(DHT)加速檢索 

2. 有組織的P2P重疊網: 有組織的P2P重疊網目前還處於學術界研究階段,如Tapestry、Chord、Pastry和CAN等網路。正在研究的新一代的P2P應用包括多播、網路儲存等都執行在這種有組織的P2P重疊網上
3. 混合型網三大類: 一些實用系統開始使用混合型結構

廣播影視資料內容的分發主要採用兩種方法

1. 一種方法是先下載,下載後再觀看,這種方法現在被稱為播客(Podcast)
2. 另一種就是用流媒體的方式邊下載邊收看。P2P技術對這兩種方式都支援

0x3: P2P流媒體直播技術進展

1. 利用P2P技術實現大規模流媒體點播和直播的系統Webcast出現於1998年。Webcast利用一棵二叉多播樹在使用者之間進行實時多媒體資料的傳輸和共享。此後由於流媒體直播服務相對簡單,首先得到快速發展
2. 2000年出現第一套P2P視訊直播系統的原型——ESM系統,該系統採用使用者網狀結構互連構造最優媒體資料多播樹的方法在使用者間傳播實時的多媒體內容。由於演算法限制,這套系統只能擴充套件到幾千人同時線上,但已經標誌著P2P流媒體直播系統進入了系統發展期。
3. 此後各種原型系統、高度可擴充套件的應用層多播協議大量湧現。其中典型的系統有提供音訊廣播的Standford大學的Peercast系統和德國的P2PRadio系統,他們均採用開放原始碼。而應用層多播協議有
    1) 微軟的Coopnet/Splitstream協議
    2) 思科的Overcast協議
    3) 馬里蘭大學的NICE協議
    4) 伯克利大學的Gossip協議等
雖然這些系統和協議尚不能實用,但為P2P流媒體直播打下了堅實的理論基礎 
5. 2004年5月歐洲盃期間,香港科技大學張欣研博士開發的CoolStreaming原型系統在Planetlab網上試用獲得成功。這套系統使用Goosip協議在使用者之間傳播控制信令,使用類似於BT的"多點對多點資料傳播協議"在使用者之間傳送媒體資料包。CoolStreaming系統是第一次真正將高可擴充套件和高可靠性的網狀多播協議應用在P2P流直播系統當中,標誌P2P直播技術進入準商業運作階段
在CoolStreaming成功的鼓舞下,中國流媒體直播技術和業務發展迅速,在世界上獨樹一幟,目前中國有10多個網站使用各自發展的軟體提供P2P流媒體直播業務。使用者最多的是PPLive網採用的Synacast系統。Synacast系統的核心是一套完整的網上視訊傳輸和運營支援業務平臺。在此平臺上可以方便地完成節目採集、釋出、認證、統計分析等功能
由於採用了P2P技術進行流媒體內容的分發,Synacast系統對伺服器端的要求比較低。通常情況下,每一個源分發服務程式只佔用5%左右的CPU負載,20 MB的記憶體和10 Mb/s的網路頻寬。以PPLive網為例,該網站原本使用的是傳統的Windows Media伺服器,一臺100 Mb/s伺服器以單播方式提供一路節目的直播,最多可支援200~300個使用者併發訪問;當使用了Synacast技術後,一臺100 Mb/s接入網際網路的普通PC伺服器可以同時提供5~10路視訊節目的直播,每一路節目均可以支援百萬使用者同時收視 

0x4: P2P流媒體點播技術進展

與直播領域相比,在流媒體點播領域,P2P技術的發展速度相對較為緩慢。主要是因為點播當中的高度互動性需求,使得實現的複雜程度較高。此外節目源版權因素對P2P點播技術的應用有阻礙

1. 2000年,美國普度大學實現的GnuStream系統是在Gnutella網路基礎之上的第一個P2P準點播系統。該系統也使用了網狀多播的策略。由於版權因素的限制,這套系統沒有能得到大規模的使用
2. 2000年之後,P2P的點播技術在適用於點播的應用層傳輸協議技術、底層編碼技術以及數字版權技術等方面都有重要進展
    1) 在應用層傳輸協議方面,比較重要的有2002年提出的P2Cast協議
    2) 2003年提出的CollectCast協議(用於PROMISE系統)
3. 目前正在發展實用的P2P點播系統,開始進入商業應用階段
4. 美國線上(AOL)和華納兄弟合作將在網際網路上採用Kontiki公司P2P VoD推出In2TV業務,為客戶提供電視劇點播業務。有6類電視劇節目,具有DVD質量的視訊效果。In2TV還提供各種互動服務如遊戲等 

P2P流媒體直播不同,P2P流媒體終端必須擁有硬碟,其成本高於直播終端

0x5: 用P2P思想改造遊戲/網遊/手遊客戶端

這裡在DHT的研究基礎上,稍微延伸出來做一些思考,我們是否可以有可能用P2P對等網路建立一個無中心化的網遊網路拓樸的可能,像遊戲客戶端這種場景和瀏覽網頁資源又不完全一樣,遊戲是需要TCP長連線hold在那裡的,而HTTP TCP是一次性的連線資源獲取後就結束了

1. 登入客戶端(包括PC客戶端和手機客戶端)需要進行改造,需要支援UDP DHT,它不再直接和中心伺服器進行握手登入,而是在P2P網路中,以對等的關係共同儲存所有使用者的帳號資料資訊 (裝備/遊戲幣等)
# 這裡需要明白,如果我們只是讓P2P來負責共享雲端的業務負載伺服器,即承擔分散式排程的問題,本質問題並沒有改變,攻擊者依然能夠很容易獲取到所有的負載業務伺服器
2. 要解決可信認真問題,即網路中任意節點廣播出來的說現網的使用者賬戶資訊,需要讓網路中的其他使用者(可以理解為礦工)進行數學可信驗證,以達到不可偽造的面對
3. 但是同時伴隨著問題2,如果用Bit幣網路那種"挖礦驗證"的方法,一次"賬本廣播"需要至少經過3層的挖礦後才可以被全網所有節點接收,而這個時間週期要花費地較長,不是很符合遊戲這種對實時性要求較高的業務場景
4. 新節點入網問題,對DHT網路來說,新節點的入網有2種方式:1)通過引導節點來引導進入、2)通過現網隨機某個節點發布的磁力連結進入網路(但是這個情況同樣需要一箇中心節點服務磁力分發)。總的來說,依然存在一個單點問題,如果這個單點入口Down了,則新節點的入網將無法進行,整個網路的魯棒性依然得不到保證

Relevant Link:

http://www.zte.com.cn/cndata/magazine/zte_communications/2006/3/magazine/200605/t20060525_150563.html
http://www.zte.com.cn/cndata/magazine/zte_communications/2007/6/magazine/200712/t20071220_150729.html
http://www.it.com.cn/f/news/079/28/484649.htm
http://www.chinaunicom.com.cn/upload/1246533688964.pdf

Copyright (c) 2017 LittleHann All rights reserved

相關文章