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 +}