refactor: simplify http_routes.go to match codebase style
- Remove httpRouteMeta/httpRouteMetaKey context value indirection, read public host/scheme from X-Forwarded-* headers directly - Inline stripPrefix into rewriteRequest - Inline modifyResponse as closure in newReverseProxy - Simplify error messages and variable names Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Yechan Kim committed
Mar 26, 2026 at 00:35 UTC
d6c6488b58af426122911a34c6f0828bd3608eab
1 file changed
+92
-125
cmd/portal-tunnel/http_routes.go
+92
-125
@@ -1,7 +1,6 @@
1
package main
2
3
import (
4
- "context"
4
"errors"
5
"fmt"
6
"net"
@@ -22,13 +21,6 @@ type httpRoute struct {
21
proxy *httputil.ReverseProxy
22
}
23
25
-type httpRouteMeta struct {
26
- publicHost string
27
- publicScheme string
28
-}
29
-
30
-type httpRouteMetaKey struct{}
31
-
24
func newHTTPRouteHandler(rawRoutes []string) (http.Handler, error) {
25
if len(rawRoutes) == 0 {
26
return nil, errors.New("at least one --http-route is required")
@@ -36,8 +28,8 @@ func newHTTPRouteHandler(rawRoutes []string) (http.Handler, error) {
28
29
routes := make([]*httpRoute, 0, len(rawRoutes))
30
seen := make(map[string]struct{}, len(rawRoutes))
39
- for _, rawRoute := range rawRoutes {
40
- route, err := parseHTTPRoute(rawRoute)
31
+ for _, raw := range rawRoutes {
32
+ route, err := parseHTTPRoute(raw)
33
if err != nil {
34
return nil, err
35
}
@@ -49,6 +41,7 @@ func newHTTPRouteHandler(rawRoutes []string) (http.Handler, error) {
41
routes = append(routes, route)
42
}
43
44
+ // longest-prefix-first
45
sort.Slice(routes, func(i, j int) bool {
46
if len(routes[i].prefix) == len(routes[j].prefix) {
47
return routes[i].prefix < routes[j].prefix
@@ -57,12 +50,12 @@ func newHTTPRouteHandler(rawRoutes []string) (http.Handler, error) {
50
})
51
52
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
60
- requestPath := r.URL.Path
61
- if requestPath == "" {
62
- requestPath = "/"
53
+ p := r.URL.Path
54
+ if p == "" {
55
+ p = "/"
56
}
57
for _, route := range routes {
65
- if route.prefix == "/" || requestPath == route.prefix || strings.HasPrefix(requestPath, route.prefix+"/") {
58
+ if route.prefix == "/" || p == route.prefix || strings.HasPrefix(p, route.prefix+"/") {
59
route.proxy.ServeHTTP(w, r)
60
return
61
}
@@ -74,62 +67,66 @@ func newHTTPRouteHandler(rawRoutes []string) (http.Handler, error) {
67
func parseHTTPRoute(raw string) (*httpRoute, error) {
68
raw = strings.TrimSpace(raw)
69
if raw == "" {
77
- return nil, errors.New("invalid --http-route: expected PATH=UPSTREAM")
70
+ return nil, errors.New("--http-route: expected PATH=UPSTREAM")
71
}
72
73
prefixRaw, upstreamRaw, ok := strings.Cut(raw, "=")
74
if !ok {
82
- return nil, fmt.Errorf("invalid --http-route %q: expected PATH=UPSTREAM", raw)
75
+ return nil, fmt.Errorf("--http-route %q: expected PATH=UPSTREAM", raw)
76
}
77
78
prefix := strings.TrimSpace(prefixRaw)
86
- switch {
87
- case prefix == "":
88
- return nil, fmt.Errorf("invalid --http-route prefix %q: %w", strings.TrimSpace(prefixRaw), errors.New("prefix is required"))
89
- case !strings.HasPrefix(prefix, "/"):
90
- return nil, fmt.Errorf("invalid --http-route prefix %q: %w", strings.TrimSpace(prefixRaw), errors.New("prefix must start with /"))
79
+ if prefix == "" {
80
+ return nil, fmt.Errorf("--http-route %q: prefix is required", raw)
81
+ }
82
+ if !strings.HasPrefix(prefix, "/") {
83
+ return nil, fmt.Errorf("--http-route %q: prefix must start with /", raw)
84
}
85
prefix = utils.NormalizeURLPath(prefix)
86
87
upstreamInput := strings.TrimSpace(upstreamRaw)
88
if upstreamInput == "" {
96
- return nil, fmt.Errorf("invalid --http-route upstream %q: %w", strings.TrimSpace(upstreamRaw), errors.New("upstream is required"))
89
+ return nil, fmt.Errorf("--http-route %q: upstream is required", raw)
90
}
98
-
91
if !strings.Contains(upstreamInput, "://") {
92
target, err := utils.NormalizeLoopbackTarget(upstreamInput)
93
if err != nil {
102
- return nil, fmt.Errorf("invalid --http-route upstream %q: %w", strings.TrimSpace(upstreamRaw), err)
94
+ return nil, fmt.Errorf("--http-route %q: %w", raw, err)
95
}
96
upstreamInput = "http://" + target
97
}
98
99
upstream, err := url.Parse(upstreamInput)
100
if err != nil {
109
- return nil, fmt.Errorf("invalid --http-route upstream %q: %w", strings.TrimSpace(upstreamRaw), err)
101
+ return nil, fmt.Errorf("--http-route %q: %w", raw, err)
102
}
103
if upstream.Host == "" {
112
- return nil, fmt.Errorf("invalid --http-route upstream %q: %w", strings.TrimSpace(upstreamRaw), errors.New("upstream host is required"))
104
+ return nil, fmt.Errorf("--http-route %q: upstream host is required", raw)
105
}
106
if upstream.Scheme != "http" && upstream.Scheme != "https" {
115
- return nil, fmt.Errorf("invalid --http-route upstream %q: %w", strings.TrimSpace(upstreamRaw), errors.New("upstream scheme must be http or https"))
107
+ return nil, fmt.Errorf("--http-route %q: scheme must be http or https", raw)
108
}
109
upstream.Fragment = ""
110
upstream.Path = utils.NormalizeURLPath(upstream.Path)
111
120
- return &httpRoute{
121
- prefix: prefix,
122
- upstream: upstream,
123
- }, nil
112
+ return &httpRoute{prefix: prefix, upstream: upstream}, nil
113
}
114
115
func (r *httpRoute) newReverseProxy() *httputil.ReverseProxy {
116
return &httputil.ReverseProxy{
128
- Rewrite: r.rewriteRequest,
129
- ModifyResponse: r.modifyResponse,
117
+ Rewrite: r.rewriteRequest,
118
+ ModifyResponse: func(resp *http.Response) error {
119
+ if resp == nil || resp.Request == nil {
120
+ return nil
121
+ }
122
+ publicHost := resp.Request.Header.Get("X-Forwarded-Host")
123
+ publicScheme := resp.Request.Header.Get("X-Forwarded-Proto")
124
+ r.rewriteLocation(resp.Header, publicHost, publicScheme)
125
+ r.rewriteSetCookies(resp.Header, publicHost)
126
+ return nil
127
+ },
128
ErrorHandler: func(w http.ResponseWriter, req *http.Request, err error) {
131
- log.Error().
132
- Err(err).
129
+ log.Error().Err(err).
130
Str("route_prefix", r.prefix).
131
Str("upstream", r.upstream.String()).
132
Msg("http route proxy failed")
@@ -139,101 +136,74 @@ func (r *httpRoute) newReverseProxy() *httputil.ReverseProxy {
136
}
137
138
func (r *httpRoute) rewriteRequest(pr *httputil.ProxyRequest) {
142
- outboundPath, outboundRawPath := r.stripPrefix(pr.In.URL.Path, pr.In.URL.RawPath)
143
- pr.Out.URL.Path = outboundPath
144
- pr.Out.URL.RawPath = outboundRawPath
139
+ // strip route prefix from the path before forwarding
140
+ reqPath := utils.NormalizeURLPath(pr.In.URL.Path)
141
+ rawPath := pr.In.URL.RawPath
142
+ if r.prefix != "/" {
143
+ if reqPath == r.prefix {
144
+ reqPath = "/"
145
+ rawPath = ""
146
+ } else {
147
+ reqPath = strings.TrimPrefix(reqPath, r.prefix)
148
+ if reqPath == "" {
149
+ reqPath = "/"
150
+ }
151
+ if rawPath == r.prefix {
152
+ rawPath = "/"
153
+ } else if strings.HasPrefix(rawPath, r.prefix+"/") {
154
+ rawPath = strings.TrimPrefix(rawPath, r.prefix)
155
+ }
156
+ }
157
+ }
158
+
159
+ pr.Out.URL.Path = reqPath
160
+ pr.Out.URL.RawPath = rawPath
161
pr.Out.URL.RawQuery = pr.In.URL.RawQuery
162
pr.SetURL(r.upstream)
163
pr.SetXForwarded()
148
- if r.prefix != "/" {
149
- pr.Out.Header.Set("X-Forwarded-Prefix", r.prefix)
150
- }
164
152
- publicScheme := "http"
153
- if pr.In.TLS != nil {
154
- publicScheme = "https"
155
- } else {
156
- proto := strings.TrimSpace(pr.In.Header.Get("X-Forwarded-Proto"))
157
- if first, _, ok := strings.Cut(proto, ","); ok {
158
- proto = first
159
- }
165
+ // SetXForwarded checks pr.In.TLS, but behind a TLS-terminating proxy
166
+ // the inbound X-Forwarded-Proto carries the real client scheme.
167
+ if pr.In.TLS == nil {
168
+ proto, _, _ := strings.Cut(pr.In.Header.Get("X-Forwarded-Proto"), ",")
169
if proto = strings.ToLower(strings.TrimSpace(proto)); proto != "" {
161
- publicScheme = proto
170
+ pr.Out.Header.Set("X-Forwarded-Proto", proto)
171
}
172
}
173
165
- meta := httpRouteMeta{
166
- publicHost: pr.In.Host,
167
- publicScheme: publicScheme,
168
- }
169
- pr.Out = pr.Out.WithContext(context.WithValue(pr.Out.Context(), httpRouteMetaKey{}, meta))
170
-}
171
-
172
-func (r *httpRoute) modifyResponse(resp *http.Response) error {
173
- if resp == nil || resp.Request == nil {
174
- return nil
175
- }
176
-
177
- meta, _ := resp.Request.Context().Value(httpRouteMetaKey{}).(httpRouteMeta)
178
- r.rewriteLocation(resp.Header, meta)
179
- r.rewriteSetCookies(resp.Header, meta.publicHost)
180
- return nil
181
-}
182
-
183
-func (r *httpRoute) stripPrefix(requestPath, rawPath string) (string, string) {
184
- requestPath = utils.NormalizeURLPath(requestPath)
185
- if r.prefix == "/" {
186
- return requestPath, rawPath
187
- }
188
- if requestPath == r.prefix {
189
- return "/", ""
190
- }
191
-
192
- trimmedPath := strings.TrimPrefix(requestPath, r.prefix)
193
- if trimmedPath == "" {
194
- trimmedPath = "/"
195
- }
196
-
197
- trimmedRawPath := rawPath
198
- if trimmedRawPath != "" {
199
- if trimmedRawPath == r.prefix {
200
- trimmedRawPath = "/"
201
- } else if strings.HasPrefix(trimmedRawPath, r.prefix+"/") {
202
- trimmedRawPath = strings.TrimPrefix(trimmedRawPath, r.prefix)
203
- }
174
+ if r.prefix != "/" {
175
+ pr.Out.Header.Set("X-Forwarded-Prefix", r.prefix)
176
}
205
- return trimmedPath, trimmedRawPath
177
}
178
208
-func (r *httpRoute) rewriteLocation(header http.Header, meta httpRouteMeta) {
209
- location := strings.TrimSpace(header.Get("Location"))
179
+func (r *httpRoute) rewriteLocation(header http.Header, publicHost, publicScheme string) {
180
+ location := header.Get("Location")
181
if location == "" {
182
return
183
}
213
-
184
parsed, err := url.Parse(location)
185
if err != nil {
186
return
187
}
188
219
- var mappedPath string
189
switch {
190
case parsed.IsAbs():
191
if !strings.EqualFold(parsed.Scheme, r.upstream.Scheme) || !strings.EqualFold(parsed.Host, r.upstream.Host) {
192
return
193
}
225
- parsed.Scheme = meta.publicScheme
226
- parsed.Host = meta.publicHost
227
- case strings.HasPrefix(location, "/") && (len(location) == 1 || (location[1] != '/' && location[1] != '\\')):
194
+ parsed.Scheme = publicScheme
195
+ parsed.Host = publicHost
196
+ case strings.HasPrefix(location, "/") && parsed.Host == "" && (len(location) == 1 || location[1] != '\\'):
197
+ // server-relative redirect
198
default:
199
return
200
}
201
232
- mappedPath = r.mapUpstreamPathToPublic(parsed.Path)
233
- if !strings.HasPrefix(mappedPath, "/") || (len(mappedPath) > 1 && (mappedPath[1] == '/' || mappedPath[1] == '\\')) {
202
+ mapped := r.mapUpstreamPathToPublic(parsed.Path)
203
+ if !strings.HasPrefix(mapped, "/") || (len(mapped) > 1 && (mapped[1] == '/' || mapped[1] == '\\')) {
204
return
205
}
236
- parsed.Path = mappedPath
206
+ parsed.Path = mapped
207
parsed.RawPath = ""
208
header.Set("Location", parsed.String())
209
}
@@ -244,12 +214,13 @@ func (r *httpRoute) rewriteSetCookies(header http.Header, publicHost string) {
214
return
215
}
216
247
- publicDomain := strings.ToLower(strings.TrimSpace(publicHost))
217
+ publicDomain := publicHost
218
if host, port, err := net.SplitHostPort(publicDomain); err == nil && port != "" {
219
publicDomain = host
220
}
251
- publicDomain = strings.Trim(publicDomain, "[]")
252
- upstreamDomain := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(r.upstream.Hostname()), "."))
221
+ publicDomain = strings.ToLower(strings.Trim(publicDomain, "[]"))
222
+ upstreamDomain := strings.ToLower(r.upstream.Hostname())
223
+
224
header.Del("Set-Cookie")
225
for _, value := range values {
226
cookie, err := http.ParseSetCookie(value)
@@ -259,26 +230,25 @@ func (r *httpRoute) rewriteSetCookies(header http.Header, publicHost string) {
230
}
231
232
changed := false
262
- if strings.TrimSpace(cookie.Path) != "" {
263
- rewrittenPath := r.mapUpstreamPathToPublic(cookie.Path)
264
- if rewrittenPath != cookie.Path {
265
- cookie.Path = rewrittenPath
233
+ if cookie.Path != "" {
234
+ if rewritten := r.mapUpstreamPathToPublic(cookie.Path); rewritten != cookie.Path {
235
+ cookie.Path = rewritten
236
changed = true
237
}
238
}
239
270
- currentDomain := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(cookie.Domain), "."))
271
- if currentDomain != "" && currentDomain != publicDomain &&
272
- (currentDomain == upstreamDomain || utils.IsLocalRelayHost(currentDomain)) {
240
+ domain := strings.ToLower(strings.TrimPrefix(cookie.Domain, "."))
241
+ if domain != "" && domain != publicDomain &&
242
+ (domain == upstreamDomain || utils.IsLocalRelayHost(domain)) {
243
cookie.Domain = ""
244
changed = true
245
}
246
247
if changed {
248
header.Add("Set-Cookie", cookie.String())
279
- continue
249
+ } else {
250
+ header.Add("Set-Cookie", value)
251
}
281
- header.Add("Set-Cookie", value)
252
}
253
}
254
@@ -289,23 +259,20 @@ func (r *httpRoute) mapUpstreamPathToPublic(raw string) string {
259
}
260
261
base := utils.NormalizeURLPath(r.upstream.Path)
292
- publicRest := raw
293
- switch {
294
- case base == "/":
295
- case raw == base:
296
- publicRest = "/"
297
- case strings.HasPrefix(raw, base+"/"):
298
- publicRest = strings.TrimPrefix(raw, base)
262
+ rest := raw
263
+ if base != "/" {
264
+ if raw == base {
265
+ rest = "/"
266
+ } else if strings.HasPrefix(raw, base+"/") {
267
+ rest = strings.TrimPrefix(raw, base)
268
+ }
269
}
270
271
if r.prefix == "/" {
302
- return publicRest
272
+ return rest
273
}
304
- if publicRest == "/" {
274
+ if rest == "/" {
275
return r.prefix
276
}
307
- if strings.HasPrefix(publicRest, "/") {
308
- return r.prefix + publicRest
309
- }
310
- return r.prefix + "/" + publicRest
277
+ return r.prefix + rest
278
}