利用Python實現內網穿透(可向公網映射內網應用程序)

寫在前面

 去年寫過一篇文章《利用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數據時,如果沒有數據則阻塞,有數據讀取才返回。

代碼

Github倉庫地址

寫在最後

本文有些部分參考了我的python學習筆記之select模塊
如果有寫的不好,名詞理解不對的地方還請指出。程序的Bug不能避免,主要是思路,還請各位多多包含~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章