//! The directory's config file: where it listens, where its database is, and the tokens it //! accepts. Tokens are only ever changed here, never over the API, so whoever can edit this //! file decides who gets in (#204). use anyhow::{Context, Result, bail}; use serde::Deserialize; use std::path::Path; #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] pub struct Config { #[serde(default = "default_bind")] pub bind: String, /// The environment variable holding the database URL, so the file holds no secret. #[serde(default = "default_database_url_env")] pub database_url_env: String, /// Where to serve /metrics for Prometheus (#224), apart from `bind`, which the tunnel /// publishes: `0.0.0.0:9188`. Empty serves none. #[serde(default)] pub metrics_bind: String, /// Apple Podcasts' top charts for this country (its two letters: `ca`, `us`) come into the /// directory each week, and Apple's categories are checked against ipx's (#252). Empty, or /// left out, does neither. #[serde(default)] pub apple_country: String, /// Internal addresses and networks the refresher may fetch feeds from all the same, as ipx's /// `fetch_internal` (#329). Empty, the default, fetches nothing off the public internet. #[serde(default)] pub fetch_internal: Vec, #[serde(default)] pub tokens: Vec, } fn default_bind() -> String { "0.0.0.0:8100".into() } fn default_database_url_env() -> String { "DIRECTORY_DATABASE_URL".into() } #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] pub struct TokenCfg { /// Who holds it: what its snapshot is filed under and what the log says, never the token. pub name: String, /// No default: a token whose access is missing or misspelled is refused when the file is /// read, rather than given some access nobody chose. pub access: Access, /// The token's SHA-256, in hex: what `ipx-directory token new` prints. The file holds no /// token, so adding or revoking one is an edit here and nothing else. pub token_sha256: String, } /// What a token may do, each level including the one before. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, serde::Serialize)] #[serde(rename_all = "lowercase")] pub enum Access { /// Browse. Ro, /// Browse and send snapshots. Rw, /// All that, and remove, block and refresh feeds and drop an instance's snapshot. Rwa, } /// A token as the server checks it. #[derive(Debug, Clone)] pub struct Token { pub name: String, pub access: Access, /// SHA-256 of the token, lower-case hex. pub sha256: String, } impl Config { pub fn load(path: &Path) -> Result { let text = std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?; Self::parse(&text).with_context(|| format!("in {}", path.display())) } pub fn parse(text: &str) -> Result { let cfg: Config = toml::from_str(text)?; let mut seen = std::collections::HashSet::new(); for t in &cfg.tokens { if t.name.trim().is_empty() { bail!("a token has an empty name"); } // Snapshots are filed by name: two instances under one name would overwrite each other. if !seen.insert(t.name.as_str()) { bail!("two tokens are named {:?}", t.name); } // Apple's charts are filed under it; a server of that name would replace them. if t.name == crate::apple::INSTANCE { bail!("{:?} is the name Apple Podcasts' charts are kept under; give the token another", t.name); } } let c = cfg.apple_country.trim(); if !c.is_empty() && !(c.len() == 2 && c.chars().all(|c| c.is_ascii_alphabetic())) { bail!("apple_country is a country's two letters, such as \"ca\" or \"us\", not {c:?}"); } Ok(cfg) } /// The tokens as the server checks them. A hash that is not 64 hex digits, or the same /// hash under two names, is an error: which name a request is would be ambiguous. pub fn tokens(&self) -> Result> { let mut seen = std::collections::HashSet::new(); self.tokens .iter() .map(|t| { let sha256 = t.token_sha256.trim().to_ascii_lowercase(); if sha256.len() != 64 || !sha256.bytes().all(|b| b.is_ascii_hexdigit()) { bail!("token {:?}: token_sha256 is not a SHA-256 in hex", t.name); } if !seen.insert(sha256.clone()) { bail!("token {:?} is the same token as another", t.name); } Ok(Token { name: t.name.clone(), access: t.access, sha256 }) }) .collect() } } /// A token's SHA-256 in hex, as the config file holds it. pub fn hash(token: &str) -> String { use sha2::Digest; sha2::Sha256::digest(token.trim().as_bytes()).iter().map(|b| format!("{b:02x}")).collect() } /// Equal without saying, by how long it took, how much of the guess was right. pub fn same_secret(a: &str, b: &str) -> bool { let (a, b) = (a.as_bytes(), b.as_bytes()); a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 } /// A new token: `ipxd_` and 256 random bits, the prefix so one found in a log says what it is. pub fn new_token() -> String { use std::io::Read; let mut bytes = [0u8; 32]; // Falling back to the clock would make a guessable token. Better to fail. std::fs::File::open("/dev/urandom") .and_then(|mut f| f.read_exact(&mut bytes)) .expect("no source of randomness for a token"); format!("ipxd_{}", bytes.iter().map(|b| format!("{b:02x}")).collect::()) } #[cfg(test)] mod tests { use super::*; const GOOD: &str = r#" [[tokens]] name = "tower" access = "rwa" token_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" [[tokens]] name = "friend" access = "ro" token_sha256 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" "#; #[test] fn tokens_carry_their_access_and_take_their_value_from_the_environment() { let cfg = Config::parse(GOOD).unwrap(); assert_eq!(cfg.bind, "0.0.0.0:8100"); let t = cfg.tokens().unwrap(); assert_eq!( (t[0].name.as_str(), t[0].access, t[0].sha256.as_str()), ("tower", Access::Rwa, "a".repeat(64).as_str()) ); assert_eq!(t[1].access, Access::Ro); assert!(Access::Ro < Access::Rw && Access::Rw < Access::Rwa); } #[test] fn a_missing_or_misspelled_access_a_reused_name_or_a_bad_hash_is_refused() { let h = "a".repeat(64); assert!(Config::parse(&format!("[[tokens]]\nname = \"a\"\ntoken_sha256 = \"{h}\"")).is_err()); assert!( Config::parse(&format!("[[tokens]]\nname = \"a\"\naccess = \"admin\"\ntoken_sha256 = \"{h}\"")).is_err() ); assert!(Config::parse(&GOOD.replace("\"friend\"", "\"tower\"")).is_err()); assert!(Config::parse(&GOOD.replace(&"b".repeat(64), "xyz")).unwrap().tokens().is_err()); assert!(Config::parse(&GOOD.replace(&"b".repeat(64), &h)).unwrap().tokens().is_err()); } #[test] fn secrets_compare_whole() { assert!(same_secret("abc", "abc")); assert!(!same_secret("abc", "abd")); assert!(!same_secret("abc", "abcd")); let t = new_token(); assert!(t.starts_with("ipxd_") && t.len() == 69 && t != new_token()); assert_eq!(hash("abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); } }