1package proxy
2
3import (
4 "fmt"
5 "net"
6 "net/http"
7 "net/http/httptest"
8 "net/url"
9 "strconv"
10 "testing"
11
12 "github.com/bnema/zerowrap"
13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/mock"
15 "github.com/stretchr/testify/require"
16
17 "github.com/bnema/gordon/internal/adapters/in/http/middleware"
18 "github.com/bnema/gordon/internal/boundaries/in"
19 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
20 "github.com/bnema/gordon/internal/domain"
21)
22
23func TestForwardToTarget_H2CEndToEnd(t *testing.T) {
24 var h2cProtos http.Protocols
25 h2cProtos.SetUnencryptedHTTP2(true)
26
27 h2cBackend := &http.Server{
28 Protocols: &h2cProtos,
29 Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
30 w.Header().Set("X-Proto", r.Proto)
31 w.WriteHeader(http.StatusOK)
32 _, _ = fmt.Fprintf(w, "proto=%s", r.Proto)
33 }),
34 }
35
36 ln, err := net.Listen("tcp", "127.0.0.1:0")
37 require.NoError(t, err)
38 defer ln.Close()
39
40 go func() {
41 _ = h2cBackend.Serve(ln)
42 }()
43 defer h2cBackend.Close()
44
45 _, portStr, splitErr := net.SplitHostPort(ln.Addr().String())
46 require.NoError(t, splitErr)
47 port, atoiErr := strconv.Atoi(portStr)
48 require.NoError(t, atoiErr)
49
50 proxySvc := inmocks.NewMockProxyService(t)
51
52 proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
53 proxySvc.EXPECT().IsRegistryDomain("grpc.example.com").Return(false)
54 proxySvc.EXPECT().GetTarget(mock.Anything, "grpc.example.com").Return(&domain.ProxyTarget{
55 Host: "127.0.0.1",
56 Port: port,
57 ContainerID: "grpc-1",
58 Scheme: "http",
59 Protocol: "h2c",
60 }, nil)
61 proxySvc.EXPECT().TrackInFlight("grpc-1").Return(func() {})
62
63 handler := NewHandler(proxySvc, nil, zerowrap.Default())
64
65 req := httptest.NewRequest(http.MethodGet, "http://grpc.example.com/test", nil)
66 req.Host = "grpc.example.com"
67 rec := httptest.NewRecorder()
68
69 handler.ServeHTTP(rec, req)
70
71 assert.Equal(t, http.StatusOK, rec.Code)
72 assert.Contains(t, rec.Body.String(), "HTTP/2")
73}
74
75func TestForwardToTarget_HTTP1StillWorks(t *testing.T) {
76 backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
77 _, _ = fmt.Fprintf(w, "proto=%s", r.Proto)
78 }))
79 defer backend.Close()
80
81 backendPort := backend.Listener.Addr().(*net.TCPAddr).Port
82
83 proxySvc := inmocks.NewMockProxyService(t)
84
85 proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
86 proxySvc.EXPECT().IsRegistryDomain("web.example.com").Return(false)
87 proxySvc.EXPECT().GetTarget(mock.Anything, "web.example.com").Return(&domain.ProxyTarget{
88 Host: "127.0.0.1",
89 Port: backendPort,
90 ContainerID: "web-1",
91 Scheme: "http",
92 }, nil)
93 proxySvc.EXPECT().TrackInFlight("web-1").Return(func() {})
94
95 handler := NewHandler(proxySvc, nil, zerowrap.Default())
96
97 req := httptest.NewRequest(http.MethodGet, "http://web.example.com/", nil)
98 req.Host = "web.example.com"
99 rec := httptest.NewRecorder()
100
101 handler.ServeHTTP(rec, req)
102
103 assert.Equal(t, http.StatusOK, rec.Code)
104 assert.Contains(t, rec.Body.String(), "HTTP/1.1")
105}
106
107func TestTransportForTarget_SelectsCorrectTransport(t *testing.T) {
108 handler := NewHandler(inmocks.NewMockProxyService(t), nil, zerowrap.Default())
109
110 h2cTarget := &domain.ProxyTarget{Protocol: "h2c"}
111 httpTarget := &domain.ProxyTarget{Protocol: ""}
112
113 assert.Equal(t, handler.h2cTransport, handler.transportForTarget(h2cTarget))
114 assert.Equal(t, handler.appTransport, handler.transportForTarget(httpTarget))
115}
116
117func TestForwardToTarget_OriginalHostPreserved(t *testing.T) {
118 var receivedHost string
119 backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
120 receivedHost = r.Host
121 w.WriteHeader(http.StatusOK)
122 }))
123 defer backend.Close()
124
125 backendPort := backend.Listener.Addr().(*net.TCPAddr).Port
126
127 proxySvc := inmocks.NewMockProxyService(t)
128
129 proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
130 proxySvc.EXPECT().IsRegistryDomain("external.example.com").Return(false)
131 proxySvc.EXPECT().GetTarget(mock.Anything, "external.example.com").Return(&domain.ProxyTarget{
132 Host: "127.0.0.1",
133 Port: backendPort,
134 ContainerID: "",
135 Scheme: "http",
136 OriginalHost: "upstream.example.com",
137 }, nil)
138 proxySvc.EXPECT().TrackInFlight("").Return(func() {})
139
140 handler := NewHandler(proxySvc, nil, zerowrap.Default())
141
142 req := httptest.NewRequest(http.MethodGet, "http://external.example.com/", nil)
143 req.Host = "external.example.com"
144 w := httptest.NewRecorder()
145 handler.ServeHTTP(w, req)
146
147 assert.Equal(t, http.StatusOK, w.Code)
148 assert.Contains(t, receivedHost, "upstream.example.com")
149}
150
151func TestForwardToTarget_BackendDown_Returns503(t *testing.T) {
152 proxySvc := inmocks.NewMockProxyService(t)
153
154 proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
155 proxySvc.EXPECT().IsRegistryDomain("down.example.com").Return(false)
156 proxySvc.EXPECT().GetTarget(mock.Anything, "down.example.com").Return(&domain.ProxyTarget{
157 Host: "127.0.0.1",
158 Port: 1,
159 ContainerID: "c-down",
160 Scheme: "http",
161 }, nil)
162 proxySvc.EXPECT().TrackInFlight("c-down").Return(func() {})
163
164 handler := NewHandler(proxySvc, nil, zerowrap.Default())
165
166 req := httptest.NewRequest(http.MethodGet, "http://down.example.com/", nil)
167 req.Host = "down.example.com"
168 w := httptest.NewRecorder()
169 handler.ServeHTTP(w, req)
170
171 assert.Equal(t, http.StatusServiceUnavailable, w.Code)
172}
173
174func TestForwardToTarget_ProxyHeaderSet(t *testing.T) {
175 backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
176 w.WriteHeader(http.StatusOK)
177 }))
178 defer backend.Close()
179
180 backendPort := backend.Listener.Addr().(*net.TCPAddr).Port
181
182 proxySvc := inmocks.NewMockProxyService(t)
183
184 proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
185 proxySvc.EXPECT().IsRegistryDomain("app.example.com").Return(false)
186 proxySvc.EXPECT().GetTarget(mock.Anything, "app.example.com").Return(&domain.ProxyTarget{
187 Host: "127.0.0.1",
188 Port: backendPort,
189 ContainerID: "c-1",
190 Scheme: "http",
191 }, nil)
192 proxySvc.EXPECT().TrackInFlight("c-1").Return(func() {})
193
194 handler := NewHandler(proxySvc, nil, zerowrap.Default())
195
196 req := httptest.NewRequest(http.MethodGet, "http://app.example.com/", nil)
197 req.Host = "app.example.com"
198 w := httptest.NewRecorder()
199 handler.ServeHTTP(w, req)
200
201 assert.Equal(t, http.StatusOK, w.Code)
202}
203func TestNewReverseProxy_XForwardedProto(t *testing.T) {
204 trustedNets := middleware.ParseTrustedProxies([]string{"10.0.0.0/8"})
205
206 tests := []struct {
207 name string
208 remoteAddr string
209 incomingProto string // X-Forwarded-Proto on incoming request, empty = not set
210 wantProto string // expected X-Forwarded-Proto at backend
211 nets []*net.IPNet
212 }{
213 {
214 name: "trusted proxy with https preserved",
215 remoteAddr: "10.0.0.1:12345",
216 incomingProto: "https",
217 wantProto: "https",
218 nets: trustedNets,
219 },
220 {
221 name: "untrusted source proto overwritten",
222 remoteAddr: "1.2.3.4:12345",
223 incomingProto: "https",
224 wantProto: "http",
225 nets: trustedNets,
226 },
227 {
228 name: "trusted proxy without proto set normally",
229 remoteAddr: "10.0.0.1:12345",
230 incomingProto: "",
231 wantProto: "http",
232 nets: trustedNets,
233 },
234 {
235 name: "no trusted nets proto not preserved",
236 remoteAddr: "10.0.0.1:12345",
237 incomingProto: "https",
238 wantProto: "http",
239 nets: nil,
240 },
241 {
242 name: "bare IPv4 no port trusted source preserves proto",
243 remoteAddr: "10.0.0.1",
244 incomingProto: "https",
245 wantProto: "https",
246 nets: trustedNets,
247 },
248 {
249 name: "bare IPv6 with brackets trusted source preserves proto",
250 remoteAddr: "[::1]",
251 incomingProto: "https",
252 wantProto: "https",
253 nets: middleware.ParseTrustedProxies([]string{"::1/128"}),
254 },
255 {
256 name: "bare IPv4 no port untrusted source strips proto",
257 remoteAddr: "1.2.3.4",
258 incomingProto: "https",
259 wantProto: "http",
260 nets: trustedNets,
261 },
262 }
263
264 for _, tt := range tests {
265 t.Run(tt.name, func(t *testing.T) {
266 var gotProto string
267 backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
268 gotProto = r.Header.Get("X-Forwarded-Proto")
269 w.WriteHeader(http.StatusOK)
270 }))
271 defer backend.Close()
272
273 backendURL, err := url.Parse(backend.URL)
274 require.NoError(t, err)
275
276 // Build incoming request (http scheme, no TLS)
277 incomingReq := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
278 incomingReq.RemoteAddr = tt.remoteAddr
279 if tt.incomingProto != "" {
280 incomingReq.Header.Set("X-Forwarded-Proto", tt.incomingProto)
281 }
282
283 proxy := newReverseProxy(backendURL, "", http.DefaultTransport, incomingReq, tt.nets,
284 func(w http.ResponseWriter, _ *http.Request, err error) {
285 http.Error(w, err.Error(), http.StatusBadGateway)
286 },
287 nil,
288 )
289
290 rec := httptest.NewRecorder()
291 proxy.ServeHTTP(rec, incomingReq)
292
293 assert.Equal(t, tt.wantProto, gotProto)
294 })
295 }
296}