Golang中TLS服务器缓冲区数据卡住问题解析

Golang中TLS服务器缓冲区数据卡住问题解析 大家好,

我在为SMTP STARTTLS功能实现测试时遇到了一个问题。具体来说,当TLS握手失败时,我想测试在优雅关闭连接之前是否发送了QUIT命令。

问题:

  • QUIT命令似乎卡在了TLS连接处理程序的缓冲区中
  • 此行为是间歇性的,似乎取决于命令发送的速度
  • 在发送QUIT命令前添加一个小的延迟可以解决此问题

为了隔离问题,我使用简单的纯文本通信(不包含SMTP特定代码)创建了一个最小化的复现案例。

package main

import (
	"bufio"
	"bytes"
	"crypto/rand"
	"crypto/rsa"
	"crypto/tls"
	"crypto/x509"
	"crypto/x509/pkix"
	"encoding/pem"
	"fmt"
	"math/big"
	"net"
	"strings"
	"sync"
	"testing"
	"time"
)

func TestTLSHandshake(t *testing.T) {
	ln := newLocalListener(t)
	defer ln.Close()

	wg := sync.WaitGroup{}
	wg.Add(1)

	go func() {
		// Client
		clientConn, _ := net.Dial("tcp", ln.Addr().String())
		defer clientConn.Close()

		_, _ = clientConn.Write([]byte("STARTTLS\r\n"))

		// Wait for readiness
		buf := make([]byte, 1024)
		_, _ = clientConn.Read(buf)
		fmt.Printf("[Client] Received reply: %s", string(buf))

		// Try TLS handshake
		tlsConn := tls.Client(clientConn, &tls.Config{
			ServerName: "localhost",
		})

		// Handshake should fail because client doesn't trust self-signed cert
		err := tlsConn.Handshake()
		if err != nil {
			fmt.Println("[Client] Handshake err: ", err)
		}

		// Delay to prevent QUIT from being consumed into TLS buffer
		time.Sleep(100 * time.Millisecond)

		_, _ = clientConn.Write([]byte("QUIT\r\n"))

		wg.Done()
	}()

	serverConn, _ := ln.Accept()
	defer serverConn.Close()

	quitReceived := false

	scanner := bufio.NewScanner(serverConn)
	for scanner.Scan() {
		input := scanner.Text()

		fmt.Printf("[Server] Received: '%s'\n", input)

		switch input {
		case "STARTTLS":
			// Generate test cert
			cert, privateKey, _ := generateTestCertificatePair()

			// Setup TLS
			keypair, _ := tls.X509KeyPair(cert, privateKey)
			config := &tls.Config{Certificates: []tls.Certificate{keypair}}

			// Reply readiness
			_, _ = serverConn.Write([]byte("Ready for handshake\r\n"))

			tlsConn := tls.Server(serverConn, config)

			handshakeErr := tlsConn.Handshake()
			if handshakeErr != nil {
				fmt.Println("[Server] Handshake error: ", handshakeErr)
				continue
			} else {
				fmt.Println("[Server] Handshake completed")
				t.Fatal("Handshake unexpectedly succeeded")
			}
		case "QUIT":
			quitReceived = true

			fmt.Println("[Server] Quit received")
		}
	}

	wg.Wait()

	if !quitReceived {
		t.Fatal("[Server] Quit not received")
	}
}

// Generate X509 encoded certificate for testing.
func generateTestCertificatePair() ([]byte, []byte, error) {
	organization := "example"
	host := "localhost"

	validFrom := time.Now()
	validTo := validFrom.Add(24 * time.Hour)

	// Generate private key
	privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
	if err != nil {
		return nil, nil, err
	}

	// Create certificate serial number
	serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)

	serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
	if err != nil {
		return nil, nil, err
	}

	template := x509.Certificate{
		SerialNumber: serialNumber,
		Subject: pkix.Name{
			Organization: []string{organization},
			CommonName:   host,
		},
		NotBefore: validFrom,
		NotAfter:  validTo,

		KeyUsage:              x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
		ExtKeyUsage:           []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
		BasicConstraintsValid: true,
	}

	hosts := strings.Split(host, ",")
	template.DNSNames = append(template.DNSNames, hosts...)

	// Create certificate
	derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
	if err != nil {
		return nil, nil, err
	}

	// Encode certificate to PEM
	certBuffer := &bytes.Buffer{}
	if err := pem.Encode(certBuffer, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil {
		return nil, nil, err
	}

	// Encode private key to PEM
	privBytes, err := x509.MarshalPKCS8PrivateKey(privateKey)
	if err != nil {
		return nil, nil, err
	}

	keyBuffer := &bytes.Buffer{}
	if err := pem.Encode(keyBuffer, &pem.Block{Type: "PRIVATE KEY", Bytes: privBytes}); err != nil {
		return nil, nil, err
	}

	return certBuffer.Bytes(), keyBuffer.Bytes(), nil
}

func newLocalListener(t *testing.T) net.Listener {
	ln, _ := net.Listen("tcp", "localhost:0")
	return ln
}

上述代码也可在此处找到。请注意,如果移除延迟,服务器将不会收到QUIT命令。


我理解TCP是一个连续的流,应用程序应该自行处理消息边界。然而,我使用的是tls.Server(),并且无法控制它将如何处理TCP连接。

有什么方法可以确保服务器能够由主处理程序(而不是TLS处理程序)接收到QUIT命令吗?

感谢您的帮助!


更多关于Golang中TLS服务器缓冲区数据卡住问题解析的实战教程也可以访问 https://www.itying.com/category-94-b0.html

4 回复

哈哈。 事实上,大多数通信协议的设计都简单粗暴。按照设计,如果进入了奇偶校验和加密握手阶段,通常的做法是如果加密失败就丢弃连接。 我们举一个简单的例子:socks协议。最初,该协议也是明文传输并选择了一种奇偶校验和加密方法,但如果握手失败,客户端和服务器都会主动关闭连接并丢弃它。 原因很简单:重新建立连接的逻辑比密码学回退以重新对齐要简单得多。

更多关于Golang中TLS服务器缓冲区数据卡住问题解析的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


感谢您花时间查看。

我最初的意图是继续使用同一个TCP连接以明文方式发送邮件,尤其是在TLS握手失败且用户未要求加密的情况下。正如您的代码所示,这需要服务器和客户端之间进行协调,以避免应用数据和TLS握手数据混在一起。

然而,查看Go TLS连接提供的方法后,我认为我们无法精细地控制发送Closure Alerts。查看其他Go语言编写的SMTP客户端,我也发现它们在遇到TLS错误时并不会尝试以明文发送,而是直接返回错误。

因此,最实际的解决方案似乎是关闭当前连接,创建一个新的TCP连接,然后通过这个新连接以明文方式发送。

无论如何,再次感谢您的关注。

// Delay to prevent QUIT from being consumed into TLS buffer time.Sleep(100 * time.Millisecond)

你对此感到疑惑吗?

这没什么可困惑的,因为在 TLS 握手失败后,客户端需要向服务器发送一条消息,而你快速发送消息是为了抢占握手完成的过程。

试试这个:

func TestTLSHandshake(t *testing.T) {
	ln := newLocalListener(t)
	defer ln.Close()

	wg := sync.WaitGroup{}
	wg.Add(1)

	go func() {
		// Client
		clientConn, _ := net.Dial("tcp", ln.Addr().String())
		defer clientConn.Close()

		_, _ = clientConn.Write([]byte("STARTTLS\r\n"))

		// Wait for readiness
		buf := make([]byte, 1024)
		_, _ = clientConn.Read(buf)
		fmt.Printf("[Client] Received reply: %s", string(buf))

		// Try TLS handshake
		tlsConn := tls.Client(clientConn, &tls.Config{
			ServerName: "localhost",
		})

		// Handshake should fail because client doesn't trust self-signed cert
		err := tlsConn.Handshake()
		if err != nil {
			fmt.Println("[Client] Handshake err: ", err)
		}

		// Delay to prevent QUIT from being consumed into TLS buffer
		//time.Sleep(100 * time.Millisecond)

		// wait server call handshake over
		_, _ = clientConn.Read(make([]byte, 4))

		_, _ = clientConn.Write([]byte("QUIT\r\n"))

		wg.Done()
	}()

	serverConn, _ := ln.Accept()
	defer serverConn.Close()

	quitReceived := false

	scanner := bufio.NewScanner(serverConn)
	for scanner.Scan() {
		input := scanner.Text()

		fmt.Printf("[Server] Received: '%s'\n", input)

		switch input {
		case "STARTTLS":
			// Generate test cert
			cert, privateKey, _ := generateTestCertificatePair()

			// Setup TLS
			keypair, _ := tls.X509KeyPair(cert, privateKey)
			config := &tls.Config{Certificates: []tls.Certificate{keypair}}

			// Reply readiness
			_, _ = serverConn.Write([]byte("Ready for handshake\r\n"))

			tlsConn := tls.Server(serverConn, config)

			handshakeErr := tlsConn.Handshake()
			if handshakeErr != nil {
				fmt.Println("[Server] Handshake error: ", handshakeErr)
			} else {
				fmt.Println("[Server] Handshake completed")
				t.Fatal("Handshake unexpectedly succeeded")
			}
			// tell client handshake over
			_, _ = serverConn.Write([]byte("QUIT"))
		case "QUIT":
			quitReceived = true

			fmt.Println("[Server] Quit received")
		}
	}

	wg.Wait()

	if !quitReceived {
		t.Fatal("[Server] Quit not received")
	}
}

请参考 TLS 握手过程以获取更多信息,这样你就不会有这些疑问了。

这个问题是由于TLS层和TCP层之间的缓冲区交互导致的。当TLS握手失败后,TLS连接并没有被正确清理,导致后续数据被卡在TLS缓冲区中。以下是解决方案:

// 修改服务器端的TLS握手错误处理部分
case "STARTTLS":
    // Generate test cert
    cert, privateKey, _ := generateTestCertificatePair()

    // Setup TLS
    keypair, _ := tls.X509KeyPair(cert, privateKey)
    config := &tls.Config{
        Certificates: []tls.Certificate{keypair},
        // 添加这个配置以便更好地控制连接
        MinVersion: tls.VersionTLS12,
    }

    // Reply readiness
    _, _ = serverConn.Write([]byte("Ready for handshake\r\n"))

    tlsConn := tls.Server(serverConn, config)

    handshakeErr := tlsConn.Handshake()
    if handshakeErr != nil {
        fmt.Println("[Server] Handshake error: ", handshakeErr)
        
        // 关键修复:在握手失败后,需要显式关闭TLS连接并恢复原始连接
        // 但注意:tls.Conn在握手失败后可能处于不一致状态
        
        // 方案1:直接使用原始连接继续读取
        // 由于握手失败,tlsConn不会接管连接,可以继续使用serverConn
        continue
    } else {
        fmt.Println("[Server] Handshake completed")
        t.Fatal("Handshake unexpectedly succeeded")
    }

更完整的解决方案是修改客户端代码,在握手失败后重新建立连接或确保数据发送时机:

// 客户端修改方案
err := tlsConn.Handshake()
if err != nil {
    fmt.Println("[Client] Handshake err: ", err)
    
    // 关键:在握手失败后,不要立即使用原始连接发送数据
    // 等待服务器端清理完成
    time.Sleep(50 * time.Millisecond)
    
    // 确保连接仍然有效
    if clientConn != nil {
        // 刷新缓冲区
        clientConn.SetDeadline(time.Now().Add(100 * time.Millisecond))
        
        // 发送QUIT命令
        _, _ = clientConn.Write([]byte("QUIT\r\n"))
        // 确保数据被发送
        _ = clientConn.(*net.TCPConn).SetLinger(0)
    }
}

另一个更可靠的方法是使用连接包装器来跟踪连接状态:

type trackedConn struct {
    net.Conn
    tlsActive bool
}

func (c *trackedConn) Write(b []byte) (int, error) {
    if c.tlsActive {
        // 如果TLS激活,可能需要特殊处理
    }
    return c.Conn.Write(b)
}

// 在服务器端使用
serverConn := &trackedConn{Conn: conn}

根本原因是当TLS握手失败时,tls.Server()创建的连接可能没有正确清理内部状态。在Go的TLS实现中,握手失败后连接可能处于半初始化状态,导致后续数据被缓冲在TLS层而不是传递给应用层。

可以通过设置更短的超时来避免这个问题:

// 在服务器端设置读取超时
serverConn.SetReadDeadline(time.Now().Add(5 * time.Second))
defer serverConn.SetReadDeadline(time.Time{}) // 清除超时

// 或者在scanner循环中
for scanner.Scan() {
    // 每次读取前重置超时
    serverConn.SetReadDeadline(time.Now().Add(2 * time.Second))
    // ... 处理逻辑
}

对于SMTP STARTTLS场景,正确的处理模式应该是:

// SMTP服务器处理STARTTLS的标准模式
func handleSTARTTLS(conn net.Conn) {
    // 发送220 Ready to start TLS
    conn.Write([]byte("220 Ready to start TLS\r\n"))
    
    // 执行TLS握手
    tlsConn := tls.Server(conn, tlsConfig)
    
    if err := tlsConn.Handshake(); err != nil {
        // 握手失败,记录日志但保持原始连接开放
        log.Printf("TLS handshake failed: %v", err)
        // 可以继续使用原始连接或关闭
        return
    }
    
    // 握手成功,切换到TLS连接
    conn = tlsConn
}

在测试中,确保在握手失败后显式关闭TLS连接并恢复使用原始TCP连接:

// 测试中的修复
handshakeErr := tlsConn.Handshake()
if handshakeErr != nil {
    fmt.Println("[Server] Handshake error: ", handshakeErr)
    
    // 尝试关闭TLS连接以释放资源
    tlsConn.Close()
    
    // 继续使用原始连接
    // 注意:此时可能需要重新创建scanner,因为底层连接可能被修改
    scanner = bufio.NewScanner(serverConn)
    continue
}

这些修改可以确保在TLS握手失败后,QUIT命令能够被服务器正确接收和处理。

回到顶部