Skip to main content

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; it does not subscribe. Following [the change
126//! stream](#the-change-stream) is a dozen lines belonging to whoever owns
127//! the reload cadence, and a task with a backoff and a reconnect policy is
128//! not something this crate should choose on an application's behalf. See
129//! [`client`].
130//!
131//! # Observability
132//!
133//! Two surfaces, and **no OpenTelemetry SDK**.
134//!
135//! [`/metrics`](#metrics) is the numbers; [`AuditSink`] is the record of who
136//! read what. Both are this crate's own, and the second is a trait rather
137//! than a `tracing` call precisely so a deployment can put its audit trail
138//! somewhere other than stderr.
139//!
140//! An OTLP exporter would mean `opentelemetry`, `opentelemetry-otlp`,
141//! `tracing-opentelemetry` and a gRPC or HTTP client — four dependency
142//! trees and a background exporter task — in the one program in this
143//! workspace that holds every service's secrets and whose stated posture is
144//! a small CVE surface: axum with three features, no multipart, no
145//! websockets, and no TLS stack unless a deployment asked for one. It is
146//! the same trade [TLS](#tls) makes and the opposite answer, because the
147//! two differ in what the dependency *buys*: TLS off the default build is
148//! still available to the deployment that needs it, one feature away, and
149//! an exporter here would buy a deployment nothing its sidecar is not
150//! already giving it.
151//!
152//! The library side of it costs nothing and is already done — the spans
153//! `dynamic-config` emits reach OTLP through `tracing-opentelemetry` in the
154//! *application's* dependency graph. [`router`] is the API, so a service
155//! that wants request spans, `traceparent` propagation and an exporter
156//! mounts this router inside its own axum application, where those are its
157//! own choices, and gets all three without this crate depending on any of
158//! them.
159//!
160//! # Authentication
161//!
162//! One credential shape: a bearer token in `Authorization`, compared without
163//! stopping at the first differing byte, against a roster in the server's
164//! own configuration. A client certificate is **not** a second credential
165//! shape — see [TLS](#tls) — and JWT validation is *absent* rather than
166//! sketched.
167//!
168//! Anonymous access exists and needs two switches thrown: a client with no
169//! `token`, and `allow_anonymous = true`. It is then a principal like any
170//! other, with its own grants, so "open for development" still cannot mean
171//! "open to everything".
172//!
173//! # TLS
174//!
175//! **Opt-in twice**: the `tls` Cargo feature, and a `[server.tls]` section.
176//! Neither alone does anything, a `[server.tls]` section in a build without
177//! the feature is a refusal rather than a key that is quietly ignored, and a
178//! build without the feature contains no TLS code at all — which is the
179//! honest half of the reasoning this crate used to record as "no TLS ever":
180//! a deployment that already terminates TLS in front keeps exactly the
181//! dependency graph it had, and the CVE surface it did not want stays out of
182//! it.
183//!
184//! ```toml
185//! [server.tls]
186//! certificate = "/etc/dynamic-config/server.pem"
187//! key = "/etc/dynamic-config/server.key"
188//! client_ca = "/etc/dynamic-config/clients-ca.pem"   # optional
189//! ```
190//!
191//! `client_ca` is the one that matters for a config server. With it,
192//! **every caller must present a certificate that chains to it** or the
193//! handshake does not complete — a second, independent factor beside the
194//! bearer token, checked before a byte of HTTP exists. Without it, the
195//! server authenticates itself to callers and asks for nothing back.
196//!
197//! **A certificate is a gate, never an identity.** It is not an alternative
198//! to the bearer token, and it does not name a caller: the token is still
199//! what produces a [`Principal`], what the grants hang off and what the
200//! audit log records. The reasoning, and the two rejected designs, are in
201//! [`tls`].
202//!
203//! Two refusals keep the matrix coherent. A non-loopback `bind` with neither
204//! TLS nor `insecure` is refused as before; and `insecure = true` *with*
205//! TLS is refused too, because the word acknowledges an unencrypted socket
206//! and there is not one — leaving it set would mean that deleting the TLS
207//! section later reopened the port in the clear instead of refusing.
208//!
209//! **The private key is the sharpest secret this program handles.** Its
210//! bytes reach no log, no error, no `Debug` and no audit line: the two
211//! errors that would have carried them — a PEM that will not parse — carry a
212//! path and a sentence instead. And a key file that anything but its owner
213//! can read is a startup refusal on Unix, for the same reason a token under
214//! 32 characters is one.
215//!
216//! [`serve_tls`] is the serving half, and [`router`] is unchanged: nothing
217//! about authorisation moves because a connection is encrypted.
218//!
219//! # It is a user of the library, not a reimplementation
220//!
221//! Each served section is a [`Dynamic<Document>`](dynamic_config::Dynamic):
222//! the same loader, the same file watcher, the same
223//! keep-serving-the-last-good-document behaviour when an edit upstream is
224//! bad, and the same [`ConfigStatus`](dynamic_config::ConfigStatus) behind
225//! `/status`. Nothing polls — a section reloads because the watcher said so
226//! — and `/status` is a handful of atomic loads, so an idle server costs no
227//! CPU however many sections it holds.
228//!
229//! # Example
230//!
231//! ```no_run
232//! use std::sync::Arc;
233//!
234//! use dynamic_config_server::{router, Server, ServerConfig};
235//!
236//! # async fn run(config: ServerConfig) -> Result<(), Box<dyn std::error::Error>> {
237//! let server = Arc::new(Server::start(&config)?);
238//! let listener = tokio::net::TcpListener::bind(server.address()).await?;
239//!
240//! axum::serve(listener, router(server)).await?;
241//! # Ok(())
242//! # }
243//! ```
244//!
245//! With a `[server.tls]` section, the same three lines with the serving one
246//! swapped — the router, the sections and the authorisation are identical,
247//! which is the point:
248//!
249//! ```no_run
250//! # #[cfg(feature = "tls")]
251//! # async fn run(config: dynamic_config_server::ServerConfig)
252//! #     -> Result<(), Box<dyn std::error::Error>> {
253//! use std::sync::Arc;
254//!
255//! use dynamic_config_server::{router, serve_tls, Server};
256//!
257//! let server = Arc::new(Server::start(&config)?);
258//! let listener = tokio::net::TcpListener::bind(server.address()).await?;
259//!
260//! serve_tls(
261//!     listener,
262//!     router(Arc::clone(&server)),
263//!     &server,
264//!     async { let _ = tokio::signal::ctrl_c().await; },
265//! )
266//! .await?;
267//! # Ok(())
268//! # }
269//! ```
270//!
271//! `cargo run -p dynamic-config-server --features tls --example tls_mutual`
272//! is the whole thing end to end: it generates a CA, a server certificate
273//! and a client certificate, starts the server over TLS, presents the
274//! client certificate, and then shows what a caller without one gets.
275//!
276//! The server's own configuration is TOML, JSON or YAML, read — of course —
277//! with `dynamic-config`:
278//!
279//! ```toml
280//! [server]
281//! bind = "127.0.0.1:8080"
282//!
283//! [[server.sections]]
284//! application = "billing"
285//! profile = "prod"
286//! files = ["/etc/config/billing.toml", "/etc/config/billing-prod.toml"]
287//!
288//! [[server.clients]]
289//! name = "billing-pod"
290//! token = "a-token-of-at-least-32-characters"
291//! applications = ["billing"]
292//! ```
293//!
294//! The section key *inside* those files is the application name: what is
295//! served as `billing` is the `[billing]` table. One fact rather than two.
296//!
297//! # What is not here
298//!
299//! Named, because a config server invites all of it and the line has to be
300//! somewhere:
301//!
302//! - **An OpenTelemetry SDK.** This crate carries none, deliberately: see
303//!   [Observability](#observability).
304//! - **JWT credentials.** One credential shape, complete, beats two with one
305//!   tested. A client certificate is not a second one: it is a gate in front
306//!   of the same one — and mutual TLS shipped without touching the
307//!   [`Authenticator`] seam, which is the evidence that seam is not under
308//!   any pressure. A second shape would also be the first thing here able to
309//!   grant an application from outside this server's own roster, which is
310//!   the design [`tls`] rejects at length.
311//! - **Certificate revocation.** `client_ca` configures no CRL and checks
312//!   none, so a client certificate is valid until it expires — and
313//!   `[server.tls] crl` is a **startup refusal** rather than a key that is
314//!   read. Measured, not assumed: rustls accepts a CRL whose `nextUpdate`
315//!   passed years ago without a word, and the one switch that refuses a
316//!   stale list refuses every client with it, so the choice is between a
317//!   check that stops checking and an outage that arrives with the CA's next
318//!   hiccup. Issue short-lived certificates and revoke the bearer *token* —
319//!   which this server can withdraw by removing a line. See
320//!   [`Refusal::RevocationUnsupported`] and
321//!   `tests/tls.rs::the_measurement_behind_refusing_revocation_still_holds`.
322//! - **Rate limiting.** Belongs to the thing in front: it is the only place
323//!   that sees every replica's share of one caller, and it is where a
324//!   fleet-wide restart is best absorbed. `max_stream_connections` is not it
325//!   and does not pretend to be — it bounds one process's sockets on one
326//!   endpoint.
327//! - **Labels (`/{application}/{profile}/{label}`).** Not for want of a git
328//!   store — that landed. A label is a coordinate the *caller* picks, so it
329//!   is a resolve the server has not done, on the request path, over a key
330//!   space no grant bounds. Two refs wanted at once is two `[[sections]]`,
331//!   which is static, bounded and already authorised.
332//! - **Writing configuration.** Every route is a `GET`. A server that could
333//!   be written to is a different product with a different threat model.
334//! - **A container image or a compose file.** Packaging rather than code:
335//!   the binary takes one argument and reads one file, and a base image is a
336//!   thing to patch on somebody's schedule rather than this crate's.
337
338#![forbid(unsafe_code)]
339#![deny(missing_docs)]
340#![warn(clippy::must_use_candidate)]
341#![cfg_attr(docsrs, feature(doc_cfg))]
342
343pub mod audit;
344pub mod auth;
345#[cfg(feature = "client")]
346#[cfg_attr(docsrs, doc(cfg(feature = "client")))]
347pub mod client;
348mod config;
349mod document;
350mod routes;
351#[cfg(feature = "tls")]
352mod serve;
353mod server;
354#[cfg(feature = "tls")]
355#[cfg_attr(docsrs, doc(cfg(feature = "tls")))]
356pub mod tls;
357
358/// How long a connection has to finish once shutdown has been asked for.
359///
360/// Graceful shutdown means finishing the request in flight — and one of the
361/// requests this server serves, `/{application}/{profile}/stream`, is a
362/// response body that never ends by design. Waiting on every body would mean
363/// waiting for every subscriber to disconnect, so a rollout would hang on
364/// exactly the clients that were paying attention.
365///
366/// So the drain has a deadline, and both serving paths hold to it. A
367/// subscriber loses its stream and reconnects, which is what an
368/// `EventSource` does by itself and what the `Last-Event-ID` rules exist
369/// for; a fetch genuinely in flight has thirty seconds, which is far longer
370/// than any endpoint here takes.
371pub const DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
372
373pub use audit::{AuditEntry, AuditSink, NoAudit, Outcome, StderrAudit};
374pub use auth::{Authenticator, Principal, Token, MIN_TOKEN_LEN};
375pub use config::{ClientConfig, Refusal, SectionConfig, ServerConfig, TlsConfig};
376pub use document::Document;
377pub use routes::router;
378#[cfg(feature = "tls")]
379#[cfg_attr(docsrs, doc(cfg(feature = "tls")))]
380pub use serve::{serve_tls, HANDSHAKE_TIMEOUT, HEADER_TIMEOUT};
381pub use server::{Section, Server, StartupError, StreamPermit};
382#[cfg(feature = "tls")]
383#[cfg_attr(docsrs, doc(cfg(feature = "tls")))]
384pub use tls::{Tls, TlsError};
385
386/// This crate's version, for a deployment that has to say which server it is
387/// running.
388pub const VERSION: &str = env!("CARGO_PKG_VERSION");