Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ your collector secrets. A standard OTLP/HTTP exporter can then use:
```sh
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
export OTEL_EXPORTER_OTLP_HEADERS="x-fanout-ingest-token=$INGEST_TOKEN"
export OTEL_EXPORTER_OTLP_HEADERS="authorization=Bearer%20$INGEST_TOKEN"
```

For a Collector on the same private container network, the forwarding side is:
Expand All @@ -96,7 +96,7 @@ exporters:
otlp_http/fanout:
endpoint: http://fanout:4318
headers:
x-fanout-ingest-token: ${env:INGEST_TOKEN}
Authorization: "Bearer ${env:INGEST_TOKEN}"

service:
pipelines:
Expand Down
4 changes: 2 additions & 2 deletions cmd/bench/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ var version = "dev"
func main() {
var cfg config
flag.StringVar(&cfg.endpoint, "endpoint", "localhost:4317", "OTLP gRPC endpoint")
flag.StringVar(&cfg.token, "token", "", "ingest token (x-fanout-ingest-token); required")
flag.StringVar(&cfg.token, "token", "", "ingest bearer token; required")
flag.Float64Var(&cfg.rate, "rate", 0, "target traces per second (aggregate); 0 ramps adaptively to find what this server sustains")
flag.DurationVar(&cfg.duration, "duration", time.Minute, "run duration for a fixed -rate run; 0 means run until interrupted")
flag.IntVar(&cfg.workers, "workers", 0, "concurrent senders; 0 sizes them from the driver's cores")
Expand Down Expand Up @@ -552,7 +552,7 @@ func (g *generator) outCtx(ctx context.Context) context.Context {
if g.cfg.token == "" {
return ctx
}
return metadata.AppendToOutgoingContext(ctx, "x-fanout-ingest-token", g.cfg.token)
return metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+g.cfg.token)
}

// eventTime returns the timestamp for an emitted event: now(), or — when
Expand Down
7 changes: 4 additions & 3 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@ application; put a customer-controlled Collector or gateway in front of Fanout
when public clients are involved.

For OTLP/HTTP, set the exporter protocol to `http/protobuf`, use the base
endpoint `http://fanout:4318`, and supply either `x-fanout-ingest-token` or an
`Authorization: Bearer` header. Exporters derive the standard `/v1/traces`,
`/v1/metrics`, and `/v1/logs` paths from the base endpoint.
endpoint `http://fanout:4318`, and supply the ingest token as
`Authorization: Bearer <token>`. Exporters derive the standard `/v1/traces`,
`/v1/metrics`, and `/v1/logs` paths from the base endpoint. OTLP/gRPC uses the
same authorization value as gRPC metadata on port `4317`.

## Mobile boundary

Expand Down
2 changes: 1 addition & 1 deletion internal/agent/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,7 @@ func TestThreadRouteHidesOtherOwnersThread(t *testing.T) {
mutation := func(method, path, body string) *httptest.ResponseRecorder {
request := httptest.NewRequest(method, path, strings.NewReader(body))
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Fanout-Request", "1")
request.Header.Set("Fanout-Request", "1")
request.AddCookie(cookie)
recorder := httptest.NewRecorder()
e.ServeHTTP(recorder, request)
Expand Down
2 changes: 1 addition & 1 deletion internal/api/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ func (h *AuthHandler) Setup(c *echo.Context) error {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to persist ingest token")
}
resp["ingest_token"] = ingestToken
resp["ingest_header_name"] = "x-fanout-ingest-token"
resp["ingest_header_name"] = "Authorization"
// The endpoint collectors should actually use. Behind a reverse proxy
// this is the advertised TLS endpoint (e.g. https://ingest.example.com),
// not the internal :4317 — see suggestedIngestEndpoint.
Expand Down
2 changes: 1 addition & 1 deletion internal/api/auth_middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,7 @@ func validBrowserMutation(r *http.Request) bool {
if strings.EqualFold(strings.TrimSpace(r.Header.Get("Sec-Fetch-Site")), "cross-site") {
return false
}
if r.Header.Get("X-Fanout-Request") == "1" {
if r.Header.Get("Fanout-Request") == "1" {
return true
}
for _, raw := range []string{r.Header.Get("Origin"), r.Header.Get("Referer")} {
Expand Down
9 changes: 6 additions & 3 deletions internal/api/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ func sessionRequest(method, target string, body *strings.Reader, cookie *http.Co
req.AddCookie(cookie)
}
if isUnsafeMethod(method) {
req.Header.Set("X-Fanout-Request", "1")
req.Header.Set("Fanout-Request", "1")
}
return req
}
Expand Down Expand Up @@ -322,12 +322,12 @@ func TestBrowserMutationValidation(t *testing.T) {
want int
}{
{name: "missing", want: http.StatusForbidden},
{name: "custom header", head: map[string]string{"X-Fanout-Request": "1"}, want: http.StatusNoContent},
{name: "custom header", head: map[string]string{"Fanout-Request": "1"}, want: http.StatusNoContent},
{name: "same origin", head: map[string]string{"Origin": "http://example.com"}, want: http.StatusNoContent},
{name: "referer", head: map[string]string{"Referer": "http://example.com/page"}, want: http.StatusNoContent},
{name: "malformed referer", head: map[string]string{"Referer": "://bad"}, want: http.StatusForbidden},
{name: "evil origin", head: map[string]string{"Origin": "https://evil.test"}, want: http.StatusForbidden},
{name: "cross site overrides header", head: map[string]string{"X-Fanout-Request": "1", "Sec-Fetch-Site": "cross-site"}, want: http.StatusForbidden},
{name: "cross site overrides header", head: map[string]string{"Fanout-Request": "1", "Sec-Fetch-Site": "cross-site"}, want: http.StatusForbidden},
} {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/rules", nil)
Expand Down Expand Up @@ -468,6 +468,9 @@ func TestSetupLifecycleAndIngestToken(t *testing.T) {
if !strings.HasPrefix(body["ingest_token"], "fo_") {
t.Fatalf("ingest token = %q", body["ingest_token"])
}
if body["ingest_header_name"] != "Authorization" {
t.Fatalf("ingest header name = %q", body["ingest_header_name"])
}
if user, err := s.users.GetByEmail("admin@example.com"); err != nil || user.Role != auth.RoleAdmin {
t.Fatalf("admin = %+v err=%v", user, err)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/api/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ func (h *DashboardHandler) Delete(c *echo.Context) error {
if err != nil {
return err
}
if c.Request().Header.Get("X-Fanout-Confirm-Delete") != c.Param("id") {
if c.Request().Header.Get("Fanout-Confirm-Delete") != c.Param("id") {
return echo.NewHTTPError(http.StatusPreconditionRequired, "dashboard deletion requires confirmation")
}
if err := h.dashboards.Delete(c.Request().Context(), owner, c.Param("id")); err != nil {
Expand Down
14 changes: 7 additions & 7 deletions internal/api/oauth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ func TestBrowserMCPUsesSessionWithoutWeakeningRemoteMCP(t *testing.T) {
t.Fatalf("Create user: %v", err)
}

anonymous := serve(t, e, http.MethodPost, "/api/mcp", "", map[string]string{"X-Fanout-Request": "1"})
anonymous := serve(t, e, http.MethodPost, "/api/mcp", "", map[string]string{"Fanout-Request": "1"})
if anonymous.Code != http.StatusUnauthorized {
t.Fatalf("anonymous browser MCP = %d, want 401", anonymous.Code)
}
Expand All @@ -227,8 +227,8 @@ func TestBrowserMCPUsesSessionWithoutWeakeningRemoteMCP(t *testing.T) {
}

browser := serve(t, e, http.MethodPost, "/api/mcp", "", map[string]string{
"Authorization": "Bearer attacker-controlled",
"X-Fanout-Request": "1",
"Authorization": "Bearer attacker-controlled",
"Fanout-Request": "1",
}, cookie)
if browser.Code != http.StatusNoContent {
t.Fatalf("session browser MCP = %d %s", browser.Code, browser.Body.String())
Expand All @@ -241,7 +241,7 @@ func TestBrowserMCPUsesSessionWithoutWeakeningRemoteMCP(t *testing.T) {
t.Fatalf("browser MCP scopes = %v, want read and dashboard access", scopes)
}

remoteWithSessionOnly := serve(t, e, http.MethodPost, "/mcp", "", map[string]string{"X-Fanout-Request": "1"}, cookie)
remoteWithSessionOnly := serve(t, e, http.MethodPost, "/mcp", "", map[string]string{"Fanout-Request": "1"}, cookie)
if remoteWithSessionOnly.Code != http.StatusUnauthorized {
t.Fatalf("remote MCP accepted browser session: %d", remoteWithSessionOnly.Code)
}
Expand Down Expand Up @@ -346,7 +346,7 @@ var formHeaders = map[string]string{"Content-Type": "application/x-www-form-urle

func oauthCookieForUser(t *testing.T, e *echo.Echo, user auth.User) *http.Cookie {
t.Helper()
rec := serve(t, e, http.MethodPost, "/api/auth/setup", "", map[string]string{"X-Test-User": user.ID, "X-Fanout-Request": "1"})
rec := serve(t, e, http.MethodPost, "/api/auth/setup", "", map[string]string{"X-Test-User": user.ID, "Fanout-Request": "1"})
if rec.Code != http.StatusNoContent {
t.Fatalf("test login = %d %s", rec.Code, rec.Body.String())
}
Expand Down Expand Up @@ -608,8 +608,8 @@ func serve(t *testing.T, e *echo.Echo, method, target, body string, headers map[
for _, cookie := range cookies {
req.AddCookie(cookie)
}
if len(cookies) > 0 && isUnsafeMethod(method) && req.Header.Get("X-Fanout-Request") == "" {
req.Header.Set("X-Fanout-Request", "1")
if len(cookies) > 0 && isUnsafeMethod(method) && req.Header.Get("Fanout-Request") == "" {
req.Header.Set("Fanout-Request", "1")
}
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
Expand Down
2 changes: 1 addition & 1 deletion internal/api/session_middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ func TestSessionMiddlewareCSRFAndMetricsCredential(t *testing.T) {

withHeader := httptest.NewRequest(http.MethodPost, "/api/rules", nil)
withHeader.AddCookie(cookie)
withHeader.Header.Set("X-Fanout-Request", "1")
withHeader.Header.Set("Fanout-Request", "1")
recorder = httptest.NewRecorder()
e.ServeHTTP(recorder, withHeader)
if recorder.Code != http.StatusNoContent {
Expand Down
4 changes: 2 additions & 2 deletions internal/api/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ func (h *SettingsHandler) GetIngest(c *echo.Context) error {
TokenRequired: current.TokenHash != "",
SuggestedEndpoint: suggestedIngestEndpoint(c.Request(), h.cfg.OTLPGRPCAddr, h.cfg.IngestAdvertisedEndpoint),
TLSConfigured: h.cfg.TLSEnabled(),
HeaderName: "x-fanout-ingest-token",
HeaderName: "Authorization",
})
}

Expand All @@ -67,7 +67,7 @@ func (h *SettingsHandler) RotateIngestToken(c *echo.Context) error {
TokenRequired: true,
SuggestedEndpoint: suggestedIngestEndpoint(c.Request(), h.cfg.OTLPGRPCAddr, h.cfg.IngestAdvertisedEndpoint),
TLSConfigured: h.cfg.TLSEnabled(),
HeaderName: "x-fanout-ingest-token",
HeaderName: "Authorization",
IngestToken: token,
})
}
Expand Down
4 changes: 2 additions & 2 deletions internal/api/settings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ func TestGetIngest_EmptyBeforeSetup(t *testing.T) {
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
if resp.TokenRequired || resp.SuggestedEndpoint != "fanout.example.com:4317" {
if resp.TokenRequired || resp.SuggestedEndpoint != "fanout.example.com:4317" || resp.HeaderName != "Authorization" {
t.Fatalf("response = %+v", resp)
}
}
Expand All @@ -56,7 +56,7 @@ func TestRotateIngestToken_PersistsHashReturnsPlaintext(t *testing.T) {
t.Fatalf("Unmarshal: %v", err)
}
current, err := store.GetIngest(req.Context())
if err != nil || resp.IngestToken == "" || !settings.CheckIngestToken(resp.IngestToken, current.TokenHash) {
if err != nil || resp.IngestToken == "" || resp.HeaderName != "Authorization" || !settings.CheckIngestToken(resp.IngestToken, current.TokenHash) {
t.Fatalf("response=%+v current=%+v err=%v", resp, current, err)
}
}
Expand Down
17 changes: 10 additions & 7 deletions internal/ingest/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,14 +118,17 @@ func ingestTokenFromContext(ctx context.Context) string {
if !ok {
return ""
}
if values := md.Get("x-fanout-ingest-token"); len(values) > 0 {
return strings.TrimSpace(values[0])
}
if values := md.Get("authorization"); len(values) > 0 {
const prefix = "Bearer "
if strings.HasPrefix(values[0], prefix) {
return strings.TrimSpace(strings.TrimPrefix(values[0], prefix))
}
return ingestTokenFromAuthorization(values[0])
}
return ""
}

func ingestTokenFromAuthorization(authorization string) string {
authorization = strings.TrimSpace(authorization)
const bearer = "Bearer "
if len(authorization) >= len(bearer) && strings.EqualFold(authorization[:len(bearer)], bearer) {
return strings.TrimSpace(authorization[len(bearer):])
}
return ""
}
29 changes: 24 additions & 5 deletions internal/ingest/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ func TestAuthorize_RejectsWhenPreSetup(t *testing.T) {
}
}

func TestAuthorize_AcceptsValidToken(t *testing.T) {
func TestAuthorize_AcceptsBearerAuthorization(t *testing.T) {
store := newRuntimeStore(t)
token, hash, err := settings.GenerateIngestToken()
if err != nil {
Expand All @@ -76,7 +76,7 @@ func TestAuthorize_AcceptsValidToken(t *testing.T) {
}

authorizer := newIngestAuthorizer(store)
ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("x-fanout-ingest-token", token))
ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("authorization", "Bearer "+token))

if err := authorizer.authorize(ctx); err != nil {
t.Fatalf("authorize with valid token: %v", err)
Expand All @@ -94,15 +94,15 @@ func TestAuthorize_RejectsWrongToken(t *testing.T) {
}

authorizer := newIngestAuthorizer(store)
ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("x-fanout-ingest-token", "fo_wrong"))
ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("authorization", "Bearer fo_wrong"))

err = authorizer.authorize(ctx)
if status.Code(err) != codes.Unauthenticated {
t.Fatalf("code = %v, want %v", status.Code(err), codes.Unauthenticated)
}
}

func TestAuthorize_AcceptsBearerAuthorization(t *testing.T) {
func TestAuthorize_AcceptsCaseInsensitiveBearerScheme(t *testing.T) {
store := newRuntimeStore(t)
token, hash, err := settings.GenerateIngestToken()
if err != nil {
Expand All @@ -113,13 +113,32 @@ func TestAuthorize_AcceptsBearerAuthorization(t *testing.T) {
}

authorizer := newIngestAuthorizer(store)
ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("authorization", "Bearer "+token))
ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("authorization", "bearer "+token))

if err := authorizer.authorize(ctx); err != nil {
t.Fatalf("authorize with Bearer header: %v", err)
}
}

func TestAuthorize_RejectsLegacyFanoutHeader(t *testing.T) {
store := newRuntimeStore(t)
token, hash, err := settings.GenerateIngestToken()
if err != nil {
t.Fatalf("GenerateIngestToken: %v", err)
}
if err := store.SetIngest(context.Background(), settings.Ingest{TokenHash: hash}); err != nil {
t.Fatalf("SetIngest: %v", err)
}

authorizer := newIngestAuthorizer(store)
ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("x-fanout-ingest-token", token))

err = authorizer.authorize(ctx)
if status.Code(err) != codes.Unauthenticated {
t.Fatalf("code = %v, want %v", status.Code(err), codes.Unauthenticated)
}
}

func TestAuthorize_RejectsMissingToken(t *testing.T) {
store := newRuntimeStore(t)
_, hash, err := settings.GenerateIngestToken()
Expand Down
10 changes: 1 addition & 9 deletions internal/ingest/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,13 +151,5 @@ func readOTLPHTTPBody(r *http.Request, limit int64) ([]byte, error) {
}

func ingestTokenFromHTTP(header http.Header) string {
if token := strings.TrimSpace(header.Get("x-fanout-ingest-token")); token != "" {
return token
}
authorization := strings.TrimSpace(header.Get("Authorization"))
const bearer = "Bearer "
if len(authorization) >= len(bearer) && strings.EqualFold(authorization[:len(bearer)], bearer) {
return strings.TrimSpace(authorization[len(bearer):])
}
return ""
return ingestTokenFromAuthorization(header.Get("Authorization"))
}
Loading
Loading