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