dynamic_config_server/lib.rs
1//! An HTTP configuration server for
2//! [dynamic-config](https://docs.rs/dynamic-config): one resolved document
3//! per application and profile, served over HTTP under per-caller
4//! authorisation.
5//!
6//! The client half already existed — a service that consumes a config server
7//! is one more `RemoteSource` — so what is new here is the server, and a
8//! server is a different kind of artefact from everything else in this
9//! workspace. Every library in it hands configuration to the process that
10//! called it. This one hands configuration **over a socket**, which makes it
11//! a security boundary, and the design below follows from that rather than
12//! the other way round.
13//!
14//! # The threat model
15//!
16//! A config server holds every service's configuration, so an authentication
17//! mistake here is every secret at once. Three sentences:
18//!
19//! 1. **Nothing is readable without a credential, and a credential is scoped
20//! to applications rather than to the server** — a leaked pod token
21//! reads that pod's section and nothing else.
22//! 2. **The server refuses to start** rather than start permissively: no
23//! clients, a token under 32 characters, a client with no token where
24//! `allow_anonymous` was not set, or a non-loopback bind where `insecure`
25//! was not set are each a refusal that names the key that would fix it.
26//! 3. **It will not be an oracle**: a caller not granted an application
27//! cannot learn whether that application exists — same status, same body,
28//! same work — and the only values that leave the process do so through
29//! the one endpoint whose job that is.
30//!
31//! What it is *not* defending: the store behind it. This server is a cache
32//! and a fan-out, not an authority. It does not sign what it serves, and a
33//! client that needs provenance it can verify should verify it at the store.
34//!
35//! # What each endpoint returns
36//!
37//! | Endpoint | Returns |
38//! |---|---|
39//! | `GET /{application}/{profile}` | the resolved document — **values**, secrets included |
40//! | `GET /{application}/{profile}/paths` | which keys exist; no values |
41//! | `GET /{application}/{profile}/explain/{path}` | every layer's answer, every value `***` |
42//! | `GET /{application}/{profile}/check` | would the next load succeed; key paths and origins |
43//! | `GET /{application}/{profile}/status` | generation, health, staleness; numbers and timestamps |
44//! | `GET /{application}/{profile}/stream` | `text/event-stream`: one event per install, carrying a generation |
45//! | `GET /metrics` | Prometheus text for the sections this caller may read |
46//! | `GET /healthz` | liveness. Unauthenticated, and says nothing else |
47//! | `GET /readyz` | readiness. Unauthenticated, and says nothing else |
48//!
49//! One row of that table returns values. The rest cannot, by construction
50//! rather than by care — the stream included, which is the row where it
51//! would have been easiest to lose: `explain` goes through
52//! [`Explanation::redacted`](dynamic_config::Explanation::redacted) — the
53//! library's own machinery, not a second copy of it — on **every** path
54//! rather than only the ones a schema called secret, and `check`, `status`
55//! and `paths` are built from types the library already guarantees carry no
56//! values.
57//!
58//! **The audit log contains no values either**, and cannot: an
59//! [`AuditEntry`] has no field one could occupy. See [`audit`].
60//!
61//! # Metrics
62//!
63//! `/metrics` is the library's [`telemetry`](dynamic_config::telemetry)
64//! rendering of the same [`ConfigStatus`](dynamic_config::ConfigStatus)
65//! that `/status` returns — one set of numbers, two shapes — labelled with
66//! the application and the profile and with nothing else. Six families,
67//! `6 × sections` series per scrape; no key path, file name or value can
68//! be a label, because nothing a label is built from holds one.
69//!
70//! **It is authenticated, and scoped to the caller's grants.** `/healthz`
71//! and `/readyz` are open because they answer a boolean and disclose
72//! nothing; a metrics endpoint that could say as little would be no use,
73//! and one that names sections is an enumeration of every service the
74//! fleet configures. A scraper is a client like any other — Prometheus
75//! reads a bearer token from its scrape configuration — and it sees exactly
76//! the applications it was granted.
77//!
78//! # The change stream
79//!
80//! `GET /{application}/{profile}/stream` is `text/event-stream`, one event
81//! per install:
82//!
83//! ```text
84//! id: 7
85//! event: generation
86//! data: {"application":"billing","profile":"prod","generation":7}
87//! ```
88//!
89//! **A number, not a document and not a diff.** That one decision is what
90//! makes the endpoint small enough to be safe:
91//!
92//! - **Resumption is a comparison.** A generation is monotonic, so the
93//! current one subsumes every one before it. `Last-Event-ID: 6` against a
94//! section at 9 is one event carrying 9 — there is no ring of recent
95//! events, so no bound to pick and no "reconnected past the end of it"
96//! case to answer.
97//! - **Memory is flat.** Per connection: one `Changes` handle, one
98//! registered waker, two short strings. Nothing proportional to the
99//! document, and nothing per event. A fleet-wide restart costs one of
100//! those per pod and one shared install.
101//! - **Backpressure needs no policy.** The stream carries a level rather
102//! than a log: a client that stops reading is not polled, and when it is
103//! polled again it gets the *latest* generation. Nothing queues, so
104//! nothing has to be dropped.
105//!
106//! It is authenticated and authorised exactly as every other endpoint is,
107//! and a subscription to a section the caller may not read is the same 404
108//! having done the same work. `max_stream_connections` bounds how many are
109//! open at once — the excess gets a 503 with a `Retry-After`, and **zero
110//! turns the endpoint off**, whereupon it answers like a path this server
111//! does not have.
112//!
113//! # The other half
114//!
115//! Behind the `client` feature, [`client::ConfigServer`] is a
116//! [`RemoteSource`](dynamic_config::RemoteSource) that reads
117//! `GET /{application}/{profile}` from a server like this one, with a bearer
118//! token and the same `TlsConfig` the store crates take. Both halves live in
119//! one crate so they are tested against each other rather than against a
120//! fixture of what each believes the other returns — `tests/client.rs`
121//! drives the source at the real router on a real socket, including the case
122//! that matters most, where the server is killed mid-run and its clients go
123//! on serving from their last known good document.
124//!
125//! It fetches *and* subscribes: [`ConfigServer::watch`](client::ConfigServer::watch)
126//! follows [the change stream](#the-change-stream), re-fetching whenever the
127//! generation moves and reconnecting from where it left off. Reported as
128//! `WatchCapability::Native`, so a caller driving it through the engine's
129//! `Remote::watch` gets a push rather than a poll without arranging
130//! anything. See [`client`].
131//!
132//! # Observability
133//!
134//! Two surfaces, and **no OpenTelemetry SDK**.
135//!
136//! [`/metrics`](#metrics) is the numbers; [`AuditSink`] is the record of who
137//! read what. Both are this crate's own, and the second is a trait rather
138//! than a `tracing` call precisely so a deployment can put its audit trail
139//! somewhere other than stderr.
140//!
141//! An OTLP exporter would mean `opentelemetry`, `opentelemetry-otlp`,
142//! `tracing-opentelemetry` and a gRPC or HTTP client — four dependency
143//! trees and a background exporter task — in the one program in this
144//! workspace that holds every service's secrets and whose stated posture is
145//! a small CVE surface: axum with three features, no multipart, no
146//! websockets, and no TLS stack unless a deployment asked for one. It is
147//! the same trade [TLS](#tls) makes and the opposite answer, because the
148//! two differ in what the dependency *buys*: TLS off the default build is
149//! still available to the deployment that needs it, one feature away, and
150//! an exporter here would buy a deployment nothing its sidecar is not
151//! already giving it.
152//!
153//! The library side of it costs nothing and is already done — the spans
154//! `dynamic-config` emits reach OTLP through `tracing-opentelemetry` in the
155//! *application's* dependency graph. [`router`] is the API, so a service
156//! that wants request spans, `traceparent` propagation and an exporter
157//! mounts this router inside its own axum application, where those are its
158//! own choices, and gets all three without this crate depending on any of
159//! them.
160//!
161//! # Authentication
162//!
163//! One credential shape: a bearer token in `Authorization`, compared without
164//! stopping at the first differing byte, against a roster in the server's
165//! own configuration. A client certificate is **not** a second credential
166//! shape — see [TLS](#tls) — and JWT validation is *absent* rather than
167//! sketched.
168//!
169//! Anonymous access exists and needs two switches thrown: a client with no
170//! `token`, and `allow_anonymous = true`. It is then a principal like any
171//! other, with its own grants, so "open for development" still cannot mean
172//! "open to everything".
173//!
174//! # TLS
175//!
176//! **Opt-in twice**: the `tls` Cargo feature, and a `[server.tls]` section.
177//! Neither alone does anything, a `[server.tls]` section in a build without
178//! the feature is a refusal rather than a key that is quietly ignored, and a
179//! build without the feature contains no TLS code at all — which is the
180//! honest half of the reasoning this crate used to record as "no TLS ever":
181//! a deployment that already terminates TLS in front keeps exactly the
182//! dependency graph it had, and the CVE surface it did not want stays out of
183//! it.
184//!
185//! ```toml
186//! [server.tls]
187//! certificate = "/etc/dynamic-config/server.pem"
188//! key = "/etc/dynamic-config/server.key"
189//! client_ca = "/etc/dynamic-config/clients-ca.pem" # optional
190//! ```
191//!
192//! `client_ca` is the one that matters for a config server. With it,
193//! **every caller must present a certificate that chains to it** or the
194//! handshake does not complete — a second, independent factor beside the
195//! bearer token, checked before a byte of HTTP exists. Without it, the
196//! server authenticates itself to callers and asks for nothing back.
197//!
198//! **A certificate is a gate, never an identity.** It is not an alternative
199//! to the bearer token, and it does not name a caller: the token is still
200//! what produces a [`Principal`], what the grants hang off and what the
201//! audit log records. The reasoning, and the two rejected designs, are in
202//! [`tls`].
203//!
204//! Two refusals keep the matrix coherent. A non-loopback `bind` with neither
205//! TLS nor `insecure` is refused as before; and `insecure = true` *with*
206//! TLS is refused too, because the word acknowledges an unencrypted socket
207//! and there is not one — leaving it set would mean that deleting the TLS
208//! section later reopened the port in the clear instead of refusing.
209//!
210//! **The private key is the sharpest secret this program handles.** Its
211//! bytes reach no log, no error, no `Debug` and no audit line: the two
212//! errors that would have carried them — a PEM that will not parse — carry a
213//! path and a sentence instead. And a key file that anything but its owner
214//! can read is a startup refusal on Unix, for the same reason a token under
215//! 32 characters is one.
216//!
217//! [`serve_tls`] is the serving half, and [`router`] is unchanged: nothing
218//! about authorisation moves because a connection is encrypted.
219//!
220//! # It is a user of the library, not a reimplementation
221//!
222//! Each served section is a [`Dynamic<Document>`](dynamic_config::Dynamic):
223//! the same loader, the same file watcher, the same
224//! keep-serving-the-last-good-document behaviour when an edit upstream is
225//! bad, and the same [`ConfigStatus`](dynamic_config::ConfigStatus) behind
226//! `/status`. Nothing polls — a section reloads because the watcher said so
227//! — and `/status` is a handful of atomic loads, so an idle server costs no
228//! CPU however many sections it holds.
229//!
230//! # Example
231//!
232//! ```no_run
233//! use std::sync::Arc;
234//!
235//! use dynamic_config_server::{router, Server, ServerConfig};
236//!
237//! # async fn run(config: ServerConfig) -> Result<(), Box<dyn std::error::Error>> {
238//! let server = Arc::new(Server::start(&config)?);
239//! let listener = tokio::net::TcpListener::bind(server.address()).await?;
240//!
241//! axum::serve(listener, router(server)).await?;
242//! # Ok(())
243//! # }
244//! ```
245//!
246//! With a `[server.tls]` section, the same three lines with the serving one
247//! swapped — the router, the sections and the authorisation are identical,
248//! which is the point:
249//!
250//! ```no_run
251//! # #[cfg(feature = "tls")]
252//! # async fn run(config: dynamic_config_server::ServerConfig)
253//! # -> Result<(), Box<dyn std::error::Error>> {
254//! use std::sync::Arc;
255//!
256//! use dynamic_config_server::{router, serve_tls, Server};
257//!
258//! let server = Arc::new(Server::start(&config)?);
259//! let listener = tokio::net::TcpListener::bind(server.address()).await?;
260//!
261//! serve_tls(
262//! listener,
263//! router(Arc::clone(&server)),
264//! &server,
265//! async { let _ = tokio::signal::ctrl_c().await; },
266//! )
267//! .await?;
268//! # Ok(())
269//! # }
270//! ```
271//!
272//! `cargo run -p dynamic-config-server --features tls --example tls_mutual`
273//! is the whole thing end to end: it generates a CA, a server certificate
274//! and a client certificate, starts the server over TLS, presents the
275//! client certificate, and then shows what a caller without one gets.
276//!
277//! The server's own configuration is TOML, JSON or YAML, read — of course —
278//! with `dynamic-config`:
279//!
280//! ```toml
281//! [server]
282//! bind = "127.0.0.1:8080"
283//!
284//! [[server.sections]]
285//! application = "billing"
286//! profile = "prod"
287//! files = ["/etc/config/billing.toml", "/etc/config/billing-prod.toml"]
288//!
289//! [[server.clients]]
290//! name = "billing-pod"
291//! token = "a-token-of-at-least-32-characters"
292//! applications = ["billing"]
293//! ```
294//!
295//! The section key *inside* those files is the application name: what is
296//! served as `billing` is the `[billing]` table. One fact rather than two.
297//!
298//! # What is not here
299//!
300//! Named, because a config server invites all of it and the line has to be
301//! somewhere:
302//!
303//! - **An OpenTelemetry SDK.** This crate carries none, deliberately: see
304//! [Observability](#observability).
305//! - **JWT credentials.** One credential shape, complete, beats two with one
306//! tested. A client certificate is not a second one: it is a gate in front
307//! of the same one — and mutual TLS shipped without touching the
308//! [`Authenticator`] seam, which is the evidence that seam is not under
309//! any pressure. A second shape would also be the first thing here able to
310//! grant an application from outside this server's own roster, which is
311//! the design [`tls`] rejects at length.
312//! - **Certificate revocation.** `client_ca` configures no CRL and checks
313//! none, so a client certificate is valid until it expires — and
314//! `[server.tls] crl` is a **startup refusal** rather than a key that is
315//! read. Measured, not assumed: rustls accepts a CRL whose `nextUpdate`
316//! passed years ago without a word, and the one switch that refuses a
317//! stale list refuses every client with it, so the choice is between a
318//! check that stops checking and an outage that arrives with the CA's next
319//! hiccup. Issue short-lived certificates and revoke the bearer *token* —
320//! which this server can withdraw by removing a line. See
321//! [`Refusal::RevocationUnsupported`] and
322//! `tests/tls.rs::the_measurement_behind_refusing_revocation_still_holds`.
323//! - **Rate limiting.** Belongs to the thing in front: it is the only place
324//! that sees every replica's share of one caller, and it is where a
325//! fleet-wide restart is best absorbed. `max_stream_connections` is not it
326//! and does not pretend to be — it bounds one process's sockets on one
327//! endpoint.
328//! - **Labels (`/{application}/{profile}/{label}`).** Not for want of a git
329//! store — that landed. A label is a coordinate the *caller* picks, so it
330//! is a resolve the server has not done, on the request path, over a key
331//! space no grant bounds. Two refs wanted at once is two `[[sections]]`,
332//! which is static, bounded and already authorised.
333//! - **Writing configuration.** Every route is a `GET`. A server that could
334//! be written to is a different product with a different threat model.
335//! - **A container image or a compose file.** Packaging rather than code:
336//! the binary takes one argument and reads one file, and a base image is a
337//! thing to patch on somebody's schedule rather than this crate's.
338
339#![forbid(unsafe_code)]
340#![deny(missing_docs)]
341#![warn(clippy::must_use_candidate)]
342#![cfg_attr(docsrs, feature(doc_cfg))]
343
344pub mod audit;
345pub mod auth;
346#[cfg(feature = "client")]
347#[cfg_attr(docsrs, doc(cfg(feature = "client")))]
348pub mod client;
349mod config;
350mod document;
351#[cfg(feature = "kubernetes-auth")]
352#[cfg_attr(docsrs, doc(cfg(feature = "kubernetes-auth")))]
353pub mod kubernetes;
354mod routes;
355#[cfg(feature = "tls")]
356mod serve;
357mod server;
358#[cfg(feature = "tls")]
359#[cfg_attr(docsrs, doc(cfg(feature = "tls")))]
360pub mod tls;
361
362/// How long a connection has to finish once shutdown has been asked for.
363///
364/// Graceful shutdown means finishing the request in flight — and one of the
365/// requests this server serves, `/{application}/{profile}/stream`, is a
366/// response body that never ends by design. Waiting on every body would mean
367/// waiting for every subscriber to disconnect, so a rollout would hang on
368/// exactly the clients that were paying attention.
369///
370/// So the drain has a deadline, and both serving paths hold to it. A
371/// subscriber loses its stream and reconnects, which is what an
372/// `EventSource` does by itself and what the `Last-Event-ID` rules exist
373/// for; a fetch genuinely in flight has thirty seconds, which is far longer
374/// than any endpoint here takes.
375pub const DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
376
377pub use audit::{AuditEntry, AuditSink, NoAudit, Outcome, StderrAudit};
378pub use auth::{Authenticator, Principal, Token, MIN_TOKEN_LEN};
379pub use config::{ClientConfig, Refusal, SectionConfig, ServerConfig, TlsConfig};
380pub use document::Document;
381pub use routes::router;
382#[cfg(feature = "tls")]
383#[cfg_attr(docsrs, doc(cfg(feature = "tls")))]
384pub use serve::{serve_tls, HANDSHAKE_TIMEOUT, HEADER_TIMEOUT};
385pub use server::{Section, Server, StartupError, StreamPermit};
386#[cfg(feature = "tls")]
387#[cfg_attr(docsrs, doc(cfg(feature = "tls")))]
388pub use tls::{Tls, TlsError};
389
390/// This crate's version, for a deployment that has to say which server it is
391/// running.
392pub const VERSION: &str = env!("CARGO_PKG_VERSION");