3bfd8f8 gave the refresher the guard with no exceptions, which broke
the browser suite: its directory refreshes fixture feeds on 127.0.0.1,
and five Directory tests waited on listings that never filled. The
cargo tests passed because none of them fetches over HTTP.
directory.toml takes fetch_internal as config.toml does, empty by
default; the suite's lists 127.0.0.1. Production's has none.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
199 lines
7.6 KiB
Rust
199 lines
7.6 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,
|
|
/// 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<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");
|
|
}
|
|
}
|