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//! ## Watching
24//!
25//! It subscribes. `GET /{application}/{profile}/stream` carries a generation
26//! number, and [`ConfigServer::watch`] follows it: connect, read events,
27//! re-fetch the document when the number moves, reconnect with the
28//! `Last-Event-ID` the server left off at. The reconnect is a comparison
29//! rather than a replay — a generation subsumes every one before it — so
30//! there is no window in which a change can be missed by being reconnected
31//! past.
32//!
33//! ## What it does not do
34//!
35//! **It does not verify provenance.** The document arrives as JSON with no
36//! signature, so a client trusts the server exactly as far as TLS and the
37//! bearer token take it. A deployment that needs more should read from the
38//! store the server reads from.
39
40mod http;
41
42use std::sync::Arc;
43use std::time::Duration;
44
45use dynamic_config::{
46 Error, Fetched, Format, Pace, RemoteSource, Revision, WatchCapability, Watching,
47};
48use dynamic_config_store_core::attempts::Attempts;
49use dynamic_config_store_core::tls::TlsConfig;
50use dynamic_config_store_core::{guarded, redacted, LoneAuthority};
51
52use http::{Budget, Connection, Endpoint, Events, Get};
53
54/// How much of a response body is read before it is refused.
55///
56/// A configuration document that does not fit in a megabyte is not a
57/// configuration document, and a client that trusts a server to send
58/// something finite is a client that can be made to allocate until it dies.
59const MOST_BYTES: usize = 1024 * 1024;
60
61/// The default deadline for one fetch — connect, handshake, request and body.
62const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
63
64/// A [`RemoteSource`] reading one application-and-profile from a config
65/// server.
66///
67/// The credential is a bearer token, scoped by the server to the applications
68/// it may read; TLS with a private authority and a client certificate is
69/// [`TlsConfig`], the same type every store crate in this workspace takes.
70pub struct ConfigServer {
71 url: String,
72 application: String,
73 profile: String,
74 token: Option<String>,
75 token_file: Option<std::path::PathBuf>,
76 tls: TlsConfig,
77 timeout: Duration,
78 /// Built once from `tls`, on the first fetch: assembling a rustls
79 /// configuration reads files, and a fetch path is not where that belongs.
80 client: std::sync::OnceLock<Arc<rustls::ClientConfig>>,
81 described: String,
82 /// Where a watch reports an attempt that came back with nothing.
83 ///
84 /// Nobody, unless [`reporting_to`](Self::reporting_to) says otherwise —
85 /// the same door the eight store crates carry, and for the same reason:
86 /// a watch swallows transport failures by design, so without this the
87 /// only thing that knew the server had been unreachable for an hour was
88 /// the loop, and `status().reachable()` went on answering `true`.
89 attempts: Attempts,
90}
91
92impl ConfigServer {
93 /// A source reading `{url}/{application}/{profile}`.
94 ///
95 /// `url` may carry a path prefix — `https://config.internal/config` — for
96 /// a server mounted behind one. A userinfo component is refused rather
97 /// than dropped: this server's credential is a bearer token, and a
98 /// password in a url is a password in every log that url reaches.
99 #[must_use]
100 pub fn new(
101 url: impl Into<String>,
102 application: impl Into<String>,
103 profile: impl Into<String>,
104 ) -> Self {
105 let (url, application, profile) = (url.into(), application.into(), profile.into());
106
107 // Redacted as the description is built, rather than where each
108 // message is written: this string is quoted into every error this
109 // source raises and is what `describe()` returns — and one of those
110 // errors is the *refusal* of a `user:password@` authority. Printing
111 // the password while saying it is refused would be a leak with a
112 // note attached.
113 let described = format!(
114 "config server {} {application}/{profile}",
115 redacted(&url, LoneAuthority::Username)
116 );
117
118 Self {
119 // Parsing is deferred to the first fetch so that `new` cannot
120 // fail: a source that refuses to be *built* is awkward to place
121 // in a builder chain, and the url is checked before it is used.
122 url,
123 application,
124 profile,
125 token: None,
126 token_file: None,
127 tls: TlsConfig::new(),
128 timeout: DEFAULT_TIMEOUT,
129 client: std::sync::OnceLock::new(),
130 described,
131 attempts: Attempts::default(),
132 }
133 }
134
135 /// Report failed attempts to `sink`, so an outage is visible.
136 ///
137 /// A watch swallows transport failures on purpose — outliving one is
138 /// what a watch is for — and the cost of that is a store that has been
139 /// unreachable for an hour while `status().reachable()` says otherwise.
140 /// This is the door the eight store crates carry, and the same
141 /// discipline: take the sink where the watch is wired, once, because a
142 /// sink captures the generation of the source installed at that moment
143 /// and that is what fences a winding-down loop's failures away from its
144 /// replacement.
145 ///
146 /// **A failure moves the failure streak and nothing else.** The fetch
147 /// count and the clock are left alone, so a dashboard keeps ageing
148 /// `last_fetch` while `up` goes to zero — the pair an alert wants. It
149 /// changes nothing about what [`watch`](Self::watch) returns.
150 #[must_use]
151 pub fn reporting_to(mut self, sink: dynamic_config::RemoteSink) -> Self {
152 self.attempts = Attempts::to(sink);
153 self
154 }
155
156 /// The bearer token this server issued for these applications.
157 ///
158 /// Without one the server answers `401` unless it was started with
159 /// anonymous access explicitly enabled.
160 #[must_use]
161 pub fn with_token(mut self, token: impl Into<String>) -> Self {
162 self.token = Some(token.into());
163 self
164 }
165
166 /// The bearer token read from a file, **re-read at every fetch** —
167 /// for credentials something else rotates underneath this client,
168 /// first among them a pod's projected service-account token (the
169 /// server's `[kubernetes]` auth reviews exactly that). Wins over
170 /// [`with_token`](Self::with_token) when both are set.
171 #[must_use]
172 pub fn with_token_file(mut self, path: impl Into<std::path::PathBuf>) -> Self {
173 self.token_file = Some(path.into());
174 self
175 }
176
177 /// A private certificate authority, a client certificate, or both.
178 ///
179 /// The same [`TlsConfig`] the store crates take, so a deployment spells
180 /// its trust once and uses it everywhere.
181 #[must_use]
182 pub fn with_tls(mut self, tls: TlsConfig) -> Self {
183 self.tls = tls;
184 self
185 }
186
187 /// The deadline for one fetch: connect, TLS handshake, request and body.
188 ///
189 /// Ten seconds by default. A fetch that hangs is a reload that never
190 /// happens, and the loop above has no other way to notice.
191 #[must_use]
192 pub fn with_timeout(mut self, timeout: Duration) -> Self {
193 self.timeout = timeout;
194 self
195 }
196
197 /// The rustls configuration, built once.
198 ///
199 /// Building it reads files, so it is done on the first fetch and kept —
200 /// not per fetch, and not at construction, where it would make `new`
201 /// fallible for a source that may never be used.
202 fn tls_client(&self, secure: bool) -> Result<Option<&Arc<rustls::ClientConfig>>, Error> {
203 if !secure {
204 return Ok(None);
205 }
206
207 if let Some(built) = self.client.get() {
208 return Ok(Some(built));
209 }
210
211 let built = self.build_tls_client()?;
212
213 Ok(Some(self.client.get_or_init(|| built)))
214 }
215
216 fn build_tls_client(&self) -> Result<Arc<rustls::ClientConfig>, Error> {
217 use rustls::pki_types::pem::PemObject as _;
218 use rustls::pki_types::{CertificateDer, PrivateKeyDer};
219
220 let mut roots = rustls::RootCertStore::empty();
221
222 // The platform store first, then the caller's authority on top: a
223 // private CA is one *more* certificate to trust, which is the whole
224 // reason this crate offers no way to turn verification off.
225 for certificate in rustls_native_certs::load_native_certs().certs {
226 let _ = roots.add(certificate);
227 }
228
229 if let Some(pem) = self.tls.ca_certificate_pem(&self.described)? {
230 let mut added = 0;
231
232 for certificate in CertificateDer::pem_slice_iter(&pem) {
233 let certificate = certificate.map_err(|_| {
234 Error::remote(format!(
235 "{}: the certificate authority is not readable as PEM",
236 self.described
237 ))
238 })?;
239
240 roots
241 .add(certificate)
242 .map_err(|error| Error::remote(format!("{}: {error}", self.described)))?;
243 added += 1;
244 }
245
246 if added == 0 {
247 return Err(Error::remote(format!(
248 "{}: the certificate authority holds no certificate",
249 self.described
250 )));
251 }
252 }
253
254 let builder = if self.tls.skips_verification() {
255 // Quarantined here, in one branch with one name, so that a
256 // reader looking for "is this connection authenticated?" finds
257 // the answer in one place rather than inferring it.
258 rustls::ClientConfig::builder()
259 .dangerous()
260 .with_custom_certificate_verifier(Arc::new(AnyServerIsFine::new()?))
261 } else {
262 rustls::ClientConfig::builder().with_root_certificates(roots)
263 };
264
265 let Some((certificate, key)) = self.tls.client_certificate_pem(&self.described)? else {
266 return Ok(Arc::new(builder.with_no_client_auth()));
267 };
268
269 let chain = CertificateDer::pem_slice_iter(&certificate)
270 .collect::<Result<Vec<_>, _>>()
271 .map_err(|_| {
272 Error::remote(format!(
273 "{}: the client certificate is not readable as PEM",
274 self.described
275 ))
276 })?;
277
278 // The key's own parse error is deliberately dropped: the one thing
279 // such an error has to say is the line it choked on, and in a key
280 // file that line is key material.
281 let key = PrivateKeyDer::from_pem_slice(&key).map_err(|_| {
282 Error::remote(format!(
283 "{}: the client private key is not readable as PEM",
284 self.described
285 ))
286 })?;
287
288 builder
289 .with_client_auth_cert(chain, key)
290 .map(Arc::new)
291 .map_err(|error| Error::remote(format!("{}: {error}", self.described)))
292 }
293
294 /// One fetch, on the current thread's runtime.
295 async fn read(&self) -> Result<Fetched, Error> {
296 let endpoint = Endpoint::parse(&self.url, &self.described)?;
297 let path = endpoint.path(&format!("/{}/{}", self.application, self.profile));
298
299 // One budget for the whole attempt, started here: the deadline
300 // `with_timeout` documents is for a fetch, and a fetch is the
301 // connect, the handshake, the request and the body together.
302 let budget = Budget::starting(self.timeout);
303
304 let secure = endpoint.secure;
305 let mut connection = Connection::open(
306 &endpoint,
307 self.tls_client(secure)?,
308 self.tls.server_name(),
309 budget,
310 &self.described,
311 )
312 .await?;
313
314 // The file wins, and is read per fetch: a projected token that
315 // rotated between two fetches must present its NEW self. One
316 // reader, shared with the watch — two copies of "which credential
317 // do we present" is one more than a credential should have.
318 let bearer = self.bearer()?;
319
320 let response = connection
321 .get(
322 &endpoint,
323 Get {
324 path: &path,
325 token: bearer.as_deref(),
326 accept: "application/json",
327 resume: None,
328 },
329 budget,
330 &self.described,
331 )
332 .await?;
333
334 if !response.status().is_success() {
335 return Err(http::refused(response.status(), &self.described));
336 }
337
338 let body = http::body(response, MOST_BYTES, budget, &self.described).await?;
339 let text = String::from_utf8(body)
340 .map_err(|_| Error::remote(format!("{}: the document is not UTF-8", self.described)))?;
341
342 // The server answers `{application, profile, generation, config}`;
343 // the engine wants the document, which is `config`. Reaching for it
344 // by name rather than deserializing the envelope keeps this working
345 // when the envelope grows a field.
346 let (document, generation) = extract(&text, &self.described)?;
347
348 let document = Fetched::new(document, Format::Json);
349
350 // The envelope's own generation, when it carries one. The server
351 // increments it, so it orders — which is what stops a slow read of
352 // an old generation landing on top of a fast read of a new one.
353 Ok(match generation {
354 Some(generation) => document.with_revision(Revision::Counter(generation)),
355 None => document,
356 })
357 }
358}
359
360/// The `config` member of the server's envelope, re-rendered, and the
361/// generation it was served at.
362fn extract(text: &str, described: &str) -> Result<(String, Option<u64>), Error> {
363 let envelope: serde_json::Value = serde_json::from_str(text)
364 .map_err(|_| Error::remote(format!("{described}: the answer is not JSON")))?;
365
366 let document = envelope.get("config").ok_or_else(|| {
367 Error::remote(format!(
368 "{described}: the answer carries no `config` member; is this a \
369 config server?"
370 ))
371 })?;
372
373 // Absent is not a failure: an older server, or one that never grew the
374 // field, still serves documents — it just cannot be ordered by one.
375 let generation = envelope
376 .get("generation")
377 .and_then(serde_json::Value::as_u64);
378
379 let document = serde_json::to_string(document)
380 .map_err(|_| Error::remote(format!("{described}: the document will not re-render")))?;
381
382 Ok((document, generation))
383}
384
385impl ConfigServer {
386 /// How long a stream may be silent before it is treated as dead.
387 ///
388 /// The server sends a comment every fifteen seconds precisely so that
389 /// silence means something; three of those is a connection a proxy has
390 /// dropped without telling either end.
391 const IDLE: Duration = Duration::from_secs(50);
392
393 /// Follows the change stream, fetching whenever the generation moves.
394 ///
395 /// Blocks until `watching` is stopped, so it belongs on a thread of its
396 /// own. `interval` is the reconnect pace rather than a poll: the stream
397 /// pushes, and this is how long to wait before trying again when it
398 /// ends. The waits are spread across a fleet and grow after a failure,
399 /// so a server coming back up is not met by every pod at once.
400 ///
401 /// Each document is delivered only when it differs from the last one:
402 /// a generation moves for every install, and an install that changed
403 /// nothing this caller can see should wake nothing.
404 ///
405 /// # Errors
406 ///
407 /// If `on_change` refuses a document. A connection failing is not an
408 /// error — reconnecting through an outage is what this is for.
409 pub fn watch<F>(
410 &self,
411 watching: &Watching,
412 interval: Duration,
413 mut on_change: F,
414 ) -> Result<(), Error>
415 where
416 F: FnMut(Fetched) -> Result<(), Error>,
417 {
418 // One runtime for the whole watch, unlike `fetch`'s per-call one: a
419 // watch is a long-lived thing by definition, so the argument that
420 // makes a per-call runtime free does not apply to it.
421 let runtime = tokio::runtime::Builder::new_current_thread()
422 .enable_all()
423 .build()
424 .map_err(|error| {
425 Error::remote(format!(
426 "{}: no runtime for the watch: {error}",
427 self.described
428 ))
429 })?;
430
431 // Settled once, before the loop, because neither can come right by
432 // being retried: a URL this crate cannot parse and a TLS
433 // configuration it cannot build are the caller's to fix, and a loop
434 // that swallowed them reconnected forever, delivered nothing and
435 // said nothing. The eight stores validate what is deterministic up
436 // front for the same reason.
437 let endpoint = Endpoint::parse(&self.url, &self.described)?;
438 self.tls_client(endpoint.secure)?;
439
440 runtime.block_on(async {
441 let mut pace = Pace::new(interval);
442 let mut resume: Option<String> = None;
443 let mut last: Option<Fetched> = None;
444
445 while watching.keep_going() {
446 match self
447 .subscribed(watching, &mut resume, &mut last, &mut on_change)
448 .await
449 {
450 Ok(()) => pace.succeeded(),
451 // The caller refusing a document is the one failure this
452 // loop does not own: it is a decision, not an outage.
453 Err(Ended::Refused(error)) => return Err(error),
454 // Everything else is swallowed on purpose, credentials
455 // included: a token file rotating between two
456 // connections looks exactly like a token that is wrong,
457 // and a watch that stopped on the first would be a pod
458 // that never recovered from a routine rotation.
459 Err(Ended::Disconnected) => pace.failed(),
460 }
461
462 sleep_while(watching, pace.next_wait()).await;
463 }
464
465 Ok(())
466 })
467 }
468
469 /// One connection's worth of stream, from subscribe to close.
470 async fn subscribed<F>(
471 &self,
472 watching: &Watching,
473 resume: &mut Option<String>,
474 last: &mut Option<Fetched>,
475 on_change: &mut F,
476 ) -> Result<(), Ended>
477 where
478 F: FnMut(Fetched) -> Result<(), Error>,
479 {
480 let endpoint = Endpoint::parse(&self.url, &self.described)
481 .map_err(|error| self.disconnected(&error))?;
482 let path = endpoint.path(&format!("/{}/{}/stream", self.application, self.profile));
483
484 // The budget covers getting the stream open — connect, handshake,
485 // request — and stops there. A deadline on the stream itself would
486 // be a deadline on the configuration not changing.
487 let budget = Budget::starting(self.timeout);
488 let secure = endpoint.secure;
489 let tls = self
490 .tls_client(secure)
491 .map_err(|error| self.disconnected(&error))?;
492 let mut connection = Connection::open(
493 &endpoint,
494 tls,
495 self.tls.server_name(),
496 budget,
497 &self.described,
498 )
499 .await
500 .map_err(|error| self.disconnected(&error))?;
501
502 let bearer = self.bearer().map_err(|error| self.disconnected(&error))?;
503 let response = connection
504 .get(
505 &endpoint,
506 Get {
507 path: &path,
508 token: bearer.as_deref(),
509 accept: "text/event-stream",
510 resume: resume.as_deref(),
511 },
512 budget,
513 &self.described,
514 )
515 .await
516 .map_err(|error| self.disconnected(&error))?;
517
518 if !response.status().is_success() {
519 let status = response.status();
520 let refusal = http::refused(status, &self.described);
521
522 // **A 404 is an answer, not an outage.** The stream path is
523 // absent when a deployment sets `max_stream_connections = 0`,
524 // and when a URL names a prefix this server does not mount —
525 // neither comes right by reconnecting, and a loop that retried
526 // them forever was a watch that delivered nothing and said
527 // nothing. Everything else is waited out, credentials included:
528 // a token file rotating between two connections looks exactly
529 // like a token that is wrong.
530 if status == 404 {
531 return Err(Ended::Refused(refusal));
532 }
533
534 return Err(self.disconnected(&refusal));
535 }
536
537 let mut events = Events::new(response);
538
539 // Whether the *first* event of this connection is the server saying
540 // where the document stands rather than that it moved. It is,
541 // exactly when this subscription sent no `Last-Event-ID`.
542 let mut opening = resume.is_none();
543
544 while watching.keep_going() {
545 let next = events
546 .next(watching, Self::IDLE, &self.described)
547 .await
548 .map_err(|error| self.disconnected(&error))?;
549
550 let Some(event) = next else {
551 // The server closed it. Ordinary — a rolling restart does
552 // exactly this — and the loop above reconnects.
553 return Ok(());
554 };
555
556 // A keep-alive says the connection is there and nothing else.
557 // Round the loop rather than through the fetch: re-reading the
558 // whole document every fifteen seconds of quiet is the poll this
559 // client exists to replace, and coming back here is also what
560 // notices a watch that has been stopped.
561 if !event.carried {
562 continue;
563 }
564
565 // The event says *something landed*; the document is fetched
566 // from the endpoint that serves documents. Reading the number
567 // out of the payload is not needed for that and is not done:
568 // an install is an install.
569 let _ = event.data;
570
571 // **The opening event is not a change.** A first subscription
572 // sends no `Last-Event-ID`, so the server opens with where the
573 // document stands — which is the current value, and
574 // "the current value is not delivered at startup" is the
575 // contract all nine sources keep. Its id is still worth having:
576 // a reconnect resumes from it.
577 if opening {
578 opening = false;
579 *resume = event.id.or_else(|| resume.take());
580
581 continue;
582 }
583
584 let fetched = self
585 .read()
586 .await
587 .map_err(|error| self.disconnected(&error))?;
588
589 if last.as_ref() != Some(&fetched) {
590 *last = Some(fetched.clone());
591
592 // Through `guarded`, as every other store delivers: a
593 // callback that panics ends the watch with an error rather
594 // than unwinding through this loop and killing the caller's
595 // thread with the `RemoteWatch` handle still looking alive.
596 guarded(on_change, fetched, &self.described).map_err(Ended::Refused)?;
597 }
598
599 // **Advanced last, and only on the way out.** Moving it before
600 // the fetch meant a fetch that failed still counted: the
601 // reconnect carried a `Last-Event-ID` for a generation this
602 // client never read, the server saw nothing newer, and the
603 // change was lost until the next install — the one window the
604 // module documentation says cannot exist.
605 if let Some(id) = event.id {
606 *resume = Some(id);
607 }
608 }
609
610 Ok(())
611 }
612
613 /// An attempt that came back with nothing, reported and then forgotten.
614 ///
615 /// Reporting happens here rather than at each call site so that a
616 /// failure branch added later cannot be the one that forgets — the
617 /// same shape the eight store crates use.
618 fn disconnected(&self, error: &Error) -> Ended {
619 self.attempts.failed(error);
620
621 Ended::Disconnected
622 }
623
624 /// The bearer token to present, file first.
625 fn bearer(&self) -> Result<Option<String>, Error> {
626 match &self.token_file {
627 Some(file) => std::fs::read_to_string(file)
628 .map(|token| Some(token.trim().to_owned()))
629 .map_err(|error| {
630 Error::auth(format!(
631 "{}: reading the bearer token file: {error}",
632 self.described
633 ))
634 }),
635 None => Ok(self.token.clone()),
636 }
637 }
638}
639
640/// Why one connection's worth of stream ended.
641///
642/// The distinction the loop above acts on, and the only one it needs: a
643/// connection that failed is waited out and tried again, and a caller that
644/// refused a document has made a decision the loop has no business
645/// overriding.
646enum Ended {
647 /// The connection failed, or the server refused the subscription. The
648 /// error is not carried past here: the loop waits and tries again, and a
649 /// message per reconnect through an outage is a log nobody can read. It
650 /// *is* reported first — see `ConfigServer::disconnected`.
651 Disconnected,
652 Refused(Error),
653}
654
655/// Sleeps for `total`, waking early once the watch is stopped.
656async fn sleep_while(watching: &Watching, total: Duration) {
657 const SLICE: Duration = Duration::from_millis(250);
658
659 let mut left = total;
660
661 while left > Duration::ZERO && watching.keep_going() {
662 let slice = left.min(SLICE);
663
664 tokio::time::sleep(slice).await;
665 left -= slice;
666 }
667}
668
669impl RemoteSource for ConfigServer {
670 fn fetch(&self) -> Result<Fetched, Error> {
671 // A blocking `fetch` on a client built from an async stack: one
672 // runtime, current-thread, for this call only. A source is fetched
673 // when a caller asks, minutes or hours apart, so the cost of starting
674 // one is not on any path that matters — and owning a long-lived
675 // runtime here would put a second one inside applications that
676 // already have theirs.
677 let runtime = tokio::runtime::Builder::new_current_thread()
678 .enable_all()
679 .build()
680 .map_err(|error| {
681 Error::remote(format!(
682 "{}: no runtime for the fetch: {error}",
683 self.described
684 ))
685 })?;
686
687 runtime.block_on(self.read())
688 }
689
690 fn describe(&self) -> String {
691 self.described.clone()
692 }
693
694 /// Native: the server pushes a generation down a `text/event-stream`.
695 fn watch_capability(&self) -> WatchCapability {
696 WatchCapability::Native
697 }
698
699 fn watch(
700 &self,
701 watching: &Watching,
702 interval: Duration,
703 on_change: &mut dyn FnMut(Fetched) -> Result<(), Error>,
704 ) -> Result<(), Error> {
705 ConfigServer::watch(self, watching, interval, on_change)
706 }
707}
708
709impl std::fmt::Debug for ConfigServer {
710 /// Shape only. The token is the credential and never prints; `TlsConfig`
711 /// redacts its own key material; and the URL is redacted too, because a
712 /// `user:password@` authority is refused at fetch time rather than at
713 /// construction — so a source carrying one can be printed.
714 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
715 formatter
716 .debug_struct("ConfigServer")
717 .field("url", &redacted(&self.url, LoneAuthority::Username))
718 .field("application", &self.application)
719 .field("profile", &self.profile)
720 .field("token", &self.token.as_ref().map(|_| "<redacted>"))
721 .field("token_file", &self.token_file)
722 .field("tls", &self.tls)
723 .field("timeout", &self.timeout)
724 .finish()
725 }
726}
727
728#[cfg(test)]
729mod tests {
730 use super::*;
731
732 #[test]
733 fn a_document_is_lifted_out_of_the_servers_envelope() {
734 let text = r#"{"application":"billing","profile":"prod","generation":7,
735 "config":{"port":8080}}"#;
736
737 assert_eq!(extract(text, "a server").unwrap().0, r#"{"port":8080}"#);
738 }
739
740 #[test]
741 fn an_answer_from_something_that_is_not_a_config_server_says_so() {
742 let error = extract(r#"{"hello":"world"}"#, "a server").unwrap_err();
743
744 assert!(error.to_string().contains("no `config` member"), "{error}");
745 }
746
747 /// A password in the URL is refused rather than sent — and the refusal
748 /// must not be where it gets printed. `new` cannot fail, so the source
749 /// exists, is `Debug`-printed and describes itself long before the
750 /// parser gets to say no.
751 #[test]
752 fn a_password_in_the_url_reaches_neither_debug_nor_a_message() {
753 let source = ConfigServer::new(
754 "https://user:hunter2-do-not-print@config.internal",
755 "billing",
756 "prod",
757 );
758
759 let rendered = format!("{source:?}");
760 assert!(!rendered.contains("hunter2"), "{rendered}");
761
762 let described = source.describe();
763 assert!(!described.contains("hunter2"), "{described}");
764 assert!(described.contains("user:***@"), "{described}");
765
766 // And the refusal itself, which quotes the description.
767 let error = Endpoint::parse(&source.url, &source.described)
768 .expect_err("a `user:password@` authority is refused");
769 assert!(!error.to_string().contains("hunter2"), "{error}");
770 }
771
772 #[test]
773 fn a_token_never_reaches_debug() {
774 let source = ConfigServer::new("https://config.internal", "billing", "prod")
775 .with_token("hunter2-do-not-print");
776
777 let rendered = format!("{source:?}");
778
779 assert!(!rendered.contains("hunter2"), "{rendered}");
780 assert!(rendered.contains("<redacted>"), "{rendered}");
781 }
782}
783
784/// A certificate verifier that accepts every server, for
785/// `with_skip_verification`.
786///
787/// **Nothing here checks anything.** Every method answers "valid", which is
788/// the whole of what it does and the whole of what it is for: a caller who
789/// asked for an unauthenticated connection gets one, from a type whose name
790/// says so, rather than from a silently missing check somewhere else.
791///
792/// The signature methods are not stubbed — a TLS handshake still has to be
793/// cryptographically well-formed, and rustls' own algorithm list does that
794/// part. What is skipped is the question of *who* the peer is.
795#[derive(Debug)]
796struct AnyServerIsFine {
797 /// The provider's algorithm list, so the two signature checks below can
798 /// stay real rather than becoming a second thing that answers yes.
799 schemes: Vec<rustls::SignatureScheme>,
800}
801
802impl AnyServerIsFine {
803 fn new() -> Result<Self, Error> {
804 let provider = rustls::crypto::CryptoProvider::get_default()
805 .cloned()
806 .or_else(|| {
807 let provider = std::sync::Arc::new(rustls::crypto::ring::default_provider());
808 let _ = rustls::crypto::CryptoProvider::install_default((*provider).clone());
809 rustls::crypto::CryptoProvider::get_default().cloned()
810 })
811 .ok_or_else(|| Error::remote("no TLS cryptography provider is installed"))?;
812
813 Ok(Self {
814 schemes: provider
815 .signature_verification_algorithms
816 .supported_schemes(),
817 })
818 }
819}
820
821impl rustls::client::danger::ServerCertVerifier for AnyServerIsFine {
822 fn verify_server_cert(
823 &self,
824 _end_entity: &rustls::pki_types::CertificateDer<'_>,
825 _intermediates: &[rustls::pki_types::CertificateDer<'_>],
826 _server_name: &rustls::pki_types::ServerName<'_>,
827 _ocsp: &[u8],
828 _now: rustls::pki_types::UnixTime,
829 ) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
830 Ok(rustls::client::danger::ServerCertVerified::assertion())
831 }
832
833 fn verify_tls12_signature(
834 &self,
835 message: &[u8],
836 cert: &rustls::pki_types::CertificateDer<'_>,
837 dss: &rustls::DigitallySignedStruct,
838 ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
839 rustls::crypto::verify_tls12_signature(
840 message,
841 cert,
842 dss,
843 &rustls::crypto::ring::default_provider().signature_verification_algorithms,
844 )
845 }
846
847 fn verify_tls13_signature(
848 &self,
849 message: &[u8],
850 cert: &rustls::pki_types::CertificateDer<'_>,
851 dss: &rustls::DigitallySignedStruct,
852 ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
853 rustls::crypto::verify_tls13_signature(
854 message,
855 cert,
856 dss,
857 &rustls::crypto::ring::default_provider().signature_verification_algorithms,
858 )
859 }
860
861 fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
862 self.schemes.clone()
863 }
864}