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 }