crabidy/crabidy-server/src/settings.rs

439 lines
18 KiB
Rust

//! Server-level configuration: `~/.config/crabidy/crabidy-server.toml`.
//!
//! Carries the enabled-providers list ([`ServerSettings::providers`]) and the
//! `[auth]` role hashes (architecture/roles-auth.md). The server writes a
//! default (all providers, no auth) on first start via
//! [`ServerSettings::ensure_default`]. The file is optional — a missing file
//! runs the server open with every provider — but a file that exists and does
//! not parse aborts startup: silently ignoring a broken auth config would run
//! an intended-to-be-locked server open (fail-closed, quality/roles-auth.md).
use std::path::Path;
use serde::{Deserialize, Serialize};
/// The server config file name inside the crabidy config directory.
pub const SETTINGS_FILE: &str = "crabidy-server.toml";
/// Every built-in provider, in the order the default config lists them. Each
/// name is a library root (`/tidal`, `/youtube`, `/fyyd`, `/fs`, `/crabidy`,
/// `/orphans`). `orphans` is a view over the store, so it needs `crabidy`.
pub const ALL_PROVIDERS: [&str; 6] = ["tidal", "youtube", "fyyd", "fs", "crabidy", "orphans"];
/// Contents of `crabidy-server.toml`.
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ServerSettings {
/// Enabled providers. The default config we write lists all of them
/// ([`ALL_PROVIDERS`]); remove a name to disable that provider. Absent —
/// the key deleted, or a fresh install with no file — enables all of them,
/// so a server never silently loses every provider (fail-open for the
/// library, unlike auth).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub providers: Option<Vec<String>>,
/// Role credentials; absent (or empty) means the server runs open.
#[serde(default, skip_serializing_if = "AuthSettings::is_default")]
pub auth: AuthSettings,
/// Audio output selection; absent means the system default device.
#[serde(default, skip_serializing_if = "AudioSettings::is_default")]
pub audio: AudioSettings,
}
/// Audio output configuration.
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct AudioSettings {
/// A substring of the output device's name (case-insensitive) to send
/// audio to. Absent uses the system default device — which on a Raspberry
/// Pi is often HDMI, so audio plays but is silent on the jack/DAC. List
/// the available names with `crabidy-server audio-devices`, then set e.g.
/// `device = "Headphones"`.
#[serde(skip_serializing_if = "Option::is_none")]
pub device: Option<String>,
}
impl AudioSettings {
/// Whether this is the empty default (no device pinned), so the
/// auto-written config omits the `[audio]` table entirely.
fn is_default(&self) -> bool {
self.device.is_none()
}
}
/// Which providers to mount, resolved from [`ServerSettings::providers`].
#[derive(Debug, Clone, Copy)]
pub struct ProviderToggles {
pub tidal: bool,
pub youtube: bool,
pub fyyd: bool,
pub fs: bool,
pub crabidy: bool,
pub orphans: bool,
}
impl ProviderToggles {
/// Every provider on — the default, and what a missing/keyless config
/// yields.
pub fn all() -> Self {
Self {
tidal: true,
youtube: true,
fyyd: true,
fs: true,
crabidy: true,
orphans: true,
}
}
}
/// One PHC password hash per role; a role without a hash cannot
/// authenticate. Generate hashes with `crabidy-server guard <role>`.
/// Hashes are not passwords, but the file should stay private anyway.
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct AuthSettings {
#[serde(skip_serializing_if = "Option::is_none")]
pub owner: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub queue_owner: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub queue_appender: Option<String>,
}
impl AuthSettings {
/// Whether any role is credentialed — the switch that turns
/// authentication on for every RPC.
pub fn enabled(&self) -> bool {
self.owner.is_some() || self.queue_owner.is_some() || self.queue_appender.is_some()
}
/// Whether this is the empty default (no roles) — so the auto-written
/// default config omits the `[auth]` table entirely.
fn is_default(&self) -> bool {
self.owner.is_none() && self.queue_owner.is_none() && self.queue_appender.is_none()
}
/// Enforces the guarding order: roles must be locked from the most
/// privileged down. Anonymous callers inherit the highest *unguarded*
/// role (architecture/roles-auth.md), so guarding a lower role while a
/// higher one is open is meaningless — the anonymous role would still
/// outrank it. Such a config is a mistake, not a subtle preference, so
/// it aborts startup rather than running with a surprising posture.
///
/// Valid guarded sets are prefixes of `[owner, queue_owner,
/// queue_appender]`: nothing, `owner`, `owner`+`queue_owner`, or all
/// three. `queue_owner` without `owner`, or `queue_appender` without
/// `queue_owner`, is rejected.
pub fn validate(&self) -> Result<(), String> {
if self.queue_owner.is_some() && self.owner.is_none() {
return Err(
"[auth] queue_owner is guarded but owner is not; guard roles from most to \
least privileged (owner, then queue_owner, then queue_appender)"
.to_string(),
);
}
if self.queue_appender.is_some() && self.queue_owner.is_none() {
return Err(
"[auth] queue_appender is guarded but queue_owner is not; guard roles from most \
to least privileged (owner, then queue_owner, then queue_appender)"
.to_string(),
);
}
Ok(())
}
}
impl ServerSettings {
/// Loads the settings from `config_dir`.
///
/// A missing file yields the defaults (auth disabled). An existing
/// file that cannot be read or parsed is an error — the caller
/// must abort startup rather than run open.
pub fn load(config_dir: &Path) -> Result<Self, String> {
let file = config_dir.join(SETTINGS_FILE);
let raw = match std::fs::read_to_string(&file) {
Ok(raw) => raw,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Ok(Self::default());
}
Err(err) => return Err(format!("cannot read {}: {err}", file.display())),
};
let settings: ServerSettings =
toml::from_str(&raw).map_err(|err| format!("invalid {}: {err}", file.display()))?;
// A broken guarding order is fail-closed like any other parse error:
// abort startup rather than run with a surprising auth posture.
settings
.auth
.validate()
.map_err(|err| format!("invalid {}: {err}", file.display()))?;
Ok(settings)
}
/// Whether the named provider is enabled: every provider when the
/// `providers` key is absent, otherwise only the names the list holds.
pub fn provider_enabled(&self, name: &str) -> bool {
self.providers
.as_ref()
.is_none_or(|list| list.iter().any(|p| p == name))
}
/// The per-provider mount decisions for the orchestrator. `orphans` also
/// requires `crabidy` (it is a view over the store); that dependency is
/// enforced where the store is built, so it is not folded in here.
pub fn provider_toggles(&self) -> ProviderToggles {
ProviderToggles {
tidal: self.provider_enabled("tidal"),
youtube: self.provider_enabled("youtube"),
fyyd: self.provider_enabled("fyyd"),
fs: self.provider_enabled("fs"),
crabidy: self.provider_enabled("crabidy"),
orphans: self.provider_enabled("orphans"),
}
}
/// Writes a default `crabidy-server.toml` — all providers enabled, no auth
/// — when none exists yet, so users have a full list to prune. A file that
/// already exists (even a pruned one) is left untouched. Best-effort: the
/// caller treats a write failure as a warning, not a startup error.
pub fn ensure_default(config_dir: &Path) -> Result<(), String> {
let file = config_dir.join(SETTINGS_FILE);
match std::fs::metadata(&file) {
Ok(_) => return Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => return Err(format!("cannot check {}: {err}", file.display())),
}
let settings = ServerSettings {
providers: Some(ALL_PROVIDERS.iter().map(|s| s.to_string()).collect()),
auth: AuthSettings::default(),
audio: AudioSettings::default(),
};
settings.store(config_dir)
}
/// Serializes the current settings back to `crabidy-server.toml` in
/// `config_dir`, creating the directory if missing.
///
/// The flat `[auth]` shape is preserved (each role a top-level key), so a
/// reload under `#[serde(deny_unknown_fields)]` still parses. Roles left
/// `None` are simply omitted — they cannot authenticate, exactly as a
/// missing key. The password never appears here (only the PHC hash).
pub fn store(&self, config_dir: &Path) -> Result<(), String> {
std::fs::create_dir_all(config_dir)
.map_err(|err| format!("cannot create {}: {err}", config_dir.display()))?;
let text = toml::to_string_pretty(self)
.map_err(|err| format!("cannot serialize server settings: {err}"))?;
let file = config_dir.join(SETTINGS_FILE);
std::fs::write(&file, text).map_err(|err| format!("cannot write {}: {err}", file.display()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn a_missing_file_disables_auth() {
let dir = TempDir::new().expect("tempdir");
let settings = ServerSettings::load(dir.path()).expect("defaults");
assert!(!settings.auth.enabled());
}
#[test]
fn hashes_load_and_enable_auth() {
let dir = TempDir::new().expect("tempdir");
std::fs::write(
dir.path().join(SETTINGS_FILE),
"[auth]\nowner = \"$argon2id$fake\"\n",
)
.expect("write");
let settings = ServerSettings::load(dir.path()).expect("parse");
assert!(settings.auth.enabled());
assert_eq!(settings.auth.owner.as_deref(), Some("$argon2id$fake"));
assert!(settings.auth.queue_owner.is_none());
assert!(settings.auth.queue_appender.is_none());
}
#[test]
fn validate_accepts_top_down_guarded_prefixes() {
// Every valid guarded set is a prefix of owner → queue_owner →
// queue_appender.
let owner = "$argon2id$o".to_string();
let qo = "$argon2id$qo".to_string();
let qa = "$argon2id$qa".to_string();
let cases = [
AuthSettings::default(),
AuthSettings {
owner: Some(owner.clone()),
queue_owner: None,
queue_appender: None,
},
AuthSettings {
owner: Some(owner.clone()),
queue_owner: Some(qo.clone()),
queue_appender: None,
},
AuthSettings {
owner: Some(owner),
queue_owner: Some(qo),
queue_appender: Some(qa),
},
];
for case in cases {
assert!(case.validate().is_ok(), "{case:?}");
}
}
#[test]
fn validate_rejects_a_role_guarded_below_an_open_one() {
// queue_owner without owner.
let broken = AuthSettings {
owner: None,
queue_owner: Some("$argon2id$qo".to_string()),
queue_appender: None,
};
assert!(broken.validate().is_err());
// queue_appender without queue_owner.
let broken = AuthSettings {
owner: Some("$argon2id$o".to_string()),
queue_owner: None,
queue_appender: Some("$argon2id$qa".to_string()),
};
assert!(broken.validate().is_err());
}
#[test]
fn a_broken_guarding_order_aborts_load() {
let dir = TempDir::new().expect("tempdir");
std::fs::write(
dir.path().join(SETTINGS_FILE),
"[auth]\nqueue_appender = \"$argon2id$fake\"\n",
)
.expect("write");
let err = ServerSettings::load(dir.path()).expect_err("must reject");
assert!(err.contains("crabidy-server.toml"), "{err}");
assert!(err.contains("queue_appender"), "{err}");
}
#[test]
fn store_sets_one_role_and_preserves_the_others() {
let dir = TempDir::new().expect("tempdir");
// Seed a file with two roles already set.
std::fs::write(
dir.path().join(SETTINGS_FILE),
"[auth]\nowner = \"$argon2id$owner\"\nqueue_owner = \"$argon2id$qo\"\n",
)
.expect("seed");
let mut settings = ServerSettings::load(dir.path()).expect("load");
// Set the third role and write it back.
settings.auth.queue_appender = Some("$argon2id$appender".to_string());
settings.store(dir.path()).expect("store");
// Reload: all three roles present, still parses under deny_unknown.
let reloaded = ServerSettings::load(dir.path()).expect("reload");
assert_eq!(reloaded.auth.owner.as_deref(), Some("$argon2id$owner"));
assert_eq!(reloaded.auth.queue_owner.as_deref(), Some("$argon2id$qo"));
assert_eq!(
reloaded.auth.queue_appender.as_deref(),
Some("$argon2id$appender")
);
}
#[test]
fn store_creates_the_config_dir_when_missing() {
let dir = TempDir::new().expect("tempdir");
let nested = dir.path().join("config").join("crabidy");
let mut settings = ServerSettings::default();
settings.auth.owner = Some("$argon2id$x".to_string());
settings.store(&nested).expect("store creates dir");
assert!(nested.join(SETTINGS_FILE).is_file());
}
#[test]
fn an_absent_providers_key_enables_everything() {
let settings = ServerSettings::default();
for provider in ALL_PROVIDERS {
assert!(settings.provider_enabled(provider), "{provider}");
}
let toggles = settings.provider_toggles();
assert!(
toggles.tidal && toggles.youtube && toggles.fs && toggles.crabidy && toggles.orphans
);
}
#[test]
fn an_explicit_list_enables_only_its_names() {
let dir = TempDir::new().expect("tempdir");
std::fs::write(
dir.path().join(SETTINGS_FILE),
"providers = [\"fs\", \"crabidy\"]\n",
)
.expect("write");
let settings = ServerSettings::load(dir.path()).expect("load");
assert!(settings.provider_enabled("fs") && settings.provider_enabled("crabidy"));
assert!(!settings.provider_enabled("tidal"));
assert!(!settings.provider_enabled("youtube"));
assert!(!settings.provider_enabled("orphans"));
let toggles = settings.provider_toggles();
assert!(toggles.fs && toggles.crabidy);
assert!(!toggles.tidal && !toggles.youtube && !toggles.orphans);
}
#[test]
fn ensure_default_writes_all_providers_and_never_clobbers() {
let dir = TempDir::new().expect("tempdir");
ServerSettings::ensure_default(dir.path()).expect("write default");
let text = std::fs::read_to_string(dir.path().join(SETTINGS_FILE)).expect("read");
for provider in ALL_PROVIDERS {
assert!(text.contains(provider), "default lists {provider}");
}
assert!(
!text.contains("[auth]"),
"no empty auth table in the default"
);
let reloaded = ServerSettings::load(dir.path()).expect("reload");
assert_eq!(
reloaded.providers.as_ref().map(Vec::len),
Some(ALL_PROVIDERS.len())
);
// A second call must not overwrite a file the user has since pruned.
std::fs::write(dir.path().join(SETTINGS_FILE), "providers = [\"fs\"]\n").expect("prune");
ServerSettings::ensure_default(dir.path()).expect("no-op");
let after = std::fs::read_to_string(dir.path().join(SETTINGS_FILE)).expect("read");
assert_eq!(after.trim(), "providers = [\"fs\"]");
}
#[test]
fn providers_and_auth_round_trip() {
let dir = TempDir::new().expect("tempdir");
std::fs::write(
dir.path().join(SETTINGS_FILE),
"providers = [\"tidal\", \"fs\"]\n[auth]\nowner = \"$argon2id$x\"\n",
)
.expect("seed");
let settings = ServerSettings::load(dir.path()).expect("load");
assert!(settings.auth.enabled());
settings.store(dir.path()).expect("store");
let reloaded = ServerSettings::load(dir.path()).expect("reload");
assert_eq!(
reloaded.providers,
Some(vec!["tidal".to_string(), "fs".to_string()])
);
assert_eq!(reloaded.auth.owner.as_deref(), Some("$argon2id$x"));
}
#[test]
fn a_broken_file_is_a_startup_error_not_an_open_server() {
let dir = TempDir::new().expect("tempdir");
for bad in [
"[auth\n",
"[auth]\nowner = 3\n",
"[auth]\nonwer = \"typo\"\n",
] {
std::fs::write(dir.path().join(SETTINGS_FILE), bad).expect("write");
let err = ServerSettings::load(dir.path()).expect_err(bad);
assert!(err.contains("crabidy-server.toml"), "{err}");
}
}
}