httpreceiver

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 27, 2026 License: MIT Imports: 17 Imported by: 0

Documentation

Overview

Package httpreceiver is crier's HTTP ingestion endpoint.

It lives in its own module (ADR-0020) because it depends on moat for the request-level guards required by IR1, and core must stay free of third-party dependencies (NFR1). An embedded consumer imports core and gets no HTTP server at all — in embedded mode the host application owns the trust boundary, so there is no receiver to own it (FR11).

What a 202 means

A record that is accepted returns 202: it was admitted to the buffer. It says nothing about any backend having stored it. Delivery is at-least-once and acceptance is not delivery (ADR-0009); a caller that reads 202 as "stored" will be wrong during every export outage.

Trust boundary

Identity is derived from the authenticated principal and never from the request body (ADR-0008). A client that asserts its own service.name has that field overwritten and the discrepancy counted — the record is still accepted, attributed to whoever actually authenticated.

Example

Example wires the ingestion endpoint: credentials, the pipeline it feeds, and the request-level guards around it.

package main

import (
	"fmt"
	"net/http"

	"github.com/JonasBorgesLM/moat/secret"

	"github.com/JonasBorgesLM/crier/core"
	httpreceiver "github.com/JonasBorgesLM/crier/receivers/http"
)

func main() {
	buffer, err := core.NewMemoryBuffer(core.MemoryBufferConfig{Capacity: 10_000})
	if err != nil {
		panic(err)
	}
	pipeline, err := core.NewPipeline(core.PipelineConfig{Buffer: buffer})
	if err != nil {
		panic(err)
	}

	// Held masked, never as a plain string (NFR4, IR2).
	auth, err := httpreceiver.NewStaticCredentials(map[string]secret.Value{
		"task-api": secret.New([]byte("ingest-token")),
	})
	if err != nil {
		panic(err)
	}

	receiver, err := httpreceiver.New(httpreceiver.Config{Pipeline: pipeline, Auth: auth})
	if err != nil {
		panic(err)
	}

	// Security headers outermost, so error responses carry them too.
	handler, err := receiver.Handler(httpreceiver.ChainConfig{})
	if err != nil {
		panic(err)
	}

	mux := http.NewServeMux()
	mux.Handle("/", handler)

	fmt.Println("serving", httpreceiver.V1.Path())
	fmt.Println("authenticated sources:", auth.Sources())

}
Output:
serving /v1/logs
authenticated sources: [task-api]

Index

Examples

Constants

View Source
const (
	// DefaultMaxBodyBytes bounds one request body. This is step 1 of the
	// canonical stage order — a transport limit, applied before anything
	// reads (ADR-0010).
	DefaultMaxBodyBytes = 4 << 20 // 4 MiB
	// DefaultRateLimitBurst is how many requests a key may make at once.
	DefaultRateLimitBurst = 200
	// DefaultRateLimitPerSecond is the sustained rate per key.
	DefaultRateLimitPerSecond = 100
)

Chain defaults.

View Source
const ContentTypeJSON = "application/json"

ContentTypeJSON is the only media type v1 accepts.

View Source
const DefaultIdentityHeader = "X-Authenticated-Source"

DefaultIdentityHeader carries the principal a trusted gateway has already authenticated.

View Source
const MaxRecordsPerRequest = 10_000

MaxRecordsPerRequest bounds how many records one request may carry.

This is a transport limit — step 1 of the canonical stage order (ADR-0010) — and it is separate from the buffer's capacity: it bounds the work one request can ask for before any of it is admitted.

Variables

View Source
var ErrUnauthenticated = errors.New("unauthenticated")

ErrUnauthenticated means the request carried no usable credential, or one that did not verify.

It is deliberately one error for both. Telling a caller whether the source identifier exists turns the credential store into an enumeration oracle, and the caller can do nothing different with the distinction anyway.

Functions

This section is empty.

Types

type Authenticator

type Authenticator interface {
	// Authenticate returns the source identity, or an error wrapping
	// ErrUnauthenticated. It must not put credential material in that error.
	Authenticate(r *http.Request) (source string, err error)
}

Authenticator establishes who is calling.

It returns the attested source identity, which is the only identity the pipeline will use: attribution, quotas and metrics all key on it, never on anything in the request body (ADR-0008, finding D-2).

Standalone mode only. In embedded mode there is no receiver, because the host application owns the trust boundary (FR11).

type BadRequestError

type BadRequestError struct {
	// Field is the offending field, empty when the failure is not about one.
	Field string
	// Reason is safe to return to the caller.
	Reason string
}

BadRequestError is a payload this receiver refuses, with a message a client can act on.

It names the offending field wherever the decoder gives one. ADR-0012's own consequences say strict parsing that cannot say *what* was wrong trades silent bugs for loud confusion, which is not an improvement.

func (*BadRequestError) Error

func (e *BadRequestError) Error() string

Error implements error.

type ChainConfig

type ChainConfig struct {
	// MaxBodyBytes bounds one request body. Zero means DefaultMaxBodyBytes.
	MaxBodyBytes int64

	// RateLimitBurst and RateLimitPerSecond bound how fast one key may send.
	// Zero means the defaults.
	RateLimitBurst     int
	RateLimitPerSecond float64

	// RateLimitKey derives the key a request is limited under. Nil limits by
	// peer address.
	//
	// Behind a reverse proxy that is wrong — every request arrives from the
	// proxy, so one key covers every tenant. Pass TrustedProxy.KeyFunc there,
	// which an untrusted peer cannot steer.
	RateLimitKey func(*http.Request) (string, error)

	// DisableRateLimit omits rate limiting.
	//
	// It exists so that omitting a control is something someone typed rather
	// than something a zero value did, following moat's own reasoning about
	// presets that quietly drop a protection they were not given.
	DisableRateLimit bool
}

ChainConfig configures the request-level guards wrapped around the receiver.

They come from moat rather than being reimplemented here (IR1): a rate limiter and a body-size guard written a second time are two more things to get subtly wrong, and moat's are the ones with the test suites.

type Config

type Config struct {
	// Pipeline receives every accepted record. Required.
	Pipeline *core.Pipeline

	// Auth establishes the source identity. Required: an ingestion endpoint
	// with no authentication accepts forged telemetry from anyone who can
	// reach it, which is the first threat in the model.
	Auth Authenticator

	// Deprecated marks wire versions scheduled for removal, mapping each to
	// the sunset date to advertise. A request on such a version is still
	// served, with a Deprecation header and a counted metric, so a migration
	// is driven by data rather than guesswork (ADR-0012).
	Deprecated map[WireVersion]string

	// Metrics receives receiver-level counters. Nil discards.
	Metrics core.Metrics

	// Now supplies ObservedTimestamp. Nil means time.Now.
	Now func() time.Time
}

Config configures a Receiver. Build one with New, which validates eagerly (NFR4).

type Receiver

type Receiver struct {
	// contains filtered or unexported fields
}

Receiver is crier's HTTP ingestion endpoint (FR1, ADR-0001).

It validates, admits, and answers 202 immediately; export happens on the dispatcher's own workers. That is what keeps a caller's request latency independent of whether a backend is healthy.

Safe for concurrent use.

func New

func New(cfg Config) (*Receiver, error)

New validates cfg and returns the receiver.

func (*Receiver) Handler

func (rc *Receiver) Handler(cfg ChainConfig) (http.Handler, error)

Handler returns the receiver wrapped in the request-level guards (IR1).

Order is a security property, not a style choice, and this one is:

secure headers -> rate limit -> content type -> body size -> receiver

Security headers are outermost so that *error* responses carry them too — moat's own ordering lesson, and the reason it is worth restating is that the mistake is invisible in the happy path, where every response already looks right.

The body-size limit sits inside the cheap rejections and outside the handler, because everything below it reads the body: a guard that runs after the read has already allowed the work it exists to prevent.

func (*Receiver) Mux

func (rc *Receiver) Mux() http.Handler

Mux serves every version this receiver speaks, without the request-level guards.

Handler wraps it in the recommended chain. This is what to compose a different chain around — but the guards it omits are real, so replacing them means providing equivalents, not dropping them.

func (*Receiver) String

func (rc *Receiver) String() string

String implements fmt.Stringer.

type StaticCredentials

type StaticCredentials struct {
	// contains filtered or unexported fields
}

StaticCredentials authenticates a shared secret per source, configured up front.

One timing residue is known and accepted. secret.Value.Equal leaks whether the two lengths differ — which moat documents and subtle's comparison cannot avoid — so the unknown-source path, comparing against a fixed-length decoy, is not perfectly indistinguishable from a known source whose credential happens to be a different length. That is far below the difference an early return would produce, and closing it would mean holding a decoy per length the store contains, which leaks the same thing from the other side.

mTLS is the recommended production alternative and is phase two (ADR-0008). This exists because a shared secret is what a small deployment will actually configure, and the alternative to supporting it well is people disabling authentication entirely.

Safe for concurrent use.

func NewStaticCredentials

func NewStaticCredentials(credentials map[string]secret.Value) (*StaticCredentials, error)

NewStaticCredentials validates the credential set eagerly (NFR4).

func (*StaticCredentials) Authenticate

func (s *StaticCredentials) Authenticate(r *http.Request) (string, error)

Authenticate implements Authenticator.

The credential is carried as `Authorization: Bearer <source>:<secret>`. The source identifier is not an identity claim — it selects which credential to verify. Identity is only conferred once that credential verifies, which is what makes this server-derived rather than client-asserted (ADR-0008).

func (*StaticCredentials) Sources

func (s *StaticCredentials) Sources() []string

Sources lists the configured source identifiers. Useful in a config dump, and safe in one — the credentials are not in it.

type TrustedProxy

type TrustedProxy struct {
	// contains filtered or unexported fields
}

TrustedProxy derives identity from an upstream that has already authenticated the caller — crier behind gateway-auth (IR7, ADR-0008).

This is never the default and cannot become one by omission: it exists only where an operator constructed it, having named the peers to believe.

It is the exact failure mode moat found and fixed in its own realip package (finding M-2), which is why the trust decision is delegated there rather than re-derived here. A header is only identity if the peer that set it is one crier was told to believe; from anyone else it is an assertion by a stranger.

Safe for concurrent use.

func NewTrustedProxy

func NewTrustedProxy(cfg TrustedProxyConfig) (*TrustedProxy, error)

NewTrustedProxy validates cfg and returns the authenticator.

Example

ExampleNewTrustedProxy shows crier behind gateway-auth, where identity comes from the gateway's assertion rather than from a credential crier checks itself (IR7).

package main

import (
	"fmt"

	httpreceiver "github.com/JonasBorgesLM/crier/receivers/http"
)

func main() {
	// Strictly opt-in, and the peers have to be named: a header is only
	// identity if the peer that set it is one crier was told to believe.
	proxy, err := httpreceiver.NewTrustedProxy(httpreceiver.TrustedProxyConfig{
		TrustedCIDRs: []string{"10.0.0.0/8"},
	})
	if err != nil {
		panic(err)
	}

	// A set covering the default route is refused, because it would make the
	// identity header forgeable by any client.
	_, err = httpreceiver.NewTrustedProxy(httpreceiver.TrustedProxyConfig{
		TrustedCIDRs: []string{"0.0.0.0/0"},
	})
	fmt.Println("default route accepted:", err == nil)
	fmt.Println("trusted prefixes:", proxy.TrustedPrefixes())

}
Output:
default route accepted: false
trusted prefixes: [10.0.0.0/8]

func (*TrustedProxy) Authenticate

func (t *TrustedProxy) Authenticate(r *http.Request) (string, error)

Authenticate implements Authenticator.

A peer outside the trusted set is rejected outright. The header is never treated as probably fine: that is precisely how a direct client forges an identity, and the whole point of naming the trusted peers is that anyone else's assertion means nothing.

func (*TrustedProxy) KeyFunc

func (t *TrustedProxy) KeyFunc() func(*http.Request) (string, error)

KeyFunc returns a rate-limit key that an untrusted peer cannot steer, for use with the middleware chain.

func (*TrustedProxy) TrustedPrefixes

func (t *TrustedProxy) TrustedPrefixes() []netip.Prefix

TrustedPrefixes reports the peers whose assertion is believed. Useful in a config dump, where "who can claim to be anyone" is the question worth being able to answer.

type TrustedProxyConfig

type TrustedProxyConfig struct {
	// TrustedCIDRs are the peers whose identity assertion is believed —
	// gateway-auth's addresses, not the internet. Required.
	TrustedCIDRs []string

	// IdentityHeader carries the asserted principal. Zero means
	// DefaultIdentityHeader.
	IdentityHeader string

	// InsecureTrustEveryPeer accepts a trusted set covering the default
	// route. Its name is the point: it has to be typed, and it shows up in a
	// diff and in review.
	InsecureTrustEveryPeer bool
}

TrustedProxyConfig configures identity derived from a reverse proxy's assertion. Build one with NewTrustedProxy, which validates eagerly (NFR4).

type WireVersion

type WireVersion string

WireVersion identifies an ingestion wire format. It is versioned in the path and independently of any module's semver (ADR-0012).

const (
	// V1 is the current format, served at /v1/logs.
	V1 WireVersion = "v1"
)

Versions this receiver serves.

func (WireVersion) Path

func (v WireVersion) Path() string

Path returns the endpoint a version is served at.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL