package aastro import ( "bytes" "context" "crypto/sha256" "encoding/hex" "fmt" "io" "net" "net/http" "sort" "strconv" "strings" "time" "github.com/go-chi/chi/v5" "github.com/goccy/go-json" "github.com/google/uuid" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/propagation" "go.opentelemetry.io/otel/trace" "go.uber.org/zap" "github.com/voidrunner3074/aastro/internal/metric" "github.com/voidrunner3074/aastro/internal/ratelimit" "github.com/voidrunner3074/aastro/internal/tracing" "github.com/voidrunner3074/aastro/sdk" ) // Keys must be net/textproto's canonical form (net/http always stores and // iterates response headers that way) - "TE" is the one entry here that // doesn't look canonical at a glance: textproto.CanonicalMIMEHeaderKey("TE") // is "Te" (no hyphen to separate words, so only the first letter is upper-cased), // not "TE". Written any other way, this lookup silently never // matches and the header leaks to the client. var hopByHopHeaders = map[string]struct{}{ "Content-Length": {}, "Transfer-Encoding": {}, "Connection": {}, "Trailer": {}, "Keep-Alive": {}, "Proxy-Authenticate": {}, "Proxy-Authorization": {}, "Te": {}, "Upgrade": {}, } var fingerprintIgnoredHeaders = map[string]struct{}{ "User-Agent": {}, "Cookie": {}, "Authorization": {}, "X-Request-Id": {}, "X-Forwarded-For": {}, "X-Forwarded-Proto": {}, "X-Forwarded-Host": {}, "X-Forwarded-Port": {}, "X-Real-Ip": {}, "Forwarded": {}, "Host": {}, "Accept-Encoding": {}, "Accept-Language": {}, } type Router struct { chiRouter *chi.Mux scatter scatter aggregator aggregator flows []flow trustedProxies []*net.IPNet log *zap.Logger metrics *metric.Metrics rateLimiter *ratelimit.RateLimit } // ServeHTTP handles incoming HTTP requests through the full router pipeline: // 1. Rate limiting - rejects requests exceeding the configured limit. // 2. Flow matching - chi router finds the flow by method and path (404 if none). // 3. Middleware execution - per-flow middlewares wrap the handler. // 4. Request plugins - run before the upstream call; may modify the request. // 5. Upstream dispatch - a streaming flow is piped through unbuffered // (handleStreaming); a single-upstream flow is called directly and its // status/headers/body are proxied as-is (buildProxyResponse); a // multi-upstream flow fans out and aggregates (merge/array/namespace, // with bestEffort support). // 6. Response plugins - run after dispatch; may modify headers or body. // 7. Response writing - status, headers, and body sent to the client. // // A single-upstream flow forwards the upstream's own status/body verbatim on // success or on a client error/redirect. A multi-upstream flow's success or // partial-success (206, bestEffort) body is the aggregated data itself, with // no gateway-added wrapper - a client never has to unwrap a response to get // at the payload. Only a response with no data at all (a hard failure with // nothing to aggregate, a rejected request that never reached an upstream) // is a body, and that body is an RFC 9457 Problem Details document // (application/problem+json), not a bespoke shape. Status codes: 200 on full // success, 206 on partial, 502/500 on failure. func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { r.metrics.IncRequestsInFlight() defer r.metrics.DecRequestsInFlight() tracer := otel.Tracer(tracing.TracerName) ctx := otel.GetTextMapPropagator().Extract(req.Context(), propagation.HeaderCarrier(req.Header)) ctx, span := tracer.Start(ctx, "aastro.request", trace.WithSpanKind(trace.SpanKindServer), trace.WithAttributes( attribute.String("http.method", req.Method), attribute.String("url.path", req.URL.Path), ), ) defer span.End() clientIP := extractClientIP(req, r.trustedProxies) ctx = withClientIP(ctx, clientIP) req = req.WithContext(ctx) if !r.allowRequest(w, clientIP) { span.SetAttributes(attribute.Int("http.status_code", http.StatusTooManyRequests)) span.SetStatus(codes.Error, "rate limited") return } r.chiRouter.ServeHTTP(w, req) } func (r *Router) Close() error { for i := range r.flows { for _, mw := range r.flows[i].middlewares { if c, ok := mw.(sdk.Closer); ok { if err := c.Close(); err != nil { r.log.Error("middleware close failed", zap.String("name", mw.Name()), zap.Error(err), ) } } } } return nil } func (r *Router) allowRequest(w http.ResponseWriter, clientIP string) bool { if r.rateLimiter == nil { return true } if r.rateLimiter.Allow(clientIP) { return true } r.metrics.IncFailedRequestsTotal(metric.FailReasonTooManyRequests) WriteError(w, ClientErrRateLimitExceeded, http.StatusTooManyRequests) return false } func (r *Router) newFlowHandler(f *flow) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { start := time.Now() defer r.metrics.UpdateRequestsDuration(f.path, f.method, start) span := trace.SpanFromContext(req.Context()) span.SetAttributes(attribute.String("http.route", f.path)) requestID := getOrCreateRequestID(req) fingerprint := computeFingerprint(req, f.path) ctx := req.Context() ctx = withRoute(withRequestID(withFingerprint(ctx, fingerprint), requestID), f.path) req = req.WithContext(ctx) span.SetAttributes( attribute.String("aastro.request.id", requestID), attribute.String("aastro.request.fingerprint", fingerprint), ) log := r.log.With( zap.String("request_id", requestID), zap.String("fingerprint", fingerprint), ) if f.streaming { r.handleStreaming(w, req, f, log) return } actx := newContext(req) if !r.executePlugins(sdk.PluginTypeRequest, w, actx, f, log) { return } httpResp, ok := r.dispatch(w, req, f, log) if !ok { return } defer func() { _ = httpResp.Body.Close() }() actx.SetResponse(httpResp) if !r.executePlugins(sdk.PluginTypeResponse, w, actx, f, log) { return } finalResp := actx.Response() if finalResp != nil && finalResp != httpResp && finalResp.Body != nil { defer func() { _ = finalResp.Body.Close() }() } if finalResp.Body != nil { bodyBytes, _ := io.ReadAll(finalResp.Body) finalResp.ContentLength = int64(len(bodyBytes)) finalResp.Body = io.NopCloser(bytes.NewReader(bodyBytes)) } w.Header().Set("Content-Length", strconv.Itoa(int(finalResp.ContentLength))) r.metrics.IncRequestsTotal(f.path, req.Method, finalResp.StatusCode) span.SetAttributes(attribute.Int("http.status_code", finalResp.StatusCode)) if finalResp.StatusCode >= http.StatusInternalServerError { span.SetStatus(codes.Error, http.StatusText(finalResp.StatusCode)) } r.copyResponse(w, finalResp) }) } // executePlugins runs all plugins of the given type in order. // On the first plugin error it writes a 500 to w and returns false - // the caller must treat false as "response already sent, stop processing". func (r *Router) executePlugins(pluginType sdk.PluginType, w http.ResponseWriter, actx sdk.Context, f *flow, log *zap.Logger) bool { tracer := otel.Tracer(tracing.TracerName) for _, p := range f.plugins { if p.Type() != pluginType { continue } log.Debug("executing plugin", zap.String("type", pluginType.String()), zap.String("name", p.Info().Name), ) ctx, span := tracer.Start(actx.Request().Context(), "aastro.plugin", trace.WithAttributes( attribute.String("aastro.plugin.name", p.Info().Name), attribute.String("aastro.plugin.type", pluginType.String()), ), ) actx.SetRequest(actx.Request().WithContext(ctx)) err := p.Execute(actx) span.End() if err != nil { span.RecordError(err) span.SetStatus(codes.Error, "plugin execution failed") log.Error("plugin execution failed", zap.String("type", pluginType.String()), zap.String("name", p.Info().Name), zap.Error(err), ) WriteError(w, ClientErrInternal, http.StatusInternalServerError) return false } } return true } // dispatch calls the flow's upstream(s) and builds the resulting HTTP // response. A single-upstream flow bypasses the scatter/aggregate machinery // entirely and is proxied directly (buildProxyResponse); a multi-upstream // flow fans out and aggregates as before (buildResponse). Returns ok=false // when it has already written an error response to w - the caller must // treat that as "response already sent, stop processing". func (r *Router) dispatch(w http.ResponseWriter, req *http.Request, f *flow, log *zap.Logger) (*http.Response, bool) { if len(f.upstreams) == 1 { resp, ok := r.scatter.call(f, req) if !ok { r.log.Error("request body too large", zap.Int("max_body_size", maxBodySize)) WriteError(w, ClientErrPayloadTooLarge, http.StatusRequestEntityTooLarge) return nil, false } return r.buildProxyResponse(req.Context(), resp, log), true } upstreamResponses := r.scatter.scatter(f, req) if upstreamResponses == nil { r.log.Error("request body too large", zap.Int("max_body_size", maxBodySize)) WriteError(w, ClientErrPayloadTooLarge, http.StatusRequestEntityTooLarge) return nil, false } if r.log.Core().Enabled(zap.DebugLevel) { var ok, failed int for _, resp := range upstreamResponses { if resp.err != nil { failed++ } else { ok++ } } r.log.Debug("scatter finished", zap.Int("ok", ok), zap.Int("failed", failed), ) } return r.buildResponse(req.Context(), upstreamResponses, f, log), true } // buildProxyResponse builds the HTTP response for a single-upstream flow. // A success or a client-error/redirect answer from the upstream (its status, // headers, and body) is forwarded to the client as-is. Any other failure - // the upstream never answered, answered with a 5xx, or failed gateway // policy - has no upstream body worth forwarding and is reported as an // RFC 9457 Problem Details document instead. func (r *Router) buildProxyResponse(ctx context.Context, resp upstreamResponse, log *zap.Logger) *http.Response { requestID := requestIDFromContext(ctx) fingerprint := fingerprintFromContext(ctx) headers := resp.headers if headers == nil { headers = make(http.Header) } headers.Set("X-Request-ID", requestID) headers.Set("X-Request-Fingerprint", fingerprint) if resp.err == nil || isPropagatable(resp.err.kind) { if headers.Get("Content-Type") == "" { headers.Set("Content-Type", "application/json; charset=utf-8") } log.Debug("proxying upstream response", zap.Int("status", resp.status), zap.Int("body_bytes", len(resp.body)), ) return &http.Response{ Status: fmt.Sprintf("%d %s", resp.status, http.StatusText(resp.status)), StatusCode: resp.status, ContentLength: int64(len(resp.body)), Body: io.NopCloser(bytes.NewReader(resp.body)), Header: headers, } } clientErr := mapUpstreamError(resp.err) status := r.statusFromErrors([]ClientError{clientErr}, false) log.Warn("upstream error, returning a problem details response", zap.String("upstream_error", resp.err.Unwrap().Error()), zap.String("client_error", clientErr.String()), ) headers.Set("Content-Type", "application/problem+json") body := mustMarshal(problemForErrors([]ClientError{clientErr}, status)) return &http.Response{ Status: fmt.Sprintf("%d %s", status, http.StatusText(status)), StatusCode: status, ContentLength: int64(len(body)), Body: io.NopCloser(bytes.NewBuffer(body)), Header: headers, } } func (r *Router) buildResponse(ctx context.Context, upstreamResponses []upstreamResponse, f *flow, log *zap.Logger) *http.Response { aggregated := r.aggregator.aggregate(f.upstreams, upstreamResponses, f.aggregation, log.Named("aggregated")) headers := aggregated.headers if headers == nil { headers = make(http.Header) } requestID := requestIDFromContext(ctx) fingerprint := fingerprintFromContext(ctx) headers.Set("X-Request-ID", requestID) headers.Set("X-Request-Fingerprint", fingerprint) log.Debug("aggregation finished", zap.String("strategy", f.aggregation.strategy.String()), zap.Int("data_bytes", len(aggregated.data)), zap.Int("errors", len(aggregated.errors)), zap.Bool("partial", aggregated.partial), ) status := r.resolveStatus(aggregated) body := r.buildResponseBody(headers, aggregated, status) return &http.Response{ Status: fmt.Sprintf("%d %s", status, http.StatusText(status)), StatusCode: status, ContentLength: int64(len(body)), Body: io.NopCloser(bytes.NewBuffer(body)), Header: headers, } } // buildResponseBody sets the outgoing Content-Type on headers and returns // the body. A full failure (no upstream produced anything) is reported as // an RFC 9457 Problem Details document; a full or partial success returns // the aggregated data verbatim, with the same shape either way - which // upstreams failed on a partial success goes on the X-Partial-Errors // header (one value per failure) instead of a body field, so a client never // has to branch on response shape depending on whether every upstream // happened to succeed. func (r *Router) buildResponseBody(headers http.Header, aggregated aggregatedResponse, status int) []byte { // RFC 9110 if status == http.StatusNoContent || status == http.StatusNotModified { return nil } if len(aggregated.errors) > 0 && !aggregated.partial { headers.Set("Content-Type", "application/problem+json") return mustMarshal(problemForErrors(aggregated.errors, status)) } headers.Set("Content-Type", "application/json; charset=utf-8") if aggregated.partial { for _, e := range aggregated.errors { headers.Add("X-Partial-Errors", e.String()) } } return aggregated.data } func (r *Router) copyResponse(w http.ResponseWriter, resp *http.Response) { for k, vv := range resp.Header { if _, skip := hopByHopHeaders[k]; skip { continue } for _, v := range vv { w.Header().Add(k, v) } } w.WriteHeader(resp.StatusCode) if resp.Body != nil { _, _ = io.Copy(w, resp.Body) } } func (r *Router) resolveStatus(agg aggregatedResponse) int { if agg.partial { return http.StatusPartialContent } if agg.status != 0 { return agg.status } return r.statusFromErrors(agg.errors, false) } // statusFromErrors maps aggregation errors to the most appropriate HTTP status code. // partial takes precedence: even with errors, 206 signals a partial success. func (r *Router) statusFromErrors(errors []ClientError, partial bool) int { if partial { return http.StatusPartialContent } if len(errors) == 0 { return http.StatusOK } return statusForError(selectPrimaryError(errors)) } // ── Package-level helpers ───────────────────────────────────────────────────── // selectPrimaryError picks the single most specific error out of a set, // by priority (see errorPriority) - used both for the HTTP status (below) // and for a multi-error Problem Details document's Type/Title (problemForErrors), // so the two always agree on which failure is "the" failure. func selectPrimaryError(errors []ClientError) ClientError { var selected ClientError maxPriority := -1 for _, e := range errors { if p := errorPriority(e); p > maxPriority { maxPriority = p selected = e } } return selected } func statusForError(e ClientError) int { switch e { case ClientErrRateLimitExceeded: return http.StatusTooManyRequests case ClientErrPayloadTooLarge: return http.StatusRequestEntityTooLarge case ClientErrUpstreamBodyTooLarge, ClientErrUpstreamUnavailable, ClientErrUpstreamError, ClientErrUpstreamMalformed, ClientErrUpstreamClientError, ClientErrUpstreamRedirect: return http.StatusBadGateway case ClientErrValueConflict: return http.StatusConflict case ClientErrAborted: // Client disconnected before the upstream responded; there is nothing // meaningful to send back, but we still need a status for logging. return http.StatusServiceUnavailable case ClientErrInternal: return http.StatusInternalServerError case ClientErrUnauthorized: return http.StatusUnauthorized } return http.StatusInternalServerError } // problemForErrors builds the RFC 9457 body for a response with one or more // failing causes. Title summarizes the single highest-priority error (Type // is always "about:blank", see ProblemDetails); Errors always carries the // full set, even when it's only the one - it's the sole machine-readable // discriminator now that Type doesn't vary. func problemForErrors(errors []ClientError, status int) ProblemDetails { return ProblemDetails{ Type: problemTypeBlank, Title: selectPrimaryError(errors).problemTitle(), Status: status, Errors: errors, } } func mustMarshal(v any) []byte { b, err := json.Marshal(v) if err != nil { return []byte(`{"type":"about:blank","title":"Internal gateway error","status":500,"errors":["INTERNAL"]}`) } return b } // extractClientIP resolves the real client IP following the chain: // X-Forwarded-For → X-Real-IP → RemoteAddr. func extractClientIP(r *http.Request, trustedProxies []*net.IPNet) string { peer := remoteIP(r) if peer == nil || !ipInNets(peer, trustedProxies) { return peerString(r, peer) } xff := r.Header.Get("X-Forwarded-For") if xff != "" { parts := strings.Split(xff, ",") for i := len(parts) - 1; i >= 0; i-- { ip := net.ParseIP(strings.TrimSpace(parts[i])) if ip == nil { break } if !ipInNets(ip, trustedProxies) { return ip.String() } } } if xrip := net.ParseIP(strings.TrimSpace(r.Header.Get("X-Real-IP"))); xrip != nil { return xrip.String() } return peerString(r, peer) } func remoteIP(r *http.Request) net.IP { host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { host = r.RemoteAddr } return net.ParseIP(host) } func peerString(r *http.Request, peer net.IP) string { if peer != nil { return peer.String() } return r.RemoteAddr } func ipInNets(ip net.IP, nets []*net.IPNet) bool { for _, n := range nets { if n.Contains(ip) { return true } } return false } func getOrCreateRequestID(r *http.Request) string { if id := r.Header.Get("X-Request-ID"); id != "" { return id } id, err := uuid.NewV7() if err != nil { return uuid.NewString() } return id.String() } func computeFingerprint(r *http.Request, flowPath string) string { var b strings.Builder b.WriteString(r.Method) b.WriteByte('|') b.WriteString(flowPath) b.WriteByte('|') headers := make([]string, 0, len(r.Header)) for name := range r.Header { if _, skip := fingerprintIgnoredHeaders[name]; skip { continue } headers = append(headers, name) } sort.Strings(headers) b.WriteString(strings.Join(headers, ",")) b.WriteByte('|') if r.URL.RawQuery != "" { qs := r.URL.Query() queries := make([]string, 0, len(qs)) for name := range qs { queries = append(queries, name) } sort.Strings(queries) b.WriteString(strings.Join(queries, ",")) } sum := sha256.Sum256([]byte(b.String())) return hex.EncodeToString(sum[:8]) } const ( errPriorityRateLimit = 100 errPriorityUnauthorized = 95 errPriorityPayloadSize = 90 errPriorityConflict = 80 errPriorityUpstream = 50 errPriorityInternal = 10 ) // clientErrorPriority determines which error code wins when multiple are present. // Higher value = more specific HTTP status code returned. func errorPriority(e ClientError) int { switch e { case ClientErrRateLimitExceeded: return errPriorityRateLimit case ClientErrUnauthorized: return errPriorityUnauthorized case ClientErrPayloadTooLarge, ClientErrUpstreamBodyTooLarge: return errPriorityPayloadSize case ClientErrValueConflict: return errPriorityConflict case ClientErrUpstreamUnavailable, ClientErrUpstreamError, ClientErrUpstreamMalformed, ClientErrAborted, ClientErrUpstreamClientError, ClientErrUpstreamRedirect: return errPriorityUpstream case ClientErrInternal: return errPriorityInternal } return 0 }