寫在前面
去年寫過一篇文章《利用Python+阿里雲實現DDNS(動態域名解析)》,針對的是有臨時公網IP的寬帶用戶,而這並不是每一家運營商都能做的到,有的朋友雖說有寬帶,但是得到的是一個內網IP,這時,就要輪到傳說中的內網穿透大展身手了。在這篇文章中,會通過幾次的端口轉發,實現內外網的端口映射,進而達到內網穿透的目的。效果很像NAPT,但又不是,畢竟沒有涉及到修改IP數據報。
比較有代表性的內網穿透的程序有:付費的有花生殼、NAT123,開源的有frp等等。這裏實現的內網穿透雖然沒有前面列舉的那些工具一樣那麼強大,但是也實現了基本的功能,目前,可以支持TCP協議的內網與外網的映射,支持http、mysql、rdp(Windows遠程桌面)、SSH應用(以上是測試過的)。
下面先上效果圖,最後會奉上代碼,由於只是實驗品,可能有BUG,還請各位看官多多包含~~
我把這個項目叫做Venus(金星–太陽系離太陽第二近的行星,具體爲啥會叫這個名,後文揭曉~)
文章會比較長,我儘量將實現的原理說的明白~
- http網站映射:
內網環境
外網環境:
- MySQL數據庫
內網環境
外網環境:
- SSH(Ubuntu 18.04 LTS)
內網環境:
外網環境:
架構與數據流
軟件架構
架構圖:
從圖中可以看出,內網的應用擁有屬於自身的端口號,比如WEB爲80,SSH爲22,RDP爲3389,經過內網穿透/內外網端口映射後,WEB端口號變爲了9000,SSH變成9001,RDP變成9002,這樣,外部用戶可以利用這些900x端口號訪問內網。顯然,要實現多個應用的部署,需要使用多進程。
單一請求的數據流
但對於單一的一次請求(例如WEB服務),內外網數據是如何進行傳遞的呢?請看下面這張圖(暫時先無視ClientC與ServerC):
圖例:
- 紅色實線表示數據出內網
- 綠色實線表示數據進入內網
- 內網中的紫色虛線框代表客戶端
- 外網中的紫色虛線框代表服務器
初看這張圖,這不就是網絡編程中最基本的Server/Client模型嗎?的確,實現內網穿透確實是基於這個模型,但是仔細一看,發現又有跟以前不太一樣的地方。例如,我們以前熟知的是Server和Client直接互傳信息,而這裏卻出現了Client與Client間,Server與Server間互傳信息的情況。但是細細想,這也有它的道理:
假設這裏的APP爲Apache。剛開始時,內網要主動與外網的VPS建立連接,因爲內網訪問外網的VPS很容易,但是外網的VPS要訪問內網就難了,因爲你可能不知道本次連接的外部端口號[1]。在應用中,ClientB與ServerA就起到了建立內外網橋樑的作用。
內外網連接建立後,外部用戶請求一個網站。那麼此時,用戶相當於一個客戶端,需要一個服務器來接收他的請求,在這裏,就是ServerB。ServerB接受了用戶的請求後,需要將這個請求進行轉發,前面說到,在剛開始的時候,ClientB與ServerA建立了內外網的連接,於是,用戶的請求就從這個路徑傳送到內網。此時可以發現,在服務器端應用中,已經出現了兩個Server,一個是ServerA,一個是ServerB,它們各司其職,一個Server將獲取的數據放在緩衝區中,由另外一個Server取走。
由於Apache是一個網站服務器,需要一個客戶端來連接。此時,用戶的請求已由ClientB傳送達到內網,需要由一個客戶端發給Apache,這就是ClientA的作用。可以發現,在客戶端應用中,出現了兩個Client,ClientA與ClientB,它們也跟服務端的一樣,各司其職,將獲取的數據放在緩衝區中,由另外一個取走。
以上,數據就已經到達了內網中的網站服務器,網站服務器經過處理後,將響應原路返回。至此,一次請求操作結束。
多用戶情況下的數據流
最初開始設計的時候,我以爲多用戶訪問一個內網時的情況與上面說的相似,只需要ServerB接受多個用戶的請求,然後將請求跟排隊似的傳給內網服務器就完事了。類似下面這張圖,當時還沒有想到心跳。
但是實踐之後發現並不是這樣,每一種協議都有其特點,下面舉幾個例子:
- http協議:用戶發出請求,服務器響應,數據傳輸完畢後,服務器斷開連接
- mysql數據庫:TCP連接建立後,數據庫服務器先發送一個握手信息,客戶端不像http那樣主動請求,直到客戶端收到這個握手信息後再進行響應,整個通訊過程中,連接是不斷開的(這裏指使用控制檯的情況,如果使用第三方可視化工具,會有不一樣)
這樣就產生一個問題,mysql數據庫的話還好,可以保持TCP連接不中斷,但是HTTP怎麼辦呢?一旦斷了就要馬上重連,一是很浪費資源,二是即使這麼做了,會大概率造成請求丟失,也就是說有時候會造成我請求AURL,結果我看到的是BURL。
後來想到的辦法是,一次請求一個線程,請求完畢就馬上釋放資源。而什麼時候通知內網進行通訊管道建立呢?這時候想到ServerB可以勝任這項工作,因爲ServerB就是用來接收外部用戶請求的。
接收到用戶請求後,應該還要有一個組件來通知內網,這就是上一節圖中ServerC和ClientC的作用了。它們就是來激活內網的數據傳輸管道的。這樣,每一次請求就啓動一個線程,請求完畢就釋放,就不會出現混亂的情況了。
心跳
心跳常常是維持網絡中某一個節點存活的機制。心跳心跳,顧名思義,我們常常用心跳來判斷一個人是否過世,這裏也一樣,用心跳來判斷某一個連接是否斷開,這裏對ServerC與ClientC應用這一機制。因爲它們對於整個軟件十分重要,沒有了它們,內網就不知道什麼時候要建立連接了,必須始終保持在線。
技術實現
這裏就到上代碼的環節了,只挑幾個關鍵的拿出來說一說。
連接監聽
這裏連接的監聽使用的是Python中select模塊的select方法。這是一個大部分平臺都支持的方法,可以用來監聽Socket。函數原型是這樣的。
fd_r_list,fd_w_list,fd_e_list = select.select(rlist, wlist, xlist,[timeout])
顯然,select方法有三個參數,三個返回值
- 當參數1序列中的fd滿足“可讀”條件時,則獲取發生變化的fd並添加進fd_r_list中
- 當參數2序列中含有fd時,則將該序列中的所有fd添加到fd_w_list中
- 當參數3序列中的fd發手錯誤時,則將該發生錯誤的fs添加到fd_e_list中
- 當超時時間爲空,則select會一直阻塞,直到監聽的句柄發生變化,當超時時間=n(正整數)時,那麼如果監聽的句柄均無任何變化,則select會阻塞n秒,之後返回三個空列表,如果監聽的句柄發生變化,則直接執行。
注:fd指File Descriptor,即文件描述符,比如Socket對象就是一個文件描述符,調用socketobj.fileno()可查看其值。select方法最多支持1024個fd。
需要注意的是,select方法會採用操作系統的內核態來檢測變化,操作還是十分方便的。由於我們需要循環監聽,所以需要一個死循環。每次監聽後,根據返回的fd_r_list列表,對於其中的對象採取不同的辦法。
以下代碼描述了在服務器端,ServerA的連接對象connA與ServerB的連接對象connB是如何進行傳輸數據的:
def TCPForwarding(self):
while True:
rs, ws, es = select.select(self.readableList, self.writeableList, self.errorList)
for each in rs:
#如果當前是connA,則接收數據轉發給connB,傳輸結束關閉連接返回,遇錯返回
if each == self.connA:
try:
tdataA = each.recv(1024)
self.connB.send(tdataA)
print(tdataA)
if not tdataA:
self.closeConnectrion()
return
except BlockingIOError as e:
print(e)
return
except ConnectionAbortedError as e:
print(e)
return
# 如果當前是connB,則接收數據轉發給connA,傳輸結束關閉連接返回,遇錯返回
elif each == self.connB:
try:
tdataB = each.recv(1024)
self.connA.send(tdataB)
if not tdataB:
self.closeConnectrion()
return
except ConnectionAbortedError as e:
print(e)
return
except ConnectionResetError as e:
print(e)
return
上面列舉的這個方法,是在ServerB監聽到外部用戶訪問後,接受來自客戶端ServerA的連接,啓動的一個線程類中的方法。這個方法的主要功能是:將用戶發給內網或內網發給用戶的數據進行轉發。
這裏就使用了select方法進行監聽connA與connB,connA與connB都已在類初始化的時候放入了readableList中。如果select監聽到此時connB有消息可讀,就讀取消息,然後由connA發送出去,同樣,如果此時connA可讀,也讀取消息,由connB發送出去。如果遇到異常,則終止。
當然,select方法不止用於這一處。
心跳實現
上面說了爲什麼要在這裏使用心跳,因爲ServerC與ClientC實在是太重要了,必須要保證他們“活着”。具體的實現方法是這樣的:
在服務器端的類對象中,有一個isAlive的變量,它用於標識ClientC是否存活,剛開始的時候,其值爲否,然後有一個線程,專門用於堅持其狀態,如果isAlive=False,則說明內網客戶端斷開了,就會阻塞整個程序,等待連接,連接上後將isAlive設置爲true;否則,每過1秒,就會發送心跳信息,如果客戶端沒有迴應,說明客戶端離線,將isAlive設置爲False。下面是代碼:
服務器端
#心跳檢測,若掛掉等待連接恢復,每秒發送一次心跳
def heartbeat(self):
while True:
if not self.isAlive:
self.initServerC()
self.connC, addC = self.serverC.accept()
print('ServerC IP : %s:%d' % addC)
self.isAlive = True
b = bytes('IAMALIVE', encoding='utf-8')
try:
self.connC.send(b)
tdataC = self.connC.recv(1024)
if not tdataC:
self.connC.close()
self.connC = None
self.isAlive = False
except:
print('serverC已斷開,等待重新連接...')
self.isAlive = False
time.sleep(1)
在服務器端的代碼中,整個方法爲一個死循環,每隔1秒判斷一次狀態,心跳信息就爲IAMALIVE的字符串,然後發送給內網的客戶端,等待迴應,如果有迴應,說明存活,否則就是離線。當然,這裏的ServerC設置成了阻塞式[2],以防止不必要的異常。
客戶端:
#remoteIP ->遠程VPS的IP地址
#commonPort -> 心跳檢測端口
#remotePort -> 遠程VPS數據監聽端口
#localIp -> 本地IP
#localPort -> 本地端口
#clientC專門與遠程VPS做心跳
clientC = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
clientC.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
clientC.connect((remoteIP, commonPort))
rl = [clientC]
#監聽
while True:
rs, ws, es = select.select(rl, [], [])
for each in rs:
if each == clientC:
tdataC = each.recv(1024)
if not tdataC:
rl.remove(clientC)
clientC.close()
clientC.connect((remoteIP, commonPort))
rl = [clientC]
break
#print(tdataC)
#若遠程VPS接收到用戶訪問請求,則激活一個線程用於處理
if tdataC == bytes('ACTIVATE',encoding='utf-8'):
foo = MappingClient(localIp,localPort,'tcp',remoteIP,remotePort)
t = Thread(target=foo.TCPMapping)
t.setDaemon(True)
t.start()
#心跳檢測
elif tdataC == bytes('IAMALIVE',encoding='utf-8'):
b = bytes('OK', encoding='utf-8')
each.send(b)
這段代碼主要就是初始化ClientC,並監聽由外網服務器端發來的針對ClientC的信息,如果是IAMALIVE,,說明是心跳包,就回應一個OK信息,如果是ACTIVATE,則會啓動一個線程,進行內外網數據傳送。
多線程與多進程
這就會稍微簡單一些了,在服務器端,一定要有了外部用戶的連接請求後,再去接受內網的ServerA的連接,然後我設計了一個線程數據轉發類MappingSubServer,專門用於ServerA與ServerB之間的數據轉發。需要注意的是,一定要將請求時刻所生成的connA與connB同時傳入,否則將會導致連接錯亂。
#如果有外部請求,激活數據管道,每個請求一個線程
connB, addB = self.serverB.accept()
print('ServerB IP : %s:%d' % addB)
b = bytes('ACTIVATE', encoding='utf-8')
self.connC.send(b)
connA, addA = self.serverA.accept()
print('ServerB IP : %s:%d' % addA)
mss = MappingSubServer(connA,connB,self.serverB)
t = Thread(target=mss.TCPForwarding)
t.setDaemon(True)
t.start()
在這裏,接收帶服務器請求後會由ServerC生成的connC對象往內網發送激活信息,然後在等待接受ServerA的連接。成功後啓動線程。
客戶端的多線程在上一節已做了說明。
多進程就更加明瞭了,軟件採用JSON爲配置文件格式,以下爲服務器端配置示例:
{
"App01": {
"commonPort": "7000",
"remotePort": "8000",
"toPort":"9000"
}
}
JSON經過Python解析後,會變成一個字典,即
{"App01":{"commonPort":"7000","remotePort":"8000","toPort":"9000"}}
然後獲取key的集合,對於每一個key,啓動一個進程就ok啦~客戶端也是同理
爲什麼叫Venus
主要還是最近迷三體 ,就想拿太陽系的行星來命名。前一陣子寫了一個局域網文件傳輸工具Mercury,Mercury也叫水星,接下來就是離太陽第二近的金星(Venus)了。
文章註解
[1] : 現在有的網絡設備使用NAT網絡地址轉換技術,NAT技術會在連接外網時,隨機選擇一個端口號進行訪問,如果是對稱型NAT,每次請求的端口都會不一樣。
[2] :非阻塞式:簡單來說,在recv數據時,沒有數據也返回
阻塞式:簡單來說,在recv數據時,如果沒有數據則阻塞,有數據讀取才返回。
代碼
寫在最後
本文有些部分參考了我的python學習筆記之select模塊。
如果有寫的不好,名詞理解不對的地方還請指出。程序的Bug不能避免,主要是思路,還請各位多多包含~