From 38297c1f4240922b5f5c3cc2ab3560d028deb35e Mon Sep 17 00:00:00 2001 From: Phil Denhoff Date: Tue, 20 Oct 2026 19:13:04 +0000 Subject: [PATCH 1/3] feat(opds): add a headless host using the current sharing runtime --- Cargo.lock | 37 ++ Cargo.toml | 1 + crates/citadel-opds/src/network.rs | 85 +++++ crates/citadel-opds/src/service/mod.rs | 14 +- crates/citadel-server/Cargo.toml | 20 ++ crates/citadel-server/example-config.toml | 13 + crates/citadel-server/src/lib.rs | 315 ++++++++++++++++++ crates/citadel-server/src/main.rs | 18 + .../citadel-server/tests/headless_process.rs | 213 ++++++++++++ docs/headless-server.md | 41 +++ src/bindings.ts | 2 +- 11 files changed, 757 insertions(+), 2 deletions(-) create mode 100644 crates/citadel-server/Cargo.toml create mode 100644 crates/citadel-server/example-config.toml create mode 100644 crates/citadel-server/src/lib.rs create mode 100644 crates/citadel-server/src/main.rs create mode 100644 crates/citadel-server/tests/headless_process.rs create mode 100644 docs/headless-server.md diff --git a/Cargo.lock b/Cargo.lock index 6542d3c6..d653324d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -607,6 +607,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "chacha20" version = "0.10.0" @@ -724,6 +730,22 @@ dependencies = [ "zip 0.6.6", ] +[[package]] +name = "citadel-server" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "chrono", + "citadel-opds", + "libcalibre", + "nix", + "reqwest 0.12.28", + "serde", + "tempfile", + "tokio", + "toml 0.9.12+spec-1.1.0", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -1651,6 +1673,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -3032,6 +3055,18 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nom" version = "8.0.0" @@ -4016,7 +4051,9 @@ dependencies = [ "base64 0.22.1", "bytes", "encoding_rs", + "futures-channel", "futures-core", + "futures-util", "h2", "http", "http-body", diff --git a/Cargo.toml b/Cargo.toml index 1faf32d4..aa2b0853 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ resolver = "2" members = [ "crates/citadel-core", "crates/citadel-opds", + "crates/citadel-server", "crates/libcalibre", "src-tauri", ] diff --git a/crates/citadel-opds/src/network.rs b/crates/citadel-opds/src/network.rs index 94c3c7f7..ab0464cc 100644 --- a/crates/citadel-opds/src/network.rs +++ b/crates/citadel-opds/src/network.rs @@ -287,6 +287,7 @@ impl WaitingReason { pub enum OpdsBindTarget { LocalNetworks, AllInterfaces, + Addresses { addresses: Vec }, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -309,6 +310,15 @@ pub(crate) fn plan_bindings( policy: BindPolicy, ) -> BindPlan { match target { + OpdsBindTarget::Addresses { addresses } => match explicit_addresses(addresses, policy) { + Ok(addresses) => BindPlan::Listen( + addresses + .into_iter() + .map(|address| SocketAddr::new(address, port)) + .collect(), + ), + Err(_) => BindPlan::Wait(WaitingReason::NoLocalInterface), + }, OpdsBindTarget::AllInterfaces => { let mut sockets = BTreeSet::new(); sockets.insert(SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), port)); @@ -634,3 +644,78 @@ mod tests { } } } + +/// Validate explicit hosts with the same exposure policy as desktop sharing. +pub fn validate_explicit_addresses( + addresses: &[String], + authenticated: bool, +) -> Result<(), String> { + explicit_addresses( + addresses, + BindPolicy { + allow_global: authenticated, + }, + ) + .map(|_| ()) +} + +fn explicit_addresses(addresses: &[String], policy: BindPolicy) -> Result, String> { + if addresses.is_empty() { + return Err("At least one listener address is required.".into()); + } + addresses + .iter() + .map(|address| { + let address: IpAddr = address + .parse() + .map_err(|_| "Invalid listener address.".to_string())?; + let scope = match address { + IpAddr::V4(a) => ipv4_scope(a), + IpAddr::V6(a) => ipv6_scope(a), + }; + match scope { + AddressScope::Loopback | AddressScope::Private | AddressScope::Shared => { + Ok(address) + } + AddressScope::Global if policy.allow_global => Ok(address), + AddressScope::Global => { + Err("Public listener addresses require reader sign-in.".into()) + } + _ => Err( + "Wildcard, link-local and non-unicast listener addresses are not allowed." + .into(), + ), + } + }) + .collect() +} + +#[cfg(test)] +mod explicit_tests { + use super::*; + #[test] + fn explicit_hosts_enforce_exposure_policy() { + for address in [ + "0.0.0.0", + "::", + "fe80::1", + "169.254.1.1", + "224.0.0.1", + "bad", + ] { + assert!( + validate_explicit_addresses(&[address.into()], true).is_err(), + "{address}" + ); + } + assert!(validate_explicit_addresses(&[], true).is_err()); + for address in ["127.0.0.1", "::1", "192.168.1.2", "fd00::1"] { + assert!( + validate_explicit_addresses(&[address.into()], false).is_ok(), + "{address}" + ); + } + assert!(validate_explicit_addresses(&["8.8.8.8".into()], false).is_err()); + assert!(validate_explicit_addresses(&["8.8.8.8".into()], true).is_ok()); + } +} diff --git a/crates/citadel-opds/src/service/mod.rs b/crates/citadel-opds/src/service/mod.rs index 5b173c6d..ccd40b37 100644 --- a/crates/citadel-opds/src/service/mod.rs +++ b/crates/citadel-opds/src/service/mod.rs @@ -668,6 +668,14 @@ impl OpdsService { let policy = BindPolicy { allow_global: stored.is_some(), }; + if let OpdsBindTarget::Addresses { addresses } = &config.target { + super::network::validate_explicit_addresses(addresses, policy.allow_global).map_err( + |message| OpdsStatusError { + code: OpdsErrorCode::ConfigurationConflict, + message, + }, + )?; + } if matches!(config.target, OpdsBindTarget::AllInterfaces) && !policy.allow_global { // AllInterfaces serves every network the computer can reach; it // exists to be paired with credentials. @@ -796,6 +804,10 @@ async fn attempt_bind( auth: OpdsBasicAuth, ) -> BindOutcome { let desired = match target { + OpdsBindTarget::Addresses { .. } => match plan_bindings(&[], target, port, policy) { + super::network::BindPlan::Listen(sockets) => sockets, + super::network::BindPlan::Wait(reason) => return BindOutcome::Waiting(reason), + }, OpdsBindTarget::AllInterfaces => { let mut sockets = BTreeSet::new(); sockets.insert(SocketAddr::new( @@ -1371,7 +1383,7 @@ mod tests { // Both sockets answer; then both shut down cleanly. let _ = std::net::TcpStream::connect(("127.0.0.1", port)).unwrap(); - drop((v4, v6)); + Listeners(vec![v4, v6]).drain().await; let probe2 = StdTcpListener::bind((Ipv4Addr::LOCALHOST, port)).unwrap(); drop(probe2); } diff --git a/crates/citadel-server/Cargo.toml b/crates/citadel-server/Cargo.toml new file mode 100644 index 00000000..7c0b6c8a --- /dev/null +++ b/crates/citadel-server/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "citadel-server" +version = "0.1.0" +edition = "2021" +rust-version.workspace = true +description = "Headless Citadel OPDS server" + +[dependencies] +chrono = "0.4.31" +citadel-opds = { path = "../citadel-opds" } +libcalibre = { path = "../libcalibre" } +serde = { version = "1.0", features = ["derive"] } +tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread", "signal", "time"] } +toml = "0.9" + +[dev-dependencies] +base64 = "0.22" +nix = { version = "0.30", features = ["signal"] } +reqwest = { version = "0.12", features = ["blocking"] } +tempfile = "3.8" diff --git a/crates/citadel-server/example-config.toml b/crates/citadel-server/example-config.toml new file mode 100644 index 00000000..e6dc7991 --- /dev/null +++ b/crates/citadel-server/example-config.toml @@ -0,0 +1,13 @@ +libraryPath = "/srv/calibre-library" +stateDirectory = "/var/lib/citadel" + +[sharing] +port = 8080 +authenticationEnabled = true + +[sharing.target.addresses] +addresses = ["127.0.0.1"] + +[credentials] +username = "reader" +passwordEnvironment = "CITADEL_OPDS_PASSWORD" diff --git a/crates/citadel-server/src/lib.rs b/crates/citadel-server/src/lib.rs new file mode 100644 index 00000000..2aad2a17 --- /dev/null +++ b/crates/citadel-server/src/lib.rs @@ -0,0 +1,315 @@ +use std::{ + ffi::OsString, + path::{Path, PathBuf}, + sync::{Arc, Mutex}, + time::Duration, +}; + +use chrono::NaiveDateTime; +use citadel_opds::{ + CatalogSource, OpdsLifecycleState, OpdsService, OpdsServiceStatus, OpdsStartConfig, +}; +use libcalibre::{BookId, BookPage, CalibreError, Library, ResolvedBookAsset}; +use serde::Deserialize; + +const CREDENTIAL_FILENAME: &str = "opds-credentials.json"; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ServerConfig { + pub library_path: PathBuf, + pub state_directory: PathBuf, + pub sharing: OpdsStartConfig, + pub credentials: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CredentialInput { + pub username: String, + pub password_environment: String, +} + +pub struct CalibreCatalogSource { + library: Mutex, +} + +impl CalibreCatalogSource { + pub fn open(library_path: &Path) -> Result { + let library_path = library_path + .to_str() + .ok_or_else(|| "libraryPath must be valid UTF-8".to_string())?; + let database = libcalibre::util::get_db_path(library_path) + .ok_or_else(|| format!("libraryPath is not a Calibre library: {library_path}"))?; + let library = Library::new(database) + .map_err(|error| format!("could not open libraryPath: {error}"))?; + Ok(Self { + library: Mutex::new(library), + }) + } +} + +impl CatalogSource for CalibreCatalogSource { + fn active_library_id(&self) -> Result { + self.library + .lock() + .expect("server library mutex poisoned") + .library_uuid() + } + + fn book_page( + &self, + limit: i64, + offset: i64, + ) -> Result<(String, Option, BookPage), CalibreError> { + let mut library = self.library.lock().expect("server library mutex poisoned"); + let library_id = library.library_uuid()?; + let updated_at = library.catalog_updated_at()?; + let page = library.query_acquirable_books(limit, offset)?; + Ok((library_id, updated_at, page)) + } + + fn book_file(&self, book_id: BookId, format: &str) -> Result { + self.library + .lock() + .expect("server library mutex poisoned") + .resolve_book_file(book_id, format) + } + + fn book_cover(&self, book_id: BookId) -> Result { + self.library + .lock() + .expect("server library mutex poisoned") + .resolve_book_cover(book_id) + } +} + +pub fn config_path_from_args(args: I) -> Result +where + I: IntoIterator, + S: Into, +{ + let mut args = args.into_iter().map(Into::into); + let program = args + .next() + .unwrap_or_else(|| OsString::from("citadel-server")); + let usage = || format!("usage: {} --config ", program.to_string_lossy()); + match (args.next(), args.next(), args.next()) { + (Some(flag), Some(path), None) if flag == "--config" => Ok(PathBuf::from(path)), + _ => Err(usage()), + } +} + +pub fn load_config(path: &Path) -> Result { + let bytes = std::fs::read(path) + .map_err(|error| format!("could not read config {}: {error}", path.display()))?; + let text = std::str::from_utf8(&bytes) + .map_err(|_| format!("config {} is not valid UTF-8", path.display()))?; + let config: ServerConfig = toml::from_str(text) + .map_err(|error| format!("could not parse config {}: {error}", path.display()))?; + validate_config(&config)?; + Ok(config) +} + +fn validate_config(config: &ServerConfig) -> Result<(), String> { + if !config.library_path.is_absolute() { + return Err("libraryPath must be absolute".to_string()); + } + if !config.state_directory.is_absolute() { + return Err("stateDirectory must be absolute".to_string()); + } + if config.sharing.port == 0 || config.sharing.port > u32::from(u16::MAX) { + return Err("sharing.port must be between 1 and 65535".to_string()); + } + if let citadel_opds::OpdsBindTarget::Addresses { addresses } = &config.sharing.target { + citadel_opds::network::validate_explicit_addresses( + addresses, + config.sharing.authentication_enabled, + )?; + } + + if let Some(credentials) = &config.credentials { + if credentials.username.trim().is_empty() { + return Err("credentials.username must not be empty".to_string()); + } + if credentials.password_environment.trim().is_empty() { + return Err("credentials.passwordEnvironment must not be empty".to_string()); + } + } + Ok(()) +} + +pub async fn run(config: ServerConfig) -> Result<(), String> { + validate_config(&config)?; + std::fs::create_dir_all(&config.state_directory).map_err(|error| { + format!( + "could not create stateDirectory {}: {error}", + config.state_directory.display() + ) + })?; + let source = Arc::new(CalibreCatalogSource::open(&config.library_path)?); + let service = OpdsService::new(source, config.state_directory.join(CREDENTIAL_FILENAME)) + .map_err(|error| format!("could not load OPDS credentials: {error}"))?; + + if let Some(credentials) = config.credentials { + let password = std::env::var_os(&credentials.password_environment).ok_or_else(|| { + format!( + "credential environment variable {} is not set", + credentials.password_environment + ) + })?; + let password = password.into_string().map_err(|_| { + format!( + "credential environment variable {} is not valid UTF-8", + credentials.password_environment + ) + })?; + service + .configure_credentials(credentials.username, password) + .map_err(|error| error.message)?; + } + + let initial = service + .start(config.sharing) + .await + .map_err(|error| error.message)?; + log_status(&initial); + if is_fatal_startup(&initial) { + return Err(initial + .error + .map(|error| error.message) + .unwrap_or_else(|| "OPDS failed to start".to_string())); + } + + let mut last_status = initial; + let mut interval = tokio::time::interval(Duration::from_secs(2)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + interval.tick().await; + let mut shutdown = Box::pin(shutdown_signal()); + loop { + tokio::select! { + result = &mut shutdown => { + result?; + break; + } + _ = interval.tick() => { + let status = service.status().await; + if status != last_status { + log_status(&status); + last_status = status; + } + } + } + } + + let stopped = service.stop().await; + log_status(&stopped); + Ok(()) +} + +fn is_fatal_startup(status: &OpdsServiceStatus) -> bool { + matches!( + status.state, + OpdsLifecycleState::Error | OpdsLifecycleState::Stopped + ) +} + +fn log_status(status: &OpdsServiceStatus) { + let urls = if status.urls.is_empty() { + "-".to_string() + } else { + status.urls.join(",") + }; + let error_code = status + .error + .as_ref() + .map(|error| format!("{:?}", error.code)) + .unwrap_or_else(|| "-".to_string()); + let message = status + .error + .as_ref() + .map(|error| format!("{:?}", error.message)) + .unwrap_or_else(|| "-".to_string()); + println!( + "citadel_server state={:?} port={} urls={} error={} message={}", + status.state, + status + .config + .as_ref() + .map(|config| config.port.to_string()) + .unwrap_or_else(|| "-".to_string()), + urls, + error_code, + message + ); +} + +#[cfg(unix)] +async fn shutdown_signal() -> Result<(), String> { + use tokio::signal::unix::{signal, SignalKind}; + + let mut interrupt = signal(SignalKind::interrupt()) + .map_err(|error| format!("could not install SIGINT handler: {error}"))?; + let mut terminate = signal(SignalKind::terminate()) + .map_err(|error| format!("could not install SIGTERM handler: {error}"))?; + tokio::select! { + _ = interrupt.recv() => Ok(()), + _ = terminate.recv() => Ok(()), + } +} + +#[cfg(not(unix))] +async fn shutdown_signal() -> Result<(), String> { + tokio::signal::ctrl_c() + .await + .map_err(|error| format!("could not install Ctrl-C handler: {error}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn arguments_require_one_explicit_config_path() { + assert_eq!( + config_path_from_args(["citadel-server", "--config", "/tmp/server.toml"]).unwrap(), + PathBuf::from("/tmp/server.toml") + ); + assert!(config_path_from_args(["citadel-server"]).is_err()); + assert!(config_path_from_args(["citadel-server", "server.toml"]).is_err()); + } + + #[test] + fn config_rejects_relative_paths_unknown_fields_and_invalid_ports() { + for text in [ + r#"libraryPath = "library" +stateDirectory = "/tmp/state" +[sharing] +port = 8080 +authenticationEnabled = false +target = "localNetworks" +"#, + r#"libraryPath = "/tmp/library" +stateDirectory = "/tmp/state" +unknown = true +[sharing] +port = 8080 +authenticationEnabled = false +target = "localNetworks" +"#, + r#"libraryPath = "/tmp/library" +stateDirectory = "/tmp/state" +[sharing] +port = 0 +authenticationEnabled = false +target = "localNetworks" +"#, + ] { + let rejected = match toml::from_str::(text) { + Ok(config) => validate_config(&config).is_err(), + Err(_) => true, + }; + assert!(rejected); + } + } +} diff --git a/crates/citadel-server/src/main.rs b/crates/citadel-server/src/main.rs new file mode 100644 index 00000000..1a794e08 --- /dev/null +++ b/crates/citadel-server/src/main.rs @@ -0,0 +1,18 @@ +use std::process::ExitCode; + +#[tokio::main] +async fn main() -> ExitCode { + let result = match citadel_server::config_path_from_args(std::env::args_os()) + .and_then(|path| citadel_server::load_config(&path)) + { + Ok(config) => citadel_server::run(config).await, + Err(error) => Err(error), + }; + match result { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("citadel_server error={error}"); + ExitCode::FAILURE + } + } +} diff --git a/crates/citadel-server/tests/headless_process.rs b/crates/citadel-server/tests/headless_process.rs new file mode 100644 index 00000000..07508c8b --- /dev/null +++ b/crates/citadel-server/tests/headless_process.rs @@ -0,0 +1,213 @@ +#![cfg(unix)] + +use std::{ + collections::HashMap, + io::{BufRead, BufReader}, + net::TcpListener, + process::{Command, Stdio}, + sync::mpsc, + time::{Duration, Instant}, +}; + +use base64::{engine::general_purpose::STANDARD, Engine}; +use libcalibre::{BookAdd, Library}; +use nix::{ + sys::signal::{kill, Signal}, + unistd::Pid, +}; + +struct ServerProcess(std::process::Child); +impl std::ops::Deref for ServerProcess { + type Target = std::process::Child; + fn deref(&self) -> &Self::Target { + &self.0 + } +} +impl std::ops::DerefMut for ServerProcess { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} +impl Drop for ServerProcess { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +#[test] +fn real_process_serves_authenticated_catalog_and_acquisition_then_stops() { + let directory = tempfile::tempdir().unwrap(); + let library_path = directory.path().join("library"); + let state_path = directory.path().join("state"); + std::fs::create_dir_all(&library_path).unwrap(); + let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../libcalibre/tests/fixtures/empty_library/metadata.db"); + std::fs::copy(fixture, library_path.join("metadata.db")).unwrap(); + + let database = libcalibre::util::get_db_path(library_path.to_str().unwrap()).unwrap(); + let mut library = Library::new(database).unwrap(); + let source = directory.path().join("source.epub"); + std::fs::write(&source, b"headless-acquisition").unwrap(); + let book = library + .add_book(BookAdd { + title: "Headless Citadel".to_string(), + author_names: vec!["Citadel Test".to_string()], + tags: None, + series: None, + series_index: None, + publisher: None, + publication_date: None, + rating: None, + comments: None, + identifiers: HashMap::new(), + language: Some("eng".to_string()), + file_paths: vec![source], + }) + .unwrap(); + drop(library); + + let reservation = TcpListener::bind("0.0.0.0:0").unwrap(); + let port = reservation.local_addr().unwrap().port(); + drop(reservation); + let config_path = directory.path().join("server.toml"); + std::fs::write( + &config_path, + format!( + r#"libraryPath = "{}" +stateDirectory = "{}" + +[sharing] +port = {port} +authenticationEnabled = true + +[sharing.target.addresses] +addresses = ["127.0.0.1"] + +[credentials] +username = "reader" +passwordEnvironment = "CITADEL_TEST_OPDS_PASSWORD" +"#, + library_path.display(), + state_path.display() + ), + ) + .unwrap(); + + let mut child = ServerProcess( + Command::new(env!("CARGO_BIN_EXE_citadel-server")) + .args(["--config", config_path.to_str().unwrap()]) + .env("CITADEL_TEST_OPDS_PASSWORD", "secret") + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .unwrap(), + ); + let stdout = child.stdout.take().unwrap(); + let (lines_tx, lines_rx) = mpsc::channel(); + std::thread::spawn(move || { + for line in BufReader::new(stdout).lines().map_while(Result::ok) { + let _ = lines_tx.send(line); + } + }); + + let deadline = Instant::now() + Duration::from_secs(15); + let mut observed = Vec::new(); + let catalog_url = loop { + assert!( + Instant::now() < deadline, + "server did not start: {observed:?}" + ); + if let Ok(line) = lines_rx.recv_timeout(Duration::from_millis(250)) { + if let Some(urls) = line + .split_whitespace() + .find_map(|field| field.strip_prefix("urls=")) + { + if line.contains("state=Running") { + break urls.split(',').next().unwrap().to_string(); + } + } + observed.push(line); + } + if let Some(status) = child.try_wait().unwrap() { + panic!("server exited before listening with {status}: {observed:?}"); + } + }; + + let client = reqwest::blocking::Client::builder() + .no_proxy() + .timeout(Duration::from_secs(5)) + .build() + .unwrap(); + assert_eq!(client.get(&catalog_url).send().unwrap().status(), 401); + let feed = client + .get(&catalog_url) + .basic_auth("reader", Some("secret")) + .send() + .unwrap(); + assert!(feed.status().is_success()); + assert!(feed.text().unwrap().contains("Headless Citadel")); + + let origin = catalog_url.strip_suffix("/opds").unwrap(); + let asset_url = format!( + "{origin}/opds/books/{}/files/EPUB/book.epub", + book.id.as_i32() + ); + let head = client + .head(&asset_url) + .basic_auth("reader", Some("secret")) + .send() + .unwrap(); + assert!(head.status().is_success()); + assert_eq!( + head.headers()["content-length"], + b"headless-acquisition".len().to_string() + ); + let range = client + .get(&asset_url) + .basic_auth("reader", Some("secret")) + .header("Range", "bytes=2-5") + .send() + .unwrap(); + assert_eq!(range.status(), 206); + assert_eq!(range.bytes().unwrap().as_ref(), b"adle"); + + let acquisition = client + .get(format!( + "{origin}/opds/books/{}/files/EPUB/book.epub", + book.id.as_i32() + )) + .header( + "Authorization", + format!("Basic {}", STANDARD.encode("reader:secret")), + ) + .send() + .unwrap(); + assert!(acquisition.status().is_success()); + assert_eq!( + acquisition.bytes().unwrap().as_ref(), + b"headless-acquisition" + ); + + kill(Pid::from_raw(child.id() as i32), Signal::SIGTERM).unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + let exit = loop { + if let Some(status) = child.try_wait().unwrap() { + break status; + } + if Instant::now() >= deadline { + child.kill().unwrap(); + panic!("server did not stop after SIGTERM"); + } + std::thread::sleep(Duration::from_millis(50)); + }; + assert!(exit.success()); + + observed.extend(lines_rx.try_iter()); + assert!(observed.iter().any(|line| line.contains("state=Stopped"))); + let credential_file = + std::fs::read_to_string(state_path.join("opds-credentials.json")).unwrap(); + assert!(credential_file.contains("reader")); + // Reversible storage is intentional: shared runtime credentials can be revealed. + assert!(credential_file.contains("secret")); +} diff --git a/docs/headless-server.md b/docs/headless-server.md new file mode 100644 index 00000000..aeb8bd8e --- /dev/null +++ b/docs/headless-server.md @@ -0,0 +1,41 @@ +# Headless OPDS server + +`citadel-server` serves one Calibre library over the same OPDS runtime used by +the desktop application. It has no desktop, WebView, or remote management UI. + +Copy `crates/citadel-server/example-config.toml`, use absolute paths for the +library and state directory, then run: + +```sh +CITADEL_OPDS_PASSWORD='choose-a-password' cargo run -p citadel-server -- \ + --config /absolute/path/to/citadel-server.toml +``` + +The password is read from the environment variable named by +`credentials.passwordEnvironment`; it is never stored in the TOML file or +printed. Citadel persists the username and password beneath the configured state +directory, using the same owner-only credential file as desktop sharing. After the first successful start, the +`credentials` section and password environment variable may be omitted while +`authenticationEnabled` remains true. + +The process logs lifecycle state and every concrete OPDS URL. It remains in the +foreground until SIGINT or SIGTERM, then gracefully closes its listeners. +The localNetworks and allInterfaces targets use the desktop IPv4/IPv6 policy; +allInterfaces requires reader sign-in. + +For a reverse proxy on the same host, the target can instead use explicit +concrete addresses without depending on interface discovery: + +```toml +[sharing.target.addresses] +addresses = ["127.0.0.1", "::1"] +``` + +Wildcard, link-local and non-unicast addresses are rejected. Public addresses +require reader sign-in. Configure every +concrete address on which the process should listen. + +Citadel serves plain HTTP. For access beyond a trusted local network, place it +behind an externally managed TLS reverse proxy and firewall. Service +supervision, certificates, public-exposure controls, and container or operating +system packages are not provided by this initial server. diff --git a/src/bindings.ts b/src/bindings.ts index a2511844..a040a027 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -631,7 +631,7 @@ export type NewAuthor = { name: string; sortable_name: string | null } * credentials (see the auth integration) since it serves every network the * computer can reach. */ -export type OpdsBindTarget = "localNetworks" | "allInterfaces" +export type OpdsBindTarget = "localNetworks" | "allInterfaces" | { addresses: { addresses: string[] } } /** * The stored secret, for the reader UI's reveal flow. The password is kept * reversibly on purpose (ADR 0005); the app process may show it to its user. From 0405d8f3600b1044b51b0793296ee5828d03105b Mon Sep 17 00:00:00 2001 From: Phil Denhoff Date: Wed, 21 Oct 2026 03:24:29 +0000 Subject: [PATCH 2/3] fix(opds): keep headless users in memory and default state to XDG --- Cargo.lock | 2 + crates/citadel-opds/src/auth.rs | 53 ++++++- crates/citadel-opds/src/credential_store.rs | 36 +++-- .../citadel-opds/src/service/credentials.rs | 51 +++--- crates/citadel-opds/src/service/mod.rs | 48 ++++++ crates/citadel-server/Cargo.toml | 2 + crates/citadel-server/example-config.toml | 7 +- crates/citadel-server/src/lib.rs | 148 +++++++++++++----- .../citadel-server/tests/headless_process.rs | 46 ++++-- docs/headless-server.md | 26 ++- 10 files changed, 315 insertions(+), 104 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d653324d..77f201e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -737,10 +737,12 @@ dependencies = [ "base64 0.22.1", "chrono", "citadel-opds", + "dirs", "libcalibre", "nix", "reqwest 0.12.28", "serde", + "serde_json", "tempfile", "tokio", "toml 0.9.12+spec-1.1.0", diff --git a/crates/citadel-opds/src/auth.rs b/crates/citadel-opds/src/auth.rs index 18ca31cf..06847a7c 100644 --- a/crates/citadel-opds/src/auth.rs +++ b/crates/citadel-opds/src/auth.rs @@ -2,7 +2,7 @@ //! //! The credential snapshot is hot-swappable: listeners read it per request, //! so rotating or replacing credentials never interrupts a running share -//! (see ADR 0005 for why the secret is stored reversibly). Consecutive +//! (desktop persistence supports reveal; external hosts can supply ephemeral users). Consecutive //! rejections back off exponentially, which is the online-guessing defense. use std::{ @@ -80,7 +80,7 @@ pub struct OpdsBasicAuth { struct AuthInner { required: AtomicBool, - credentials: Mutex>>, + credentials: Mutex>>>, backoff: Mutex, } @@ -112,8 +112,17 @@ impl OpdsBasicAuth { self.inner.required.load(Ordering::Relaxed) } + #[cfg(test)] pub(crate) fn swap_credentials(&self, credentials: Option) { - *self.inner.credentials.lock().unwrap() = credentials.map(Arc::new); + self.swap_credential_list(credentials.into_iter().collect()); + } + + pub(crate) fn swap_credential_list(&self, credentials: Vec) { + *self.inner.credentials.lock().unwrap() = if credentials.is_empty() { + None + } else { + Some(Arc::new(credentials)) + }; } fn authorize(&self, authorization: Option<&[u8]>) -> AuthOutcome { @@ -130,8 +139,13 @@ impl OpdsBasicAuth { let credentials = self.inner.credentials.lock().unwrap().clone(); let parsed = authorization.and_then(parse_basic_credentials); - let authorized = parsed - .is_some_and(|provided| credentials.is_some_and(|stored| stored.matches(&provided))); + let authorized = parsed.is_some_and(|provided| { + credentials.is_some_and(|stored| { + stored + .iter() + .fold(false, |matched, user| matched | user.matches(&provided)) + }) + }); { let mut backoff = self.inner.backoff.lock().unwrap(); if authorized { @@ -429,4 +443,33 @@ mod tests { "Basic realm=\"Citadel\", charset=\"UTF-8\"" ); } + #[test] + fn multiple_users_have_independent_passwords() { + let auth = OpdsBasicAuth::new(); + auth.set_required(true); + auth.swap_credential_list(vec![ + OpdsAuthCredentials { + username: "alice".into(), + password: "one".into(), + }, + OpdsAuthCredentials { + username: "bob".into(), + password: "two:three".into(), + }, + ]); + for (user, password) in [("alice", "one"), ("bob", "two:three")] { + assert!(matches!( + auth.authorize(Some(&basic_header(user, password.as_bytes()))), + AuthOutcome::Authorized + )); + } + assert!(matches!( + auth.authorize(Some(&basic_header("alice", b"two:three"))), + AuthOutcome::Rejected + )); + assert!(matches!( + auth.authorize(Some(&basic_header("bob", b"two:three"))), + AuthOutcome::Authorized + )); + } } diff --git a/crates/citadel-opds/src/credential_store.rs b/crates/citadel-opds/src/credential_store.rs index 20403492..a3f70bd6 100644 --- a/crates/citadel-opds/src/credential_store.rs +++ b/crates/citadel-opds/src/credential_store.rs @@ -51,7 +51,7 @@ pub(crate) struct OpdsCredentialStore { struct CredentialStoreInner { path: Option, - credentials: RwLock>, + credentials: RwLock>, } impl OpdsCredentialStore { @@ -74,7 +74,7 @@ impl OpdsCredentialStore { Ok(Self { inner: Arc::new(CredentialStoreInner { path: Some(path), - credentials: RwLock::new(credentials), + credentials: RwLock::new(credentials.into_iter().collect()), }), }) } @@ -84,41 +84,53 @@ impl OpdsCredentialStore { Self { inner: Arc::new(CredentialStoreInner { path: None, - credentials: RwLock::new(None), + credentials: RwLock::new(Vec::new()), }), } } - fn read(&self) -> std::sync::RwLockReadGuard<'_, Option> { + fn read(&self) -> std::sync::RwLockReadGuard<'_, Vec> { self.inner .credentials .read() .unwrap_or_else(std::sync::PoisonError::into_inner) } - fn write(&self) -> std::sync::RwLockWriteGuard<'_, Option> { + fn write(&self) -> std::sync::RwLockWriteGuard<'_, Vec> { self.inner .credentials .write() .unwrap_or_else(std::sync::PoisonError::into_inner) } - /// Disk is the source of truth; memory mirrors the last successful disk - /// operation. + /// Desktop stores mirror disk; ephemeral host stores stay in memory. pub fn status(&self) -> OpdsCredentialStatus { let credentials = self.read(); OpdsCredentialStatus { - configured: credentials.is_some(), + configured: !credentials.is_empty(), username: credentials - .as_ref() + .first() .map(|credentials| credentials.username.clone()), } } - pub fn get(&self) -> Option { + pub fn all(&self) -> Vec { self.read().clone() } + pub fn ephemeral(credentials: Vec) -> Self { + Self { + inner: Arc::new(CredentialStoreInner { + path: None, + credentials: RwLock::new(credentials), + }), + } + } + + pub fn get(&self) -> Option { + self.read().first().cloned() + } + pub fn secret(&self) -> Option { self.get().map(|credentials| OpdsCredentialSecret { username: credentials.username, @@ -130,7 +142,7 @@ impl OpdsCredentialStore { if let Some(path) = &self.inner.path { persist(path, &credentials)?; } - *self.write() = Some(credentials); + *self.write() = vec![credentials]; Ok(()) } @@ -142,7 +154,7 @@ impl OpdsCredentialStore { Err(error) => return Err(error), } } - *self.write() = None; + self.write().clear(); Ok(()) } } diff --git a/crates/citadel-opds/src/service/credentials.rs b/crates/citadel-opds/src/service/credentials.rs index 333d62d6..2892fe0e 100644 --- a/crates/citadel-opds/src/service/credentials.rs +++ b/crates/citadel-opds/src/service/credentials.rs @@ -34,24 +34,7 @@ impl OpdsService { username: String, password: String, ) -> Result { - if username.trim().is_empty() || password.is_empty() { - return Err(OpdsStatusError { - code: OpdsErrorCode::Unexpected, - message: "Reader sign-in needs a non-empty username and password.".to_string(), - }); - } - // HTTP Basic splits `user:pass` at the first colon, so a colon in the - // username could never authenticate. - if username.contains(USERNAME_FORBIDDEN) { - return Err(OpdsStatusError { - code: OpdsErrorCode::Unexpected, - message: "Usernames can't contain a colon (:).".to_string(), - }); - } - let credentials = StoredOpdsCredentials { - username: username.trim().to_string(), - password, - }; + let credentials = validate_credentials(username, password)?; self.inner .credentials .set(credentials) @@ -85,11 +68,37 @@ impl OpdsService { let snapshot = self .inner .credentials - .get() + .all() + .into_iter() .map(|credentials| OpdsAuthCredentials { username: credentials.username, password: credentials.password, - }); - self.inner.auth.swap_credentials(snapshot); + }) + .collect(); + self.inner.auth.swap_credential_list(snapshot); } } + +pub(super) fn validate_credentials( + username: String, + password: String, +) -> Result { + if username.trim().is_empty() || password.is_empty() { + return Err(OpdsStatusError { + code: OpdsErrorCode::Unexpected, + message: "Reader sign-in needs a non-empty username and password.".to_string(), + }); + } + // HTTP Basic splits `user:pass` at the first colon, so a colon in the + // username could never authenticate. + if username.contains(USERNAME_FORBIDDEN) { + return Err(OpdsStatusError { + code: OpdsErrorCode::Unexpected, + message: "Usernames can't contain a colon (:).".to_string(), + }); + } + Ok(StoredOpdsCredentials { + username: username.trim().to_string(), + password, + }) +} diff --git a/crates/citadel-opds/src/service/mod.rs b/crates/citadel-opds/src/service/mod.rs index ccd40b37..d2e359d9 100644 --- a/crates/citadel-opds/src/service/mod.rs +++ b/crates/citadel-opds/src/service/mod.rs @@ -480,6 +480,30 @@ impl OpdsService { )) } + /// Credentials supplied by an external host remain in memory for this process. + pub fn with_ephemeral_credentials( + source: Arc, + users: Vec, + ) -> Result { + let mut names = BTreeSet::new(); + let mut credentials = Vec::new(); + for user in users { + let user = credentials::validate_credentials(user.username, user.password)?; + if !names.insert(user.username.clone()) { + return Err(OpdsStatusError { + code: OpdsErrorCode::ConfigurationConflict, + message: "Duplicate reader username.".into(), + }); + } + credentials.push(user); + } + Ok(Self::with_dependencies_and_credentials( + source, + OpdsCredentialStore::ephemeral(credentials), + ServiceDependencies::default(), + )) + } + #[cfg(test)] fn with_dependencies( source: Arc, @@ -972,6 +996,30 @@ mod tests { } } + #[test] + fn ephemeral_users_reject_duplicate_names_and_empty_passwords() { + use crate::credential_store::OpdsCredentialSecret; + let users = vec![ + OpdsCredentialSecret { + username: "reader".into(), + password: "one".into(), + }, + OpdsCredentialSecret { + username: " reader ".into(), + password: "two".into(), + }, + ]; + assert!(OpdsService::with_ephemeral_credentials(missing_source(), users).is_err()); + assert!(OpdsService::with_ephemeral_credentials( + missing_source(), + vec![OpdsCredentialSecret { + username: "reader".into(), + password: String::new() + }] + ) + .is_err()); + } + struct TestSource { library_id: Option, } diff --git a/crates/citadel-server/Cargo.toml b/crates/citadel-server/Cargo.toml index 7c0b6c8a..ddfd50d5 100644 --- a/crates/citadel-server/Cargo.toml +++ b/crates/citadel-server/Cargo.toml @@ -9,6 +9,8 @@ description = "Headless Citadel OPDS server" chrono = "0.4.31" citadel-opds = { path = "../citadel-opds" } libcalibre = { path = "../libcalibre" } +dirs = "6" +serde_json = "1" serde = { version = "1.0", features = ["derive"] } tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread", "signal", "time"] } toml = "0.9" diff --git a/crates/citadel-server/example-config.toml b/crates/citadel-server/example-config.toml index e6dc7991..bbc248b2 100644 --- a/crates/citadel-server/example-config.toml +++ b/crates/citadel-server/example-config.toml @@ -1,5 +1,6 @@ libraryPath = "/srv/calibre-library" -stateDirectory = "/var/lib/citadel" +# Optional. On Unix defaults to $XDG_STATE_HOME/citadel or ~/.local/state/citadel. +# stateDirectory = "/var/lib/citadel" [sharing] port = 8080 @@ -7,7 +8,3 @@ authenticationEnabled = true [sharing.target.addresses] addresses = ["127.0.0.1"] - -[credentials] -username = "reader" -passwordEnvironment = "CITADEL_OPDS_PASSWORD" diff --git a/crates/citadel-server/src/lib.rs b/crates/citadel-server/src/lib.rs index 2aad2a17..6004e930 100644 --- a/crates/citadel-server/src/lib.rs +++ b/crates/citadel-server/src/lib.rs @@ -12,22 +12,14 @@ use citadel_opds::{ use libcalibre::{BookId, BookPage, CalibreError, Library, ResolvedBookAsset}; use serde::Deserialize; -const CREDENTIAL_FILENAME: &str = "opds-credentials.json"; +const USERS_ENVIRONMENT: &str = "CITADEL_OPDS_USERS"; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ServerConfig { pub library_path: PathBuf, - pub state_directory: PathBuf, + pub state_directory: Option, pub sharing: OpdsStartConfig, - pub credentials: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct CredentialInput { - pub username: String, - pub password_environment: String, } pub struct CalibreCatalogSource { @@ -115,7 +107,11 @@ fn validate_config(config: &ServerConfig) -> Result<(), String> { if !config.library_path.is_absolute() { return Err("libraryPath must be absolute".to_string()); } - if !config.state_directory.is_absolute() { + if config + .state_directory + .as_ref() + .is_some_and(|path| !path.is_absolute()) + { return Err("stateDirectory must be absolute".to_string()); } if config.sharing.port == 0 || config.sharing.port > u32::from(u16::MAX) { @@ -128,46 +124,29 @@ fn validate_config(config: &ServerConfig) -> Result<(), String> { )?; } - if let Some(credentials) = &config.credentials { - if credentials.username.trim().is_empty() { - return Err("credentials.username must not be empty".to_string()); - } - if credentials.password_environment.trim().is_empty() { - return Err("credentials.passwordEnvironment must not be empty".to_string()); - } - } Ok(()) } pub async fn run(config: ServerConfig) -> Result<(), String> { validate_config(&config)?; - std::fs::create_dir_all(&config.state_directory).map_err(|error| { + let state_directory = config + .state_directory + .clone() + .map(Ok) + .unwrap_or_else(default_state_directory)?; + std::fs::create_dir_all(&state_directory).map_err(|error| { format!( "could not create stateDirectory {}: {error}", - config.state_directory.display() + state_directory.display() ) })?; - let source = Arc::new(CalibreCatalogSource::open(&config.library_path)?); - let service = OpdsService::new(source, config.state_directory.join(CREDENTIAL_FILENAME)) - .map_err(|error| format!("could not load OPDS credentials: {error}"))?; - - if let Some(credentials) = config.credentials { - let password = std::env::var_os(&credentials.password_environment).ok_or_else(|| { - format!( - "credential environment variable {} is not set", - credentials.password_environment - ) - })?; - let password = password.into_string().map_err(|_| { - format!( - "credential environment variable {} is not valid UTF-8", - credentials.password_environment - ) - })?; - service - .configure_credentials(credentials.username, password) - .map_err(|error| error.message)?; + let users = load_users(std::env::var_os(USERS_ENVIRONMENT))?; + if config.sharing.authentication_enabled && users.is_empty() { + return Err("Reader sign-in requires CITADEL_OPDS_USERS.".into()); } + let source = Arc::new(CalibreCatalogSource::open(&config.library_path)?); + let service = + OpdsService::with_ephemeral_credentials(source, users).map_err(|error| error.message)?; let initial = service .start(config.sharing) @@ -313,3 +292,90 @@ target = "localNetworks" } } } + +fn load_users( + value: Option, +) -> Result, String> { + let Some(value) = value else { + return Ok(Vec::new()); + }; + let value = value + .into_string() + .map_err(|_| "CITADEL_OPDS_USERS must be valid UTF-8.".to_string())?; + let entries: Vec = serde_json::from_str(&value).map_err(|_| { + "CITADEL_OPDS_USERS must be a JSON array of USER:PASSWORD strings.".to_string() + })?; + entries + .into_iter() + .map(|entry| { + let (username, password) = entry.split_once(':').ok_or_else(|| { + "Each CITADEL_OPDS_USERS entry must contain USER:PASSWORD.".to_string() + })?; + Ok(citadel_opds::credential_store::OpdsCredentialSecret { + username: username.into(), + password: password.into(), + }) + }) + .collect() +} + +#[cfg(unix)] +fn xdg_state_directory(xdg: Option<&Path>, home: Option<&Path>) -> Result { + xdg.filter(|path| path.is_absolute()) + .map(|path| path.join("citadel")) + .or_else(|| { + home.filter(|path| path.is_absolute()) + .map(|path| path.join(".local/state/citadel")) + }) + .ok_or_else(|| "Set an absolute stateDirectory or configure XDG_STATE_HOME/HOME.".into()) +} + +fn default_state_directory() -> Result { + #[cfg(unix)] + { + xdg_state_directory( + std::env::var_os("XDG_STATE_HOME").as_deref().map(Path::new), + dirs::home_dir().as_deref(), + ) + } + #[cfg(not(unix))] + { + dirs::data_local_dir() + .map(|path| path.join("citadel")) + .ok_or_else(|| "Set an absolute stateDirectory.".into()) + } +} + +#[cfg(test)] +mod environment_tests { + use super::*; + #[test] + fn users_are_an_explicit_list_and_passwords_may_contain_colons() { + let users = load_users(Some(r#"["alice:one", "bob:two:three"]"#.into())).unwrap(); + assert_eq!(users.len(), 2); + assert_eq!(users[1].password, "two:three"); + assert!(load_users(None).unwrap().is_empty()); + for invalid in ["alice:one", "[", r#"["missing-separator"]"#] { + assert!(load_users(Some(invalid.into())).is_err()); + } + } + #[cfg(unix)] + #[test] + fn xdg_state_defaults_and_relative_xdg_values() { + assert_eq!( + xdg_state_directory(Some(Path::new("/state")), Some(Path::new("/home/reader"))) + .unwrap(), + PathBuf::from("/state/citadel") + ); + assert_eq!( + xdg_state_directory(None, Some(Path::new("/home/reader"))).unwrap(), + PathBuf::from("/home/reader/.local/state/citadel") + ); + assert_eq!( + xdg_state_directory(Some(Path::new("relative")), Some(Path::new("/home/reader"))) + .unwrap(), + PathBuf::from("/home/reader/.local/state/citadel") + ); + assert!(xdg_state_directory(None, None).is_err()); + } +} diff --git a/crates/citadel-server/tests/headless_process.rs b/crates/citadel-server/tests/headless_process.rs index 07508c8b..dea14b35 100644 --- a/crates/citadel-server/tests/headless_process.rs +++ b/crates/citadel-server/tests/headless_process.rs @@ -75,7 +75,6 @@ fn real_process_serves_authenticated_catalog_and_acquisition_then_stops() { &config_path, format!( r#"libraryPath = "{}" -stateDirectory = "{}" [sharing] port = {port} @@ -84,12 +83,8 @@ authenticationEnabled = true [sharing.target.addresses] addresses = ["127.0.0.1"] -[credentials] -username = "reader" -passwordEnvironment = "CITADEL_TEST_OPDS_PASSWORD" "#, - library_path.display(), - state_path.display() + library_path.display() ), ) .unwrap(); @@ -97,7 +92,11 @@ passwordEnvironment = "CITADEL_TEST_OPDS_PASSWORD" let mut child = ServerProcess( Command::new(env!("CARGO_BIN_EXE_citadel-server")) .args(["--config", config_path.to_str().unwrap()]) - .env("CITADEL_TEST_OPDS_PASSWORD", "secret") + .env( + "CITADEL_OPDS_USERS", + r#"["reader:secret","reviewer:second:secret"]"#, + ) + .env("XDG_STATE_HOME", &state_path) .stdout(Stdio::piped()) .stderr(Stdio::inherit()) .spawn() @@ -148,6 +147,22 @@ passwordEnvironment = "CITADEL_TEST_OPDS_PASSWORD" assert!(feed.status().is_success()); assert!(feed.text().unwrap().contains("Headless Citadel")); + let second = client + .get(&catalog_url) + .basic_auth("reviewer", Some("second:secret")) + .send() + .unwrap(); + assert_eq!(second.status(), 200); + assert_eq!( + client + .get(&catalog_url) + .basic_auth("reader", Some("wrong")) + .send() + .unwrap() + .status(), + 401 + ); + let origin = catalog_url.strip_suffix("/opds").unwrap(); let asset_url = format!( "{origin}/opds/books/{}/files/EPUB/book.epub", @@ -205,9 +220,16 @@ passwordEnvironment = "CITADEL_TEST_OPDS_PASSWORD" observed.extend(lines_rx.try_iter()); assert!(observed.iter().any(|line| line.contains("state=Stopped"))); - let credential_file = - std::fs::read_to_string(state_path.join("opds-credentials.json")).unwrap(); - assert!(credential_file.contains("reader")); - // Reversible storage is intentional: shared runtime credentials can be revealed. - assert!(credential_file.contains("secret")); + let state_directory = state_path.join("citadel"); + assert!(state_directory.is_dir()); + assert_eq!(std::fs::read_dir(&state_directory).unwrap().count(), 0); + let restart = Command::new(env!("CARGO_BIN_EXE_citadel-server")) + .args(["--config", config_path.to_str().unwrap()]) + .env_remove("CITADEL_OPDS_USERS") + .env("XDG_STATE_HOME", &state_path) + .output() + .unwrap(); + assert!(!restart.status.success()); + assert!(String::from_utf8_lossy(&restart.stderr).contains("requires CITADEL_OPDS_USERS")); + assert_eq!(std::fs::read_dir(state_directory).unwrap().count(), 0); } diff --git a/docs/headless-server.md b/docs/headless-server.md index aeb8bd8e..da3c5037 100644 --- a/docs/headless-server.md +++ b/docs/headless-server.md @@ -4,19 +4,29 @@ the desktop application. It has no desktop, WebView, or remote management UI. Copy `crates/citadel-server/example-config.toml`, use absolute paths for the -library and state directory, then run: +library, then run: ```sh -CITADEL_OPDS_PASSWORD='choose-a-password' cargo run -p citadel-server -- \ +CITADEL_OPDS_USERS='["alice:choose-a-password","bob:another-password"]' cargo run -p citadel-server -- \ --config /absolute/path/to/citadel-server.toml ``` -The password is read from the environment variable named by -`credentials.passwordEnvironment`; it is never stored in the TOML file or -printed. Citadel persists the username and password beneath the configured state -directory, using the same owner-only credential file as desktop sharing. After the first successful start, the -`credentials` section and password environment variable may be omitted while -`authenticationEnabled` remains true. +`CITADEL_OPDS_USERS` is a JSON array of `USER:PASSWORD` strings. Split is at +the first colon, so passwords may contain colons. Usernames must be nonempty, +unique (after trimming), and contain no colon; passwords must be nonempty. +Every user has the same read access to the configured library. + +Credentials are read from the environment once at startup and held only in +memory. Restart the server to change them. The headless host never loads or +writes credential files and does not load `.env` or secret files. Authentication +enabled with a missing or empty list fails closed. Desktop credential persistence +and password reveal are unchanged. + +`stateDirectory` is optional. On Unix it defaults to `$XDG_STATE_HOME/citadel` +when XDG_STATE_HOME is absolute, otherwise `~/.local/state/citadel`. On Windows +it defaults to the local application-data directory's `citadel` subdirectory. +An explicit override must be absolute. The directory is reserved for server +state; this version creates it but writes no files there. The process logs lifecycle state and every concrete OPDS URL. It remains in the foreground until SIGINT or SIGTERM, then gracefully closes its listeners. From d110f5b16d42fe16415803a3d3eb9c3e2f54c5f0 Mon Sep 17 00:00:00 2001 From: Phil Denhoff Date: Wed, 21 Oct 2026 04:57:46 +0000 Subject: [PATCH 3/3] fix(opds): serve headless libraries through read-only connections --- crates/citadel-server/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/citadel-server/src/lib.rs b/crates/citadel-server/src/lib.rs index 6004e930..30894f40 100644 --- a/crates/citadel-server/src/lib.rs +++ b/crates/citadel-server/src/lib.rs @@ -33,7 +33,7 @@ impl CalibreCatalogSource { .ok_or_else(|| "libraryPath must be valid UTF-8".to_string())?; let database = libcalibre::util::get_db_path(library_path) .ok_or_else(|| format!("libraryPath is not a Calibre library: {library_path}"))?; - let library = Library::new(database) + let library = Library::open_read_only(database) .map_err(|error| format!("could not open libraryPath: {error}"))?; Ok(Self { library: Mutex::new(library),