feat: implement HTTP route handling with prefix matching and response rewriting
Kim committed
Apr 27, 2026 at 11:16 UTC
bd7be167c6e7aaa237800a36d0e9949132e4ad19
5 files changed
+382
-298
cmd/portal-tunnel/http_routes.go
deleted
-293
@@ -1,293 +0,0 @@
1
-package main
2
-
3
-import (
4
- "errors"
5
- "fmt"
6
- "net"
7
- "net/http"
8
- "net/http/httputil"
9
- "net/url"
10
- "sort"
11
- "strings"
12
-
13
- "github.com/rs/zerolog/log"
14
-
15
- "github.com/gosuda/portal-tunnel/v2/utils"
16
-)
17
-
18
-type httpRoute struct {
19
- prefix string
20
- prefixSlash string
21
- upstream *url.URL
22
- upstreamPath string
23
- upstreamPathSlash string
24
- upstreamDomain string
25
- proxy *httputil.ReverseProxy
26
-}
27
-
28
-func newHTTPRouteHandler(rawRoutes []string) (http.Handler, error) {
29
- if len(rawRoutes) == 0 {
30
- return nil, errors.New("at least one --http-route is required")
31
- }
32
-
33
- routes := make([]*httpRoute, 0, len(rawRoutes))
34
- seen := make(map[string]struct{}, len(rawRoutes))
35
- for _, raw := range rawRoutes {
36
- route, err := parseHTTPRoute(raw)
37
- if err != nil {
38
- return nil, err
39
- }
40
- if _, ok := seen[route.prefix]; ok {
41
- return nil, fmt.Errorf("duplicate --http-route prefix %q", route.prefix)
42
- }
43
- seen[route.prefix] = struct{}{}
44
- route.proxy = route.newReverseProxy()
45
- routes = append(routes, route)
46
- }
47
-
48
- // longest-prefix-first
49
- sort.Slice(routes, func(i, j int) bool {
50
- if len(routes[i].prefix) == len(routes[j].prefix) {
51
- return routes[i].prefix < routes[j].prefix
52
- }
53
- return len(routes[i].prefix) > len(routes[j].prefix)
54
- })
55
-
56
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
57
- p := r.URL.Path
58
- if p == "" {
59
- p = "/"
60
- }
61
- for _, route := range routes {
62
- if route.prefix == "/" || p == route.prefix || strings.HasPrefix(p, route.prefixSlash) {
63
- route.proxy.ServeHTTP(w, r)
64
- return
65
- }
66
- }
67
- http.NotFound(w, r)
68
- }), nil
69
-}
70
-
71
-func parseHTTPRoute(raw string) (*httpRoute, error) {
72
- raw = strings.TrimSpace(raw)
73
- if raw == "" {
74
- return nil, errors.New("--http-route: expected PATH=UPSTREAM")
75
- }
76
-
77
- prefixRaw, upstreamRaw, ok := strings.Cut(raw, "=")
78
- if !ok {
79
- return nil, fmt.Errorf("--http-route %q: expected PATH=UPSTREAM", raw)
80
- }
81
-
82
- prefix := strings.TrimSpace(prefixRaw)
83
- if prefix == "" {
84
- return nil, fmt.Errorf("--http-route %q: prefix is required", raw)
85
- }
86
- if !strings.HasPrefix(prefix, "/") {
87
- return nil, fmt.Errorf("--http-route %q: prefix must start with /", raw)
88
- }
89
- prefix = utils.NormalizeURLPath(prefix)
90
-
91
- upstreamInput := strings.TrimSpace(upstreamRaw)
92
- if upstreamInput == "" {
93
- return nil, fmt.Errorf("--http-route %q: upstream is required", raw)
94
- }
95
- if !strings.Contains(upstreamInput, "://") {
96
- target, err := utils.NormalizeLoopbackTarget(upstreamInput)
97
- if err != nil {
98
- return nil, fmt.Errorf("--http-route %q: %w", raw, err)
99
- }
100
- upstreamInput = "http://" + target
101
- }
102
-
103
- upstream, err := url.Parse(upstreamInput)
104
- if err != nil {
105
- return nil, fmt.Errorf("--http-route %q: %w", raw, err)
106
- }
107
- if upstream.Host == "" {
108
- return nil, fmt.Errorf("--http-route %q: upstream host is required", raw)
109
- }
110
- if upstream.Scheme != "http" && upstream.Scheme != "https" {
111
- return nil, fmt.Errorf("--http-route %q: scheme must be http or https", raw)
112
- }
113
- upstream.Fragment = ""
114
- upstream.Path = utils.NormalizeURLPath(upstream.Path)
115
-
116
- route := &httpRoute{
117
- prefix: prefix,
118
- upstream: upstream,
119
- upstreamPath: upstream.Path,
120
- upstreamDomain: utils.NormalizeHostname(upstream.Hostname()),
121
- }
122
- if prefix != "/" {
123
- route.prefixSlash = prefix + "/"
124
- }
125
- if upstream.Path != "/" {
126
- route.upstreamPathSlash = upstream.Path + "/"
127
- }
128
- return route, nil
129
-}
130
-
131
-func (r *httpRoute) newReverseProxy() *httputil.ReverseProxy {
132
- return &httputil.ReverseProxy{
133
- Rewrite: r.rewriteRequest,
134
- ModifyResponse: r.rewriteResponse,
135
- ErrorHandler: func(w http.ResponseWriter, req *http.Request, err error) {
136
- log.Error().Err(err).
137
- Str("route_prefix", r.prefix).
138
- Str("upstream", r.upstream.String()).
139
- Msg("http route proxy failed")
140
- http.Error(w, "bad gateway", http.StatusBadGateway)
141
- },
142
- }
143
-}
144
-
145
-func (r *httpRoute) rewriteRequest(pr *httputil.ProxyRequest) {
146
- pr.Out.URL.Path, pr.Out.URL.RawPath = r.publicRequestPathToUpstream(pr.In.URL.Path, pr.In.URL.RawPath)
147
- pr.Out.URL.RawQuery = pr.In.URL.RawQuery
148
- pr.SetURL(r.upstream)
149
- pr.SetXForwarded()
150
-
151
- // SetXForwarded checks pr.In.TLS, but behind a TLS-terminating proxy
152
- // the inbound X-Forwarded-Proto carries the real client scheme.
153
- if pr.In.TLS == nil {
154
- proto, _, _ := strings.Cut(pr.In.Header.Get("X-Forwarded-Proto"), ",")
155
- if proto = strings.ToLower(strings.TrimSpace(proto)); proto != "" {
156
- pr.Out.Header.Set("X-Forwarded-Proto", proto)
157
- }
158
- }
159
-
160
- if r.prefix != "/" {
161
- pr.Out.Header.Set("X-Forwarded-Prefix", r.prefix)
162
- }
163
-}
164
-
165
-func (r *httpRoute) rewriteResponse(resp *http.Response) error {
166
- if resp == nil || resp.Request == nil {
167
- return nil
168
- }
169
-
170
- header := resp.Header
171
- publicHost := resp.Request.Header.Get("X-Forwarded-Host")
172
- publicScheme := resp.Request.Header.Get("X-Forwarded-Proto")
173
-
174
- location := header.Get("Location")
175
- if location != "" {
176
- parsed, err := url.Parse(location)
177
- if err == nil {
178
- switch {
179
- case parsed.IsAbs():
180
- if strings.EqualFold(parsed.Scheme, r.upstream.Scheme) && strings.EqualFold(parsed.Host, r.upstream.Host) {
181
- parsed.Scheme = publicScheme
182
- parsed.Host = publicHost
183
- } else {
184
- parsed = nil
185
- }
186
- case strings.HasPrefix(location, "/") && parsed.Host == "" && (len(location) == 1 || (location[1] != '\\' && location[1] != '/')):
187
- // server-relative redirect
188
- default:
189
- parsed = nil
190
- }
191
-
192
- if parsed != nil {
193
- mapped := r.upstreamPathToPublic(parsed.Path)
194
- if strings.HasPrefix(mapped, "/") && (len(mapped) == 1 || (mapped[1] != '/' && mapped[1] != '\\')) {
195
- parsed.Path = mapped
196
- parsed.RawPath = ""
197
- header.Set("Location", parsed.String())
198
- }
199
- }
200
- }
201
- }
202
-
203
- values := header.Values("Set-Cookie")
204
- if len(values) == 0 {
205
- return nil
206
- }
207
-
208
- publicDomain := publicHost
209
- if host, port, err := net.SplitHostPort(publicDomain); err == nil && port != "" {
210
- publicDomain = host
211
- }
212
- publicDomain = utils.NormalizeHostname(strings.Trim(publicDomain, "[]"))
213
-
214
- header.Del("Set-Cookie")
215
- for _, value := range values {
216
- cookie, err := http.ParseSetCookie(value)
217
- if err != nil {
218
- header.Add("Set-Cookie", value)
219
- continue
220
- }
221
-
222
- changed := false
223
- if cookie.Path != "" {
224
- if rewritten := r.upstreamPathToPublic(cookie.Path); rewritten != cookie.Path {
225
- cookie.Path = rewritten
226
- changed = true
227
- }
228
- }
229
-
230
- domain := utils.NormalizeHostname(strings.TrimPrefix(cookie.Domain, "."))
231
- if domain != "" && domain != publicDomain &&
232
- (domain == r.upstreamDomain || utils.IsLocalRelayHost(domain)) {
233
- cookie.Domain = ""
234
- changed = true
235
- }
236
-
237
- if changed {
238
- header.Add("Set-Cookie", cookie.String())
239
- continue
240
- }
241
- header.Add("Set-Cookie", value)
242
- }
243
-
244
- return nil
245
-}
246
-
247
-func (r *httpRoute) publicRequestPathToUpstream(path, rawPath string) (string, string) {
248
- path = utils.NormalizeURLPath(path)
249
- if r.prefix == "/" {
250
- return path, rawPath
251
- }
252
- if path == r.prefix {
253
- return "/", ""
254
- }
255
- path = strings.TrimPrefix(path, r.prefix)
256
- if path == "" {
257
- path = "/"
258
- }
259
-
260
- if rawPath != "" {
261
- switch {
262
- case rawPath == r.prefix:
263
- rawPath = "/"
264
- case strings.HasPrefix(rawPath, r.prefixSlash):
265
- rawPath = strings.TrimPrefix(rawPath, r.prefix)
266
- }
267
- }
268
- return path, rawPath
269
-}
270
-
271
-func (r *httpRoute) upstreamPathToPublic(raw string) string {
272
- raw = utils.NormalizeURLPath(raw)
273
- if r.prefix != "/" && (raw == r.prefix || strings.HasPrefix(raw, r.prefixSlash)) {
274
- return raw
275
- }
276
-
277
- rest := raw
278
- if r.upstreamPath != "/" {
279
- if raw == r.upstreamPath {
280
- rest = "/"
281
- } else if strings.HasPrefix(raw, r.upstreamPathSlash) {
282
- rest = strings.TrimPrefix(raw, r.upstreamPath)
283
- }
284
- }
285
-
286
- if r.prefix == "/" {
287
- return rest
288
- }
289
- if rest == "/" {
290
- return r.prefix
291
- }
292
- return r.prefix + rest
293
-}
cmd/portal-tunnel/main.go
+14
-5
@@ -9,6 +9,7 @@ import (
9
"os"
10
"path/filepath"
11
"runtime"
12
+ "strings"
13
"sync"
14
"time"
15
@@ -112,6 +113,7 @@ func runExposeCommand(args []string) error {
113
printExposeUsage(os.Stderr)
114
return errors.New("--udp cannot be combined with --http-route")
115
}
116
+
117
ctx, stop := utils.SignalContext()
118
defer stop()
119
@@ -142,13 +144,20 @@ func runExposeCommand(args []string) error {
144
}
145
printUpdateHint(updateCh)
146
if len(flags.httpRoutes) > 0 {
145
- handler, err := newHTTPRouteHandler(flags.httpRoutes)
146
- if err != nil {
147
- _ = exposure.Close()
148
- return err
147
+ httpRoutes := make([]sdk.HTTPRoute, 0, len(flags.httpRoutes))
148
+ for _, raw := range flags.httpRoutes {
149
+ prefix, upstream, ok := strings.Cut(raw, "=")
150
+ if !ok {
151
+ return fmt.Errorf("--http-route %q: expected PATH=UPSTREAM", raw)
152
+ }
153
+ httpRoutes = append(httpRoutes, sdk.HTTPRoute{
154
+ Prefix: strings.TrimSpace(prefix),
155
+ Upstream: strings.TrimSpace(upstream),
156
+ })
157
}
158
+
159
defer exposure.Close()
151
- return exposure.RunHTTP(ctx, handler, "")
160
+ return exposure.RunHTTPRoutes(ctx, httpRoutes, "")
161
}
162
return proxyExposure(ctx, exposure)
163
}
sdk/expose.go
+9
@@ -301,6 +301,15 @@ func (e *Exposure) WaitDatagramReady(ctx context.Context) ([]string, error) {
301
}
302
}
303
304
+// RunHTTPRoutes serves path-routed HTTP upstreams through the exposure.
305
+func (e *Exposure) RunHTTPRoutes(ctx context.Context, routes []HTTPRoute, localAddr string) error {
306
+ handler, err := newHTTPRouteHandler(routes)
307
+ if err != nil {
308
+ return err
309
+ }
310
+ return e.RunHTTP(ctx, handler, localAddr)
311
+}
312
+
313
func (e *Exposure) RunHTTP(ctx context.Context, handler http.Handler, localAddr string) error {
314
if handler == nil {
315
handler = http.NotFoundHandler()
sdk/http.go
+279
@@ -9,11 +9,17 @@ import (
9
"io"
10
"net"
11
"net/http"
12
+ "net/http/httputil"
13
+ "net/url"
14
+ "sort"
15
"strconv"
16
"strings"
17
"sync"
18
19
"github.com/andybalholm/brotli"
20
+ "github.com/rs/zerolog/log"
21
+
22
+ "github.com/gosuda/portal-tunnel/v2/utils"
23
)
24
25
func RunHTTP(ctx context.Context, relayListener net.Listener, handler http.Handler, localAddr string) error {
@@ -119,6 +125,279 @@ func RunHTTP(ctx context.Context, relayListener net.Listener, handler http.Handl
125
return errors.Join(serveErr, shutdownErr)
126
}
127
128
+// HTTPRoute maps one public path prefix to one local HTTP upstream.
129
+type HTTPRoute struct {
130
+ // Prefix is the public request path prefix, such as "/api" or "/".
131
+ Prefix string
132
+ // Upstream is the target HTTP URL, or a loopback host:port shorthand.
133
+ Upstream string
134
+}
135
+
136
+type httpRoute struct {
137
+ prefix string
138
+ prefixSlash string
139
+ upstream *url.URL
140
+ upstreamPath string
141
+ upstreamPathSlash string
142
+ upstreamDomain string
143
+ proxy *httputil.ReverseProxy
144
+}
145
+
146
+func newHTTPRouteHandler(routeConfigs []HTTPRoute) (http.Handler, error) {
147
+ if len(routeConfigs) == 0 {
148
+ return nil, errors.New("at least one http route is required")
149
+ }
150
+
151
+ routes := make([]*httpRoute, 0, len(routeConfigs))
152
+ seen := make(map[string]struct{}, len(routeConfigs))
153
+ for _, routeConfig := range routeConfigs {
154
+ route, err := newHTTPRoute(routeConfig)
155
+ if err != nil {
156
+ return nil, err
157
+ }
158
+ if _, ok := seen[route.prefix]; ok {
159
+ return nil, fmt.Errorf("duplicate http route prefix %q", route.prefix)
160
+ }
161
+ seen[route.prefix] = struct{}{}
162
+ route.proxy = route.newReverseProxy()
163
+ routes = append(routes, route)
164
+ }
165
+
166
+ sort.Slice(routes, func(i, j int) bool {
167
+ if len(routes[i].prefix) == len(routes[j].prefix) {
168
+ return routes[i].prefix < routes[j].prefix
169
+ }
170
+ return len(routes[i].prefix) > len(routes[j].prefix)
171
+ })
172
+
173
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
174
+ p := r.URL.Path
175
+ if p == "" {
176
+ p = "/"
177
+ }
178
+ for _, route := range routes {
179
+ if route.prefix == "/" || p == route.prefix || strings.HasPrefix(p, route.prefixSlash) {
180
+ route.proxy.ServeHTTP(w, r)
181
+ return
182
+ }
183
+ }
184
+ http.NotFound(w, r)
185
+ }), nil
186
+}
187
+
188
+func newHTTPRoute(routeConfig HTTPRoute) (*httpRoute, error) {
189
+ prefix := strings.TrimSpace(routeConfig.Prefix)
190
+ if prefix == "" {
191
+ return nil, errors.New("http route prefix is required")
192
+ }
193
+ if !strings.HasPrefix(prefix, "/") {
194
+ return nil, fmt.Errorf("http route prefix %q must start with /", prefix)
195
+ }
196
+ prefix = utils.NormalizeURLPath(prefix)
197
+
198
+ upstreamInput := strings.TrimSpace(routeConfig.Upstream)
199
+ if upstreamInput == "" {
200
+ return nil, fmt.Errorf("http route %q upstream is required", prefix)
201
+ }
202
+ if !strings.Contains(upstreamInput, "://") {
203
+ target, err := utils.NormalizeLoopbackTarget(upstreamInput)
204
+ if err != nil {
205
+ return nil, fmt.Errorf("http route %q upstream: %w", prefix, err)
206
+ }
207
+ upstreamInput = "http://" + target
208
+ }
209
+
210
+ upstream, err := url.Parse(upstreamInput)
211
+ if err != nil {
212
+ return nil, fmt.Errorf("http route %q upstream: %w", prefix, err)
213
+ }
214
+ if upstream.Host == "" {
215
+ return nil, fmt.Errorf("http route %q upstream host is required", prefix)
216
+ }
217
+ if upstream.Scheme != "http" && upstream.Scheme != "https" {
218
+ return nil, fmt.Errorf("http route %q upstream scheme must be http or https", prefix)
219
+ }
220
+ upstream.Fragment = ""
221
+ upstream.Path = utils.NormalizeURLPath(upstream.Path)
222
+
223
+ route := &httpRoute{
224
+ prefix: prefix,
225
+ upstream: upstream,
226
+ upstreamPath: upstream.Path,
227
+ upstreamDomain: utils.NormalizeHostname(upstream.Hostname()),
228
+ }
229
+ if prefix != "/" {
230
+ route.prefixSlash = prefix + "/"
231
+ }
232
+ if upstream.Path != "/" {
233
+ route.upstreamPathSlash = upstream.Path + "/"
234
+ }
235
+ return route, nil
236
+}
237
+
238
+func (r *httpRoute) newReverseProxy() *httputil.ReverseProxy {
239
+ return &httputil.ReverseProxy{
240
+ Rewrite: r.rewriteRequest,
241
+ ModifyResponse: r.rewriteResponse,
242
+ ErrorHandler: func(w http.ResponseWriter, req *http.Request, err error) {
243
+ log.Error().Err(err).
244
+ Str("route_prefix", r.prefix).
245
+ Str("upstream", r.upstream.String()).
246
+ Msg("http route proxy failed")
247
+ http.Error(w, "bad gateway", http.StatusBadGateway)
248
+ },
249
+ }
250
+}
251
+
252
+func (r *httpRoute) rewriteRequest(pr *httputil.ProxyRequest) {
253
+ pr.Out.URL.Path, pr.Out.URL.RawPath = r.publicRequestPathToUpstream(pr.In.URL.Path, pr.In.URL.RawPath)
254
+ pr.Out.URL.RawQuery = pr.In.URL.RawQuery
255
+ pr.SetURL(r.upstream)
256
+ pr.SetXForwarded()
257
+
258
+ // SetXForwarded checks pr.In.TLS, but behind a TLS-terminating proxy
259
+ // the inbound X-Forwarded-Proto carries the real client scheme.
260
+ if pr.In.TLS == nil {
261
+ proto, _, _ := strings.Cut(pr.In.Header.Get("X-Forwarded-Proto"), ",")
262
+ if proto = strings.ToLower(strings.TrimSpace(proto)); proto != "" {
263
+ pr.Out.Header.Set("X-Forwarded-Proto", proto)
264
+ }
265
+ }
266
+
267
+ if r.prefix != "/" {
268
+ pr.Out.Header.Set("X-Forwarded-Prefix", r.prefix)
269
+ }
270
+}
271
+
272
+func (r *httpRoute) rewriteResponse(resp *http.Response) error {
273
+ if resp == nil || resp.Request == nil {
274
+ return nil
275
+ }
276
+
277
+ header := resp.Header
278
+ publicHost := resp.Request.Header.Get("X-Forwarded-Host")
279
+ publicScheme := resp.Request.Header.Get("X-Forwarded-Proto")
280
+
281
+ location := header.Get("Location")
282
+ if location != "" {
283
+ parsed, err := url.Parse(location)
284
+ if err == nil {
285
+ switch {
286
+ case parsed.IsAbs():
287
+ if strings.EqualFold(parsed.Scheme, r.upstream.Scheme) && strings.EqualFold(parsed.Host, r.upstream.Host) {
288
+ parsed.Scheme = publicScheme
289
+ parsed.Host = publicHost
290
+ } else {
291
+ parsed = nil
292
+ }
293
+ case strings.HasPrefix(location, "/") && parsed.Host == "" && (len(location) == 1 || (location[1] != '\\' && location[1] != '/')):
294
+ default:
295
+ parsed = nil
296
+ }
297
+
298
+ if parsed != nil {
299
+ mapped := r.upstreamPathToPublic(parsed.Path)
300
+ if strings.HasPrefix(mapped, "/") && (len(mapped) == 1 || (mapped[1] != '/' && mapped[1] != '\\')) {
301
+ parsed.Path = mapped
302
+ parsed.RawPath = ""
303
+ header.Set("Location", parsed.String())
304
+ }
305
+ }
306
+ }
307
+ }
308
+
309
+ values := header.Values("Set-Cookie")
310
+ if len(values) == 0 {
311
+ return nil
312
+ }
313
+
314
+ publicDomain := publicHost
315
+ if host, port, err := net.SplitHostPort(publicDomain); err == nil && port != "" {
316
+ publicDomain = host
317
+ }
318
+ publicDomain = utils.NormalizeHostname(strings.Trim(publicDomain, "[]"))
319
+
320
+ header.Del("Set-Cookie")
321
+ for _, value := range values {
322
+ cookie, err := http.ParseSetCookie(value)
323
+ if err != nil {
324
+ header.Add("Set-Cookie", value)
325
+ continue
326
+ }
327
+
328
+ changed := false
329
+ if cookie.Path != "" {
330
+ if rewritten := r.upstreamPathToPublic(cookie.Path); rewritten != cookie.Path {
331
+ cookie.Path = rewritten
332
+ changed = true
333
+ }
334
+ }
335
+
336
+ domain := utils.NormalizeHostname(strings.TrimPrefix(cookie.Domain, "."))
337
+ if domain != "" && domain != publicDomain &&
338
+ (domain == r.upstreamDomain || utils.IsLocalRelayHost(domain)) {
339
+ cookie.Domain = ""
340
+ changed = true
341
+ }
342
+
343
+ if changed {
344
+ header.Add("Set-Cookie", cookie.String())
345
+ continue
346
+ }
347
+ header.Add("Set-Cookie", value)
348
+ }
349
+
350
+ return nil
351
+}
352
+
353
+func (r *httpRoute) publicRequestPathToUpstream(path, rawPath string) (string, string) {
354
+ path = utils.NormalizeURLPath(path)
355
+ if r.prefix == "/" {
356
+ return path, rawPath
357
+ }
358
+ if path == r.prefix {
359
+ return "/", ""
360
+ }
361
+ path = strings.TrimPrefix(path, r.prefix)
362
+ if path == "" {
363
+ path = "/"
364
+ }
365
+
366
+ if rawPath != "" {
367
+ switch {
368
+ case rawPath == r.prefix:
369
+ rawPath = "/"
370
+ case strings.HasPrefix(rawPath, r.prefixSlash):
371
+ rawPath = strings.TrimPrefix(rawPath, r.prefix)
372
+ }
373
+ }
374
+ return path, rawPath
375
+}
376
+
377
+func (r *httpRoute) upstreamPathToPublic(raw string) string {
378
+ raw = utils.NormalizeURLPath(raw)
379
+ if r.prefix != "/" && (raw == r.prefix || strings.HasPrefix(raw, r.prefixSlash)) {
380
+ return raw
381
+ }
382
+
383
+ rest := raw
384
+ if r.upstreamPath != "/" {
385
+ if raw == r.upstreamPath {
386
+ rest = "/"
387
+ } else if strings.HasPrefix(raw, r.upstreamPathSlash) {
388
+ rest = strings.TrimPrefix(raw, r.upstreamPath)
389
+ }
390
+ }
391
+
392
+ if r.prefix == "/" {
393
+ return rest
394
+ }
395
+ if rest == "/" {
396
+ return r.prefix
397
+ }
398
+ return r.prefix + rest
399
+}
400
+
401
func serveCompressedHTTP(handler http.Handler, w http.ResponseWriter, r *http.Request) {
402
if handler == nil {
403
http.NotFound(w, r)
sdk/http_test.go
+80
@@ -3,6 +3,7 @@ package sdk
3
import (
4
"net/http"
5
"net/http/httptest"
6
+ "strings"
7
"testing"
8
)
9
@@ -124,3 +125,82 @@ func TestServeCompressedHTTPIgnoresSmallThresholdWithoutContentLength(t *testing
125
t.Fatalf("Content-Encoding = %q, want gzip", got)
126
}
127
}
128
+
129
+func TestHTTPRoutesUseLongestPrefix(t *testing.T) {
130
+ t.Parallel()
131
+
132
+ gotPath := make(chan string, 1)
133
+ apiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
134
+ gotPath <- r.URL.RequestURI()
135
+ _, _ = w.Write([]byte("api"))
136
+ }))
137
+ defer apiServer.Close()
138
+
139
+ rootServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
140
+ _, _ = w.Write([]byte("root"))
141
+ }))
142
+ defer rootServer.Close()
143
+
144
+ handler, err := newHTTPRouteHandler([]HTTPRoute{
145
+ {Prefix: "/", Upstream: rootServer.URL},
146
+ {Prefix: "/api", Upstream: apiServer.URL},
147
+ })
148
+ if err != nil {
149
+ t.Fatalf("newHTTPRouteHandler() error = %v", err)
150
+ }
151
+
152
+ req := httptest.NewRequest(http.MethodGet, "https://public.example/api/users?active=true", nil)
153
+ rec := httptest.NewRecorder()
154
+ handler.ServeHTTP(rec, req)
155
+
156
+ if got := rec.Body.String(); got != "api" {
157
+ t.Fatalf("body = %q, want api", got)
158
+ }
159
+ if got := <-gotPath; got != "/users?active=true" {
160
+ t.Fatalf("upstream path = %q, want /users?active=true", got)
161
+ }
162
+}
163
+
164
+func TestHTTPRoutesRewriteResponseHeaders(t *testing.T) {
165
+ t.Parallel()
166
+
167
+ var upstreamURL string
168
+ upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
169
+ w.Header().Set("Location", upstreamURL+"/base/login")
170
+ http.SetCookie(w, &http.Cookie{Name: "sid", Value: "1", Path: "/base/session"})
171
+ w.WriteHeader(http.StatusFound)
172
+ }))
173
+ defer upstreamServer.Close()
174
+ upstreamURL = upstreamServer.URL
175
+
176
+ handler, err := newHTTPRouteHandler([]HTTPRoute{
177
+ {Prefix: "/app", Upstream: upstreamURL + "/base"},
178
+ })
179
+ if err != nil {
180
+ t.Fatalf("newHTTPRouteHandler() error = %v", err)
181
+ }
182
+
183
+ req := httptest.NewRequest(http.MethodGet, "http://public.example/app/dashboard", nil)
184
+ req.Header.Set("X-Forwarded-Proto", "https")
185
+ rec := httptest.NewRecorder()
186
+ handler.ServeHTTP(rec, req)
187
+
188
+ if got := rec.Header().Get("Location"); got != "https://public.example/app/login" {
189
+ t.Fatalf("Location = %q, want https://public.example/app/login", got)
190
+ }
191
+ if got := rec.Header().Get("Set-Cookie"); !strings.Contains(got, "Path=/app/session") {
192
+ t.Fatalf("Set-Cookie = %q, want rewritten path", got)
193
+ }
194
+}
195
+
196
+func TestHTTPRoutesRejectDuplicateNormalizedPrefixes(t *testing.T) {
197
+ t.Parallel()
198
+
199
+ _, err := newHTTPRouteHandler([]HTTPRoute{
200
+ {Prefix: "/api", Upstream: "127.0.0.1:3001"},
201
+ {Prefix: "/api/", Upstream: "127.0.0.1:3002"},
202
+ })
203
+ if err == nil {
204
+ t.Fatal("newHTTPRouteHandler() error = nil, want duplicate prefix error")
205
+ }
206
+}