本文所討論的網路埠復用並非指網路編程中採用SO_REUSEADDR選項的 Socket Bind 復用。它更像是一個帶特定路由功能的埠轉發工具,在應用層實現。可以在80埠上復用一個SSH服務。 ...
本文所討論的網路埠復用並非指網路編程中採用SO_REUSEADDR選項的 Socket Bind 復用。它更像是一個帶特定路由功能的埠轉發工具,在應用層實現。
背景
筆者所處網路中防火牆只開放了一個埠,但卻希望能夠提供多種網路服務用於測試。所以需要尋求一種解決方案,能夠對TCP數據包特征進行識別,用以實現在一個開放埠上同時提供HTTP/SSH/MQTT等多種服務。
比如說,你可以在80埠上復用一個SSH服務,普通用戶只知道瀏覽器訪問http://x.x.x.x/ ,而你卻可以用 ssh [email protected] -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和埠值。
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達到一定長度要求的情況下逐一匹配。
func (p *REGEX) Probe(header []byte) (result ProbeResult, address string) {
if p.MinLength > 0 && len(header) < p.MinLength {
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等常用的協議。在實現中,我加入了一些常用協議的支持,省去用戶自定義的麻煩。
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協議識別實現如下:
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格式,主要是為了方便和靈活。下麵是一個示例:
{
"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個字元。
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伺服器運行正常。
$ 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!名稱衝突。安裝和使用都很簡單:
$ 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版本:
$ go get github.com/jackyspy/switcher
sslh運行的命令如下:
$ 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:
{
"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時更改埠號即可。
$ curl -o /dev/null localhost:9999/10g
$ $GOPATH/bin/hey -n 100000 http://localhost:9999/1
OK,萬事俱備,只待開測。
開始測試
測試分為兩塊,一是測試大文件下載速率,為了不受限於網卡速率,在本機測試。另一塊是測試Web請求併發量,在另一臺電腦上發起測試。
為了減少人工操作量,簡單用Python寫了一段代碼,用於多次測試速度並輸出結果:
# 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略低,差異不是太大。
同樣的,為了方便測試併發請求響應,也寫了一個腳本來完成:
# 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地址從訪問列表中剔除,從一定程度上加強了伺服器安全。