首页 > 解决方案 > 可以有条件地转发或终止传入 TLS 连接的 Go 服务器

问题描述

我的总体目标如下:我想编写一个接受传入 TLS 连接并通过TLS SNI 扩展检查客户端指示的服务器名称的 Go 服务器。根据服务器名称,我的服务器将:

  1. 将 TCP 连接转发(反向代理)到不同的服务器,而不终止 TLS,或者
  2. 终止 TLS 并自行处理请求

这篇优秀的博客文章描述了一个反向代理,它检查 SNI 扩展并将连接转发到其他地方或终止它。基本技巧是从 TCP 连接中窥视足够的字节来解析 TLS ClientHello,如果应该转发服务器名称,反向代理会打开到最终目的地的 TCP 连接,将窥视的字节写入连接,然后设置goroutines 复制其余字节,直到在来自客户端的 TCP 连接和到最终目的地的连接之间关闭。遵循该帖子中的模型,我只需进行少量更改即可实现行为 1。

问题在于一种情况,即行为 2,我的服务器应该终止 TLS 并自行处理应用层 HTTP 请求。我正在使用 Go 标准库的 HTTP 服务器,但它的 API 没有我需要的东西。具体来说,在我查看了 ClientHello 并确定连接应该由我的服务器处理之后,无法net.Connhttp.Server. 我需要一个类似的 API:

// Does not actually exist
func (srv *http.Server) HandleConnection(c net.Conn) error

但我能得到的最接近的是

func (srv *http.Server) Serve(l net.Listener) error

或等效的 TLS,

func (srv *http.Server) ServeTLS(l net.Listener, certFile, keyFile string) error

两者都接受net.Listener,并在内部执行自己的for-accept 循环

现在,我能想到的唯一前进方法是创建我自己的net.Listener由 Go 通道支持的“合成”,我将其传递给func (srv *http.Server) ServeTLS. 然后,当我从真正的 TCP 接收net.Listener到服务器应自行处理的连接时,我将连接发送到合成侦听器,这会导致该侦听Accept器将新连接返回到 waiting http.Server。不过,这个解决方案感觉不太好,我正在寻找能够更干净地实现我的总体目标的东西。


这是我正在尝试做的简化版本。TODO标记我不知道如何进行的部分。

func main() {
    l, _ := net.Listen("tcp", ":443")
    // Server to handle request that should be handled directly
    server := http.Server{
        // Config omitted for brevity
    }
    for {
        conn, err := l.Accept()
        if err != nil {
            continue
        }
        go handleConnection(conn, &server)
    }
}

func handleConnection(clientConn net.Conn, server *http.Server) {
    defer clientConn.Close()

    clientHello, clientReader, _ := peekClientHello(clientConn)

    if shouldHandleServerName(clientHello.ServerName) {
        // Terminate TLS and handle it ourselves
        // TODO: How to use `server` to handle `clientConn`?
        return
    }

    // Else, forward to another server without terminating TLS
    backendConn, _ := net.DialTimeout("tcp", net.JoinHostPort(clientHello.ServerName, "443"), 5*time.Second)
    defer backendConn.Close()

    var wg sync.WaitGroup
    wg.Add(2)

    go func() {
        io.Copy(clientConn, backendConn)
        clientConn.(*net.TCPConn).CloseWrite()
        wg.Done()
    }()
    go func() {
        io.Copy(backendConn, clientReader)
        backendConn.(*net.TCPConn).CloseWrite()
        wg.Done()
    }()

    wg.Wait()
}

// Returns true if we should handle this connection, and false if we should forward
func shouldHandleServerName(serverName string) bool {
    // Implementation omitted for brevity
}

// Reads bytes from reader until it can parse a TLS ClientHello. Returns the
// parsed ClientHello and a new io.Reader that contains all the bytes from the
// original reader, including those that made up the ClientHello, so that the
// connection can be transparently forwarded.
func peekClientHello(reader io.Reader) (*tls.ClientHelloInfo, io.Reader, error) {
    // Implementation omitted for brevity, mostly identical to
    // https://www.agwa.name/blog/post/writing_an_sni_proxy_in_go
}

标签: httpgossl

解决方案


最干净的解决方案可能是您通过实施自定义net.Listener.

我会修改peekClientHello函数以返回一个net.Conn,它实际上只是一个现有函数的包装器,net.Conn就像io.TeeReader现有函数已经使用的一样。现在我们有了一个可以复制到后端或由Accept函数返回的新对象。您现在可以对net.ListenerCustomListener和进行分层tls.Listener

你最终会得到这样的结果:

func main() {
    // Server to handle request that should be handled directly
    server := http.Server{
        // Config omitted for brevity
    }

    tcpListener, _ := net.Listen("tcp", ":443")
    l := tls.NewListener(
        &CustomListener{
            InnerListener: tcpListener,
        },
        nil, // some custom tls config
    )

    server.Serve(l)
}

type CustomListener struct {
    InnerListener net.Listener
    // TODO add settings to be used by shouldHandleServerName
}

// Accept waits for and returns the next connection to the listener.
func (cl *CustomListener) Accept() (net.Conn, error) {
    for {
        clientConn, err := cl.InnerListener.Accept()
        if err != nil {
            return nil, err
        }

        clientHello, teeConn, _ := peekClientHello(clientConn)

        // Terminate TLS and handle it ourselves
        if !cl.shouldHandleServerName(clientHello.ServerName) {
            return teeConn, err
        }

        go forwardConnection(clientHello.ServerName, teeConn)
    }
}

func forwardConnection(serverName string, clientConn net.Conn) {
    defer clientConn.Close()
    // Else, forward to another server without terminating TLS
    backendConn, _ := net.DialTimeout("tcp", net.JoinHostPort(serverName, "443"), 5*time.Second)
    defer backendConn.Close()

    var wg sync.WaitGroup
    wg.Add(2)

    go func() {
        io.Copy(clientConn, backendConn)
        clientConn.(*net.TCPConn).CloseWrite()
        wg.Done()
    }()
    go func() {
        io.Copy(backendConn, clientConn)
        backendConn.(*net.TCPConn).CloseWrite()
        wg.Done()
    }()

    wg.Wait()
}

// Close closes the listener.
// Any blocked Accept operations will be unblocked and return errors.
func (cl *CustomListener) Close() error {
    return cl.InnerListener.Close()
}

// Addr returns the listener's network address.
func (cl *CustomListener) Addr() net.Addr {
    return cl.InnerListener.Addr()
}

// Returns true if we should handle this connection, and false if we should forward
func (cl *CustomListener) shouldHandleServerName(serverName string) bool {
    // Implementation omitted for brevity
}

// Reads bytes from reader until it can parse a TLS ClientHello. Returns the
// parsed ClientHello and a new net.Conn that contains all the bytes from the
// original reader, including those that made up the ClientHello, so that the
// connection can be transparently forwarded.
func peekClientHello(reader io.Reader) (*tls.ClientHelloInfo, net.Conn, error) {
    // Implementation omitted for brevity, mostly identical to
    // https://www.agwa.name/blog/post/writing_an_sni_proxy_in_go
}

推荐阅读