test: add http2 handler e2e suite and tighten host-mapper + idle-timeout tests
Add tests/e2e/http2_test.go covering the http2 handler/listener/connector/dialer
end-to-end through the canonical gost-as-client pattern (8 subtests: forward,
auth, bypass, probeResist/metadata, h2 stream multiplexing). Include
testdata/http2/{server,server_auth,server_bypass,server_proberesist,client,
client_auth}.yaml. Note that h2/h2c listeners pair with the tunnel handler
and only http2 listener pairs with handler http2 (reads r/w from metadata).
Tighten TestDNSHostMapper by pointing the handler at an unreachable upstream
so unmapped names fail at the exchanger, confirming the host-mapper path was
the sole reason mapped names resolved. Fix http_idle_timeout.py to speak HTTP
through the CONNECT tunnel rather than a raw ping, matching the echo backend.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -168,9 +168,12 @@ func (s *DNSSuite) TestDNSBypass() {
|
|||||||
// TestDNSHostMapper verifies DNS resolution via the host mapper before
|
// TestDNSHostMapper verifies DNS resolution via the host mapper before
|
||||||
// reaching the upstream exchanger.
|
// reaching the upstream exchanger.
|
||||||
//
|
//
|
||||||
// Config: server_hosts.yaml maps mapped.example.com → 10.0.0.100 with no
|
// Config: server_hosts.yaml maps mapped.example.com → 10.0.0.100 and
|
||||||
// working upstream. The handler checks the host mapper before the exchange
|
// points the handler at an unreachable upstream (udp://127.0.0.1:1).
|
||||||
// path, so mapped names resolve without needing any upstream DNS.
|
// 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() {
|
func (s *DNSSuite) TestDNSHostMapper() {
|
||||||
gostC, err := RunGostContainerWithFiles(s.ctx, SharedNetworkName,
|
gostC, err := RunGostContainerWithFiles(s.ctx, SharedNetworkName,
|
||||||
"testdata/dns/server_hosts.yaml",
|
"testdata/dns/server_hosts.yaml",
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -28,11 +28,19 @@ def main():
|
|||||||
print(f"FAIL: expected 200, got {resp.decode(errors='replace')}")
|
print(f"FAIL: expected 200, got {resp.decode(errors='replace')}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# Send request through the CONNECT tunnel
|
# Send an HTTP GET through the CONNECT tunnel. The tunnel target
|
||||||
s.sendall(b"ping-1\n")
|
# is an HTTP echo server (BaseHTTPRequestHandler), so we must speak
|
||||||
data = s.recv(4096)
|
# HTTP or the connection closes.
|
||||||
if b"ping-1" not in data:
|
req = b"GET / HTTP/1.0\r\nHost: tcp-echo\r\n\r\n"
|
||||||
print(f"FAIL: expected ping-1 echo, got {data!r}")
|
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)
|
sys.exit(1)
|
||||||
print(f"PASS: first request through tunnel succeeded")
|
print(f"PASS: first request through tunnel succeeded")
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -4,7 +4,10 @@ services:
|
|||||||
handler:
|
handler:
|
||||||
type: dns
|
type: dns
|
||||||
metadata:
|
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
|
timeout: 2s
|
||||||
listener:
|
listener:
|
||||||
type: dns
|
type: dns
|
||||||
|
|||||||
Vendored
+20
@@ -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
|
||||||
+23
@@ -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
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
services:
|
||||||
|
- name: h2-proxy
|
||||||
|
addr: :8443
|
||||||
|
handler:
|
||||||
|
type: http2
|
||||||
|
listener:
|
||||||
|
type: http2
|
||||||
+18
@@ -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
|
||||||
+20
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user