如何让HTTP和HTTPS监听同一个端口?这个问题听起来很奇怪,这是因为之前在写HTTPS小故事时提到 HTTPS 并不是新的协议,而是基于HTTP协议之上封装了一层加密而已。既然都是基于HTTP协议的,那么在同一个端口同时实现HTTP和HTTPS应该也是可以的,说搞就搞。
第一步,看源码
先看了一下 Go 标准库的HTTP和HTTPS的实现,果然,HTTPS 是基于HTTP的,可以在 源码 中看到,http.ServerTLS
是使用 http.Serve
实现的,只是由 net.Listener
转为了 tls.listener
,重写了 Accept
方法,然后在 tls.Server
设置了使用 tls 握手。
也就是说,我们需要在 tls 握手前判断请求是HTTP还是HTTPS的,如果是HTTPS的就升级为 tls.listener
进行 tls 握手,如果不是继续使用 net.Listener
就可以了。
那么有办法判断呢?
根据 TCP 报文判断 HTTPS
在HTTPS小故事中有讲解 HTTPS认证过程,在TCP三次握手建立会话后客户端会发送Client Hello,Client Hello里有TLS相关的信息,那么我们就可以通过Client Hello是否有TLS信息判断是不是HTTPS请求。
根据HTTPS小故事文章中的参考资料,可以知道Client Hello中前5个byte是Record Header,里面记录了是否是TLS请求和TLS版本:
第一个byte是16,这里是十六进制0x16
,转换十进制为22,22 表示这个报文是 TLS 握手类型,可以查看 TLS HandshakeType 了解相关内容。
第二个byte固定为03
,00
/01
/02
是指TLS之前的SSL协议,已废弃,这里的03
为TLS协议,所以判断是否为03
就行了。
第三个byte为TLS小版本,目前 TLS 有四个小版本1.0
/1.1
/1.2
/1.3
,对应为00~03
。
前三个byte就够判断是否为TLS,后面的内容我们就不需要关心了,对应的信息也可以看下图更清晰:
Coding
上面分析完了,下面就编码实现吧。
启动TCP监听
要读取TCP报文首先需要启动一个TCP服务,在Go里可以通过net.Listen
来启动一个TCP监听某个端口:
ln, err := net.Listen("tcp", ":6789")
如果是正常的HTTP(s)服务只需要在这个TCP监听上Serve一下就可以了,但我们需要区分HTTP和HTTPS,所以我们需要改造一下这个net.Listener
。
读取TCP报文
net.Listener
提供了Accept
方法,可以通过该方法获取net.Conn
,然后从 net.Conn.Read
方法来获取 TCP 报文。我们可以自己实现一个Listener包裹一下net.Listener
,然后重写Accept
方法来进行覆盖,实现大概如下:
type Listener struct {
net.Listener
}
func (ln *Listener) Accept() (net.Conn, error) {
conn, err := ln.Listener.Accept()
if err != nil {
return nil, err
}
// 从报文取出前3个 byte 来判断是否为 HTTPS
b := make([]byte, 3)
_, err = conn.Read(b)
if err != nil {
conn.Close()
if err != io.EOF {
return nil, err
}
}
if b[0] == 0x16 && b[1] == 0x03 && b[2] <= 0x03 {
log.Println("HTTPS")
return tls.Server(peekConn, &tls.Config{
ClientAuth: tls.NoClientCert,
GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
cert, err := tls.LoadX509KeyPair("server.cert.pem", "server.key.pem")
if err != nil {
return nil, err
}
return &cert, nil
},
}), nil
}
log.Println("HTTP")
return conn, nil
}
上面就可以从TCP里读取报文来判断是否为HTTP和HTTPS了,当然 tls.Config
里还需要配置一下网站的证书,否则HTTPS访问会失败。
实际运行一下会发现报错,这时候为什么呢?
Peek Conn
我们在上面代码里读取报文使用了 conn.Read(b)
,这样从报文里取出来了三个byte,破坏了报文的结构,后面HTTP(S)实际处理报文的时候就会失败,所以我们需要在读取报文的时候还要保留报文的完整性,这里实现一个 PeekConn
,使用 Peek
方法来读取报文:
// here's a buffered conn for peeking into the connection
type PeekConn struct {
net.Conn
r *bufio.Reader
}
func (c *PeekConn) Read(b []byte) (int, error) {
return c.r.Read(b)
}
func (c *PeekConn) Peek(n int) ([]byte, error) {
return c.r.Peek(n)
}
func newPeekConn(c net.Conn) *PeekConn {
return &PeekConn{c, bufio.NewReader(c)}
}
修改 Accept
方法如下:
func (ln *Listener) Accept() (net.Conn, error) {
conn, err := ln.Listener.Accept()
if err != nil {
return nil, err
}
peekConn := newPeekConn(conn)
b, err := peekConn.Peek(3)
if err != nil {
peekConn.Close()
if err != io.EOF {
return nil, err
}
}
if b[0] == 0x16 && b[1] == 0x03 && b[2] <= 0x03 {
log.Println("HTTPS")
return tls.Server(peekConn, &tls.Config{
ClientAuth: tls.NoClientCert,
GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
cert, err := tls.LoadX509KeyPair("server.cert.pem", "server.key.pem")
if err != nil {
return nil, err
}
return &cert, nil
},
}), nil
}
log.Println("HTTP")
return peekConn, nil
}
测试
我们使用 go run .
来启动服务,然后通过 cURL 来测试一下:
$ curl --resolve "*:6789:127.0.0.1" http://foreverz.cn:6789/123
hello from http!
$ curl --resolve "*:6789:127.0.0.1" https://foreverz.cn:6789/123
hello from https!
可以发现HTTP和HTTPS都能正常工作。
完整代码
所有代码都在GitHub上开源:
完结,撒花 🎉🎉🎉