# 如何让HTTP和HTTPs监听同一个端口？

如何让HTTP和HTTPS监听同一个端口？这个问题听起来很奇怪，这是因为之前在写[HTTPS小故事](https://foreverz.cn/https)时提到 HTTPS 并不是新的协议，而是基于HTTP协议之上封装了一层加密而已。既然都是基于HTTP协议的，那么在同一个端口同时实现HTTP和HTTPS应该也是可以的，说搞就搞。

# 第一步，看源码

先看了一下 Go 标准库的HTTP和HTTPS的实现，果然，HTTPS 是基于HTTP的，可以在 [源码](https://github.com/golang/go/blob/master/src/net/http/server.go#L3089) 中看到，`http.ServerTLS` 是使用 `http.Serve` 实现的，只是由 `net.Listener` 转为了 `tls.listener`，重写了 [`Accept`](https://github.com/golang/go/blob/master/src/crypto/tls/tls.go#L65) 方法，然后在 [`tls.Server`](https://github.com/golang/go/blob/master/src/crypto/tls/tls.go#L34) 设置了使用 tls 握手。

也就是说，我们需要在 tls 握手前判断请求是HTTP还是HTTPS的，如果是HTTPS的就升级为 `tls.listener` 进行 tls 握手，如果不是继续使用 `net.Listener` 就可以了。

![image.png](https://cdn.hashnode.com/res/hashnode/image/upload/v1651773414949/BIa_bquVJ.png align="center")

那么有办法判断呢？

# 根据 TCP 报文判断 HTTPS

在[HTTPS小故事](https://foreverz.cn/https)中有讲解 HTTPS认证过程，在TCP三次握手建立会话后客户端会发送Client Hello，Client Hello里有TLS相关的信息，那么我们就可以通过Client Hello是否有TLS信息判断是不是HTTPS请求。

根据[HTTPS小故事](https://foreverz.cn/https)文章中的参考资料，可以知道Client Hello中前5个byte是Record Header，里面记录了是否是TLS请求和TLS版本：

![image.png](https://cdn.hashnode.com/res/hashnode/image/upload/v1651817410741/k6tsvmfwd.png align="center")

第一个byte是16，这里是十六进制`0x16`，转换十进制为22，22 表示这个报文是 TLS 握手类型，可以查看 [TLS HandshakeType](https://www.iana.org/assignments/tls-parameters/tls-parameters.xhtml#tls-parameters-7) 了解相关内容。

第二个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，后面的内容我们就不需要关心了，对应的信息也可以看下图更清晰：

![image.png](https://cdn.hashnode.com/res/hashnode/image/upload/v1651818859646/iP3Jt76IL.png align="center")


# Coding

上面分析完了，下面就编码实现吧。

## 启动TCP监听

要读取TCP报文首先需要启动一个TCP服务，在Go里可以通过`net.Listen`来启动一个TCP监听某个端口：

```go
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`方法来进行覆盖，实现大概如下：

```go
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` 方法来读取报文：

```go
// 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` 方法如下：

```go
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 来测试一下：

```shell
$ 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上开源：

%[https://github.com/foreverzmy/http-s-listen-same-port]

完结，撒花 🎉🎉🎉


# 参考

1. [Serving http and https on the same port?](https://groups.google.com/g/golang-nuts/c/4oZp1csAm2o/m/nTTKDvvFJQ0J)
2. [The Illustrated TLS 1.3 Connection](https://tls13.ulfheim.net/)
3. [Transport Layer Security (TLS) Parameters](https://www.iana.org/assignments/tls-parameters/tls-parameters.xhtml)
4. [Nodejs HTTP and HTTPS over same port](https://stackoverflow.com/a/42019773/9114324)


