Refactor: HTTP clients, unified HTTP2/QUIC options, Apple engines
This commit is contained in:
@@ -0,0 +1,453 @@
|
||||
//go:build darwin && cgo
|
||||
|
||||
package tls
|
||||
|
||||
import (
|
||||
"context"
|
||||
stdtls "crypto/tls"
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/option"
|
||||
"github.com/sagernet/sing/common/json/badoption"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
)
|
||||
|
||||
const appleTLSTestTimeout = 5 * time.Second
|
||||
|
||||
const (
|
||||
appleTLSSuccessHandshakeLoops = 20
|
||||
appleTLSFailureRecoveryLoops = 10
|
||||
)
|
||||
|
||||
type appleTLSServerResult struct {
|
||||
state stdtls.ConnectionState
|
||||
err error
|
||||
}
|
||||
|
||||
func TestAppleClientHandshakeAppliesALPNAndVersion(t *testing.T) {
|
||||
serverCertificate, serverCertificatePEM := newAppleTestCertificate(t, "localhost")
|
||||
for index := 0; index < appleTLSSuccessHandshakeLoops; index++ {
|
||||
serverResult, serverAddress := startAppleTLSTestServer(t, &stdtls.Config{
|
||||
Certificates: []stdtls.Certificate{serverCertificate},
|
||||
MinVersion: stdtls.VersionTLS12,
|
||||
MaxVersion: stdtls.VersionTLS12,
|
||||
NextProtos: []string{"h2"},
|
||||
})
|
||||
|
||||
clientConn, err := newAppleTestClientConn(t, serverAddress, option.OutboundTLSOptions{
|
||||
Enabled: true,
|
||||
Engine: "apple",
|
||||
ServerName: "localhost",
|
||||
MinVersion: "1.2",
|
||||
MaxVersion: "1.2",
|
||||
ALPN: badoption.Listable[string]{"h2"},
|
||||
Certificate: badoption.Listable[string]{serverCertificatePEM},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("iteration %d: %v", index, err)
|
||||
}
|
||||
|
||||
clientState := clientConn.ConnectionState()
|
||||
if clientState.Version != stdtls.VersionTLS12 {
|
||||
_ = clientConn.Close()
|
||||
t.Fatalf("iteration %d: unexpected negotiated version: %x", index, clientState.Version)
|
||||
}
|
||||
if clientState.NegotiatedProtocol != "h2" {
|
||||
_ = clientConn.Close()
|
||||
t.Fatalf("iteration %d: unexpected negotiated protocol: %q", index, clientState.NegotiatedProtocol)
|
||||
}
|
||||
_ = clientConn.Close()
|
||||
|
||||
result := <-serverResult
|
||||
if result.err != nil {
|
||||
t.Fatalf("iteration %d: %v", index, result.err)
|
||||
}
|
||||
if result.state.Version != stdtls.VersionTLS12 {
|
||||
t.Fatalf("iteration %d: server negotiated unexpected version: %x", index, result.state.Version)
|
||||
}
|
||||
if result.state.NegotiatedProtocol != "h2" {
|
||||
t.Fatalf("iteration %d: server negotiated unexpected protocol: %q", index, result.state.NegotiatedProtocol)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppleClientHandshakeRejectsVersionMismatch(t *testing.T) {
|
||||
serverCertificate, serverCertificatePEM := newAppleTestCertificate(t, "localhost")
|
||||
serverResult, serverAddress := startAppleTLSTestServer(t, &stdtls.Config{
|
||||
Certificates: []stdtls.Certificate{serverCertificate},
|
||||
MinVersion: stdtls.VersionTLS13,
|
||||
MaxVersion: stdtls.VersionTLS13,
|
||||
})
|
||||
|
||||
clientConn, err := newAppleTestClientConn(t, serverAddress, option.OutboundTLSOptions{
|
||||
Enabled: true,
|
||||
Engine: "apple",
|
||||
ServerName: "localhost",
|
||||
MaxVersion: "1.2",
|
||||
Certificate: badoption.Listable[string]{serverCertificatePEM},
|
||||
})
|
||||
if err == nil {
|
||||
clientConn.Close()
|
||||
t.Fatal("expected version mismatch handshake to fail")
|
||||
}
|
||||
|
||||
if result := <-serverResult; result.err == nil {
|
||||
t.Fatal("expected server handshake to fail on version mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppleClientHandshakeRejectsServerNameMismatch(t *testing.T) {
|
||||
serverCertificate, serverCertificatePEM := newAppleTestCertificate(t, "localhost")
|
||||
serverResult, serverAddress := startAppleTLSTestServer(t, &stdtls.Config{
|
||||
Certificates: []stdtls.Certificate{serverCertificate},
|
||||
})
|
||||
|
||||
clientConn, err := newAppleTestClientConn(t, serverAddress, option.OutboundTLSOptions{
|
||||
Enabled: true,
|
||||
Engine: "apple",
|
||||
ServerName: "example.com",
|
||||
Certificate: badoption.Listable[string]{serverCertificatePEM},
|
||||
})
|
||||
if err == nil {
|
||||
clientConn.Close()
|
||||
t.Fatal("expected server name mismatch handshake to fail")
|
||||
}
|
||||
|
||||
if result := <-serverResult; result.err == nil {
|
||||
t.Fatal("expected server handshake to fail on server name mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppleClientHandshakeRecoversAfterFailure(t *testing.T) {
|
||||
serverCertificate, serverCertificatePEM := newAppleTestCertificate(t, "localhost")
|
||||
testCases := []struct {
|
||||
name string
|
||||
serverConfig *stdtls.Config
|
||||
clientOptions option.OutboundTLSOptions
|
||||
}{
|
||||
{
|
||||
name: "version mismatch",
|
||||
serverConfig: &stdtls.Config{
|
||||
Certificates: []stdtls.Certificate{serverCertificate},
|
||||
MinVersion: stdtls.VersionTLS13,
|
||||
MaxVersion: stdtls.VersionTLS13,
|
||||
},
|
||||
clientOptions: option.OutboundTLSOptions{
|
||||
Enabled: true,
|
||||
Engine: "apple",
|
||||
ServerName: "localhost",
|
||||
MaxVersion: "1.2",
|
||||
Certificate: badoption.Listable[string]{serverCertificatePEM},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "server name mismatch",
|
||||
serverConfig: &stdtls.Config{
|
||||
Certificates: []stdtls.Certificate{serverCertificate},
|
||||
},
|
||||
clientOptions: option.OutboundTLSOptions{
|
||||
Enabled: true,
|
||||
Engine: "apple",
|
||||
ServerName: "example.com",
|
||||
Certificate: badoption.Listable[string]{serverCertificatePEM},
|
||||
},
|
||||
},
|
||||
}
|
||||
successClientOptions := option.OutboundTLSOptions{
|
||||
Enabled: true,
|
||||
Engine: "apple",
|
||||
ServerName: "localhost",
|
||||
MinVersion: "1.2",
|
||||
MaxVersion: "1.2",
|
||||
ALPN: badoption.Listable[string]{"h2"},
|
||||
Certificate: badoption.Listable[string]{serverCertificatePEM},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
for index := 0; index < appleTLSFailureRecoveryLoops; index++ {
|
||||
failedResult, failedAddress := startAppleTLSTestServer(t, testCase.serverConfig)
|
||||
failedConn, err := newAppleTestClientConn(t, failedAddress, testCase.clientOptions)
|
||||
if err == nil {
|
||||
_ = failedConn.Close()
|
||||
t.Fatalf("iteration %d: expected handshake failure", index)
|
||||
}
|
||||
if result := <-failedResult; result.err == nil {
|
||||
t.Fatalf("iteration %d: expected server handshake failure", index)
|
||||
}
|
||||
|
||||
successResult, successAddress := startAppleTLSTestServer(t, &stdtls.Config{
|
||||
Certificates: []stdtls.Certificate{serverCertificate},
|
||||
MinVersion: stdtls.VersionTLS12,
|
||||
MaxVersion: stdtls.VersionTLS12,
|
||||
NextProtos: []string{"h2"},
|
||||
})
|
||||
successConn, err := newAppleTestClientConn(t, successAddress, successClientOptions)
|
||||
if err != nil {
|
||||
t.Fatalf("iteration %d: follow-up handshake failed: %v", index, err)
|
||||
}
|
||||
clientState := successConn.ConnectionState()
|
||||
if clientState.NegotiatedProtocol != "h2" {
|
||||
_ = successConn.Close()
|
||||
t.Fatalf("iteration %d: unexpected negotiated protocol after failure: %q", index, clientState.NegotiatedProtocol)
|
||||
}
|
||||
_ = successConn.Close()
|
||||
|
||||
result := <-successResult
|
||||
if result.err != nil {
|
||||
t.Fatalf("iteration %d: follow-up server handshake failed: %v", index, result.err)
|
||||
}
|
||||
if result.state.NegotiatedProtocol != "h2" {
|
||||
t.Fatalf("iteration %d: follow-up server negotiated unexpected protocol: %q", index, result.state.NegotiatedProtocol)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppleClientReadDeadline(t *testing.T) {
|
||||
serverCertificate, serverCertificatePEM := newAppleTestCertificate(t, "localhost")
|
||||
serverDone, serverAddress := startAppleTLSSilentServer(t, &stdtls.Config{
|
||||
Certificates: []stdtls.Certificate{serverCertificate},
|
||||
MinVersion: stdtls.VersionTLS12,
|
||||
MaxVersion: stdtls.VersionTLS12,
|
||||
})
|
||||
|
||||
clientConn, err := newAppleTestClientConn(t, serverAddress, option.OutboundTLSOptions{
|
||||
Enabled: true,
|
||||
Engine: "apple",
|
||||
ServerName: "localhost",
|
||||
MinVersion: "1.2",
|
||||
MaxVersion: "1.2",
|
||||
Certificate: badoption.Listable[string]{serverCertificatePEM},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer clientConn.Close()
|
||||
defer close(serverDone)
|
||||
|
||||
err = clientConn.SetReadDeadline(time.Now().Add(200 * time.Millisecond))
|
||||
if err != nil {
|
||||
t.Fatalf("SetReadDeadline: %v", err)
|
||||
}
|
||||
|
||||
readDone := make(chan error, 1)
|
||||
buffer := make([]byte, 64)
|
||||
go func() {
|
||||
_, readErr := clientConn.Read(buffer)
|
||||
readDone <- readErr
|
||||
}()
|
||||
|
||||
select {
|
||||
case readErr := <-readDone:
|
||||
if !errors.Is(readErr, os.ErrDeadlineExceeded) {
|
||||
t.Fatalf("expected os.ErrDeadlineExceeded, got %v", readErr)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Read did not return within 2s after deadline")
|
||||
}
|
||||
|
||||
_, err = clientConn.Read(buffer)
|
||||
if !errors.Is(err, os.ErrDeadlineExceeded) {
|
||||
t.Fatalf("sticky deadline: expected os.ErrDeadlineExceeded, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppleClientSetDeadlineClearsPreExpiredSticky(t *testing.T) {
|
||||
serverCertificate, serverCertificatePEM := newAppleTestCertificate(t, "localhost")
|
||||
serverDone, serverAddress := startAppleTLSSilentServer(t, &stdtls.Config{
|
||||
Certificates: []stdtls.Certificate{serverCertificate},
|
||||
MinVersion: stdtls.VersionTLS12,
|
||||
MaxVersion: stdtls.VersionTLS12,
|
||||
})
|
||||
|
||||
clientConn, err := newAppleTestClientConn(t, serverAddress, option.OutboundTLSOptions{
|
||||
Enabled: true,
|
||||
Engine: "apple",
|
||||
ServerName: "localhost",
|
||||
MinVersion: "1.2",
|
||||
MaxVersion: "1.2",
|
||||
Certificate: badoption.Listable[string]{serverCertificatePEM},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer clientConn.Close()
|
||||
defer close(serverDone)
|
||||
|
||||
err = clientConn.SetReadDeadline(time.Now().Add(-time.Second))
|
||||
if err != nil {
|
||||
t.Fatalf("SetReadDeadline past: %v", err)
|
||||
}
|
||||
|
||||
// Pre-expired deadline trips sticky flag without cancelling nw_connection
|
||||
// (prepareReadTimeout short-circuits before the C read is issued).
|
||||
buffer := make([]byte, 64)
|
||||
_, err = clientConn.Read(buffer)
|
||||
if !errors.Is(err, os.ErrDeadlineExceeded) {
|
||||
t.Fatalf("pre-expired: expected os.ErrDeadlineExceeded, got %v", err)
|
||||
}
|
||||
|
||||
err = clientConn.SetReadDeadline(time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("SetReadDeadline zero: %v", err)
|
||||
}
|
||||
|
||||
newDeadline := 300 * time.Millisecond
|
||||
err = clientConn.SetReadDeadline(time.Now().Add(newDeadline))
|
||||
if err != nil {
|
||||
t.Fatalf("SetReadDeadline future: %v", err)
|
||||
}
|
||||
|
||||
readStart := time.Now()
|
||||
_, err = clientConn.Read(buffer)
|
||||
readElapsed := time.Since(readStart)
|
||||
if !errors.Is(err, os.ErrDeadlineExceeded) {
|
||||
t.Fatalf("after clear: expected os.ErrDeadlineExceeded, got %v", err)
|
||||
}
|
||||
if readElapsed < newDeadline-50*time.Millisecond {
|
||||
t.Fatalf("sticky flag was not cleared: Read returned after %v, expected ~%v", readElapsed, newDeadline)
|
||||
}
|
||||
}
|
||||
|
||||
func startAppleTLSSilentServer(t *testing.T, tlsConfig *stdtls.Config) (chan<- struct{}, string) {
|
||||
t.Helper()
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
listener.Close()
|
||||
})
|
||||
|
||||
if tcpListener, isTCP := listener.(*net.TCPListener); isTCP {
|
||||
err = tcpListener.SetDeadline(time.Now().Add(appleTLSTestTimeout))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
conn, acceptErr := listener.Accept()
|
||||
if acceptErr != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
handshakeErr := conn.SetDeadline(time.Now().Add(appleTLSTestTimeout))
|
||||
if handshakeErr != nil {
|
||||
return
|
||||
}
|
||||
tlsConn := stdtls.Server(conn, tlsConfig)
|
||||
defer tlsConn.Close()
|
||||
handshakeErr = tlsConn.Handshake()
|
||||
if handshakeErr != nil {
|
||||
return
|
||||
}
|
||||
handshakeErr = conn.SetDeadline(time.Time{})
|
||||
if handshakeErr != nil {
|
||||
return
|
||||
}
|
||||
<-done
|
||||
}()
|
||||
return done, listener.Addr().String()
|
||||
}
|
||||
|
||||
func newAppleTestCertificate(t *testing.T, serverName string) (stdtls.Certificate, string) {
|
||||
t.Helper()
|
||||
|
||||
privateKeyPEM, certificatePEM, err := GenerateCertificate(nil, nil, time.Now, serverName, time.Now().Add(time.Hour))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
certificate, err := stdtls.X509KeyPair(certificatePEM, privateKeyPEM)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return certificate, string(certificatePEM)
|
||||
}
|
||||
|
||||
func startAppleTLSTestServer(t *testing.T, tlsConfig *stdtls.Config) (<-chan appleTLSServerResult, string) {
|
||||
t.Helper()
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
listener.Close()
|
||||
})
|
||||
|
||||
if tcpListener, isTCP := listener.(*net.TCPListener); isTCP {
|
||||
err = tcpListener.SetDeadline(time.Now().Add(appleTLSTestTimeout))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
result := make(chan appleTLSServerResult, 1)
|
||||
go func() {
|
||||
defer close(result)
|
||||
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
result <- appleTLSServerResult{err: err}
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
err = conn.SetDeadline(time.Now().Add(appleTLSTestTimeout))
|
||||
if err != nil {
|
||||
result <- appleTLSServerResult{err: err}
|
||||
return
|
||||
}
|
||||
|
||||
tlsConn := stdtls.Server(conn, tlsConfig)
|
||||
defer tlsConn.Close()
|
||||
|
||||
err = tlsConn.Handshake()
|
||||
if err != nil {
|
||||
result <- appleTLSServerResult{err: err}
|
||||
return
|
||||
}
|
||||
|
||||
result <- appleTLSServerResult{state: tlsConn.ConnectionState()}
|
||||
}()
|
||||
|
||||
return result, listener.Addr().String()
|
||||
}
|
||||
|
||||
func newAppleTestClientConn(t *testing.T, serverAddress string, options option.OutboundTLSOptions) (Conn, error) {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), appleTLSTestTimeout)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
clientConfig, err := NewClientWithOptions(ClientOptions{
|
||||
Context: ctx,
|
||||
Logger: logger.NOP(),
|
||||
ServerAddress: "",
|
||||
Options: options,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
conn, err := net.DialTimeout("tcp", serverAddress, appleTLSTestTimeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tlsConn, err := ClientHandshake(ctx, conn, clientConfig)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return tlsConn, nil
|
||||
}
|
||||
Reference in New Issue
Block a user