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", §ion.application),
290 ("profile", §ion.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| §ion.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}