本文所討論的網路埠複用並非指網路程式設計中採用SO_REUSEADDR選項的 Socket Bind 複用。它更像是一個帶特定路由功能的埠轉發工具,在應用層實現。
背景
筆者所處網路中防火牆只開放了一個埠,但卻希望能夠提供多種網路服務用於測試。所以需要尋求一種解決方案,能夠對TCP資料包特徵進行識別,用以實現在一個開放埠上同時提供HTTP/SSH/MQTT等多種服務。
比如說,你可以在80埠上覆用一個SSH服務,普通使用者只知道瀏覽器訪問http://x.x.x.x/ ,而你卻可以用 ssh user@x.x.x.x -p 80
這樣的方式來訪問你的伺服器,這也不失為一種隱藏SSH服務的辦法。
埠複用神器 – sslh
sslh是一款採用C語言編寫的開源埠複用軟體,目前支援 HTTP、SSL、SSH、OpenVPN、tinc、XMPP等多種協議識別。它主要執行於*nix環境,原始碼託管在GitHub上。據官網介紹,Windows系統下可在Cygwin環境中編譯執行,筆者未作測試。
編譯過程並不複雜,直接按照官方文件操作,不在此贅述。Debian使用者可直接通過sudo apt-get install sslh
安裝。
編譯生成兩個可執行檔案:sslh-fork 和 sslh-select 。二者的區別在於工作模式的差異:
sslh-fork
採用*nix的程式fork模型,為每一個TCP連線fork一個子程式來處理包的轉發。對於長連線而言,無需頻繁建立大量新連線,fork帶來的開銷基本可以忽略。但是如果對像HTTP這樣的短連線請求,採用fork子程式的方式來進行包轉發的話,在出現大量併發請求時,效率會受到一定影響。不過fork模式經過了良好測試,執行起來穩定可靠。sslh-select
採用單執行緒監控管理所有網路連線,是比較新的一種方式。但相對epool等基於事件的I/O機制來說,select的傳統輪詢模式效率還是相對較低的。
sslh支援在配置檔案中使用正規表示式來自定義協議識別規則,但是我在嘗試 MQTT v3.1 協議識別時,出現了問題。當然也有可能是我編寫的正規表示式和它使用的正則庫不匹配。
高效能負載均衡器 – HAProxy
HAProxy是一款開源高效能的 TCP/HTTP 軟體負載均衡器,目前在遊戲後端服務和Web伺服器負載均衡等方面都有著非常廣泛的應用。通過配置,可以實現多種SSL應用複用同一個埠,比如 HTTPS、SSH、OpenVPN等。這裡有一篇參考文件。
雖然HAProxy效能卓越,但它不容易通過擴充套件來滿足特定的需求。
為網路而生的現代語言 – Go
Go語言是近幾年我學習研究過的優秀程式語言之一,它的簡潔和高效深深吸引了我(我喜歡簡單的東西,比如Python)。Go語言的goroutine在語言級別提供併發支援,channel又在這些協程之間提供便捷可靠的通訊機制。結合起來,Go語言非常適合編寫高併發的網路應用。之前也打算過用Python+gevent的方式,最後還是考慮到Go語言靜態編譯後的高效率,沒有選擇Python。
在Github上翻騰,找到一個Go語言實現的類sslh專案——Switcher。它很久沒有更新,支援的協議也非常少——實際上它只能識別SSH協議。Switcher的實現非常簡單,核心程式碼不到200行。於是決定在它的基礎上進行改造,實現我所需要的功能。
D——I——Y
到Github上fork了一份Switcher程式碼,在它的基礎上修改。說是修改,其實已面目全非。新的實現中調整了原有架構,去掉對SSH協議的直接支援,轉而採用更加通用的協議識別模式,以求達到可以不通過修改程式而只需簡單配置即可支援大部分協議,讓程式通用性更強一些。
首先最常見的協議匹配模式是根據packet頭幾個位元組對目標協議特徵進行比對。如果只是儲存每個協議的頭N個位元組,不加任何處理逐一比對的話,可能會存在一定的效率問題。一方面,需要對所有pattern進行遍歷,逐個與收到的packet進行比較;另一方面,如果網路延時較大,不能一次性收集到足夠多的位元組,則需要反覆多次比對。舉一個比較極端的例子,假設我有100個目標協議需要比對匹配,pattern大小都在10位元組以上,這時候我通過telnet/netcat連線伺服器,一個位元組一個位元組的傳送資料,則伺服器可能要進行10*100次字串比較。
為了解決這個問題,簡單設計了一個樹形結構,把所有的pattern都以位元組為單位填充到這棵樹上,直至末梢。葉節點上儲存協議對應的目標IP和埠值。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
func (t *MatchTree) Add(p *PREFIX) { for _, patternStr := range p.Patterns { pattern := []byte(patternStr) node := t.Root for i, b := range pattern { nodes := node.ChildNodes if next_node, ok := nodes[b]; ok { node = next_node continue } if nodes == nil { nodes = make(map[byte]*MatchTreeNode) node.ChildNodes = nodes } root, leaf := createSubTree(pattern[i+1:]) leaf.Address = p.Address nodes[b] = root break } } } |
也許是我想太多,在需要比對的協議數量很少的情況下,可能這樣的設計並不能帶來根本上的效率提升。不過我喜歡這種為了可能的效率提升而不斷努力的趕腳 ^_^
相比packet prefix匹配的模式,正規表示式會更加靈活。所以我採取類似sslh的方式,加入了對正規表示式的支援。考慮到效率和具體實現的問題,對正規表示式匹配規則加入了一定的限制,比如需要知道目標字串的最大長度。正規表示式只能在packet buffer達到一定長度要求的情況下逐一匹配。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
func (p *REGEX) Probe(header []byte) (result ProbeResult, address string) { if p.MinLength > 0 && len(header) return TRYAGAIN, "" } for _, re := range p.regexpList { if re.Match(header) { return MATCH, p.Address } } if p.MaxLength > 0 && len(header) >= p.MaxLength { return UNMATCH, "" } return TRYAGAIN, "" } |
基於上述兩種簡單的匹配規則,很容易可以構造出ssh、http等常用的協議。在實現中,我加入了一些常用協議的支援,省去使用者自定義的麻煩。
1 2 3 4 5 6 |
case "ssh": service = "prefix" p = &PREFIX{ps.BaseConfig, []string{"SSH"}} case "http": service = "prefix" p = &PREFIX{ps.BaseConfig, []string{"GET ", "POST ", "PUT ", "DELETE ", "HEAD ", "OPTIONS "}} |
特殊的協議還是需要單獨實現的。比如說,我所需要的MQTT協議就無法通過簡單的字串比對或者正規表示式方式來進行識別。因為它沒有既定的模式,結構也不是固定長度。MQTT協議識別實現如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
func (s *MQTT) Probe(header []byte) (result ProbeResult, address string) { if header[0] != 0x10 { return UNMATCH, "" } if len(header) < 13 { return TRYAGAIN, "" } i := 1 for ; ; i++ { if header[i]&0x80 == 0 { break } if i == 4 { return UNMATCH, "" } } i++ if bytes.Compare(header[i:i+8], []byte("\x00\x06MQIsdp")) == 0 || bytes.Compare(header[i:i+6], []byte("\x00\x04MQTT")) == 0 { return MATCH, s.Address } return UNMATCH, "" } |
配置檔案採用了json格式,主要是為了方便和靈活。下面是一個示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
{ "listen": ":80", "default": "127.0.0.1:80", "timeout": 1, "connect_timeout": 1, "protocols": [ { "service": "ssh", "addr": "127.0.0.1:22" }, { "service": "mqtt", "addr": "127.0.0.1:1883" }, { "name": "custom_http", "service": "regex", "addr": "127.0.0.1:8080", "patterns": [ "^(GET|POST|PUT|DELETE|HEAD|\\x79PTIONS) " ] }, { "service": "prefix", "addr": "127.0.0.1:8081", "patterns": [ "GET ", "POST " ] } ] } |
效能測試
準備工作
首先準備一個簡單的Web服務應用。之前用Python+bjoern寫過一個簡易指令碼,用來自己測試網路頻寬,但找了半天沒找著。乾脆用Go語言重新弄了一個,功能是根據傳入引數值N,返回N個字元。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 |
package main import ( "bytes" "flag" "fmt" "log" "net/http" "regexp" "strconv" "strings" ) func defaultHandler(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" { fmt.Fprintln(w, "It works.") return } myHandler(w, r) } func myHandler(w http.ResponseWriter, r *http.Request) { re := regexp.MustCompile(`^/(\d+)([kKmMgGtT]?)$`) match := re.FindStringSubmatch(r.URL.Path) if match == nil { http.NotFound(w, r) return } buffSize := 20480 buff := bytes.Repeat([]byte{'X'}, buffSize) size, _ := strconv.ParseInt(match[1], 10, 64) switch strings.ToLower(match[2]) { case "k": size *= 1 << 10 case "m": size *= 1 << 20 case "g": size *= 1 << 30 case "t": size *= 1 << 40 } w.Header().Set("Content-Length", strconv.FormatInt(size, 10)) for buffSize := int64(buffSize); size >= buffSize; size -= buffSize { w.Write(buff) } if size > 0 { w.Write(bytes.Repeat([]byte{'X'}, int(size))) } } func main() { portPtr := flag.Int("port", 8080, "監聽埠") flag.Parse() http.HandleFunc("/", defaultHandler) err := http.ListenAndServe(fmt.Sprintf(":%d", *portPtr), nil) if err != nil { log.Fatal("ListenAndServe: ", err) } } |
編譯執行,測試Web伺服器執行正常。
1 2 3 4 5 6 7 8 9 10 |
$ go build test.go $ ./test -port 9999 & $ curl localhost:9999/1 X $ curl localhost:9999/10 XXXXXXXXXX $ curl -o /dev/null localhost:9999/10g % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 100 10.0G 100 10.0G 0 0 1437M 0 0:00:07 0:00:07 --:--:-- 1469M |
類似於上面的演示過程,我用curl下載大檔案來測試網路I/O速率。當然本次測試過程並沒有經過物理網路卡,而是直接通過了loopback介面。這樣可以更客觀的比對在經過代理後,速度下降的幅度。
另外,採用類似Apache的ab壓力測試工具,測試高併發情況下的Web響應速度。這裡,我使用了比ab更變態的boom。它是一款Go語言實現的開源壓測軟體,最近剛更名為hey,主頁上稱因其與Python版壓力測試工具Boom!名稱衝突。安裝和使用都很簡單:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
$ go get -u github.com/rakyll/hey $ $GOPATH/bin/hey http://localhost:9999/1 ...... All requests done. Summary: Total: 0.0223 secs Slowest: 0.0182 secs Fastest: 0.0002 secs Average: 0.0039 secs Requests/sec: 8962.9371 Total data: 200 bytes Size/request: 1 bytes ...... |
下載安裝我修改過的Switcher版本:
1 |
$ go get github.com/jackyspy/switcher |
sslh執行的命令如下:
1 |
$ sudo sslh-select -n -p 127.0.0.1:9998 --ssh 127.0.0.1:22 --http 127.0.0.1:9999 |
測試Switcher採用下面的配置檔案default.cfg:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
{ "listen": ":9997", "default": "127.0.0.1:22", "timeout": 1, "connect_timeout": 1, "protocols": [ { "service": "ssh", "addr": "127.0.0.1:22" }, { "service": "http", "addr": "127.0.0.1:9999" } ] } |
測試過程主要用到下面兩條命令,測試sslh和switcher時更改埠號即可。
1 2 |
$ curl -o /dev/null localhost:9999/10g $ $GOPATH/bin/hey -n 100000 http://localhost:9999/1 |
OK,萬事俱備,只待開測。
開始測試
測試分為兩塊,一是測試大檔案下載速率,為了不受限於網路卡速率,在本機測試。另一塊是測試Web請求併發量,在另一臺電腦上發起測試。
為了減少人工操作量,簡單用Python寫了一段程式碼,用於多次測試速度並輸出結果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
# coding=utf-8 from __future__ import print_function import itertools from subprocess import check_output def get_speed(port): cmd = 'curl -o /dev/null -s -w %{{speed_download}} localhost:{}/10g'.format(port) # noqa speed = check_output(cmd.split()) return float(speed) def test_multi_times(port, times): return map(get_speed, itertools.repeat(port, times)) def format_speed(speed): return str(int(0.5 + speed / 1024 / 1024)) def main(): testcases = { 'Direct': 9999, 'sslh': 9998, 'switcher': 9997 } count = 10 print('| Target | {} | Avg | '.format( ' | '.join(str(x) for x in range(1, count + 1)))) print(' --: '.join('|' * (count + 3))) for name, port in testcases.items(): speed_list = test_multi_times(port, count) speed_list.append(sum(speed_list) / len(speed_list)) print('|{}|{}|'.format(name, '|'.join(map(format_speed, speed_list)))) if __name__ == '__main__': main() |
執行後得到結果如下(速度單位是MB/s):
Target | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Avg |
---|---|---|---|---|---|---|---|---|---|---|---|
switcher | 870 | 876 | 924 | 915 | 885 | 928 | 904 | 880 | 909 | 898 | 899 |
sslh | 866 | 865 | 860 | 880 | 865 | 861 | 866 | 863 | 864 | 856 | 865 |
Direct | 1446 | 1505 | 1392 | 1362 | 1423 | 1419 | 1395 | 1492 | 1412 | 1427 | 1427 |
可以看出經過代理後,下行速率有明顯下降。其中sslh比switcher略低,差異不是太大。
同樣的,為了方便測試併發請求響應,也寫了一個指令碼來完成:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
# coding=utf-8 from __future__ import print_function import itertools from subprocess import check_output def get_speed(url): cmd = "hey -n 100000 -c 50 {} | grep 'Requests/sec'".format(url) # noqa output = check_output(cmd, shell=True) return float(output.partition(':')[2]) def test_multi_times(url, times): return map(get_speed, itertools.repeat(url, times)) def main(): testcases = { 'Direct': 'http://x.x.x.x:9999/1', 'sslh': 'http://x.x.x.x:9998/1', 'switcher': 'http://x.x.x.x:9997/1' } count = 10 print('| Target | {} | Average | '.format( ' | '.join(str(x) for x in range(1, count + 1)))) print(' --: '.join('|' * (count + 3))) for name, port in testcases.items(): speed_list = test_multi_times(port, count) speed_list.append(sum(speed_list) / len(speed_list)) print('|{}|{}|'.format(name, '|'.join('{:.0f}'.format(x + 0.5) for x in speed_list))) if __name__ == '__main__': main() |
執行後得到結果如下(速度單位是Requests/s):
Target | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Average |
---|---|---|---|---|---|---|---|---|---|---|---|
switcher | 14367 | 14886 | 15144 | 14289 | 15456 | 14834 | 14871 | 14951 | 14610 | 14865 | 14827 |
sslh | 13892 | 14281 | 14469 | 14352 | 14468 | 14132 | 14510 | 14565 | 14633 | 14555 | 14386 |
Direct | 20494 | 20110 | 20558 | 19519 | 19467 | 19891 | 19777 | 19682 | 20737 | 20396 | 20063 |
類似前面的測試,RPS在經過代理後也存在較明顯下降。sslh比switcher略低,差異不大。
更多應用場景 ??
本文描述的網路埠複用,其實現方式本質上還是一個TCP應用代理。基於這一點,我們還可以擴充套件出很多其他的應用場景。
我想到的一種場景是動態IP認證。我們對HTTP和SSH進行復用,預設情況下HTTP可以被所有人訪問,但SSH卻需要通過IP地址認證後才會進行包轉發。跟iptables等防火牆實現的IP地址訪問規則不同,它是在應用層面來進行限制的,具有很強的靈活性,可以通過程式動態增加和刪除。比如說,我通過手機瀏覽器訪問特定的鑑權頁面,通過驗證後,系統自動將我當前在用的公網IP地址加入到訪問列表,然後就能夠順利地通過SSH訪問伺服器了。連線建立後,可以將臨時IP地址從訪問列表中剔除,從一定程度上加強了伺服器安全。