439 lines
18 KiB
Rust
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}");
|
|
}
|
|
}
|
|
}
|