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 gordonhttp "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
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.Equal(t, net.JoinHostPort("upstream.example.com", strconv.Itoa(backendPort)), receivedHost)
149}
150
151func TestForwardToTarget_RouteHostPreservedForManagedTarget(t *testing.T) {
152 var receivedHost string
153 var receivedForwardedHost string
154 backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
155 receivedHost = r.Host
156 receivedForwardedHost = r.Header.Get("X-Forwarded-Host")
157 w.WriteHeader(http.StatusOK)
158 }))
159 defer backend.Close()
160
161 backendPort := backend.Listener.Addr().(*net.TCPAddr).Port
162
163 proxySvc := inmocks.NewMockProxyService(t)
164
165 proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
166 proxySvc.EXPECT().IsRegistryDomain("app.example.com").Return(false)
167 proxySvc.EXPECT().GetTarget(mock.Anything, "app.example.com").Return(&domain.ProxyTarget{
168 Host: "127.0.0.1",
169 Port: backendPort,
170 ContainerID: "c-1",
171 Scheme: "http",
172 RouteHost: "app.example.com",
173 }, nil)
174 proxySvc.EXPECT().TrackInFlight("c-1").Return(func() {})
175
176 handler := NewHandler(proxySvc, nil, zerowrap.Default())
177
178 req := httptest.NewRequest(http.MethodGet, "http://app.example.com/", nil)
179 req.Host = "App.Example.com:443"
180 w := httptest.NewRecorder()
181 handler.ServeHTTP(w, req)
182
183 assert.Equal(t, http.StatusOK, w.Code)
184 assert.Equal(t, "app.example.com", receivedHost)
185 assert.Equal(t, "app.example.com", receivedForwardedHost)
186}
187
188func TestForwardToTarget_BackendDown_Returns503(t *testing.T) {
189 proxySvc := inmocks.NewMockProxyService(t)
190
191 proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
192 proxySvc.EXPECT().IsRegistryDomain("down.example.com").Return(false)
193 proxySvc.EXPECT().GetTarget(mock.Anything, "down.example.com").Return(&domain.ProxyTarget{
194 Host: "127.0.0.1",
195 Port: 1,
196 ContainerID: "c-down",
197 Scheme: "http",
198 }, nil)
199 proxySvc.EXPECT().TrackInFlight("c-down").Return(func() {})
200
201 handler := NewHandler(proxySvc, nil, zerowrap.Default())
202
203 req := httptest.NewRequest(http.MethodGet, "http://down.example.com/", nil)
204 req.Host = "down.example.com"
205 w := httptest.NewRecorder()
206 handler.ServeHTTP(w, req)
207
208 assert.Equal(t, http.StatusServiceUnavailable, w.Code)
209}
210
211func TestForwardToTarget_ProxyHeaderSet(t *testing.T) {
212 backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
213 w.WriteHeader(http.StatusOK)
214 }))
215 defer backend.Close()
216
217 backendPort := backend.Listener.Addr().(*net.TCPAddr).Port
218
219 proxySvc := inmocks.NewMockProxyService(t)
220
221 proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
222 proxySvc.EXPECT().IsRegistryDomain("app.example.com").Return(false)
223 proxySvc.EXPECT().GetTarget(mock.Anything, "app.example.com").Return(&domain.ProxyTarget{
224 Host: "127.0.0.1",
225 Port: backendPort,
226 ContainerID: "c-1",
227 Scheme: "http",
228 }, nil)
229 proxySvc.EXPECT().TrackInFlight("c-1").Return(func() {})
230
231 handler := NewHandler(proxySvc, nil, zerowrap.Default())
232
233 req := httptest.NewRequest(http.MethodGet, "http://app.example.com/", nil)
234 req.Host = "app.example.com"
235 w := httptest.NewRecorder()
236 handler.ServeHTTP(w, req)
237
238 assert.Equal(t, http.StatusOK, w.Code)
239}
240func TestNewReverseProxy_XForwardedProto(t *testing.T) {
241 trustedNets := gordonhttp.ParseTrustedProxies([]string{"10.0.0.0/8"})
242
243 tests := []struct {
244 name string
245 remoteAddr string
246 incomingProto string // X-Forwarded-Proto on incoming request, empty = not set
247 wantProto string // expected X-Forwarded-Proto at backend
248 nets []*net.IPNet
249 }{
250 {
251 name: "trusted proxy with https preserved",
252 remoteAddr: "10.0.0.1:12345",
253 incomingProto: "https",
254 wantProto: "https",
255 nets: trustedNets,
256 },
257 {
258 name: "untrusted source proto overwritten",
259 remoteAddr: "1.2.3.4:12345",
260 incomingProto: "https",
261 wantProto: "http",
262 nets: trustedNets,
263 },
264 {
265 name: "trusted proxy without proto set normally",
266 remoteAddr: "10.0.0.1:12345",
267 incomingProto: "",
268 wantProto: "http",
269 nets: trustedNets,
270 },
271 {
272 name: "no trusted nets proto not preserved",
273 remoteAddr: "10.0.0.1:12345",
274 incomingProto: "https",
275 wantProto: "http",
276 nets: nil,
277 },
278 {
279 name: "bare IPv4 no port trusted source preserves proto",
280 remoteAddr: "10.0.0.1",
281 incomingProto: "https",
282 wantProto: "https",
283 nets: trustedNets,
284 },
285 {
286 name: "bare IPv6 with brackets trusted source preserves proto",
287 remoteAddr: "[::1]",
288 incomingProto: "https",
289 wantProto: "https",
290 nets: gordonhttp.ParseTrustedProxies([]string{"::1/128"}),
291 },
292 {
293 name: "bare IPv4 no port untrusted source strips proto",
294 remoteAddr: "1.2.3.4",
295 incomingProto: "https",
296 wantProto: "http",
297 nets: trustedNets,
298 },
299 }
300
301 for _, tt := range tests {
302 t.Run(tt.name, func(t *testing.T) {
303 var gotProto string
304 backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
305 gotProto = r.Header.Get("X-Forwarded-Proto")
306 w.WriteHeader(http.StatusOK)
307 }))
308 defer backend.Close()
309
310 backendURL, err := url.Parse(backend.URL)
311 require.NoError(t, err)
312
313 // Build incoming request (http scheme, no TLS)
314 incomingReq := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
315 incomingReq.RemoteAddr = tt.remoteAddr
316 if tt.incomingProto != "" {
317 incomingReq.Header.Set("X-Forwarded-Proto", tt.incomingProto)
318 }
319
320 proxy := newReverseProxy(reverseProxyOptions{
321 targetURL: backendURL,
322 transport: http.DefaultTransport,
323 incomingReq: incomingReq,
324 trustedNets: tt.nets,
325 errorHandler: func(w http.ResponseWriter, _ *http.Request, err error) {
326 http.Error(w, err.Error(), http.StatusBadGateway)
327 },
328 })
329
330 rec := httptest.NewRecorder()
331 proxy.ServeHTTP(rec, incomingReq)
332
333 assert.Equal(t, tt.wantProto, gotProto)
334 })
335 }
336}