Skip to main content

dynamic_config_server/
config.rs

1//! The server's own configuration, and every reason it refuses to start.
2//!
3//! A config server is the one program whose misconfiguration is not its own
4//! problem: it hands other services their secrets. So the checks here are
5//! refusals rather than warnings, and each one names the key that would fix
6//! it. The list is deliberately long and deliberately loud — the failure
7//! mode this exists to prevent is a server that starts, looks healthy, and
8//! is serving `billing` to anyone who asks.
9
10use std::fmt;
11use std::net::SocketAddr;
12
13use serde::Deserialize;
14
15use crate::auth::{Token, MIN_TOKEN_LEN};
16
17/// The default bind address: loopback, so a server started with no `bind`
18/// at all is reachable from nowhere but its own host.
19fn default_bind() -> String {
20    "127.0.0.1:8080".to_owned()
21}
22
23/// The default debounce for the file watcher, in milliseconds.
24fn default_debounce_ms() -> u64 {
25    250
26}
27
28/// The default ceiling on concurrent change-stream connections.
29///
30/// A thousand-pod fleet reconnecting at once is the shape this number is
31/// chosen against: each connection costs one `Changes` handle and one
32/// registered waker and holds no document, so a thousand is nothing — and a
33/// ceiling that a fleet does not reach in normal operation is a backstop
34/// against a client that reconnects in a loop rather than a rate limit.
35fn default_max_streams() -> usize {
36    4096
37}
38
39/// One served application-and-profile pair.
40///
41/// The section key inside the files **is** the application name: a document
42/// served as `billing` is the `[billing]` table of the configured files.
43/// That is one fact rather than two, and it keeps a URL and a file readable
44/// against each other.
45#[derive(Debug, Clone, Deserialize)]
46#[serde(deny_unknown_fields)]
47pub struct SectionConfig {
48    /// The application, which is both the first path segment and the
49    /// section key inside the files.
50    pub application: String,
51    /// The profile, which is the second path segment.
52    ///
53    /// A profile here is a *different set of files*, chosen by the
54    /// operator, rather than the library's `profile_env` — that one is a
55    /// process-wide environment variable, and a server serving two profiles
56    /// cannot have two of those at once.
57    pub profile: String,
58    /// The files to merge, in order; later files win.
59    pub files: Vec<String>,
60    /// An environment-variable prefix layered above the files, as in
61    /// `APP_` reading `APP_BILLING_*`.
62    #[serde(default)]
63    pub env_prefix: Option<String>,
64    /// Whether these files carry a section header at all.
65    ///
66    /// `false` — the default — reads the application as a top-level key
67    /// inside each file, so one file can hold several applications.
68    ///
69    /// `true` says each file *is* this section: `{"host": …, "port": …}`
70    /// with nothing above it. A config server is routinely pointed at
71    /// files somebody else's tool writes, and those files have no reason
72    /// to carry a header this server invented.
73    #[serde(default)]
74    pub whole_document: bool,
75}
76
77/// Where the server's own certificate, key and client CA live.
78///
79/// Its presence is what turns TLS on; there is no `enabled` key, because a
80/// block that names a certificate and does nothing is a deployment that
81/// believes it is encrypted and is not.
82///
83/// ```toml
84/// [server.tls]
85/// certificate = "/etc/dynamic-config/server.pem"
86/// key = "/etc/dynamic-config/server.key"
87/// client_ca = "/etc/dynamic-config/clients-ca.pem"   # optional; see below
88/// ```
89///
90/// Only paths live here. The key's *bytes* are read once, at startup, by
91/// [`Tls::load`](crate::tls::Tls::load), and never reach a diagnostic — see
92/// [`TlsError`](crate::tls::TlsError).
93#[derive(Debug, Clone, Deserialize)]
94#[serde(deny_unknown_fields)]
95pub struct TlsConfig {
96    /// PEM holding the server's certificate, then any intermediates, leaf
97    /// first.
98    pub certificate: String,
99    /// PEM holding that certificate's private key: PKCS#8, PKCS#1 or SEC1.
100    ///
101    /// On Unix the server **refuses to start** if this file is readable by
102    /// anything but its owner, for the same reason it refuses a token under
103    /// 32 characters.
104    pub key: String,
105    /// PEM holding the certificate authority every client certificate must
106    /// chain to.
107    ///
108    /// Present means **mutual TLS is required**: a caller that presents no
109    /// certificate, or one signed by anything else, does not complete the
110    /// handshake and never becomes a request. Absent means the server
111    /// authenticates itself to callers and asks for nothing back.
112    ///
113    /// A certificate is a second gate, never a second identity: it is not an
114    /// alternative to the bearer token and it does not name a caller. See
115    /// the [`tls`](crate::tls) module.
116    ///
117    /// **No revocation is checked.** A certificate that chains here is good
118    /// until it expires; see [`crl`](Self::crl).
119    #[serde(default)]
120    pub client_ca: Option<String>,
121    /// A certificate revocation list — **a startup refusal**, never a file
122    /// this server reads.
123    ///
124    /// The key exists so that an operator who reaches for revocation is told
125    /// that this server does not check it, rather than being told `unknown
126    /// field 'crl'` and going looking for a different spelling. It is the
127    /// same reason [`tls`](ServerConfig::tls) itself is parsed in a build
128    /// without the feature: a security-relevant key that reads as a typo is
129    /// worse than one that reads as a decision.
130    ///
131    /// The decision, and it was measured rather than assumed
132    /// (`RevocationUnsupported`'s message is the short form): rustls will
133    /// accept a CRL whose `nextUpdate` passed years ago without a word,
134    /// because `ExpirationPolicy::Ignore` is the default — so the twenty
135    /// lines that look like revocation are a check that stops being true the
136    /// moment the file stops being refreshed, with nothing anywhere
137    /// reporting it. The one switch that refuses a stale list,
138    /// `enforce_revocation_expiration`, refuses **every** client while it is
139    /// stale, which turns a CRL publishing hiccup into a fleet-wide
140    /// configuration outage. Neither is a posture this crate will ship, and
141    /// a file watcher does not rescue it: the failure to catch is the
142    /// *absence* of a write, and no filesystem event fires for that.
143    ///
144    /// What to do instead is in [`tls`](crate::tls): short-lived client
145    /// certificates, and revoke the bearer token — the credential that
146    /// actually authorises, and the one this server can withdraw by removing
147    /// a line.
148    #[serde(default)]
149    pub crl: Option<String>,
150}
151
152/// One caller, and what it may read.
153#[derive(Debug, Clone, Deserialize)]
154#[serde(deny_unknown_fields)]
155pub struct ClientConfig {
156    /// The client's name. Appears in the audit log and nowhere else.
157    pub name: String,
158    /// The bearer token this client presents.
159    ///
160    /// Absent means **anonymous**: this client is whoever calls without a
161    /// credential. That needs [`allow_anonymous`](ServerConfig::allow_anonymous)
162    /// as well, so an omitted token can never be the accident that opens a
163    /// server up.
164    #[serde(default)]
165    pub token: Option<Token>,
166    /// The applications this client may read, by name. Exact, no wildcards.
167    pub applications: Vec<String>,
168}
169
170impl ClientConfig {
171    /// Whether this client is the anonymous one.
172    #[must_use]
173    pub fn is_anonymous(&self) -> bool {
174        self.token.is_none()
175    }
176}
177
178/// Everything the server needs to start.
179///
180/// `deny_unknown_fields` on purpose: a misspelled `allow_anonymous` that
181/// silently stayed `false` would be a harmless surprise, and a misspelled
182/// `applications` that silently granted nothing would be a confusing one —
183/// but a key this struct does not know is, in a security-relevant file, a
184/// key the operator believes is doing something. Refuse it.
185#[derive(Debug, Clone, Deserialize)]
186#[serde(deny_unknown_fields)]
187pub struct ServerConfig {
188    /// The address to listen on. Loopback unless said otherwise.
189    #[serde(default = "default_bind")]
190    pub bind: String,
191    /// Permits a bind address that is not loopback **when this server
192    /// terminates no TLS**.
193    ///
194    /// Without [`tls`](Self::tls), a non-loopback bind means configuration —
195    /// secrets included — crossing a network in the clear unless something
196    /// in front of it is doing the encryption. Setting this is the operator
197    /// saying that something is.
198    ///
199    /// With [`tls`](Self::tls) it is a **refusal**, not a no-op. The word
200    /// acknowledges an unencrypted socket, and there is not one; leaving it
201    /// set while TLS is on would make it stop meaning anything, so that
202    /// removing the TLS block later would quietly reopen the port instead of
203    /// refusing.
204    #[serde(default)]
205    pub insecure: bool,
206    /// TLS termination, and the client certificate that goes with it.
207    ///
208    /// Absent — the default — is a server that speaks plain HTTP and expects
209    /// a terminator in front of it, exactly as before. Present is this
210    /// process terminating TLS itself, and needs the `tls` Cargo feature: a
211    /// build without it **refuses to start** rather than ignoring the block.
212    #[serde(default)]
213    pub tls: Option<TlsConfig>,
214    /// Permits a client with no token.
215    #[serde(default)]
216    pub allow_anonymous: bool,
217    /// The file watcher's debounce, in milliseconds. Zero disables
218    /// watching, which is what an operator who reloads by other means
219    /// wants.
220    #[serde(default = "default_debounce_ms")]
221    pub watch_debounce_ms: u64,
222    /// How many change-stream connections may be open at once, across every
223    /// caller and every section.
224    ///
225    /// **Zero turns the endpoint off**, and a server with it off answers
226    /// `/stream` with the same 404 as everything else it does not serve — a
227    /// deployment that does not want long-lived connections says so once
228    /// here rather than in whatever is in front of it.
229    ///
230    /// It is a backstop, not a rate limit. Per-*caller* limiting belongs to
231    /// the thing in front, which is the only place that sees every replica's
232    /// share of a caller; what this bounds is the total number of sockets one
233    /// process will hold open on this endpoint, so a client reconnecting in
234    /// a loop cannot take the process with it.
235    #[serde(default = "default_max_streams")]
236    pub max_stream_connections: usize,
237    /// The served applications and profiles.
238    pub sections: Vec<SectionConfig>,
239    /// The callers.
240    pub clients: Vec<ClientConfig>,
241}
242
243impl Default for ServerConfig {
244    fn default() -> Self {
245        Self {
246            bind: default_bind(),
247            insecure: false,
248            tls: None,
249            allow_anonymous: false,
250            watch_debounce_ms: default_debounce_ms(),
251            max_stream_connections: default_max_streams(),
252            sections: Vec::new(),
253            clients: Vec::new(),
254        }
255    }
256}
257
258impl ServerConfig {
259    /// Every reason this configuration will not start a server.
260    ///
261    /// Pure, and separate from starting, so the whole refusal surface is
262    /// testable without a socket. [`Server::start`](crate::Server::start)
263    /// calls it first and does nothing else if it says no.
264    ///
265    /// # Errors
266    ///
267    /// The first [`Refusal`] that applies, in the order this checks them:
268    /// the shape of the roster before the shape of the network, because an
269    /// operator fixing two problems would rather be told about the one that
270    /// is about who can read what.
271    pub fn validate(&self) -> Result<(), Refusal> {
272        if self.sections.is_empty() {
273            return Err(Refusal::NoSections);
274        }
275        if self.clients.is_empty() {
276            return Err(Refusal::NoClients);
277        }
278
279        let mut seen = Vec::new();
280
281        for section in &self.sections {
282            // The predicate the handlers apply to the path segments, applied
283            // where the mistake was made. Without this a section named with
284            // a space in it — or one 65 characters long — loads, starts, and
285            // reports ready, while every request for it is refused by
286            // `is_name` before the section map is even consulted: a section
287            // that exists and can never be reached.
288            for (part, value) in [
289                ("application", &section.application),
290                ("profile", &section.profile),
291            ] {
292                if !crate::routes::is_name(value) {
293                    return Err(Refusal::UnroutableSection {
294                        application: section.application.clone(),
295                        profile: section.profile.clone(),
296                        part,
297                    });
298                }
299            }
300
301            let pair = (section.application.as_str(), section.profile.as_str());
302
303            if seen.contains(&pair) {
304                return Err(Refusal::DuplicateSection {
305                    application: section.application.clone(),
306                    profile: section.profile.clone(),
307                });
308            }
309
310            seen.push(pair);
311        }
312
313        let mut names: Vec<&str> = Vec::new();
314        let mut anonymous = 0;
315
316        for client in &self.clients {
317            if names.contains(&client.name.as_str()) {
318                return Err(Refusal::DuplicateClient {
319                    name: client.name.clone(),
320                });
321            }
322
323            names.push(&client.name);
324
325            match &client.token {
326                None => {
327                    anonymous += 1;
328
329                    if !self.allow_anonymous {
330                        return Err(Refusal::AnonymousNotAllowed {
331                            client: client.name.clone(),
332                        });
333                    }
334                    if anonymous > 1 {
335                        return Err(Refusal::SeveralAnonymousClients);
336                    }
337                }
338                Some(token) if token.len() < MIN_TOKEN_LEN => {
339                    return Err(Refusal::WeakToken {
340                        client: client.name.clone(),
341                    });
342                }
343                Some(_) => {}
344            }
345
346            // A grant naming an application nothing serves is a typo that
347            // reads as a working deployment right up to the first 404. Two
348            // sections can share an application (one per profile), so the
349            // grant is checked against the application names, not the pairs.
350            for application in &client.applications {
351                if !self
352                    .sections
353                    .iter()
354                    .any(|section| &section.application == application)
355                {
356                    return Err(Refusal::UnservedGrant {
357                        client: client.name.clone(),
358                        application: application.clone(),
359                    });
360                }
361            }
362        }
363
364        // Two clients sharing a token is not a smaller version of one client
365        // with two grants: whichever is listed first silently wins, and the
366        // audit log then names the wrong caller for every request.
367        for (index, client) in self.clients.iter().enumerate() {
368            let Some(token) = &client.token else { continue };
369
370            for other in self.clients.iter().skip(index + 1) {
371                if other.token.as_ref().is_some_and(|it| token.same_as(it)) {
372                    return Err(Refusal::DuplicateToken);
373                }
374            }
375        }
376
377        let address = self
378            .bind
379            .parse::<SocketAddr>()
380            .map_err(|_| Refusal::UnparsableBind {
381                bind: self.bind.clone(),
382            })?;
383
384        // The whole matrix of TLS against the bind, in one place. Four
385        // starting shapes and three refusals:
386        //
387        // | tls     | bind         | insecure | outcome                    |
388        // |---------|--------------|----------|----------------------------|
389        // | absent  | loopback     | either   | starts, in the clear       |
390        // | absent  | non-loopback | false    | `ExposedBind`              |
391        // | absent  | non-loopback | true     | starts; a terminator is in front |
392        // | present | anything     | false    | starts, terminating TLS    |
393        // | present | anything     | true     | `InsecureWithTls`          |
394        // | present | anything     | —        | `TlsUnsupported` if the feature is off |
395        match &self.tls {
396            Some(tls) => {
397                // A build without the feature has no rustls in it at all. The
398                // block is still *parsed* — a key that only exists in some
399                // builds would be an unknown field in the others, and this
400                // crate refuses unknown fields — so the refusal is here,
401                // where it can name the feature.
402                if !cfg!(feature = "tls") {
403                    return Err(Refusal::TlsUnsupported);
404                }
405                // Before the path checks, because this one is about who can
406                // read what and they are about which file: an operator with
407                // both problems would rather hear that revocation is not
408                // checked than that a path is blank.
409                if tls.crl.is_some() {
410                    return Err(Refusal::RevocationUnsupported);
411                }
412                if tls.certificate.trim().is_empty() {
413                    return Err(Refusal::TlsPathMissing { key: "certificate" });
414                }
415                if tls.key.trim().is_empty() {
416                    return Err(Refusal::TlsPathMissing { key: "key" });
417                }
418                if tls
419                    .client_ca
420                    .as_ref()
421                    .is_some_and(|it| it.trim().is_empty())
422                {
423                    return Err(Refusal::TlsPathMissing { key: "client_ca" });
424                }
425                if self.insecure {
426                    return Err(Refusal::InsecureWithTls);
427                }
428            }
429            None => {
430                if !address.ip().is_loopback() && !self.insecure {
431                    return Err(Refusal::ExposedBind {
432                        bind: self.bind.clone(),
433                    });
434                }
435            }
436        }
437
438        Ok(())
439    }
440
441    /// The validated bind address.
442    ///
443    /// # Errors
444    ///
445    /// If `bind` is not a literal `address:port`. A hostname is refused
446    /// rather than resolved: which of a name's addresses a server ends up
447    /// on is not a thing to discover at startup.
448    pub fn address(&self) -> Result<SocketAddr, Refusal> {
449        self.bind
450            .parse::<SocketAddr>()
451            .map_err(|_| Refusal::UnparsableBind {
452                bind: self.bind.clone(),
453            })
454    }
455}
456
457/// Why a configuration will not start a server.
458///
459/// Every variant's `Display` names the key that fixes it, and none of them
460/// carries a token: a refusal is printed to a terminal and scraped into a
461/// log, which is the last place a credential should turn up.
462#[derive(Debug, Clone, PartialEq, Eq)]
463#[non_exhaustive]
464pub enum Refusal {
465    /// No `sections` — the server would serve nothing.
466    NoSections,
467    /// No `clients` — nobody could ever read anything.
468    NoClients,
469    /// Two sections claim the same application and profile.
470    DuplicateSection {
471        /// The application both claim.
472        application: String,
473        /// The profile both claim.
474        profile: String,
475    },
476    /// A section names an application or profile no route can carry, so
477    /// nothing could ever reach it.
478    UnroutableSection {
479        /// The application, as configured.
480        application: String,
481        /// The profile, as configured.
482        profile: String,
483        /// Which of the two was refused: `application` or `profile`.
484        part: &'static str,
485    },
486    /// Two clients share a name.
487    DuplicateClient {
488        /// The name.
489        name: String,
490    },
491    /// Two clients share a token.
492    DuplicateToken,
493    /// A configured token is shorter than [`MIN_TOKEN_LEN`].
494    WeakToken {
495        /// The client whose token is too short.
496        client: String,
497    },
498    /// A client has no token and `allow_anonymous` is not set.
499    AnonymousNotAllowed {
500        /// The client with no token.
501        client: String,
502    },
503    /// More than one client has no token, so "the anonymous caller" names
504    /// two different grants.
505    SeveralAnonymousClients,
506    /// A client is granted an application no section serves.
507    UnservedGrant {
508        /// The client.
509        client: String,
510        /// The application it was granted.
511        application: String,
512    },
513    /// A non-loopback `bind` with neither `tls` nor `insecure`.
514    ExposedBind {
515        /// The address.
516        bind: String,
517    },
518    /// `bind` is not a literal `address:port`.
519    UnparsableBind {
520        /// What was written.
521        bind: String,
522    },
523    /// `[server.tls]` in a build compiled without the `tls` feature.
524    TlsUnsupported,
525    /// `insecure` is set and `[server.tls]` is configured: an
526    /// acknowledgement of something that is not true.
527    InsecureWithTls,
528    /// A `[server.tls]` key that must name a file names an empty string.
529    TlsPathMissing {
530        /// Which key.
531        key: &'static str,
532    },
533    /// `tls.crl` is configured. This server checks no revocation and says so
534    /// rather than accepting a key it would ignore.
535    RevocationUnsupported,
536}
537
538impl fmt::Display for Refusal {
539    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
540        match self {
541            Self::NoSections => {
542                f.write_str("no `sections` are configured: this server would serve nothing at all")
543            }
544            Self::NoClients => f.write_str(
545                "no `clients` are configured: nothing could ever be read. Add a client with \
546                 a `token` and the `applications` it may read, or an anonymous one with \
547                 `allow_anonymous = true`",
548            ),
549            Self::DuplicateSection {
550                application,
551                profile,
552            } => write!(
553                f,
554                "two `sections` claim `{application}`/`{profile}`; one application and \
555                 profile is served by exactly one section"
556            ),
557            Self::UnroutableSection {
558                application,
559                profile,
560                part,
561            } => write!(
562                f,
563                "the section `{application}`/`{profile}` has a `{part}` no request can \
564                 name: a path segment is up to 64 characters, starts with a letter or a \
565                 digit, and carries only letters, digits, `.`, `_` and `-`. The server \
566                 would start, report ready and answer `404` for that section forever"
567            ),
568            Self::DuplicateClient { name } => {
569                write!(f, "two `clients` are named `{name}`; names identify a caller in the audit log and must be unique")
570            }
571            Self::DuplicateToken => f.write_str(
572                "two `clients` share a `token`; the first listed would silently win every \
573                 request and the audit log would name the wrong caller",
574            ),
575            Self::WeakToken { client } => write!(
576                f,
577                "the `token` for client `{client}` is shorter than {MIN_TOKEN_LEN} characters"
578            ),
579            Self::AnonymousNotAllowed { client } => write!(
580                f,
581                "client `{client}` has no `token`, which makes it the anonymous caller; set \
582                 `allow_anonymous = true` to say that is intended, or give it a token"
583            ),
584            Self::SeveralAnonymousClients => f.write_str(
585                "more than one client has no `token`; there is one anonymous caller, so it \
586                 can have only one set of grants",
587            ),
588            Self::UnservedGrant {
589                client,
590                application,
591            } => write!(
592                f,
593                "client `{client}` is granted `{application}`, which no section serves; a \
594                 grant that matches nothing is a typo that reads as a working deployment"
595            ),
596            Self::ExposedBind { bind } => write!(
597                f,
598                "`bind` is `{bind}`, which is not loopback, and this server is terminating no \
599                 TLS: that would put configuration — secrets included — on the network in the \
600                 clear. Terminate TLS here with a `[server.tls]` section, or put a terminator \
601                 in front of it and set `insecure = true` to say so, or bind loopback"
602            ),
603            Self::UnparsableBind { bind } => write!(
604                f,
605                "`bind` is `{bind}`, which is not a literal `address:port`; a hostname is \
606                 refused rather than resolved"
607            ),
608            Self::TlsUnsupported => f.write_str(
609                "`[server.tls]` is configured, but this binary was built without the `tls` \
610                 feature and contains no TLS at all. Rebuild it with `--features tls`, or \
611                 remove `[server.tls]` and put a terminator in front",
612            ),
613            Self::InsecureWithTls => f.write_str(
614                "`insecure = true` is set and `[server.tls]` is configured. `insecure` \
615                 acknowledges that this server's own socket is unencrypted, which is no longer \
616                 true — remove it, so that removing the TLS section later refuses again \
617                 instead of quietly serving in the clear",
618            ),
619            Self::TlsPathMissing { key } => {
620                write!(f, "`tls.{key}` is empty; it has to name a PEM file")
621            }
622            Self::RevocationUnsupported => f.write_str(
623                "`tls.crl` is configured, but this server checks no certificate revocation and \
624                 will not pretend to. A CRL whose `nextUpdate` has passed is accepted silently \
625                 by default, so the list would stop being true the moment it stopped being \
626                 refreshed and nothing would report it; the one setting that refuses a stale \
627                 list refuses every client along with it, which turns a publishing hiccup into \
628                 an outage for every service at once. Remove the key. Issue short-lived client \
629                 certificates, and revoke the `token` — delete the client's line and restart — \
630                 which is the credential that actually authorises here",
631            ),
632        }
633    }
634}
635
636impl std::error::Error for Refusal {}
637
638#[cfg(test)]
639mod tests {
640    use super::*;
641
642    fn section(application: &str, profile: &str) -> SectionConfig {
643        SectionConfig {
644            application: application.to_owned(),
645            profile: profile.to_owned(),
646            files: vec!["config.toml".to_owned()],
647            env_prefix: None,
648            whole_document: false,
649        }
650    }
651
652    fn client(name: &str, token: Option<&str>, applications: &[&str]) -> ClientConfig {
653        ClientConfig {
654            name: name.to_owned(),
655            token: token.map(Token::new),
656            applications: applications.iter().map(|it| (*it).to_owned()).collect(),
657        }
658    }
659
660    const GOOD: &str = "0123456789abcdef0123456789abcdef";
661    const OTHER: &str = "fedcba9876543210fedcba9876543210";
662
663    fn valid() -> ServerConfig {
664        ServerConfig {
665            sections: vec![section("billing", "prod")],
666            clients: vec![client("billing-pod", Some(GOOD), &["billing"])],
667            ..ServerConfig::default()
668        }
669    }
670
671    #[test]
672    fn a_complete_configuration_starts() {
673        assert_eq!(valid().validate(), Ok(()));
674    }
675
676    #[test]
677    fn an_empty_roster_is_refused_at_both_ends() {
678        let mut config = valid();
679        config.sections.clear();
680        assert_eq!(config.validate(), Err(Refusal::NoSections));
681
682        let mut config = valid();
683        config.clients.clear();
684        assert_eq!(config.validate(), Err(Refusal::NoClients));
685    }
686
687    #[test]
688    fn a_duplicate_section_is_refused_but_two_profiles_are_not() {
689        let mut config = valid();
690        config.sections.push(section("billing", "prod"));
691
692        assert_eq!(
693            config.validate(),
694            Err(Refusal::DuplicateSection {
695                application: "billing".to_owned(),
696                profile: "prod".to_owned(),
697            })
698        );
699
700        let mut config = valid();
701        config.sections.push(section("billing", "staging"));
702        assert_eq!(config.validate(), Ok(()));
703    }
704
705    /// A section whose name no path segment can carry is refused where it
706    /// was written, not answered `404` forever. The predicate is the
707    /// handlers' own, so the two cannot drift.
708    #[test]
709    fn a_section_no_route_could_name_is_refused_at_startup() {
710        for (part, application, profile) in [
711            ("application", "billing api", "prod"),
712            ("profile", "billing", ".hidden"),
713            ("application", "", "prod"),
714            ("profile", "billing", "../etc"),
715        ] {
716            let mut config = valid();
717            config.sections = vec![section(application, profile)];
718            config.clients = vec![client("pod", Some(GOOD), &[application])];
719
720            assert_eq!(
721                config.validate(),
722                Err(Refusal::UnroutableSection {
723                    application: application.to_owned(),
724                    profile: profile.to_owned(),
725                    part,
726                }),
727                "`{application}`/`{profile}` must be refused"
728            );
729        }
730
731        // And the shapes a deployment actually uses still pass.
732        let mut config = valid();
733        config.sections = vec![section("billing-api.v2", "prod_1")];
734        config.clients = vec![client("pod", Some(GOOD), &["billing-api.v2"])];
735        assert_eq!(config.validate(), Ok(()));
736
737        // Sixty-four characters is the ceiling, and it is inclusive.
738        let mut config = valid();
739        let long = "a".repeat(65);
740        config.sections = vec![section(&long, "prod")];
741        config.clients = vec![client("pod", Some(GOOD), &[&long])];
742        assert!(matches!(
743            config.validate(),
744            Err(Refusal::UnroutableSection { .. })
745        ));
746    }
747
748    #[test]
749    fn duplicate_client_names_and_tokens_are_refused() {
750        let mut config = valid();
751        config
752            .clients
753            .push(client("billing-pod", Some(OTHER), &["billing"]));
754
755        assert_eq!(
756            config.validate(),
757            Err(Refusal::DuplicateClient {
758                name: "billing-pod".to_owned()
759            })
760        );
761
762        let mut config = valid();
763        config
764            .clients
765            .push(client("other", Some(GOOD), &["billing"]));
766
767        assert_eq!(config.validate(), Err(Refusal::DuplicateToken));
768    }
769
770    #[test]
771    fn a_short_token_is_refused() {
772        let mut config = valid();
773        config.clients = vec![client("billing-pod", Some("short"), &["billing"])];
774
775        assert_eq!(
776            config.validate(),
777            Err(Refusal::WeakToken {
778                client: "billing-pod".to_owned()
779            })
780        );
781    }
782
783    /// The switch the threat model turns on: no credential is nobody unless
784    /// the deployment says otherwise, in as many words.
785    #[test]
786    fn anonymous_access_needs_an_explicit_opt_in() {
787        let mut config = valid();
788        config.clients = vec![client("anonymous", None, &["billing"])];
789
790        assert_eq!(
791            config.validate(),
792            Err(Refusal::AnonymousNotAllowed {
793                client: "anonymous".to_owned()
794            })
795        );
796
797        config.allow_anonymous = true;
798        assert_eq!(config.validate(), Ok(()));
799
800        config.clients.push(client("also", None, &["billing"]));
801        assert_eq!(config.validate(), Err(Refusal::SeveralAnonymousClients));
802    }
803
804    #[test]
805    fn a_grant_nothing_serves_is_refused() {
806        let mut config = valid();
807        config.clients = vec![client("billing-pod", Some(GOOD), &["biling"])];
808
809        assert_eq!(
810            config.validate(),
811            Err(Refusal::UnservedGrant {
812                client: "billing-pod".to_owned(),
813                application: "biling".to_owned(),
814            })
815        );
816    }
817
818    #[test]
819    fn a_non_loopback_bind_is_refused_without_the_flag() {
820        let mut config = valid();
821        config.bind = "0.0.0.0:8080".to_owned();
822
823        let refusal = config.validate().unwrap_err();
824        assert_eq!(
825            refusal,
826            Refusal::ExposedBind {
827                bind: "0.0.0.0:8080".to_owned()
828            }
829        );
830        assert!(
831            refusal.to_string().contains("insecure"),
832            "the refusal has to name the key that fixes it: {refusal}"
833        );
834
835        config.insecure = true;
836        assert_eq!(config.validate(), Ok(()));
837    }
838
839    fn tls(client_ca: Option<&str>) -> TlsConfig {
840        TlsConfig {
841            certificate: "/etc/tls/server.pem".to_owned(),
842            key: "/etc/tls/server.key".to_owned(),
843            client_ca: client_ca.map(ToOwned::to_owned),
844            crl: None,
845        }
846    }
847
848    /// The half of the matrix that only exists because TLS does: terminating
849    /// it here is itself the answer to "that address is not loopback", so no
850    /// acknowledgement is asked for.
851    #[cfg(feature = "tls")]
852    #[test]
853    fn tls_is_the_acknowledgement_a_non_loopback_bind_needs() {
854        let mut config = valid();
855        config.bind = "0.0.0.0:8443".to_owned();
856        config.tls = Some(tls(None));
857
858        assert_eq!(config.validate(), Ok(()));
859    }
860
861    /// And the refusal that keeps `insecure` meaning one thing. Without it,
862    /// a configuration that had both would keep starting after the TLS block
863    /// was deleted — in the clear, on a public address, having been
864    /// pre-approved months earlier.
865    #[cfg(feature = "tls")]
866    #[test]
867    fn insecure_and_tls_together_are_a_contradiction_rather_than_a_no_op() {
868        let mut config = valid();
869        config.bind = "0.0.0.0:8443".to_owned();
870        config.tls = Some(tls(Some("/etc/tls/ca.pem")));
871        config.insecure = true;
872
873        let refusal = config.validate().unwrap_err();
874
875        assert_eq!(refusal, Refusal::InsecureWithTls);
876        assert!(refusal.to_string().contains("insecure"), "{refusal}");
877    }
878
879    #[cfg(feature = "tls")]
880    #[test]
881    fn a_tls_section_that_names_no_file_is_refused_per_key() {
882        for (key, mut broken) in [
883            ("certificate", tls(None)),
884            ("key", tls(None)),
885            ("client_ca", tls(Some(""))),
886        ] {
887            match key {
888                "certificate" => broken.certificate = String::new(),
889                "key" => broken.key = "   ".to_owned(),
890                _ => {}
891            }
892
893            let mut config = valid();
894            config.tls = Some(broken);
895
896            assert_eq!(config.validate(), Err(Refusal::TlsPathMissing { key }));
897        }
898    }
899
900    /// Revocation is refused rather than half-implemented, and the refusal
901    /// has to name what to do instead — an operator who configured a CRL is
902    /// an operator who has a certificate to withdraw, and leaving them with
903    /// "no" and no answer is how the key ends up back in the file next week.
904    #[cfg(feature = "tls")]
905    #[test]
906    fn a_crl_is_refused_and_the_refusal_names_the_credential_that_can_be_revoked() {
907        let mut config = valid();
908        let mut with_crl = tls(Some("/etc/tls/ca.pem"));
909        with_crl.crl = Some("/etc/tls/clients.crl".to_owned());
910        config.tls = Some(with_crl);
911
912        let refusal = config.validate().unwrap_err();
913
914        assert_eq!(refusal, Refusal::RevocationUnsupported);
915
916        let rendered = refusal.to_string();
917
918        assert!(rendered.contains("`tls.crl`"), "{rendered}");
919        assert!(rendered.contains("token"), "{rendered}");
920        assert!(rendered.contains("short-lived"), "{rendered}");
921    }
922
923    /// The key is *understood* rather than unknown, which is the whole point
924    /// of it existing: `deny_unknown_fields` would otherwise answer an
925    /// operator asking for revocation with "unknown field", which reads as a
926    /// misspelling and sends them looking for the right one.
927    #[test]
928    fn a_crl_key_parses_so_that_the_refusal_can_explain_rather_than_serde() {
929        let config: ServerConfig = serde_json::from_str(
930            r#"{"sections":[{"application":"a","profile":"p","files":["f"]}],
931                "clients":[{"name":"c","token":"0123456789abcdef0123456789abcdef","applications":["a"]}],
932                "tls":{"certificate":"c.pem","key":"k.pem","crl":"clients.crl"}}"#,
933        )
934        .expect("the key is understood, not unknown");
935
936        assert_eq!(
937            config.tls.expect("the block parsed").crl.as_deref(),
938            Some("clients.crl")
939        );
940    }
941
942    /// A build with no TLS in it says so, rather than serving in the clear
943    /// on a port the operator believes is encrypted. This is the one refusal
944    /// that is about the binary rather than the file.
945    #[cfg(not(feature = "tls"))]
946    #[test]
947    fn a_build_without_the_feature_refuses_a_tls_section() {
948        let mut config = valid();
949        config.tls = Some(tls(None));
950
951        let refusal = config.validate().unwrap_err();
952
953        assert_eq!(refusal, Refusal::TlsUnsupported);
954        assert!(refusal.to_string().contains("--features tls"), "{refusal}");
955    }
956
957    /// The block parses in *both* builds. It has to: `deny_unknown_fields`
958    /// would otherwise turn a build without the feature into "unknown field
959    /// `tls`", which reads as a typo rather than as a missing feature.
960    #[test]
961    fn a_tls_section_is_understood_whether_or_not_the_feature_is_on() {
962        let config: ServerConfig = serde_json::from_str(
963            r#"{"sections":[{"application":"a","profile":"p","files":["f"]}],
964                "clients":[{"name":"c","token":"0123456789abcdef0123456789abcdef","applications":["a"]}],
965                "tls":{"certificate":"c.pem","key":"k.pem","client_ca":"ca.pem"}}"#,
966        )
967        .expect("the shape is complete");
968
969        let tls = config.tls.expect("the block is understood");
970
971        assert_eq!(tls.certificate, "c.pem");
972        assert_eq!(tls.client_ca.as_deref(), Some("ca.pem"));
973    }
974
975    /// Nothing above may have moved the plain-HTTP half of the matrix.
976    #[test]
977    fn without_tls_a_non_loopback_bind_still_needs_the_acknowledgement() {
978        let mut config = valid();
979        config.bind = "0.0.0.0:8080".to_owned();
980
981        assert!(matches!(
982            config.validate(),
983            Err(Refusal::ExposedBind { .. })
984        ));
985
986        config.insecure = true;
987        assert_eq!(config.validate(), Ok(()));
988    }
989
990    #[test]
991    fn ipv6_loopback_counts_as_loopback() {
992        let mut config = valid();
993        config.bind = "[::1]:8080".to_owned();
994
995        assert_eq!(config.validate(), Ok(()));
996    }
997
998    #[test]
999    fn a_hostname_is_refused_rather_than_resolved() {
1000        let mut config = valid();
1001        config.bind = "localhost:8080".to_owned();
1002
1003        assert_eq!(
1004            config.validate(),
1005            Err(Refusal::UnparsableBind {
1006                bind: "localhost:8080".to_owned()
1007            })
1008        );
1009    }
1010
1011    /// Refusals are printed at startup and end up in a log. None of them may
1012    /// carry a token there.
1013    #[test]
1014    fn no_refusal_prints_a_token() {
1015        let mut config = valid();
1016        config
1017            .clients
1018            .push(client("other", Some(GOOD), &["billing"]));
1019
1020        let refusal = config.validate().unwrap_err();
1021
1022        assert!(
1023            !refusal.to_string().contains(GOOD) && !format!("{refusal:?}").contains(GOOD),
1024            "a credential escaped through a refusal: {refusal}"
1025        );
1026    }
1027
1028    /// The stream ceiling has a default a fleet does not reach, and zero is
1029    /// a legal value rather than a refusal: it is how a deployment says it
1030    /// does not want long-lived connections at all.
1031    #[test]
1032    fn the_stream_ceiling_defaults_high_and_zero_is_a_valid_answer() {
1033        let config: ServerConfig = serde_json::from_str(
1034            r#"{"sections":[{"application":"a","profile":"p","files":["f"]}],
1035                "clients":[{"name":"c","token":"0123456789abcdef0123456789abcdef","applications":["a"]}]}"#,
1036        )
1037        .expect("the shape is complete");
1038
1039        assert_eq!(config.max_stream_connections, 4096);
1040        assert_eq!(config.validate(), Ok(()));
1041
1042        let mut off = config;
1043        off.max_stream_connections = 0;
1044        assert_eq!(off.validate(), Ok(()));
1045    }
1046
1047    #[test]
1048    fn a_key_the_server_does_not_know_is_refused() {
1049        let error = serde_json::from_str::<ServerConfig>(
1050            r#"{"sections":[],"clients":[],"allow_anonymou":true}"#,
1051        )
1052        .unwrap_err();
1053
1054        assert!(error.to_string().contains("unknown field"), "{error}");
1055    }
1056}