diff --git a/go.mod b/go.mod index d4d8234..6d9b027 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,8 @@ require ( github.com/codefly-dev/sdk-go/workcontext v0.0.0-20261005062635-dd6ca019c685 github.com/google/uuid v1.6.0 github.com/modelcontextprotocol/go-sdk v1.8.0 + go.opentelemetry.io/otel v1.46.0 + go.opentelemetry.io/otel/trace v1.46.0 golang.org/x/tools v0.51.0 google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 google.golang.org/protobuf v1.36.12 @@ -23,6 +25,7 @@ require ( github.com/Masterminds/semver v1.5.0 // indirect github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/go-openapi/analysis v1.0.0 // indirect github.com/go-openapi/errors v0.22.8 // indirect github.com/go-openapi/jsonpointer v1.0.1 // indirect diff --git a/passthrough.go b/passthrough.go index b2a3129..f77007e 100644 --- a/passthrough.go +++ b/passthrough.go @@ -400,7 +400,9 @@ func (s *Server) passthroughHandler(routes map[string]passthroughRoute) http.Han http.NotFound(w, r) return } - mux.ServeHTTP(w, r) + // The caller's W3C trace context, and only it, rides to the module + // call: see withInboundTraceContext. + mux.ServeHTTP(w, withInboundTraceContext(r)) })) } diff --git a/stream.go b/stream.go index b566f5f..3cb58ae 100644 --- a/stream.go +++ b/stream.go @@ -203,13 +203,14 @@ func (g *Gateway) TranscodedStream(ctx context.Context, prefix, method string, r if call.body != nil { httpReq.Header.Set("content-type", "application/json") } + injectTraceContext(ctx, httpReq.Header) var late atomic.Bool if options.firstAnswer > 0 { // A module that never starts its stream is abandoned; the stream // itself, once started, is bounded only by ctx. timer := time.AfterFunc(options.firstAnswer, func() { late.Store(true); cancelReq() }) defer timer.Stop() - httpResp, err := g.HTTPClient().Do(httpReq) + httpResp, err := g.moduleClient().Do(httpReq) timer.Stop() if err != nil { if late.Load() && ctx.Err() == nil { @@ -219,7 +220,7 @@ func (g *Gateway) TranscodedStream(ctx context.Context, prefix, method string, r } return readStream(ctx, call, httpResp, options, newMsg, each) } - httpResp, err := g.HTTPClient().Do(httpReq) + httpResp, err := g.moduleClient().Do(httpReq) if err != nil { return fmt.Errorf("%s %s: %w", call.verb, call.template, err) } diff --git a/tracecontext.go b/tracecontext.go new file mode 100644 index 0000000..2547461 --- /dev/null +++ b/tracecontext.go @@ -0,0 +1,205 @@ +package solution + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/trace" +) + +// --- W3C trace context across the passthrough --- +// +// A page's call through the passthrough is one hop of a trace the page may +// have started: the module behind it should record its work under the same +// trace, as a child of the span that called. The passthrough therefore carries +// the W3C trace context (traceparent, tracestate) from the inbound call to the +// module call, and nothing else of the caller's. +// +// This runtime starts no span, installs no tracer provider and exports nothing: +// it is a library, and which exporter a deployment uses is not its business. It +// carries the caller's context through, unchanged in identity, so the module's +// span parents directly on the caller's. +// +// It uses its own TraceContext propagator rather than the global one. The +// global propagator is whatever the consumer's process installed — nothing, by +// default, which silently propagates nothing — or a composite that also +// carries baggage, which this runtime never forwards: baggage is caller-chosen +// key/values, and the module is a different trust domain. +// +// Nothing is copied byte for byte. The inbound headers are parsed into a span +// context, and the outbound headers are written from that parsed value, so a +// malformed traceparent forwards nothing, and a tracestate is forwarded only as +// the list members that parse, capped at the length W3C asks every vendor to +// propagate. +var traceContextPropagator = propagation.TraceContext{} + +const ( + // traceparentHeader and tracestateHeader are the W3C Trace Context fields. + traceparentHeader = "traceparent" + tracestateHeader = "tracestate" + // traceparentVersion00 is the only traceparent version this runtime knows + // the exact length of: version 00 is exactly traceparentV00Length bytes, + // with nothing after the flags. A later version may be longer (§3.2.4). + traceparentVersion00 = "00" + traceparentV00Length = 55 + // maxRedirects is net/http's default redirect limit, kept by the module + // client's redirect policy so it changes nothing but the trace headers. + maxRedirects = 10 + // maxForwardedTraceState is the tracestate length W3C Trace Context asks + // every platform to propagate at least (§3.3.1.5). Forwarding no more keeps + // a caller from growing every downstream request's headers at will. + maxForwardedTraceState = 512 + // largeTraceStateMember is the member length W3C names as the first to drop + // when a tracestate must be truncated. + largeTraceStateMember = 128 +) + +// withInboundTraceContext returns r with the W3C trace context its own headers +// carry — and only that. A request without a valid traceparent gets an empty +// span context, so a span some outer middleware put into the context is not +// forwarded in place of the caller's: what reaches the module is exactly what +// the caller sent, re-validated, or nothing. +func withInboundTraceContext(r *http.Request) *http.Request { + sc := trace.SpanContextFromContext(traceContextPropagator.Extract(context.Background(), inboundTraceCarrier(r.Header))) + if sc.IsValid() { + sc = sc.WithTraceState(capTraceState(sc.TraceState())) + } else { + sc = trace.SpanContext{} + } + return r.WithContext(trace.ContextWithRemoteSpanContext(r.Context(), sc)) +} + +// inboundTraceCarrier reads the W3C trace context fields of header the way +// W3C Trace Context requires, not the way propagation.HeaderCarrier does (it +// reads only the first field of each name): +// +// - traceparent must arrive as exactly one field. None, or two or more — +// even identical ones — is not a traceparent, and nothing is forwarded. +// - A version-00 traceparent must be exactly 55 bytes. The pinned parser +// accepts one with a trailing "-" (an empty fifth field); this does not. +// Other versions are left to the parser, which reads their version-00 +// prefix and requires a "-" after it when there is more (§3.2.4). +// - tracestate may be split across fields (RFC 9110 §5.3): every field is +// joined, in the order received, with a comma before it is parsed, so a +// key repeated in a later field is the duplicate it is and the whole +// tracestate is dropped (§3.3.1.4), and the cap applies to the whole list. +func inboundTraceCarrier(header http.Header) propagation.MapCarrier { + carrier := propagation.MapCarrier{} + parents := header.Values(traceparentHeader) + if len(parents) != 1 || !traceparentLengthOK(parents[0]) { + return carrier + } + carrier[traceparentHeader] = parents[0] + if states := header.Values(tracestateHeader); len(states) > 0 { + carrier[tracestateHeader] = strings.Join(states, ",") + } + return carrier +} + +// traceparentLengthOK reports whether tp has the exact length its version +// requires; only version 00 has one. +func traceparentLengthOK(tp string) bool { + if strings.HasPrefix(tp, traceparentVersion00) { + return len(tp) == traceparentV00Length + } + return true +} + +// capTraceState truncates ts to maxForwardedTraceState the way W3C Trace +// Context §3.3.1.5 prescribes: members longer than largeTraceStateMember go +// first, from the right; then members from the right until the rest fits. +func capTraceState(ts trace.TraceState) trace.TraceState { + if len(ts.String()) <= maxForwardedTraceState { + return ts + } + var keys []string + var sizes []int + ts.Walk(func(key, value string) bool { + keys = append(keys, key) + sizes = append(sizes, len(key)+1+len(value)) + return true + }) + for i := len(keys) - 1; i >= 0 && len(ts.String()) > maxForwardedTraceState; i-- { + if sizes[i] > largeTraceStateMember { + ts = ts.Delete(keys[i]) + } + } + for i := len(keys) - 1; i >= 0 && len(ts.String()) > maxForwardedTraceState; i-- { + ts = ts.Delete(keys[i]) + } + return ts +} + +// injectTraceContext writes the W3C trace context of ctx into an outbound +// module request: traceparent and tracestate, serialized from the parsed span +// context. A context without a valid span context writes nothing. +func injectTraceContext(ctx context.Context, header http.Header) { + sc := trace.SpanContextFromContext(ctx) + if !sc.IsValid() { + return + } + ctx = trace.ContextWithSpanContext(ctx, sc.WithTraceState(capTraceState(sc.TraceState()))) + traceContextPropagator.Inject(ctx, propagation.HeaderCarrier(header)) +} + +// moduleClient is HTTPClient for a module request that carries the caller's +// trace context. A redirect is followed as HTTPClient follows it, except that +// once a hop leaves the gateway's origin, traceparent and tracestate are +// dropped from it and from every hop after it: net/http copies every header +// that is not a credential onto a redirect, wherever it points, and the trace +// context is the caller's, meant for the module behind this gateway only. +// +// The policy is set on the client HTTPClient returns, which is built per call +// and shared with nothing, so no other request's redirects change. +func (g *Gateway) moduleClient() *http.Client { + client := g.HTTPClient() + client.CheckRedirect = traceContextRedirectPolicy(g.baseURL) + return client +} + +// traceContextRedirectPolicy is net/http's default redirect policy (stop after +// maxRedirects) that also strips the W3C trace context from a hop once the +// chain has left base's origin. A base that does not parse has no origin, and +// every hop is stripped. +func traceContextRedirectPolicy(base string) func(*http.Request, []*http.Request) error { + gateway, err := url.Parse(base) + return func(req *http.Request, via []*http.Request) error { + if len(via) >= maxRedirects { + return fmt.Errorf("stopped after %d redirects", maxRedirects) + } + left := err != nil || !sameOrigin(gateway, req.URL) + for _, hop := range via { + left = left || !sameOrigin(gateway, hop.URL) + } + if left { + req.Header.Del(traceparentHeader) + req.Header.Del(tracestateHeader) + } + return nil + } +} + +// sameOrigin reports whether a and b share a scheme, host and port, the port +// defaulted from the scheme when either leaves it out. +func sameOrigin(a, b *url.URL) bool { + return strings.EqualFold(a.Scheme, b.Scheme) && + strings.EqualFold(a.Hostname(), b.Hostname()) && + originPort(a) == originPort(b) +} + +func originPort(u *url.URL) string { + if port := u.Port(); port != "" { + return port + } + switch strings.ToLower(u.Scheme) { + case "http": + return "80" + case "https": + return "443" + } + return "" +} diff --git a/tracecontext_test.go b/tracecontext_test.go new file mode 100644 index 0000000..8f8d07e --- /dev/null +++ b/tracecontext_test.go @@ -0,0 +1,528 @@ +package solution + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" + + "github.com/codefly-dev/sdk-go/workcontext" + "go.opentelemetry.io/otel/trace" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" +) + +const ( + callerTraceID = "4bf92f3577b34da6a3ce929d0e0e4736" + callerSpanID = "00f067aa0ba902b7" + callerTraceparnt = "00-" + callerTraceID + "-" + callerSpanID + "-01" +) + +// callWithHeaders is call with the caller's own headers added, and returns +// the request the module received. +func callWithHeaders(t *testing.T, header http.Header) *http.Request { + t.Helper() + return callWithContext(t, context.Background(), header) +} + +func callWithContext(t *testing.T, ctx context.Context, header http.Header) *http.Request { + t.Helper() + gw := newModuleGateway(t, http.StatusOK, `{"entryId":"e1"}`) + s := passthroughServer(t, gw.URL, passthroughModule()) + routes, err := resolvePassthrough(s.consumed) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequestWithContext(ctx, http.MethodPost, searchPath, strings.NewReader(`{"entryId":"e1"}`)) + req.Header.Set("content-type", "application/json") + req.Header.Set("connect-protocol-version", "1") + req.Header.Set("authorization", "Bearer viewer") + req.Header.Set(orgHeader, "org-1") + req.Header.Set(sessionHeader, "session-1") + req.Header.Set(workcontext.InstallationIDHeaderName, testInstallation) + for k, vs := range header { + for _, v := range vs { + req.Header.Add(k, v) + } + } + rec := httptest.NewRecorder() + s.passthroughHandler(routes).ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) + } + if len(gw.calls) != 1 { + t.Fatalf("module calls = %d, want 1", len(gw.calls)) + } + return gw.calls[0] +} + +func TestPassthroughCarriesTheCallersTraceContextToTheModule(t *testing.T) { + got := callWithHeaders(t, http.Header{ + "Traceparent": {callerTraceparnt}, + "Tracestate": {"vendor=abc,other=xyz"}, + }) + // Same trace, and the caller's span is the parent: no span was started here. + if tp := got.Header.Get("traceparent"); tp != callerTraceparnt { + t.Fatalf("traceparent = %q, want %q", tp, callerTraceparnt) + } + if ts := got.Header.Get("tracestate"); ts != "vendor=abc,other=xyz" { + t.Fatalf("tracestate = %q", ts) + } + // The authority headers are unchanged by it. + if got.Header.Get("authorization") != "Bearer viewer" { + t.Fatal("the viewer's bearer was not forwarded") + } +} + +func TestPassthroughNeverForwardsBaggage(t *testing.T) { + got := callWithHeaders(t, http.Header{ + "Traceparent": {callerTraceparnt}, + "Baggage": {"user=alice,tenant=t1"}, + }) + if b := got.Header.Get("baggage"); b != "" { + t.Fatalf("baggage = %q forwarded to the module", b) + } + if got.Header.Get("traceparent") != callerTraceparnt { + t.Fatal("traceparent not forwarded alongside the dropped baggage") + } +} + +func TestPassthroughForwardsNoTraceContextTheCallerDidNotSend(t *testing.T) { + t.Run("absent", func(t *testing.T) { + got := callWithHeaders(t, http.Header{"Tracestate": {"vendor=abc"}}) + if got.Header.Get("traceparent") != "" || got.Header.Get("tracestate") != "" { + t.Fatalf("forwarded %q / %q with no traceparent from the caller", got.Header.Get("traceparent"), got.Header.Get("tracestate")) + } + }) + for name, tp := range map[string]string{ + "malformed": "not-a-traceparent", + "zero trace id": "00-00000000000000000000000000000000-" + callerSpanID + "-01", + "zero span id": "00-" + callerTraceID + "-0000000000000000-01", + "uppercase hex": "00-" + strings.ToUpper(callerTraceID) + "-" + callerSpanID + "-01", + "trailing bytes": callerTraceparnt + "-extra", + "version ff": "ff-" + callerTraceID + "-" + callerSpanID + "-01", + } { + t.Run(name, func(t *testing.T) { + got := callWithHeaders(t, http.Header{"Traceparent": {tp}, "Tracestate": {"vendor=abc"}}) + if got.Header.Get("traceparent") != "" || got.Header.Get("tracestate") != "" { + t.Fatalf("forwarded %q / %q for traceparent %q", got.Header.Get("traceparent"), got.Header.Get("tracestate"), tp) + } + }) + } + t.Run("a span already in the request context is not substituted", func(t *testing.T) { + outer := trace.NewSpanContext(trace.SpanContextConfig{ + TraceID: trace.TraceID{1}, SpanID: trace.SpanID{2}, TraceFlags: trace.FlagsSampled, + }) + got := callWithContext(t, trace.ContextWithSpanContext(context.Background(), outer), nil) + if tp := got.Header.Get("traceparent"); tp != "" { + t.Fatalf("traceparent = %q, want none: the caller sent none", tp) + } + }) +} + +func TestPassthroughRewritesAForgedTraceStateRatherThanCopyingIt(t *testing.T) { + t.Run("invalid tracestate is dropped, traceparent kept", func(t *testing.T) { + for _, ts := range []string{ + "vendor=abc\r\nx-injected: 1", + "UPPER=abc", + "vendor=abc,vendor=dup", + "=novalue", + "vendor=a b=c", + } { + got := callWithHeaders(t, http.Header{"Traceparent": {callerTraceparnt}, "Tracestate": {ts}}) + if got.Header.Get("traceparent") != callerTraceparnt { + t.Fatalf("traceparent dropped for tracestate %q", ts) + } + if out := got.Header.Get("tracestate"); out != "" { + t.Fatalf("tracestate %q forwarded as %q", ts, out) + } + if got.Header.Get("x-injected") != "" { + t.Fatal("header injected through tracestate") + } + } + }) + t.Run("non-canonical tracestate is never copied raw", func(t *testing.T) { + raw := "vendor=abc , ,other=xyz" + got := callWithHeaders(t, http.Header{"Traceparent": {callerTraceparnt}, "Tracestate": {raw}}) + if out := got.Header.Get("tracestate"); out == raw { + t.Fatalf("tracestate copied byte for byte: %q", out) + } + }) + t.Run("more than 32 members is dropped", func(t *testing.T) { + members := make([]string, 33) + for i := range members { + members[i] = "k" + strings.Repeat("a", i) + "=v" + } + got := callWithHeaders(t, http.Header{"Traceparent": {callerTraceparnt}, "Tracestate": {strings.Join(members, ",")}}) + if out := got.Header.Get("tracestate"); out != "" { + t.Fatalf("tracestate with 33 members forwarded as %q", out) + } + }) + t.Run("oversized tracestate is capped by W3C truncation", func(t *testing.T) { + big := "big=" + strings.Repeat("x", 200) // > 128: dropped first + var members []string + members = append(members, "first=1") + for i := range 20 { + members = append(members, "m"+strings.Repeat("a", i)+"="+strings.Repeat("v", 20)) + } + members = append(members, big) + in := strings.Join(members, ",") + if len(in) <= maxForwardedTraceState { + t.Fatalf("fixture is %d bytes, want over %d", len(in), maxForwardedTraceState) + } + got := callWithHeaders(t, http.Header{"Traceparent": {callerTraceparnt}, "Tracestate": {in}}) + out := got.Header.Get("tracestate") + if len(out) > maxForwardedTraceState || out == "" { + t.Fatalf("tracestate forwarded at %d bytes, want 1..%d", len(out), maxForwardedTraceState) + } + if strings.Contains(out, "big=") { + t.Fatal("the over-128-byte member survived truncation") + } + if !strings.HasPrefix(out, "first=1,") { + t.Fatalf("truncation dropped from the left: %q", out) + } + if !strings.HasPrefix(in, out) { + t.Fatalf("tracestate %q is not a left prefix of the members sent", out) + } + }) +} + +func TestCapTraceStateKeepsWhatFits(t *testing.T) { + ts, err := trace.ParseTraceState("a=1,b=2") + if err != nil { + t.Fatal(err) + } + if got := capTraceState(ts).String(); got != "a=1,b=2" { + t.Fatalf("capTraceState = %q", got) + } +} + +func TestPassthroughStreamCarriesTheCallersTraceContextToTheModule(t *testing.T) { + gw := newStreamGateway(t) + s := passthroughServer(t, gw.URL, streamModule(ConsumedMethod{})) + close(gw.lines) + req := watchRequest("e1") + req.Header().Set("traceparent", callerTraceparnt) + req.Header().Set("tracestate", "vendor=abc") + req.Header().Set("baggage", "user=alice") + stream, err := watchClient(t, s).CallServerStream(context.Background(), req) + if err != nil { + t.Fatal(err) + } + if _, err := receiveAll(stream); err != nil { + t.Fatal(err) + } + h := gw.watches[0].Header + if h.Get("traceparent") != callerTraceparnt || h.Get("tracestate") != "vendor=abc" || h.Get("baggage") != "" { + t.Fatalf("module stream headers: traceparent %q, tracestate %q, baggage %q", h.Get("traceparent"), h.Get("tracestate"), h.Get("baggage")) + } +} + +func TestPassthroughRequiresExactlyOneTraceparent(t *testing.T) { + other := "00-" + strings.Repeat("a", 32) + "-" + strings.Repeat("b", 16) + "-01" + for name, parents := range map[string][]string{ + "two distinct": {callerTraceparnt, other}, + "two identical": {callerTraceparnt, callerTraceparnt}, + "one empty, one": {"", callerTraceparnt}, + } { + t.Run(name, func(t *testing.T) { + got := callWithHeaders(t, http.Header{"Traceparent": parents, "Tracestate": {"vendor=abc"}}) + if got.Header.Get("traceparent") != "" || got.Header.Get("tracestate") != "" { + t.Fatalf("forwarded %q / %q for traceparents %q", got.Header.Get("traceparent"), got.Header.Get("tracestate"), parents) + } + }) + } +} + +func TestPassthroughJoinsTraceStateFieldsInOrder(t *testing.T) { + t.Run("split across fields is merged", func(t *testing.T) { + got := callWithHeaders(t, http.Header{"Traceparent": {callerTraceparnt}, "Tracestate": {"vendor=abc,second=2", "other=xyz"}}) + if ts := got.Header.Get("tracestate"); ts != "vendor=abc,second=2,other=xyz" { + t.Fatalf("tracestate = %q, want the fields joined in order", ts) + } + if len(got.Header.Values("tracestate")) != 1 { + t.Fatalf("tracestate forwarded as %d fields, want 1", len(got.Header.Values("tracestate"))) + } + }) + t.Run("an empty field adds no member", func(t *testing.T) { + got := callWithHeaders(t, http.Header{"Traceparent": {callerTraceparnt}, "Tracestate": {"vendor=abc", "", "other=xyz"}}) + if ts := got.Header.Get("tracestate"); ts != "vendor=abc,other=xyz" { + t.Fatalf("tracestate = %q", ts) + } + }) + t.Run("a key repeated across fields drops the tracestate", func(t *testing.T) { + got := callWithHeaders(t, http.Header{"Traceparent": {callerTraceparnt}, "Tracestate": {"vendor=abc", "vendor=forged"}}) + if got.Header.Get("traceparent") != callerTraceparnt { + t.Fatal("traceparent dropped with the duplicate tracestate") + } + if ts := got.Header.Get("tracestate"); ts != "" { + t.Fatalf("tracestate with a duplicate key forwarded as %q", ts) + } + }) + t.Run("the cap applies to the joined list", func(t *testing.T) { + var fields []string + for i := range 20 { + fields = append(fields, "m"+strings.Repeat("a", i)+"="+strings.Repeat("v", 20)) + } + got := callWithHeaders(t, http.Header{"Traceparent": {callerTraceparnt}, "Tracestate": fields}) + if ts := got.Header.Get("tracestate"); ts == "" || len(ts) > maxForwardedTraceState || !strings.HasPrefix(ts, fields[0]+",") { + t.Fatalf("tracestate forwarded as %q (%d bytes)", ts, len(ts)) + } + }) +} + +func TestPassthroughEnforcesTheVersion00TraceparentLength(t *testing.T) { + for name, tp := range map[string]string{ + "trailing separator": callerTraceparnt + "-", + "54 bytes": callerTraceparnt[:54], + "56 bytes": callerTraceparnt + "1", + "trailing space": callerTraceparnt + " ", + "trailing tab": callerTraceparnt + "\t", + "leading space": " " + callerTraceparnt, + "trailing empty pair": callerTraceparnt + "--", + } { + t.Run(name, func(t *testing.T) { + got := callWithHeaders(t, http.Header{"Traceparent": {tp}, "Tracestate": {"vendor=abc"}}) + if got.Header.Get("traceparent") != "" || got.Header.Get("tracestate") != "" { + t.Fatalf("forwarded %q / %q for traceparent %q (%d bytes)", got.Header.Get("traceparent"), got.Header.Get("tracestate"), tp, len(tp)) + } + }) + } + t.Run("a later version may be longer", func(t *testing.T) { + got := callWithHeaders(t, http.Header{"Traceparent": {"01-" + callerTraceID + "-" + callerSpanID + "-01-future"}}) + // Re-serialized at the version this runtime speaks. + if tp := got.Header.Get("traceparent"); tp != callerTraceparnt { + t.Fatalf("traceparent = %q, want %q", tp, callerTraceparnt) + } + }) + t.Run("a later version still needs a separator after the flags", func(t *testing.T) { + got := callWithHeaders(t, http.Header{"Traceparent": {"01-" + callerTraceID + "-" + callerSpanID + "-01x"}}) + if tp := got.Header.Get("traceparent"); tp != "" { + t.Fatalf("traceparent = %q forwarded", tp) + } + }) +} + +// redirectingGateway is a gateway whose module answers at /moved and whose +// every other path redirects there: directly, on the gateway's own origin, or, +// when crossOrigin is set, by way of a second server that bounces the request +// back. It records the trace headers each hop received. +type redirectingGateway struct { + gateway, foreign *httptest.Server + mu sync.Mutex + hops []traceHop +} + +type traceHop struct { + origin, path, traceparent, tracestate string +} + +func newRedirectingGateway(t *testing.T, crossOrigin bool, answer http.HandlerFunc) *redirectingGateway { + t.Helper() + g := &redirectingGateway{} + record := func(origin string, r *http.Request) { + g.mu.Lock() + defer g.mu.Unlock() + g.hops = append(g.hops, traceHop{origin, r.URL.Path, r.Header.Get("traceparent"), r.Header.Get("tracestate")}) + } + g.gateway = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + record("gateway", r) + switch { + case r.URL.Path == "/moved": + answer(w, r) + case crossOrigin: + http.Redirect(w, r, g.foreign.URL+"/bounce", http.StatusTemporaryRedirect) + default: + http.Redirect(w, r, "/moved", http.StatusTemporaryRedirect) + } + })) + g.foreign = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + record("foreign", r) + http.Redirect(w, r, g.gateway.URL+"/moved", http.StatusTemporaryRedirect) + })) + t.Cleanup(g.gateway.Close) + t.Cleanup(g.foreign.Close) + return g +} + +// callerTraceContext is the context the passthrough hands a module call for a +// caller that sent callerTraceparnt and vendor=abc. +func callerTraceContext() context.Context { + r := httptest.NewRequest(http.MethodPost, "/", nil) + r.Header.Set("traceparent", callerTraceparnt) + r.Header.Set("tracestate", "vendor=abc") + return withInboundTraceContext(r).Context() +} + +// checkHops asserts how a redirected module call went. Same origin, the +// redirect is followed and both hops carry the trace context. Cross origin, +// the gateway's origin pin refuses the hop before it is sent, so the foreign +// server receives nothing at all — the trace context included — and the call +// fails; the redirect policy strips the trace headers from that hop first, +// so they would not travel even past a transport without the pin (see +// TestTraceContextRedirectPolicyStripsOnceTheChainLeavesTheOrigin). +func (g *redirectingGateway) checkHops(t *testing.T, crossOrigin bool, err error) { + t.Helper() + g.mu.Lock() + defer g.mu.Unlock() + if crossOrigin { + if err == nil || !strings.Contains(err.Error(), "refusing to send") { + t.Fatalf("err = %v, want the origin pin's refusal", err) + } + } else if err != nil { + t.Fatal(err) + } + want := 2 + if crossOrigin { + want = 1 + } + if len(g.hops) != want { + t.Fatalf("hops = %+v, want %d", g.hops, want) + } + for _, hop := range g.hops { + if hop.origin != "gateway" { + t.Fatalf("hop %+v reached a server that is not the gateway", hop) + } + if hop.traceparent != callerTraceparnt || hop.tracestate != "vendor=abc" { + t.Fatalf("same-origin hop %+v lost the trace context", hop) + } + } + if last := g.hops[len(g.hops)-1]; !crossOrigin && last.path != "/moved" { + t.Fatalf("the chain ended at %+v, want the gateway's /moved", last) + } +} + +func TestTranscodedDropsTraceContextOnACrossOriginRedirect(t *testing.T) { + for name, crossOrigin := range map[string]bool{ + "same-origin redirect keeps it": false, + "cross-origin redirect is refused, the trace context unsent": true, + } { + t.Run(name, func(t *testing.T) { + g := newRedirectingGateway(t, crossOrigin, func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, `{"entryId":"e1"}`) + }) + gw := newGateway(g.gateway.URL, "Bearer viewer", "org-1", "session-1") + req := thingMessage("GetThingRequest") + setField(req, "entry_id", protoreflect.ValueOfString("e1")) + err := gw.Transcoded(callerTraceContext(), "/v1/things", "/things.v1.Things/Get", req, thingMessage("Thing")) + g.checkHops(t, crossOrigin, err) + }) + } +} + +func TestTranscodedStreamDropsTraceContextOnACrossOriginRedirect(t *testing.T) { + for name, crossOrigin := range map[string]bool{ + "same-origin redirect keeps it": false, + "cross-origin redirect is refused, the trace context unsent": true, + } { + for _, firstAnswer := range []time.Duration{0, time.Minute} { + t.Run(fmt.Sprintf("%s/first answer %s", name, firstAnswer), func(t *testing.T) { + g := newRedirectingGateway(t, crossOrigin, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("content-type", "application/x-ndjson") + _, _ = io.WriteString(w, `{"result":{"entryId":"e1"}}`+"\n") + }) + gw := newGateway(g.gateway.URL, "Bearer viewer", "org-1", "session-1") + req := thingMessage("GetThingRequest") + setField(req, "entry_id", protoreflect.ValueOfString("e1")) + var opts []StreamOption + if firstAnswer > 0 { + opts = append(opts, StreamFirstAnswerWithin(firstAnswer)) + } + n := 0 + err := gw.TranscodedStream(callerTraceContext(), "/v1/things", watchName, req, + func() proto.Message { return thingMessage("Thing") }, + func(proto.Message) error { n++; return nil }, opts...) + g.checkHops(t, crossOrigin, err) + if want := map[bool]int{false: 1, true: 0}[crossOrigin]; n != want { + t.Fatalf("messages = %d, want %d", n, want) + } + }) + } + } +} + +func TestTraceContextRedirectPolicyKeepsTheDefaultLimit(t *testing.T) { + policy := traceContextRedirectPolicy("http://gateway.local") + req := httptest.NewRequest(http.MethodGet, "http://gateway.local/x", nil) + via := make([]*http.Request, maxRedirects) + for i := range via { + via[i] = req + } + if err := policy(req, via[:maxRedirects-1]); err != nil { + t.Fatalf("redirect %d refused: %v", maxRedirects, err) + } + if err := policy(req, via); err == nil || err.Error() != "stopped after 10 redirects" { + t.Fatalf("redirect %d: err = %v, want net/http's default", maxRedirects+1, err) + } +} + +func TestSameOrigin(t *testing.T) { + gateway, _ := url.Parse("http://Gateway.local") + for raw, want := range map[string]bool{ + "http://gateway.local:80/p": true, + "HTTP://GATEWAY.LOCAL/p": true, + "https://gateway.local/p": false, + "http://gateway.local:8080/": false, + "http://other.local/": false, + "/relative": false, + } { + u, _ := url.Parse(raw) + if got := sameOrigin(gateway, u); got != want { + t.Errorf("sameOrigin(%q) = %v, want %v", raw, got, want) + } + } +} + +func TestTraceContextRedirectPolicyStripsOnceTheChainLeavesTheOrigin(t *testing.T) { + policy := traceContextRedirectPolicy("http://gateway.local") + hop := func(raw string) *http.Request { + r := httptest.NewRequest(http.MethodGet, raw, nil) + r.Header.Set("traceparent", callerTraceparnt) + r.Header.Set("tracestate", "vendor=abc") + return r + } + for name, tc := range map[string]struct { + via []string + next string + keep bool + }{ + "same origin": {[]string{"http://gateway.local/a"}, "http://gateway.local/b", true}, + "to another origin": {[]string{"http://gateway.local/a"}, "http://other.local/b", false}, + "back to the gateway after leaving": {[]string{"http://gateway.local/a", "http://other.local/b"}, "http://gateway.local/c", false}, + "to another port": {[]string{"http://gateway.local/a"}, "http://gateway.local:8080/b", false}, + "to another scheme": {[]string{"http://gateway.local/a"}, "https://gateway.local/b", false}, + } { + t.Run(name, func(t *testing.T) { + var via []*http.Request + for _, raw := range tc.via { + via = append(via, hop(raw)) + } + next := hop(tc.next) + if err := policy(next, via); err != nil { + t.Fatal(err) + } + kept := next.Header.Get("traceparent") != "" || next.Header.Get("tracestate") != "" + if kept != tc.keep { + t.Fatalf("trace context kept = %v, want %v", kept, tc.keep) + } + if next.Header.Get("authorization") != "" { + t.Fatal("policy added a header") + } + }) + } + t.Run("an unparseable gateway URL strips every hop", func(t *testing.T) { + next := hop("http://gateway.local/b") + if err := traceContextRedirectPolicy("http://[::1")(next, []*http.Request{hop("http://gateway.local/a")}); err != nil { + t.Fatal(err) + } + if next.Header.Get("traceparent") != "" { + t.Fatal("trace context kept with no gateway origin to hold it to") + } + }) +} diff --git a/transcode.go b/transcode.go index 8886a9e..43d5fab 100644 --- a/transcode.go +++ b/transcode.go @@ -85,7 +85,8 @@ func (g *Gateway) Transcoded(ctx context.Context, prefix, method string, req, re if call.body != nil { httpReq.Header.Set("content-type", "application/json") } - httpResp, err := g.HTTPClient().Do(httpReq) + injectTraceContext(ctx, httpReq.Header) + httpResp, err := g.moduleClient().Do(httpReq) if err != nil { return fmt.Errorf("%s %s: %w", call.verb, call.template, err) }