diff --git a/go.mod b/go.mod index 740ca0d..5171892 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.26.3 require ( github.com/go-gost/core v0.5.3 - github.com/go-gost/x v0.13.9 + github.com/go-gost/x v0.13.11 github.com/judwhite/go-svc v1.2.1 github.com/moby/moby/client v0.4.0 github.com/stretchr/testify v1.11.1 diff --git a/go.sum b/go.sum index f764ee6..ca4f35f 100644 --- a/go.sum +++ b/go.sum @@ -99,6 +99,10 @@ github.com/go-gost/tls-dissector v0.2.0 h1:9tE6WOzzpurATTBWn60DU4R8gibpGNY8/qVcc github.com/go-gost/tls-dissector v0.2.0/go.mod h1:/9QfdewqmHdaE362Hv5nDaSWLx3pCmtD870d6GaquXs= github.com/go-gost/x v0.13.9 h1:/pK8rOiueV239i0ntesDnU15UeYrq8vL6iVeGPJTzJo= github.com/go-gost/x v0.13.9/go.mod h1:etkIOM5JjH9pnb3UOvLKexodznKcK3oRNGniPk0AGxk= +github.com/go-gost/x v0.13.10 h1:RCd65A5KuB7ZLDw317tt9O//sjrlxijXXTO0jbpkfkY= +github.com/go-gost/x v0.13.10/go.mod h1:etkIOM5JjH9pnb3UOvLKexodznKcK3oRNGniPk0AGxk= +github.com/go-gost/x v0.13.11 h1:508vT37VjgeGqQPBj+DG3yw0Z1TMLwKLl90wLeVknWI= +github.com/go-gost/x v0.13.11/go.mod h1:etkIOM5JjH9pnb3UOvLKexodznKcK3oRNGniPk0AGxk= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= diff --git a/tests/e2e/testdata/utls/client_insecure.yaml b/tests/e2e/testdata/utls/client_insecure.yaml new file mode 100644 index 0000000..e285e63 --- /dev/null +++ b/tests/e2e/testdata/utls/client_insecure.yaml @@ -0,0 +1,24 @@ +services: +- name: http-proxy + addr: :8080 + handler: + type: http + chain: utls-chain + listener: + type: tcp + +chains: +- name: utls-chain + hops: + - name: hop-0 + nodes: + - name: node-0 + addr: {{.ServerAddr}} + connector: + type: http + dialer: + type: utls + metadata: + fingerprint: Chrome + tls: + secure: false diff --git a/tests/e2e/testdata/utls/client_secure.yaml b/tests/e2e/testdata/utls/client_secure.yaml new file mode 100644 index 0000000..558688b --- /dev/null +++ b/tests/e2e/testdata/utls/client_secure.yaml @@ -0,0 +1,26 @@ +services: +- name: http-proxy + addr: :8080 + handler: + type: http + chain: utls-chain + listener: + type: tcp + +chains: +- name: utls-chain + hops: + - name: hop-0 + nodes: + - name: node-0 + addr: {{.ServerAddr}} + connector: + type: http + dialer: + type: utls + metadata: + fingerprint: Chrome + tls: + secure: true + serverName: utls-server + caFile: /certs/ca.pem diff --git a/tests/e2e/testdata/utls/server_auto.yaml b/tests/e2e/testdata/utls/server_auto.yaml new file mode 100644 index 0000000..cbb66d2 --- /dev/null +++ b/tests/e2e/testdata/utls/server_auto.yaml @@ -0,0 +1,7 @@ +services: +- name: https-server + addr: :8443 + handler: + type: http + listener: + type: tls diff --git a/tests/e2e/testdata/utls/server_ca.yaml b/tests/e2e/testdata/utls/server_ca.yaml new file mode 100644 index 0000000..eb76711 --- /dev/null +++ b/tests/e2e/testdata/utls/server_ca.yaml @@ -0,0 +1,10 @@ +services: +- name: https-server + addr: :8443 + handler: + type: http + listener: + type: tls + tls: + certFile: /certs/server.pem + keyFile: /certs/server-key.pem diff --git a/tests/e2e/utls_test.go b/tests/e2e/utls_test.go new file mode 100644 index 0000000..42add09 --- /dev/null +++ b/tests/e2e/utls_test.go @@ -0,0 +1,188 @@ +package e2e + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "io" + "math/big" + "os" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/suite" + "github.com/testcontainers/testcontainers-go" +) + +// UTLSSuite exercises the utls dialer in a forward-proxy chain. +// +// Topology: +// +// curl -> client gost (http proxy, :8080) +// -> chain node (http connector + utls dialer) +// -> server gost (http proxy over TLS listener, :8443) +// -> tcp-echo +type UTLSSuite struct { + suite.Suite + ctx context.Context + echoC testcontainers.Container + echoIP string + certDir string +} + +func (s *UTLSSuite) SetupSuite() { + s.ctx = context.Background() + + certDir, err := os.MkdirTemp("", "gost-utls-certs-*") + s.Require().NoError(err) + s.certDir = certDir + s.generateCerts() + + echoC, err := RunEchoContainer(s.ctx, SharedNetworkName) + s.Require().NoError(err) + s.echoC = echoC + + echoIP, err := echoC.ContainerIP(s.ctx) + s.Require().NoError(err) + s.echoIP = echoIP +} + +func (s *UTLSSuite) TearDownSuite() { + if s.echoC != nil { + s.echoC.Terminate(s.ctx) + } + os.RemoveAll(s.certDir) +} + +// generateCerts creates a self-signed CA and a server cert for the +// "utls-server" hostname, writing PEM files to s.certDir. +func (s *UTLSSuite) generateCerts() { + caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + s.Require().NoError(err) + caTmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "Test CA"}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, + BasicConstraintsValid: true, + IsCA: true, + } + caDER, err := x509.CreateCertificate(rand.Reader, caTmpl, caTmpl, &caKey.PublicKey, caKey) + s.Require().NoError(err) + s.writeCertPEM(s.certDir+"/ca.pem", "CERTIFICATE", caDER) + s.writeKeyPEM(s.certDir+"/ca-key.pem", "EC PRIVATE KEY", caKey) + + serverKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + s.Require().NoError(err) + serverTmpl := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: "utls-server"}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + DNSNames: []string{"utls-server"}, + } + serverDER, err := x509.CreateCertificate(rand.Reader, serverTmpl, caTmpl, &serverKey.PublicKey, caKey) + s.Require().NoError(err) + s.writeCertPEM(s.certDir+"/server.pem", "CERTIFICATE", serverDER) + s.writeKeyPEM(s.certDir+"/server-key.pem", "EC PRIVATE KEY", serverKey) +} + +// TestUTLSInsecure is the regression test for go-gost/gost#887. +// +// A utls dialer with `secure: false` must still complete the handshake +// (InsecureSkipVerify must be honoured). The previous unsafe.Pointer cast +// read garbage for InsecureSkipVerify, so it came back false and the +// handshake failed with "at least one of ServerName, InsecureSkipVerify or +// InsecureServerNameToVerify must be specified". +func (s *UTLSSuite) TestUTLSInsecure() { + rendered, err := RenderConfig("testdata/utls/client_insecure.yaml", + ConfigData{ServerAddr: "utls-server:8443"}) + s.Require().NoError(err) + defer os.Remove(rendered) + + serverC, err := RunGostContainerWithOptions(s.ctx, SharedNetworkName, + "testdata/utls/server_auto.yaml", []string{"utls-server"}, []string{"8443/tcp"}) + s.Require().NoError(err) + defer serverC.Terminate(s.ctx) + + clientC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName, rendered, "8080/tcp") + s.Require().NoError(err) + defer clientC.Terminate(s.ctx) + + s.requireProxyWorks(clientC, serverC) +} + +// TestUTLSSecureWithCA verifies the converter's RootCAs/ServerName path: +// with `secure: true` and a CA file, the utls dialer must verify the +// server's CA-signed certificate and still reach the echo server. +func (s *UTLSSuite) TestUTLSSecureWithCA() { + rendered, err := RenderConfig("testdata/utls/client_secure.yaml", + ConfigData{ServerAddr: "utls-server:8443"}) + s.Require().NoError(err) + defer os.Remove(rendered) + + serverC, err := runGostContainer(s.ctx, SharedNetworkName, + "testdata/utls/server_ca.yaml", + []string{"utls-server"}, []string{"8443/tcp"}, + []testcontainers.ContainerFile{ + {HostFilePath: s.certDir + "/server.pem", ContainerFilePath: "/certs/server.pem", FileMode: 0644}, + {HostFilePath: s.certDir + "/server-key.pem", ContainerFilePath: "/certs/server-key.pem", FileMode: 0644}, + }) + s.Require().NoError(err) + defer serverC.Terminate(s.ctx) + + clientC, err := RunGostContainerWithFiles(s.ctx, SharedNetworkName, rendered, + []testcontainers.ContainerFile{ + {HostFilePath: s.certDir + "/ca.pem", ContainerFilePath: "/certs/ca.pem", FileMode: 0644}, + }, + "8080/tcp") + s.Require().NoError(err) + defer clientC.Terminate(s.ctx) + + s.requireProxyWorks(clientC, serverC) +} + +// requireProxyWorks drives curl through the client proxy and asserts the +// echo server's "hello-gost" response is returned. +func (s *UTLSSuite) requireProxyWorks(clientC, serverC testcontainers.Container) { + cmd := []string{"curl", "-v", "-s", "--connect-timeout", "5", + "-x", "http://127.0.0.1:8080", + fmt.Sprintf("http://%s:5678", s.echoIP)} + code, out, err := clientC.Exec(s.ctx, cmd) + body, err2 := io.ReadAll(out) + if err != nil || err2 != nil || code != 0 || !strings.Contains(string(body), "hello-gost") { + DumpLogs(s.T(), s.ctx, "utls client logs", clientC) + DumpLogs(s.T(), s.ctx, "utls server logs", serverC) + } + s.Require().NoError(err) + s.Require().NoError(err2) + s.Require().Equal(0, code) + s.Require().Contains(string(body), "hello-gost") +} + +func TestUTLSSuite(t *testing.T) { + suite.Run(t, new(UTLSSuite)) +} + +// --- helpers (mirror mtls_test.go) --- + +func (s *UTLSSuite) writeCertPEM(path, blockType string, der []byte) { + err := os.WriteFile(path, pem.EncodeToMemory(&pem.Block{Type: blockType, Bytes: der}), 0644) + s.Require().NoError(err) +} + +func (s *UTLSSuite) writeKeyPEM(path, blockType string, key *ecdsa.PrivateKey) { + b, err := x509.MarshalECPrivateKey(key) + s.Require().NoError(err) + err = os.WriteFile(path, pem.EncodeToMemory(&pem.Block{Type: blockType, Bytes: b}), 0600) + s.Require().NoError(err) +}