Skip to main content

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}