use std::fmt;
use std::net::SocketAddr;
use serde::Deserialize;
use crate::auth::{Token, MIN_TOKEN_LEN};
fn default_bind() -> String {
"127.0.0.1:8080".to_owned()
}
fn default_debounce_ms() -> u64 {
250
}
fn default_max_streams() -> usize {
4096
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SectionConfig {
pub application: String,
pub profile: String,
pub files: Vec<String>,
#[serde(default)]
pub env_prefix: Option<String>,
#[serde(default)]
pub whole_document: bool,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TlsConfig {
pub certificate: String,
pub key: String,
#[serde(default)]
pub client_ca: Option<String>,
#[serde(default)]
pub crl: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ClientConfig {
pub name: String,
#[serde(default)]
pub token: Option<Token>,
pub applications: Vec<String>,
}
impl ClientConfig {
#[must_use]
pub fn is_anonymous(&self) -> bool {
self.token.is_none()
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ServerConfig {
#[serde(default = "default_bind")]
pub bind: String,
#[serde(default)]
pub insecure: bool,
#[serde(default)]
pub tls: Option<TlsConfig>,
#[serde(default)]
pub allow_anonymous: bool,
#[serde(default = "default_debounce_ms")]
pub watch_debounce_ms: u64,
#[serde(default = "default_max_streams")]
pub max_stream_connections: usize,
pub sections: Vec<SectionConfig>,
pub clients: Vec<ClientConfig>,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
bind: default_bind(),
insecure: false,
tls: None,
allow_anonymous: false,
watch_debounce_ms: default_debounce_ms(),
max_stream_connections: default_max_streams(),
sections: Vec::new(),
clients: Vec::new(),
}
}
}
impl ServerConfig {
pub fn validate(&self) -> Result<(), Refusal> {
if self.sections.is_empty() {
return Err(Refusal::NoSections);
}
if self.clients.is_empty() {
return Err(Refusal::NoClients);
}
let mut seen = Vec::new();
for section in &self.sections {
for (part, value) in [
("application", §ion.application),
("profile", §ion.profile),
] {
if !crate::routes::is_name(value) {
return Err(Refusal::UnroutableSection {
application: section.application.clone(),
profile: section.profile.clone(),
part,
});
}
}
let pair = (section.application.as_str(), section.profile.as_str());
if seen.contains(&pair) {
return Err(Refusal::DuplicateSection {
application: section.application.clone(),
profile: section.profile.clone(),
});
}
seen.push(pair);
}
let mut names: Vec<&str> = Vec::new();
let mut anonymous = 0;
for client in &self.clients {
if names.contains(&client.name.as_str()) {
return Err(Refusal::DuplicateClient {
name: client.name.clone(),
});
}
names.push(&client.name);
match &client.token {
None => {
anonymous += 1;
if !self.allow_anonymous {
return Err(Refusal::AnonymousNotAllowed {
client: client.name.clone(),
});
}
if anonymous > 1 {
return Err(Refusal::SeveralAnonymousClients);
}
}
Some(token) if token.len() < MIN_TOKEN_LEN => {
return Err(Refusal::WeakToken {
client: client.name.clone(),
});
}
Some(_) => {}
}
for application in &client.applications {
if !self
.sections
.iter()
.any(|section| §ion.application == application)
{
return Err(Refusal::UnservedGrant {
client: client.name.clone(),
application: application.clone(),
});
}
}
}
for (index, client) in self.clients.iter().enumerate() {
let Some(token) = &client.token else { continue };
for other in self.clients.iter().skip(index + 1) {
if other.token.as_ref().is_some_and(|it| token.same_as(it)) {
return Err(Refusal::DuplicateToken);
}
}
}
let address = self
.bind
.parse::<SocketAddr>()
.map_err(|_| Refusal::UnparsableBind {
bind: self.bind.clone(),
})?;
match &self.tls {
Some(tls) => {
if !cfg!(feature = "tls") {
return Err(Refusal::TlsUnsupported);
}
if tls.crl.is_some() {
return Err(Refusal::RevocationUnsupported);
}
if tls.certificate.trim().is_empty() {
return Err(Refusal::TlsPathMissing { key: "certificate" });
}
if tls.key.trim().is_empty() {
return Err(Refusal::TlsPathMissing { key: "key" });
}
if tls
.client_ca
.as_ref()
.is_some_and(|it| it.trim().is_empty())
{
return Err(Refusal::TlsPathMissing { key: "client_ca" });
}
if self.insecure {
return Err(Refusal::InsecureWithTls);
}
}
None => {
if !address.ip().is_loopback() && !self.insecure {
return Err(Refusal::ExposedBind {
bind: self.bind.clone(),
});
}
}
}
Ok(())
}
pub fn address(&self) -> Result<SocketAddr, Refusal> {
self.bind
.parse::<SocketAddr>()
.map_err(|_| Refusal::UnparsableBind {
bind: self.bind.clone(),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Refusal {
NoSections,
NoClients,
DuplicateSection {
application: String,
profile: String,
},
UnroutableSection {
application: String,
profile: String,
part: &'static str,
},
DuplicateClient {
name: String,
},
DuplicateToken,
WeakToken {
client: String,
},
AnonymousNotAllowed {
client: String,
},
SeveralAnonymousClients,
UnservedGrant {
client: String,
application: String,
},
ExposedBind {
bind: String,
},
UnparsableBind {
bind: String,
},
TlsUnsupported,
InsecureWithTls,
TlsPathMissing {
key: &'static str,
},
RevocationUnsupported,
}
impl fmt::Display for Refusal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NoSections => {
f.write_str("no `sections` are configured: this server would serve nothing at all")
}
Self::NoClients => f.write_str(
"no `clients` are configured: nothing could ever be read. Add a client with \
a `token` and the `applications` it may read, or an anonymous one with \
`allow_anonymous = true`",
),
Self::DuplicateSection {
application,
profile,
} => write!(
f,
"two `sections` claim `{application}`/`{profile}`; one application and \
profile is served by exactly one section"
),
Self::UnroutableSection {
application,
profile,
part,
} => write!(
f,
"the section `{application}`/`{profile}` has a `{part}` no request can \
name: a path segment is up to 64 characters, starts with a letter or a \
digit, and carries only letters, digits, `.`, `_` and `-`. The server \
would start, report ready and answer `404` for that section forever"
),
Self::DuplicateClient { name } => {
write!(f, "two `clients` are named `{name}`; names identify a caller in the audit log and must be unique")
}
Self::DuplicateToken => f.write_str(
"two `clients` share a `token`; the first listed would silently win every \
request and the audit log would name the wrong caller",
),
Self::WeakToken { client } => write!(
f,
"the `token` for client `{client}` is shorter than {MIN_TOKEN_LEN} characters"
),
Self::AnonymousNotAllowed { client } => write!(
f,
"client `{client}` has no `token`, which makes it the anonymous caller; set \
`allow_anonymous = true` to say that is intended, or give it a token"
),
Self::SeveralAnonymousClients => f.write_str(
"more than one client has no `token`; there is one anonymous caller, so it \
can have only one set of grants",
),
Self::UnservedGrant {
client,
application,
} => write!(
f,
"client `{client}` is granted `{application}`, which no section serves; a \
grant that matches nothing is a typo that reads as a working deployment"
),
Self::ExposedBind { bind } => write!(
f,
"`bind` is `{bind}`, which is not loopback, and this server is terminating no \
TLS: that would put configuration — secrets included — on the network in the \
clear. Terminate TLS here with a `[server.tls]` section, or put a terminator \
in front of it and set `insecure = true` to say so, or bind loopback"
),
Self::UnparsableBind { bind } => write!(
f,
"`bind` is `{bind}`, which is not a literal `address:port`; a hostname is \
refused rather than resolved"
),
Self::TlsUnsupported => f.write_str(
"`[server.tls]` is configured, but this binary was built without the `tls` \
feature and contains no TLS at all. Rebuild it with `--features tls`, or \
remove `[server.tls]` and put a terminator in front",
),
Self::InsecureWithTls => f.write_str(
"`insecure = true` is set and `[server.tls]` is configured. `insecure` \
acknowledges that this server's own socket is unencrypted, which is no longer \
true — remove it, so that removing the TLS section later refuses again \
instead of quietly serving in the clear",
),
Self::TlsPathMissing { key } => {
write!(f, "`tls.{key}` is empty; it has to name a PEM file")
}
Self::RevocationUnsupported => f.write_str(
"`tls.crl` is configured, but this server checks no certificate revocation and \
will not pretend to. A CRL whose `nextUpdate` has passed is accepted silently \
by default, so the list would stop being true the moment it stopped being \
refreshed and nothing would report it; the one setting that refuses a stale \
list refuses every client along with it, which turns a publishing hiccup into \
an outage for every service at once. Remove the key. Issue short-lived client \
certificates, and revoke the `token` — delete the client's line and restart — \
which is the credential that actually authorises here",
),
}
}
}
impl std::error::Error for Refusal {}
#[cfg(test)]
mod tests {
use super::*;
fn section(application: &str, profile: &str) -> SectionConfig {
SectionConfig {
application: application.to_owned(),
profile: profile.to_owned(),
files: vec!["config.toml".to_owned()],
env_prefix: None,
whole_document: false,
}
}
fn client(name: &str, token: Option<&str>, applications: &[&str]) -> ClientConfig {
ClientConfig {
name: name.to_owned(),
token: token.map(Token::new),
applications: applications.iter().map(|it| (*it).to_owned()).collect(),
}
}
const GOOD: &str = "0123456789abcdef0123456789abcdef";
const OTHER: &str = "fedcba9876543210fedcba9876543210";
fn valid() -> ServerConfig {
ServerConfig {
sections: vec![section("billing", "prod")],
clients: vec![client("billing-pod", Some(GOOD), &["billing"])],
..ServerConfig::default()
}
}
#[test]
fn a_complete_configuration_starts() {
assert_eq!(valid().validate(), Ok(()));
}
#[test]
fn an_empty_roster_is_refused_at_both_ends() {
let mut config = valid();
config.sections.clear();
assert_eq!(config.validate(), Err(Refusal::NoSections));
let mut config = valid();
config.clients.clear();
assert_eq!(config.validate(), Err(Refusal::NoClients));
}
#[test]
fn a_duplicate_section_is_refused_but_two_profiles_are_not() {
let mut config = valid();
config.sections.push(section("billing", "prod"));
assert_eq!(
config.validate(),
Err(Refusal::DuplicateSection {
application: "billing".to_owned(),
profile: "prod".to_owned(),
})
);
let mut config = valid();
config.sections.push(section("billing", "staging"));
assert_eq!(config.validate(), Ok(()));
}
#[test]
fn a_section_no_route_could_name_is_refused_at_startup() {
for (part, application, profile) in [
("application", "billing api", "prod"),
("profile", "billing", ".hidden"),
("application", "", "prod"),
("profile", "billing", "../etc"),
] {
let mut config = valid();
config.sections = vec![section(application, profile)];
config.clients = vec![client("pod", Some(GOOD), &[application])];
assert_eq!(
config.validate(),
Err(Refusal::UnroutableSection {
application: application.to_owned(),
profile: profile.to_owned(),
part,
}),
"`{application}`/`{profile}` must be refused"
);
}
let mut config = valid();
config.sections = vec![section("billing-api.v2", "prod_1")];
config.clients = vec![client("pod", Some(GOOD), &["billing-api.v2"])];
assert_eq!(config.validate(), Ok(()));
let mut config = valid();
let long = "a".repeat(65);
config.sections = vec![section(&long, "prod")];
config.clients = vec![client("pod", Some(GOOD), &[&long])];
assert!(matches!(
config.validate(),
Err(Refusal::UnroutableSection { .. })
));
}
#[test]
fn duplicate_client_names_and_tokens_are_refused() {
let mut config = valid();
config
.clients
.push(client("billing-pod", Some(OTHER), &["billing"]));
assert_eq!(
config.validate(),
Err(Refusal::DuplicateClient {
name: "billing-pod".to_owned()
})
);
let mut config = valid();
config
.clients
.push(client("other", Some(GOOD), &["billing"]));
assert_eq!(config.validate(), Err(Refusal::DuplicateToken));
}
#[test]
fn a_short_token_is_refused() {
let mut config = valid();
config.clients = vec![client("billing-pod", Some("short"), &["billing"])];
assert_eq!(
config.validate(),
Err(Refusal::WeakToken {
client: "billing-pod".to_owned()
})
);
}
#[test]
fn anonymous_access_needs_an_explicit_opt_in() {
let mut config = valid();
config.clients = vec![client("anonymous", None, &["billing"])];
assert_eq!(
config.validate(),
Err(Refusal::AnonymousNotAllowed {
client: "anonymous".to_owned()
})
);
config.allow_anonymous = true;
assert_eq!(config.validate(), Ok(()));
config.clients.push(client("also", None, &["billing"]));
assert_eq!(config.validate(), Err(Refusal::SeveralAnonymousClients));
}
#[test]
fn a_grant_nothing_serves_is_refused() {
let mut config = valid();
config.clients = vec![client("billing-pod", Some(GOOD), &["biling"])];
assert_eq!(
config.validate(),
Err(Refusal::UnservedGrant {
client: "billing-pod".to_owned(),
application: "biling".to_owned(),
})
);
}
#[test]
fn a_non_loopback_bind_is_refused_without_the_flag() {
let mut config = valid();
config.bind = "0.0.0.0:8080".to_owned();
let refusal = config.validate().unwrap_err();
assert_eq!(
refusal,
Refusal::ExposedBind {
bind: "0.0.0.0:8080".to_owned()
}
);
assert!(
refusal.to_string().contains("insecure"),
"the refusal has to name the key that fixes it: {refusal}"
);
config.insecure = true;
assert_eq!(config.validate(), Ok(()));
}
fn tls(client_ca: Option<&str>) -> TlsConfig {
TlsConfig {
certificate: "/etc/tls/server.pem".to_owned(),
key: "/etc/tls/server.key".to_owned(),
client_ca: client_ca.map(ToOwned::to_owned),
crl: None,
}
}
#[cfg(feature = "tls")]
#[test]
fn tls_is_the_acknowledgement_a_non_loopback_bind_needs() {
let mut config = valid();
config.bind = "0.0.0.0:8443".to_owned();
config.tls = Some(tls(None));
assert_eq!(config.validate(), Ok(()));
}
#[cfg(feature = "tls")]
#[test]
fn insecure_and_tls_together_are_a_contradiction_rather_than_a_no_op() {
let mut config = valid();
config.bind = "0.0.0.0:8443".to_owned();
config.tls = Some(tls(Some("/etc/tls/ca.pem")));
config.insecure = true;
let refusal = config.validate().unwrap_err();
assert_eq!(refusal, Refusal::InsecureWithTls);
assert!(refusal.to_string().contains("insecure"), "{refusal}");
}
#[cfg(feature = "tls")]
#[test]
fn a_tls_section_that_names_no_file_is_refused_per_key() {
for (key, mut broken) in [
("certificate", tls(None)),
("key", tls(None)),
("client_ca", tls(Some(""))),
] {
match key {
"certificate" => broken.certificate = String::new(),
"key" => broken.key = " ".to_owned(),
_ => {}
}
let mut config = valid();
config.tls = Some(broken);
assert_eq!(config.validate(), Err(Refusal::TlsPathMissing { key }));
}
}
#[cfg(feature = "tls")]
#[test]
fn a_crl_is_refused_and_the_refusal_names_the_credential_that_can_be_revoked() {
let mut config = valid();
let mut with_crl = tls(Some("/etc/tls/ca.pem"));
with_crl.crl = Some("/etc/tls/clients.crl".to_owned());
config.tls = Some(with_crl);
let refusal = config.validate().unwrap_err();
assert_eq!(refusal, Refusal::RevocationUnsupported);
let rendered = refusal.to_string();
assert!(rendered.contains("`tls.crl`"), "{rendered}");
assert!(rendered.contains("token"), "{rendered}");
assert!(rendered.contains("short-lived"), "{rendered}");
}
#[test]
fn a_crl_key_parses_so_that_the_refusal_can_explain_rather_than_serde() {
let config: ServerConfig = serde_json::from_str(
r#"{"sections":[{"application":"a","profile":"p","files":["f"]}],
"clients":[{"name":"c","token":"0123456789abcdef0123456789abcdef","applications":["a"]}],
"tls":{"certificate":"c.pem","key":"k.pem","crl":"clients.crl"}}"#,
)
.expect("the key is understood, not unknown");
assert_eq!(
config.tls.expect("the block parsed").crl.as_deref(),
Some("clients.crl")
);
}
#[cfg(not(feature = "tls"))]
#[test]
fn a_build_without_the_feature_refuses_a_tls_section() {
let mut config = valid();
config.tls = Some(tls(None));
let refusal = config.validate().unwrap_err();
assert_eq!(refusal, Refusal::TlsUnsupported);
assert!(refusal.to_string().contains("--features tls"), "{refusal}");
}
#[test]
fn a_tls_section_is_understood_whether_or_not_the_feature_is_on() {
let config: ServerConfig = serde_json::from_str(
r#"{"sections":[{"application":"a","profile":"p","files":["f"]}],
"clients":[{"name":"c","token":"0123456789abcdef0123456789abcdef","applications":["a"]}],
"tls":{"certificate":"c.pem","key":"k.pem","client_ca":"ca.pem"}}"#,
)
.expect("the shape is complete");
let tls = config.tls.expect("the block is understood");
assert_eq!(tls.certificate, "c.pem");
assert_eq!(tls.client_ca.as_deref(), Some("ca.pem"));
}
#[test]
fn without_tls_a_non_loopback_bind_still_needs_the_acknowledgement() {
let mut config = valid();
config.bind = "0.0.0.0:8080".to_owned();
assert!(matches!(
config.validate(),
Err(Refusal::ExposedBind { .. })
));
config.insecure = true;
assert_eq!(config.validate(), Ok(()));
}
#[test]
fn ipv6_loopback_counts_as_loopback() {
let mut config = valid();
config.bind = "[::1]:8080".to_owned();
assert_eq!(config.validate(), Ok(()));
}
#[test]
fn a_hostname_is_refused_rather_than_resolved() {
let mut config = valid();
config.bind = "localhost:8080".to_owned();
assert_eq!(
config.validate(),
Err(Refusal::UnparsableBind {
bind: "localhost:8080".to_owned()
})
);
}
#[test]
fn no_refusal_prints_a_token() {
let mut config = valid();
config
.clients
.push(client("other", Some(GOOD), &["billing"]));
let refusal = config.validate().unwrap_err();
assert!(
!refusal.to_string().contains(GOOD) && !format!("{refusal:?}").contains(GOOD),
"a credential escaped through a refusal: {refusal}"
);
}
#[test]
fn the_stream_ceiling_defaults_high_and_zero_is_a_valid_answer() {
let config: ServerConfig = serde_json::from_str(
r#"{"sections":[{"application":"a","profile":"p","files":["f"]}],
"clients":[{"name":"c","token":"0123456789abcdef0123456789abcdef","applications":["a"]}]}"#,
)
.expect("the shape is complete");
assert_eq!(config.max_stream_connections, 4096);
assert_eq!(config.validate(), Ok(()));
let mut off = config;
off.max_stream_connections = 0;
assert_eq!(off.validate(), Ok(()));
}
#[test]
fn a_key_the_server_does_not_know_is_refused() {
let error = serde_json::from_str::<ServerConfig>(
r#"{"sections":[],"clients":[],"allow_anonymou":true}"#,
)
.unwrap_err();
assert!(error.to_string().contains("unknown field"), "{error}");
}
}