main
go 506 lines 13 KB
Raw
1 package sdk
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "net"
9 "net/http"
10 "net/http/httputil"
11 "net/url"
12 "sort"
13 "strings"
14 "sync"
15
16 "github.com/rs/zerolog/log"
17
18 "github.com/gosuda/portal-tunnel/v2/portal/x402"
19 "github.com/gosuda/portal-tunnel/v2/types"
20 "github.com/gosuda/portal-tunnel/v2/utils"
21 )
22
23 func RunHTTP(ctx context.Context, relayListener net.Listener, handler http.Handler, localAddr string) error {
24 if relayListener == nil && localAddr == "" {
25 return errors.New("relay listener or local address is required")
26 }
27
28 if handler == nil {
29 handler = http.NotFoundHandler()
30 }
31
32 var relaySrv *http.Server
33 if relayListener != nil {
34 relaySrv = &http.Server{
35 Handler: handler,
36 ReadHeaderTimeout: defaultRequestTimeout,
37 }
38 }
39
40 var localSrv *http.Server
41 if localAddr != "" {
42 localSrv = &http.Server{
43 Addr: localAddr,
44 Handler: handler,
45 ReadHeaderTimeout: defaultRequestTimeout,
46 }
47 }
48
49 serverCount := 0
50 if relaySrv != nil {
51 serverCount++
52 }
53 if localSrv != nil {
54 serverCount++
55 }
56
57 results := make(chan error, serverCount)
58 normalizeServeErr := func(err error, prefix string) error {
59 if err == nil || errors.Is(err, http.ErrServerClosed) || errors.Is(err, net.ErrClosed) {
60 return nil
61 }
62 return fmt.Errorf("%s: %w", prefix, err)
63 }
64
65 var (
66 shutdownOnce sync.Once
67 shutdownErr error
68 )
69 shutdown := func() error {
70 shutdownOnce.Do(func() {
71 shutdownCtx, cancel := context.WithTimeout(context.Background(), defaultHTTPShutdownTimeout)
72 defer cancel()
73
74 var localErr error
75 if localSrv != nil {
76 localErr = localSrv.Shutdown(shutdownCtx)
77 if errors.Is(localErr, http.ErrServerClosed) {
78 localErr = nil
79 }
80 }
81
82 var relayErr error
83 if relaySrv != nil {
84 relayErr = relaySrv.Shutdown(shutdownCtx)
85 if errors.Is(relayErr, http.ErrServerClosed) {
86 relayErr = nil
87 }
88 }
89
90 shutdownErr = errors.Join(localErr, relayErr)
91 })
92 return shutdownErr
93 }
94
95 if localSrv != nil {
96 go func() {
97 results <- normalizeServeErr(localSrv.ListenAndServe(), "serve local http")
98 }()
99 }
100 if relaySrv != nil {
101 go func() {
102 results <- normalizeServeErr(relaySrv.Serve(relayListener), "serve relay http")
103 }()
104 }
105
106 var serveErr error
107 remaining := serverCount
108 ctxDone := ctx.Done()
109 for remaining > 0 {
110 select {
111 case err := <-results:
112 remaining--
113 if err != nil {
114 serveErr = errors.Join(serveErr, err)
115 _ = shutdown()
116 }
117 case <-ctxDone:
118 _ = shutdown()
119 ctxDone = nil
120 }
121 }
122
123 return errors.Join(serveErr, shutdownErr)
124 }
125
126 // HTTPRouteConfig maps one public path prefix to one local HTTP upstream and optional x402 payment.
127 type HTTPRouteConfig struct {
128 // Prefix is the public request path prefix, such as "/api" or "/".
129 Prefix string
130 // Upstream is the target HTTP URL, or a loopback host:port shorthand.
131 Upstream string
132 // Methods limits payment to these HTTP methods. Empty means every method.
133 Methods []string
134 // Amount enables Sui USDC x402 payment for this public path prefix.
135 // It is a human USDC amount such as "0.01"; x402 converts it to atomic units.
136 Amount string
137 }
138
139 // HTTPRoutes serves HTTPRouteConfig upstreams and the shared x402 prepare endpoint.
140 type HTTPRoutes struct {
141 routes []*httpRoute
142 }
143
144 // NewHTTPRoutes creates a handler for path-routed upstreams and the shared x402 prepare endpoint.
145 func NewHTTPRoutes(routeConfigs []HTTPRouteConfig, x402PayTo string, x402Testnet bool) (*HTTPRoutes, error) {
146 if len(routeConfigs) == 0 {
147 return nil, errors.New("at least one http route is required")
148 }
149
150 x402PayTo = strings.TrimSpace(x402PayTo)
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, x402PayTo, x402Testnet)
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.handler = route.newHandler()
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 &HTTPRoutes{routes: routes}, nil
174 }
175
176 func (h *HTTPRoutes) ServeHTTP(w http.ResponseWriter, r *http.Request) {
177 path := "/"
178 if r.URL != nil {
179 path = r.URL.Path
180 }
181 path = utils.NormalizeURLPath(path)
182 if path == types.X402ClientPath {
183 x402.ServeClientJS(w, r)
184 return
185 }
186 prepare := path == types.X402PreparePath
187 var paymentSender string
188 paymentMethod := http.MethodGet
189 if prepare {
190 if !utils.RequireMethod(w, r, http.MethodPost) {
191 return
192 }
193 var req types.X402PreparePaymentRequest
194 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
195 http.Error(w, "invalid payment prepare request", http.StatusBadRequest)
196 return
197 }
198 if strings.TrimSpace(req.Path) == "" {
199 http.Error(w, "path is required", http.StatusBadRequest)
200 return
201 }
202 path = utils.NormalizeURLPath(req.Path)
203 paymentSender = req.Sender
204 if method := strings.ToUpper(strings.TrimSpace(req.Method)); method != "" {
205 paymentMethod = method
206 }
207 }
208
209 for _, route := range h.routes {
210 if route.prefix != "/" && path != route.prefix && !strings.HasPrefix(path, route.prefix+"/") {
211 continue
212 }
213
214 if prepare {
215 paid := route.payment != nil
216 if paid && len(route.paymentMethods) > 0 {
217 _, paid = route.paymentMethods[paymentMethod]
218 }
219 if !paid {
220 http.Error(w, "x402 payment is not enabled for path", http.StatusNotFound)
221 return
222 }
223 route.payment.WritePrepare(w, r, paymentSender, path)
224 return
225 }
226
227 route.handler.ServeHTTP(w, r)
228 return
229 }
230 http.NotFound(w, r)
231 }
232
233 type httpRoute struct {
234 prefix string
235 upstream *url.URL
236 upstreamPath string
237 upstreamDomain string
238 payment *x402.Payment
239 paymentMethods map[string]struct{}
240 handler http.Handler
241 }
242
243 func newHTTPRoute(routeConfig HTTPRouteConfig, x402PayTo string, x402Testnet bool) (*httpRoute, error) {
244 prefix := strings.TrimSpace(routeConfig.Prefix)
245 if prefix == "" {
246 return nil, errors.New("http route prefix is required")
247 }
248 if !strings.HasPrefix(prefix, "/") {
249 return nil, fmt.Errorf("http route prefix %q must start with /", prefix)
250 }
251 prefix = utils.NormalizeURLPath(prefix)
252
253 upstreamInput := strings.TrimSpace(routeConfig.Upstream)
254 if upstreamInput == "" {
255 return nil, fmt.Errorf("http route %q upstream is required", prefix)
256 }
257 if !strings.Contains(upstreamInput, "://") {
258 target, err := utils.NormalizeLoopbackTarget(upstreamInput)
259 if err != nil {
260 return nil, fmt.Errorf("http route %q upstream: %w", prefix, err)
261 }
262 upstreamInput = "http://" + target
263 }
264
265 upstream, err := url.Parse(upstreamInput)
266 if err != nil {
267 return nil, fmt.Errorf("http route %q upstream: %w", prefix, err)
268 }
269 if upstream.Host == "" {
270 return nil, fmt.Errorf("http route %q upstream host is required", prefix)
271 }
272 if upstream.Scheme != "http" && upstream.Scheme != "https" {
273 return nil, fmt.Errorf("http route %q upstream scheme must be http or https", prefix)
274 }
275 upstream.Fragment = ""
276 upstream.Path = utils.NormalizeURLPath(upstream.Path)
277
278 route := &httpRoute{
279 prefix: prefix,
280 upstream: upstream,
281 upstreamPath: upstream.Path,
282 upstreamDomain: utils.NormalizeHostname(upstream.Hostname()),
283 }
284 amount := strings.TrimSpace(routeConfig.Amount)
285 if amount == "" && len(routeConfig.Methods) > 0 {
286 return nil, fmt.Errorf("http route %q payment methods require amount", route.prefix)
287 }
288 if amount != "" {
289 if x402PayTo == "" {
290 return nil, fmt.Errorf("http route %q amount requires x402 pay-to", route.prefix)
291 }
292 methods := make(map[string]struct{}, len(routeConfig.Methods))
293 for _, rawMethod := range routeConfig.Methods {
294 method := strings.ToUpper(strings.TrimSpace(rawMethod))
295 if method == "" {
296 return nil, fmt.Errorf("http route %q payment method is required", route.prefix)
297 }
298 methods[method] = struct{}{}
299 }
300 payment, err := x402.NewUSDCPayment(types.X402Payment{
301 Testnet: x402Testnet,
302 PayTo: x402PayTo,
303 Amount: amount,
304 })
305 if err != nil {
306 return nil, fmt.Errorf("http route %q x402 payment: %w", route.prefix, err)
307 }
308 route.payment = payment
309 route.paymentMethods = methods
310 }
311 return route, nil
312 }
313
314 func (r *httpRoute) newHandler() http.Handler {
315 proxy := &httputil.ReverseProxy{
316 Rewrite: r.rewriteProxyRequest,
317 ModifyResponse: r.rewriteProxyResponse,
318 ErrorHandler: func(w http.ResponseWriter, req *http.Request, err error) {
319 log.Error().Err(err).
320 Str("route_prefix", r.prefix).
321 Str("upstream", r.upstream.String()).
322 Msg("http route proxy failed")
323 http.Error(w, "bad gateway", http.StatusBadGateway)
324 },
325 }
326 if r.payment == nil {
327 return proxy
328 }
329 return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
330 if len(r.paymentMethods) > 0 {
331 if _, ok := r.paymentMethods[strings.ToUpper(req.Method)]; !ok {
332 proxy.ServeHTTP(w, req)
333 return
334 }
335 }
336
337 settled, ok := r.payment.Settle(req.Context(), w, req)
338 if !ok {
339 return
340 }
341 utils.SetPaymentResponseHeaders(w.Header(), settled)
342 proxy.ServeHTTP(w, req)
343 })
344 }
345
346 func (r *httpRoute) rewriteProxyRequest(pr *httputil.ProxyRequest) {
347 path := utils.NormalizeURLPath(pr.In.URL.Path)
348 rawPath := pr.In.URL.RawPath
349 if r.prefix != "/" {
350 switch path {
351 case r.prefix:
352 path = "/"
353 default:
354 path = strings.TrimPrefix(path, r.prefix)
355 if path == "" {
356 path = "/"
357 }
358 }
359
360 if rawPath != "" {
361 if rawPath == r.prefix {
362 rawPath = "/"
363 } else if strings.HasPrefix(rawPath, r.prefix+"/") {
364 rawPath = strings.TrimPrefix(rawPath, r.prefix)
365 }
366 }
367 }
368
369 pr.Out.URL.Path = path
370 pr.Out.URL.RawPath = rawPath
371 pr.Out.URL.RawQuery = pr.In.URL.RawQuery
372 pr.SetURL(r.upstream)
373 pr.SetXForwarded()
374 paid := r.payment != nil
375 if paid && len(r.paymentMethods) > 0 {
376 _, paid = r.paymentMethods[strings.ToUpper(pr.In.Method)]
377 }
378 if paid {
379 utils.StripPaymentHeaders(pr.Out.Header)
380 }
381
382 // SetXForwarded checks pr.In.TLS, but behind a TLS-terminating proxy
383 // the inbound X-Forwarded-Proto carries the real client scheme.
384 if pr.In.TLS == nil {
385 proto, _, _ := strings.Cut(pr.In.Header.Get("X-Forwarded-Proto"), ",")
386 if proto = strings.ToLower(strings.TrimSpace(proto)); proto != "" {
387 pr.Out.Header.Set("X-Forwarded-Proto", proto)
388 }
389 }
390
391 if r.prefix != "/" {
392 pr.Out.Header.Set("X-Forwarded-Prefix", r.prefix)
393 }
394 }
395
396 func (r *httpRoute) rewriteProxyResponse(resp *http.Response) error {
397 if resp == nil || resp.Request == nil {
398 return nil
399 }
400
401 header := resp.Header
402 paid := r.payment != nil
403 if paid && len(r.paymentMethods) > 0 {
404 _, paid = r.paymentMethods[strings.ToUpper(resp.Request.Method)]
405 }
406 if paid {
407 utils.StripPaymentHeaders(header)
408 }
409 publicHost := resp.Request.Header.Get("X-Forwarded-Host")
410 publicScheme := resp.Request.Header.Get("X-Forwarded-Proto")
411 publicPath := func(raw string) string {
412 raw = utils.NormalizeURLPath(raw)
413 if r.prefix != "/" && (raw == r.prefix || strings.HasPrefix(raw, r.prefix+"/")) {
414 return raw
415 }
416
417 rest := raw
418 if r.upstreamPath != "/" {
419 switch {
420 case raw == r.upstreamPath:
421 rest = "/"
422 case strings.HasPrefix(raw, r.upstreamPath+"/"):
423 rest = strings.TrimPrefix(raw, r.upstreamPath)
424 }
425 }
426
427 if r.prefix == "/" {
428 return rest
429 }
430 if rest == "/" {
431 return r.prefix
432 }
433 return r.prefix + rest
434 }
435
436 location := header.Get("Location")
437 if location != "" {
438 parsed, err := url.Parse(location)
439 if err == nil {
440 switch {
441 case parsed.IsAbs():
442 if strings.EqualFold(parsed.Scheme, r.upstream.Scheme) && strings.EqualFold(parsed.Host, r.upstream.Host) {
443 parsed.Scheme = publicScheme
444 parsed.Host = publicHost
445 } else {
446 parsed = nil
447 }
448 case strings.HasPrefix(location, "/") && parsed.Host == "" && (len(location) == 1 || (location[1] != '\\' && location[1] != '/')):
449 default:
450 parsed = nil
451 }
452
453 if parsed != nil {
454 mapped := publicPath(parsed.Path)
455 if strings.HasPrefix(mapped, "/") && (len(mapped) == 1 || (mapped[1] != '/' && mapped[1] != '\\')) {
456 parsed.Path = mapped
457 parsed.RawPath = ""
458 header.Set("Location", parsed.String())
459 }
460 }
461 }
462 }
463
464 values := header.Values("Set-Cookie")
465 if len(values) == 0 {
466 return nil
467 }
468
469 publicDomain := publicHost
470 if host, port, err := net.SplitHostPort(publicDomain); err == nil && port != "" {
471 publicDomain = host
472 }
473 publicDomain = utils.NormalizeHostname(strings.Trim(publicDomain, "[]"))
474
475 header.Del("Set-Cookie")
476 for _, value := range values {
477 cookie, err := http.ParseSetCookie(value)
478 if err != nil {
479 header.Add("Set-Cookie", value)
480 continue
481 }
482
483 changed := false
484 if cookie.Path != "" {
485 if rewritten := publicPath(cookie.Path); rewritten != cookie.Path {
486 cookie.Path = rewritten
487 changed = true
488 }
489 }
490
491 domain := utils.NormalizeHostname(strings.TrimPrefix(cookie.Domain, "."))
492 if domain != "" && domain != publicDomain &&
493 (domain == r.upstreamDomain || utils.IsLocalRelayHost(domain)) {
494 cookie.Domain = ""
495 changed = true
496 }
497
498 if changed {
499 header.Add("Set-Cookie", cookie.String())
500 continue
501 }
502 header.Add("Set-Cookie", value)
503 }
504
505 return nil
506 }