如何在Web服務器80端口上開啟SSH服務


本文所討論的網絡端口復用並非指網絡編程中采用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和端口值。

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地址從訪問列表中剔除,從一定程度上加強了服務器安全。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM