The code was formatted by hand, and `cargo fmt --check` differed in 866 places. rustfmt.toml keeps the house's long lines (120) and short expressions on one line, which halves what changes; this commit is only what rustfmt does with it, and is named in .git-blame-ignore-revs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
195 lines
7.4 KiB
Rust
195 lines
7.4 KiB
Rust
//! 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,
|
|
#[serde(default)]
|
|
pub tokens: Vec<TokenCfg>,
|
|
}
|
|
|
|
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<Self> {
|
|
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<Self> {
|
|
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<Vec<Token>> {
|
|
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::<String>())
|
|
}
|
|
|
|
#[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");
|
|
}
|
|
}
|