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