dynamic_config_server/client/mod.rs
1//! Reading configuration *from* a config server.
2//!
3//! The other half of this crate, behind the `client` feature: a
4//! [`RemoteSource`] that fetches `GET /{application}/{profile}` and hands the
5//! document to the engine, exactly as an etcd or a Vault source does. The two
6//! halves live in one crate so that they are tested against each other —
7//! every test in `tests/client.rs` drives this against the real router rather
8//! than against a fixture of what the router is believed to return.
9//!
10//! ```no_run
11//! use std::time::Duration;
12//! use dynamic_config_server::client::ConfigServer;
13//!
14//! # fn main() -> Result<(), dynamic_config::Error> {
15//! let source = ConfigServer::new("https://config.internal", "billing", "prod")
16//! .with_token(std::env::var("CONFIG_TOKEN").unwrap_or_default())
17//! .with_timeout(Duration::from_secs(5));
18//! # let _ = source;
19//! # Ok(())
20//! # }
21//! ```
22//!
23//! ## What it does not do
24//!
25//! **It does not subscribe.** `GET /{application}/{profile}/stream` carries a
26//! generation, and a client that follows it calls
27//! `refresh_remote()` when the number
28//! moves — a loop of a dozen lines that belongs to whoever owns the reload
29//! cadence. Building it in would mean this crate owning a task, a backoff and
30//! a reconnect policy that the application is better placed to choose; what
31//! this crate owes is the half that is fiddly to get right, which is the
32//! bounded, deadline-covered, credential-carrying fetch below.
33//!
34//! **It does not verify provenance.** The document arrives as JSON with no
35//! signature, so a client trusts the server exactly as far as TLS and the
36//! bearer token take it. A deployment that needs more should read from the
37//! store the server reads from.
38
39mod http;
40
41use std::sync::Arc;
42use std::time::Duration;
43
44use dynamic_config::{Error, Fetched, Format, RemoteSource};
45use dynamic_config_store_core::tls::TlsConfig;
46use dynamic_config_store_core::{redacted, LoneAuthority};
47
48use http::{Budget, Connection, Endpoint};
49
50/// How much of a response body is read before it is refused.
51///
52/// A configuration document that does not fit in a megabyte is not a
53/// configuration document, and a client that trusts a server to send
54/// something finite is a client that can be made to allocate until it dies.
55const MOST_BYTES: usize = 1024 * 1024;
56
57/// The default deadline for one fetch — connect, handshake, request and body.
58const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
59
60/// A [`RemoteSource`] reading one application-and-profile from a config
61/// server.
62///
63/// The credential is a bearer token, scoped by the server to the applications
64/// it may read; TLS with a private authority and a client certificate is
65/// [`TlsConfig`], the same type every store crate in this workspace takes.
66pub struct ConfigServer {
67 url: String,
68 application: String,
69 profile: String,
70 token: Option<String>,
71 token_file: Option<std::path::PathBuf>,
72 tls: TlsConfig,
73 timeout: Duration,
74 /// Built once from `tls`, on the first fetch: assembling a rustls
75 /// configuration reads files, and a fetch path is not where that belongs.
76 client: std::sync::OnceLock<Arc<rustls::ClientConfig>>,
77 described: String,
78}
79
80impl ConfigServer {
81 /// A source reading `{url}/{application}/{profile}`.
82 ///
83 /// `url` may carry a path prefix — `https://config.internal/config` — for
84 /// a server mounted behind one. A userinfo component is refused rather
85 /// than dropped: this server's credential is a bearer token, and a
86 /// password in a url is a password in every log that url reaches.
87 #[must_use]
88 pub fn new(
89 url: impl Into<String>,
90 application: impl Into<String>,
91 profile: impl Into<String>,
92 ) -> Self {
93 let (url, application, profile) = (url.into(), application.into(), profile.into());
94
95 // Redacted as the description is built, rather than where each
96 // message is written: this string is quoted into every error this
97 // source raises and is what `describe()` returns — and one of those
98 // errors is the *refusal* of a `user:password@` authority. Printing
99 // the password while saying it is refused would be a leak with a
100 // note attached.
101 let described = format!(
102 "config server {} {application}/{profile}",
103 redacted(&url, LoneAuthority::Username)
104 );
105
106 Self {
107 // Parsing is deferred to the first fetch so that `new` cannot
108 // fail: a source that refuses to be *built* is awkward to place
109 // in a builder chain, and the url is checked before it is used.
110 url,
111 application,
112 profile,
113 token: None,
114 token_file: None,
115 tls: TlsConfig::new(),
116 timeout: DEFAULT_TIMEOUT,
117 client: std::sync::OnceLock::new(),
118 described,
119 }
120 }
121
122 /// The bearer token this server issued for these applications.
123 ///
124 /// Without one the server answers `401` unless it was started with
125 /// anonymous access explicitly enabled.
126 #[must_use]
127 pub fn with_token(mut self, token: impl Into<String>) -> Self {
128 self.token = Some(token.into());
129 self
130 }
131
132 /// The bearer token read from a file, **re-read at every fetch** —
133 /// for credentials something else rotates underneath this client,
134 /// first among them a pod's projected service-account token (the
135 /// server's `[kubernetes]` auth reviews exactly that). Wins over
136 /// [`with_token`](Self::with_token) when both are set.
137 #[must_use]
138 pub fn with_token_file(mut self, path: impl Into<std::path::PathBuf>) -> Self {
139 self.token_file = Some(path.into());
140 self
141 }
142
143 /// A private certificate authority, a client certificate, or both.
144 ///
145 /// The same [`TlsConfig`] the store crates take, so a deployment spells
146 /// its trust once and uses it everywhere.
147 #[must_use]
148 pub fn with_tls(mut self, tls: TlsConfig) -> Self {
149 self.tls = tls;
150 self
151 }
152
153 /// The deadline for one fetch: connect, TLS handshake, request and body.
154 ///
155 /// Ten seconds by default. A fetch that hangs is a reload that never
156 /// happens, and the loop above has no other way to notice.
157 #[must_use]
158 pub fn with_timeout(mut self, timeout: Duration) -> Self {
159 self.timeout = timeout;
160 self
161 }
162
163 /// The rustls configuration, built once.
164 ///
165 /// Building it reads files, so it is done on the first fetch and kept —
166 /// not per fetch, and not at construction, where it would make `new`
167 /// fallible for a source that may never be used.
168 fn tls_client(&self, secure: bool) -> Result<Option<&Arc<rustls::ClientConfig>>, Error> {
169 if !secure {
170 return Ok(None);
171 }
172
173 if let Some(built) = self.client.get() {
174 return Ok(Some(built));
175 }
176
177 let built = self.build_tls_client()?;
178
179 Ok(Some(self.client.get_or_init(|| built)))
180 }
181
182 fn build_tls_client(&self) -> Result<Arc<rustls::ClientConfig>, Error> {
183 use rustls::pki_types::pem::PemObject as _;
184 use rustls::pki_types::{CertificateDer, PrivateKeyDer};
185
186 let mut roots = rustls::RootCertStore::empty();
187
188 // The platform store first, then the caller's authority on top: a
189 // private CA is one *more* certificate to trust, which is the whole
190 // reason this crate offers no way to turn verification off.
191 for certificate in rustls_native_certs::load_native_certs().certs {
192 let _ = roots.add(certificate);
193 }
194
195 if let Some(pem) = self.tls.ca_certificate_pem(&self.described)? {
196 let mut added = 0;
197
198 for certificate in CertificateDer::pem_slice_iter(&pem) {
199 let certificate = certificate.map_err(|_| {
200 Error::remote(format!(
201 "{}: the certificate authority is not readable as PEM",
202 self.described
203 ))
204 })?;
205
206 roots
207 .add(certificate)
208 .map_err(|error| Error::remote(format!("{}: {error}", self.described)))?;
209 added += 1;
210 }
211
212 if added == 0 {
213 return Err(Error::remote(format!(
214 "{}: the certificate authority holds no certificate",
215 self.described
216 )));
217 }
218 }
219
220 let builder = rustls::ClientConfig::builder().with_root_certificates(roots);
221
222 let Some((certificate, key)) = self.tls.client_certificate_pem(&self.described)? else {
223 return Ok(Arc::new(builder.with_no_client_auth()));
224 };
225
226 let chain = CertificateDer::pem_slice_iter(&certificate)
227 .collect::<Result<Vec<_>, _>>()
228 .map_err(|_| {
229 Error::remote(format!(
230 "{}: the client certificate is not readable as PEM",
231 self.described
232 ))
233 })?;
234
235 // The key's own parse error is deliberately dropped: the one thing
236 // such an error has to say is the line it choked on, and in a key
237 // file that line is key material.
238 let key = PrivateKeyDer::from_pem_slice(&key).map_err(|_| {
239 Error::remote(format!(
240 "{}: the client private key is not readable as PEM",
241 self.described
242 ))
243 })?;
244
245 builder
246 .with_client_auth_cert(chain, key)
247 .map(Arc::new)
248 .map_err(|error| Error::remote(format!("{}: {error}", self.described)))
249 }
250
251 /// One fetch, on the current thread's runtime.
252 async fn read(&self) -> Result<Fetched, Error> {
253 let endpoint = Endpoint::parse(&self.url, &self.described)?;
254 let path = endpoint.path(&format!("/{}/{}", self.application, self.profile));
255
256 // One budget for the whole attempt, started here: the deadline
257 // `with_timeout` documents is for a fetch, and a fetch is the
258 // connect, the handshake, the request and the body together.
259 let budget = Budget::starting(self.timeout);
260
261 let secure = endpoint.secure;
262 let mut connection =
263 Connection::open(&endpoint, self.tls_client(secure)?, budget, &self.described).await?;
264
265 // The file wins, and is read per fetch: a projected token that
266 // rotated between two fetches must present its NEW self.
267 let fresh = match &self.token_file {
268 None => None,
269 Some(file) => Some(std::fs::read_to_string(file).map_err(|error| {
270 Error::auth(format!(
271 "{}: reading the bearer token file: {error}",
272 self.described
273 ))
274 })?),
275 };
276 let bearer = fresh.as_deref().map(str::trim).or(self.token.as_deref());
277
278 let response = connection
279 .get(
280 &endpoint,
281 &path,
282 bearer,
283 "application/json",
284 budget,
285 &self.described,
286 )
287 .await?;
288
289 if !response.status().is_success() {
290 return Err(http::refused(response.status(), &self.described));
291 }
292
293 let body = http::body(response, MOST_BYTES, budget, &self.described).await?;
294 let text = String::from_utf8(body)
295 .map_err(|_| Error::remote(format!("{}: the document is not UTF-8", self.described)))?;
296
297 // The server answers `{application, profile, generation, config}`;
298 // the engine wants the document, which is `config`. Reaching for it
299 // by name rather than deserializing the envelope keeps this working
300 // when the envelope grows a field.
301 let document = extract(&text, &self.described)?;
302
303 Ok(Fetched::new(document, Format::Json))
304 }
305}
306
307/// The `config` member of the server's envelope, re-rendered.
308fn extract(text: &str, described: &str) -> Result<String, Error> {
309 let envelope: serde_json::Value = serde_json::from_str(text)
310 .map_err(|_| Error::remote(format!("{described}: the answer is not JSON")))?;
311
312 let document = envelope.get("config").ok_or_else(|| {
313 Error::remote(format!(
314 "{described}: the answer carries no `config` member; is this a \
315 config server?"
316 ))
317 })?;
318
319 serde_json::to_string(document)
320 .map_err(|_| Error::remote(format!("{described}: the document will not re-render")))
321}
322
323impl RemoteSource for ConfigServer {
324 fn fetch(&self) -> Result<Fetched, Error> {
325 // A blocking `fetch` on a client built from an async stack: one
326 // runtime, current-thread, for this call only. A source is fetched
327 // when a caller asks, minutes or hours apart, so the cost of starting
328 // one is not on any path that matters — and owning a long-lived
329 // runtime here would put a second one inside applications that
330 // already have theirs.
331 let runtime = tokio::runtime::Builder::new_current_thread()
332 .enable_all()
333 .build()
334 .map_err(|error| {
335 Error::remote(format!(
336 "{}: no runtime for the fetch: {error}",
337 self.described
338 ))
339 })?;
340
341 runtime.block_on(self.read())
342 }
343
344 fn describe(&self) -> String {
345 self.described.clone()
346 }
347}
348
349impl std::fmt::Debug for ConfigServer {
350 /// Shape only. The token is the credential and never prints; `TlsConfig`
351 /// redacts its own key material; and the URL is redacted too, because a
352 /// `user:password@` authority is refused at fetch time rather than at
353 /// construction — so a source carrying one can be printed.
354 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
355 formatter
356 .debug_struct("ConfigServer")
357 .field("url", &redacted(&self.url, LoneAuthority::Username))
358 .field("application", &self.application)
359 .field("profile", &self.profile)
360 .field("token", &self.token.as_ref().map(|_| "<redacted>"))
361 .field("token_file", &self.token_file)
362 .field("tls", &self.tls)
363 .field("timeout", &self.timeout)
364 .finish()
365 }
366}
367
368#[cfg(test)]
369mod tests {
370 use super::*;
371
372 #[test]
373 fn a_document_is_lifted_out_of_the_servers_envelope() {
374 let text = r#"{"application":"billing","profile":"prod","generation":7,
375 "config":{"port":8080}}"#;
376
377 assert_eq!(extract(text, "a server").unwrap(), r#"{"port":8080}"#);
378 }
379
380 #[test]
381 fn an_answer_from_something_that_is_not_a_config_server_says_so() {
382 let error = extract(r#"{"hello":"world"}"#, "a server").unwrap_err();
383
384 assert!(error.to_string().contains("no `config` member"), "{error}");
385 }
386
387 /// A password in the URL is refused rather than sent — and the refusal
388 /// must not be where it gets printed. `new` cannot fail, so the source
389 /// exists, is `Debug`-printed and describes itself long before the
390 /// parser gets to say no.
391 #[test]
392 fn a_password_in_the_url_reaches_neither_debug_nor_a_message() {
393 let source = ConfigServer::new(
394 "https://user:hunter2-do-not-print@config.internal",
395 "billing",
396 "prod",
397 );
398
399 let rendered = format!("{source:?}");
400 assert!(!rendered.contains("hunter2"), "{rendered}");
401
402 let described = source.describe();
403 assert!(!described.contains("hunter2"), "{described}");
404 assert!(described.contains("user:***@"), "{described}");
405
406 // And the refusal itself, which quotes the description.
407 let error = Endpoint::parse(&source.url, &source.described)
408 .expect_err("a `user:password@` authority is refused");
409 assert!(!error.to_string().contains("hunter2"), "{error}");
410 }
411
412 #[test]
413 fn a_token_never_reaches_debug() {
414 let source = ConfigServer::new("https://config.internal", "billing", "prod")
415 .with_token("hunter2-do-not-print");
416
417 let rendered = format!("{source:?}");
418
419 assert!(!rendered.contains("hunter2"), "{rendered}");
420 assert!(rendered.contains("<redacted>"), "{rendered}");
421 }
422}