如何在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地址从访问列表中剔除,从一定程度上加强了服务器安全。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,198评论 4 359
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,663评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 106,985评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,673评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 51,994评论 3 285
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,399评论 1 211
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,717评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,407评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,112评论 1 239
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,371评论 2 241
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,891评论 1 256
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,255评论 2 250
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,881评论 3 233
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,010评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,764评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,412评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,299评论 2 260

推荐阅读更多精彩内容

  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,087评论 18 139
  • 端口:0 服务:Reserved 说明:通常用于分析操作系统。这一方法能够工作是因为在一些系统中“0”是无效端口,...
    萧玄辞阅读 1,113评论 0 2
  • 名词延伸 通俗的说,域名就相当于一个家庭的门牌号码,别人通过这个号码可以很容易的找到你。如果把IP地址比作一间房子...
    杨大虾阅读 20,383评论 2 57
  • 下午没有课,于是便睡多了。结果就是现在这个时间,睡不着。翻来覆去,耳朵里塞着耳机,听着电台里下载的初级财会的讲...
    正在上天阅读 237评论 0 2
  • 我的班主任姓黄,她个子长得不高,一头俏丽的短发, 一双大大的眼睛,小巧的鼻子上有一副黑颜色的眼镜。她的衣...
    皓皓_f9d7阅读 231评论 0 0