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 ¶
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.
const ContentTypeJSON = "application/json"
ContentTypeJSON is the only media type v1 accepts.
const DefaultIdentityHeader = "X-Authenticated-Source"
DefaultIdentityHeader carries the principal a trusted gateway has already authenticated.
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 ¶
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.
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 (*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.
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.