diff --git a/tests/e2e/dns_test.go b/tests/e2e/dns_test.go index 0d3d586..a688814 100644 --- a/tests/e2e/dns_test.go +++ b/tests/e2e/dns_test.go @@ -168,9 +168,12 @@ func (s *DNSSuite) TestDNSBypass() { // TestDNSHostMapper verifies DNS resolution via the host mapper before // reaching the upstream exchanger. // -// Config: server_hosts.yaml maps mapped.example.com → 10.0.0.100 with no -// working upstream. The handler checks the host mapper before the exchange -// path, so mapped names resolve without needing any upstream DNS. +// Config: server_hosts.yaml maps mapped.example.com → 10.0.0.100 and +// points the handler at an unreachable upstream (udp://127.0.0.1:1). +// The handler checks the host mapper before the exchange path, so +// mapped names resolve without needing any upstream DNS. Unmapped +// names must fall through to the exchanger, which fails — confirming +// the host-mapper path was the only reason mapped names worked. func (s *DNSSuite) TestDNSHostMapper() { gostC, err := RunGostContainerWithFiles(s.ctx, SharedNetworkName, "testdata/dns/server_hosts.yaml", diff --git a/tests/e2e/http2_test.go b/tests/e2e/http2_test.go new file mode 100644 index 0000000..eeefc6d --- /dev/null +++ b/tests/e2e/http2_test.go @@ -0,0 +1,216 @@ +package e2e + +import ( + "context" + "fmt" + "io" + "os" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/testcontainers/testcontainers-go" +) + +// HTTP2Suite covers the HTTP/2 proxy handler (handler type: http2) together +// with the HTTP/2 listener (listener type: http2). The http2 listener wraps +// the underlying TCP listener with TLS and configures an h2 server, so the +// handler is always reached over HTTP/2 frames. +// +// Because Alpine's curl cannot be relied on to speak HTTP/2 proxy to the +// server directly, the suite uses the canonical GOST chaining pattern: a +// client container exposes a plain HTTP proxy which forwards through an +// http2 connector + http2 dialer to the http2 server. This exercises the +// h2 listener, the h2 handler, and the h2 connector/dialer together. +type HTTP2Suite struct { + suite.Suite + ctx context.Context + echoC testcontainers.Container + echoIP string +} + +func (s *HTTP2Suite) SetupSuite() { + s.ctx = context.Background() + + s.T().Logf("start tcp echo container...") + 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 *HTTP2Suite) TearDownSuite() { + if s.echoC != nil { + s.echoC.Terminate(s.ctx) + } +} + +// startChain brings up an http2 server (alias "h2-server") and an http client +// container that chains to it through connector/dialer type http2. The client +// exposes port 8080 for curl. The rendered client config is cleaned up by the +// caller-supplied template path resolving {{.ServerAddr}} to h2-server:8443. +func (s *HTTP2Suite) startChain(serverYAML, clientTmpl string) (testcontainers.Container, testcontainers.Container) { + s.T().Helper() + + serverC, err := RunGostContainerWithOptions(s.ctx, SharedNetworkName, + serverYAML, []string{"h2-server"}, []string{"8443/tcp"}) + s.Require().NoError(err) + + rendered, err := RenderConfig(clientTmpl, ConfigData{ServerAddr: "h2-server:8443"}) + s.Require().NoError(err) + s.T().Cleanup(func() { os.Remove(rendered) }) + + clientC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName, + rendered, "8080/tcp") + s.Require().NoError(err) + + return serverC, clientC +} + +// curlEcho runs curl through the client's local http proxy and returns the +// process exit code plus the captured body. +func (s *HTTP2Suite) curlEcho(clientC testcontainers.Container) (int, string) { + s.T().Helper() + cmd := []string{"curl", "-v", "-s", "-x", "http://127.0.0.1:8080", + fmt.Sprintf("http://%s:5678", s.echoIP)} + code, out, _ := clientC.Exec(s.ctx, cmd) + body, _ := io.ReadAll(out) + return code, string(body) +} + +func (s *HTTP2Suite) dump(label string, cs ...testcontainers.Container) { + for _, c := range cs { + DumpLogs(s.T(), s.ctx, label, c) + } +} + +// TestHTTP2ForwardProxy verifies the core HTTP/2 proxy path: a plain HTTP +// request through the client proxy is tunneled to the http2 server via an h2 +// CONNECT stream and reaches the echo backend. +// +// Covers: +// - listener type: http2 (TLS + h2 server) +// - handler type: http2 (CONNECT tunnel + bidirectional pipe) +// - connector type: http2, dialer type: http2 (h2 client) +func (s *HTTP2Suite) TestHTTP2ForwardProxy() { + serverC, clientC := s.startChain("testdata/http2/server.yaml", "testdata/http2/client.yaml") + defer serverC.Terminate(s.ctx) + defer clientC.Terminate(s.ctx) + + code, body := s.curlEcho(clientC) + if code != 0 || !strings.Contains(body, "hello-gost") { + s.dump("http2-forward logs", clientC, serverC) + } + s.Require().Equal(0, code) + s.Require().Contains(body, "hello-gost") +} + +// TestHTTP2Auth verifies proxy authentication over the h2 tunnel, plus +// authBasicRealm and hash metadata parsing on the http2 handler. +// +// - with auth: CONNECT succeeds → echo body returned +// - without auth: server rejects CONNECT (407) → client cannot reach echo +func (s *HTTP2Suite) TestHTTP2Auth() { + s.T().Run("with-auth-success", func(t *testing.T) { + serverC, clientC := s.startChain("testdata/http2/server_auth.yaml", "testdata/http2/client_auth.yaml") + defer serverC.Terminate(s.ctx) + defer clientC.Terminate(s.ctx) + + code, body := s.curlEcho(clientC) + if code != 0 || !strings.Contains(body, "hello-gost") { + s.dump("http2-auth-success logs", clientC, serverC) + } + s.Require().Equal(0, code) + s.Require().Contains(body, "hello-gost") + }) + + s.T().Run("no-auth-fails", func(t *testing.T) { + // Server requires auth; client sends none → the http2 CONNECT is + // rejected with 407, so curl cannot obtain the echo body. + serverC, clientC := s.startChain("testdata/http2/server_auth.yaml", "testdata/http2/client.yaml") + defer serverC.Terminate(s.ctx) + defer clientC.Terminate(s.ctx) + + code, body := s.curlEcho(clientC) + if strings.Contains(body, "hello-gost") { + s.dump("http2-auth-noauth logs", clientC, serverC) + } + s.Require().Zero(code, + "curl should still receive an HTTP response from the local proxy") + s.Require().NotContains(body, "hello-gost", + "without auth the echo backend must be unreachable") + }) +} + +// TestHTTP2Bypass verifies that a bypass matcher on the http2 handler blocks +// the CONNECT target after authentication succeeds. The server returns 403, so +// the client cannot reach the echo backend. +func (s *HTTP2Suite) TestHTTP2Bypass() { + serverC, clientC := s.startChain("testdata/http2/server_bypass.yaml", "testdata/http2/client_auth.yaml") + defer serverC.Terminate(s.ctx) + defer clientC.Terminate(s.ctx) + + code, body := s.curlEcho(clientC) + if strings.Contains(body, "hello-gost") { + s.dump("http2-bypass logs", clientC, serverC) + } + s.Require().Zero(code, + "curl should still receive an HTTP response from the local proxy") + s.Require().NotContains(body, "hello-gost", + "bypass should prevent reaching the echo backend") +} + +// TestHTTP2ProbeResist verifies that probeResist, header and authBasicRealm +// metadata parse cleanly on the http2 handler and do not break the success +// path. With correct credentials the request still reaches the echo backend. +func (s *HTTP2Suite) TestHTTP2ProbeResist() { + serverC, clientC := s.startChain("testdata/http2/server_proberesist.yaml", "testdata/http2/client_auth.yaml") + defer serverC.Terminate(s.ctx) + defer clientC.Terminate(s.ctx) + + code, body := s.curlEcho(clientC) + if code != 0 || !strings.Contains(body, "hello-gost") { + s.dump("http2-proberesist logs", clientC, serverC) + } + s.Require().Equal(0, code) + s.Require().Contains(body, "hello-gost") +} + +// TestHTTP2Multiplex verifies HTTP/2 stream multiplexing: many concurrent +// requests through the client proxy are tunneled as parallel h2 streams over a +// single underlying connection to the http2 server. All requests must reach +// the echo backend. +func (s *HTTP2Suite) TestHTTP2Multiplex() { + serverC, clientC := s.startChain("testdata/http2/server.yaml", "testdata/http2/client.yaml") + defer serverC.Terminate(s.ctx) + defer clientC.Terminate(s.ctx) + + const n = 6 + var wg sync.WaitGroup + wg.Add(n) + failed := make(chan string, n) + for i := 0; i < n; i++ { + go func() { + defer wg.Done() + _, body := s.curlEcho(clientC) + if !strings.Contains(body, "hello-gost") { + failed <- "missing echo body" + } + }() + } + wg.Wait() + close(failed) + + if len(failed) > 0 { + s.dump("http2-multiplex logs", clientC, serverC) + } + s.Require().Empty(failed, "all concurrent h2 requests should reach the echo backend") +} + +func TestHTTP2Suite(t *testing.T) { + suite.Run(t, new(HTTP2Suite)) +} diff --git a/tests/e2e/scripts/http_idle_timeout.py b/tests/e2e/scripts/http_idle_timeout.py index 89f8ee9..418b4ce 100644 --- a/tests/e2e/scripts/http_idle_timeout.py +++ b/tests/e2e/scripts/http_idle_timeout.py @@ -28,11 +28,19 @@ def main(): print(f"FAIL: expected 200, got {resp.decode(errors='replace')}") sys.exit(1) - # Send request through the CONNECT tunnel - s.sendall(b"ping-1\n") - data = s.recv(4096) - if b"ping-1" not in data: - print(f"FAIL: expected ping-1 echo, got {data!r}") + # Send an HTTP GET through the CONNECT tunnel. The tunnel target + # is an HTTP echo server (BaseHTTPRequestHandler), so we must speak + # HTTP or the connection closes. + req = b"GET / HTTP/1.0\r\nHost: tcp-echo\r\n\r\n" + s.sendall(req) + data = b"" + while b"hello-gost" not in data: + chunk = s.recv(4096) + if not chunk: + break + data += chunk + if b"hello-gost" not in data: + print(f"FAIL: expected hello-gost echo, got {data!r}") sys.exit(1) print(f"PASS: first request through tunnel succeeded") diff --git a/tests/e2e/testdata/dns/server_hosts.yaml b/tests/e2e/testdata/dns/server_hosts.yaml index 163c9b1..90dfcb5 100644 --- a/tests/e2e/testdata/dns/server_hosts.yaml +++ b/tests/e2e/testdata/dns/server_hosts.yaml @@ -4,7 +4,10 @@ services: handler: type: dns metadata: - # No upstream — host mapper resolves before exchanger is ever reached. + # Unreachable upstream so the system-DNS fallback is bypassed. + # The host mapper resolves mapped names before the exchanger is + # reached; unmapped names must hit the exchanger and fail. + dns: udp://127.0.0.1:1 timeout: 2s listener: type: dns diff --git a/tests/e2e/testdata/http2/client.yaml b/tests/e2e/testdata/http2/client.yaml new file mode 100644 index 0000000..9e505c6 --- /dev/null +++ b/tests/e2e/testdata/http2/client.yaml @@ -0,0 +1,20 @@ +services: +- name: http-proxy + addr: :8080 + handler: + type: http + chain: h2-chain + listener: + type: tcp + +chains: +- name: h2-chain + hops: + - name: hop-0 + nodes: + - name: node-0 + addr: {{.ServerAddr}} + connector: + type: http2 + dialer: + type: http2 diff --git a/tests/e2e/testdata/http2/client_auth.yaml b/tests/e2e/testdata/http2/client_auth.yaml new file mode 100644 index 0000000..559d16b --- /dev/null +++ b/tests/e2e/testdata/http2/client_auth.yaml @@ -0,0 +1,23 @@ +services: +- name: http-proxy + addr: :8080 + handler: + type: http + chain: h2-chain + listener: + type: tcp + +chains: +- name: h2-chain + hops: + - name: hop-0 + nodes: + - name: node-0 + addr: {{.ServerAddr}} + connector: + type: http2 + auth: + username: user + password: pass + dialer: + type: http2 diff --git a/tests/e2e/testdata/http2/server.yaml b/tests/e2e/testdata/http2/server.yaml new file mode 100644 index 0000000..c3cd398 --- /dev/null +++ b/tests/e2e/testdata/http2/server.yaml @@ -0,0 +1,7 @@ +services: +- name: h2-proxy + addr: :8443 + handler: + type: http2 + listener: + type: http2 diff --git a/tests/e2e/testdata/http2/server_auth.yaml b/tests/e2e/testdata/http2/server_auth.yaml new file mode 100644 index 0000000..7df402e --- /dev/null +++ b/tests/e2e/testdata/http2/server_auth.yaml @@ -0,0 +1,18 @@ +services: +- name: h2-proxy-auth + addr: :8443 + handler: + type: http2 + auther: auther-0 + metadata: + # authBasicRealm: custom realm in 407 Proxy-Authenticate header + authBasicRealm: gost-e2e-realm + # hash: pin upstream selection by request host (metadata parse coverage) + hash: host + listener: + type: http2 +authers: +- name: auther-0 + auths: + - username: user + password: pass diff --git a/tests/e2e/testdata/http2/server_bypass.yaml b/tests/e2e/testdata/http2/server_bypass.yaml new file mode 100644 index 0000000..8695bc0 --- /dev/null +++ b/tests/e2e/testdata/http2/server_bypass.yaml @@ -0,0 +1,20 @@ +bypasses: +- name: bypass-0 + matchers: + - 0.0.0.0/0 + +services: +- name: h2-proxy-bypass + addr: :8443 + handler: + type: http2 + auther: auther-0 + listener: + type: http2 + bypass: bypass-0 + +authers: +- name: auther-0 + auths: + - username: user + password: pass diff --git a/tests/e2e/testdata/http2/server_proberesist.yaml b/tests/e2e/testdata/http2/server_proberesist.yaml new file mode 100644 index 0000000..3642787 --- /dev/null +++ b/tests/e2e/testdata/http2/server_proberesist.yaml @@ -0,0 +1,20 @@ +services: +- name: h2-proxy-pr + addr: :8443 + handler: + type: http2 + auther: auther-0 + metadata: + # probeResist: hide the proxy behind a 404 decoy on auth failure + probeResist: code:404 + # header: custom response headers set on proxy responses + header: + X-Proxy-Info: gost-e2e + authBasicRealm: gost-e2e-realm + listener: + type: http2 +authers: +- name: auther-0 + auths: + - username: user + password: pass