From 35d490f10159ce5ec53192672d7a59991a3b79cd Mon Sep 17 00:00:00 2001 From: Phil Denhoff Date: Sun, 2 Aug 2026 02:15:20 -0400 Subject: [PATCH] refactor(opds): extract reusable runtime crate feat(server): add headless OPDS host --- Cargo.lock | 40 ++- Cargo.toml | 1 + crates/citadel-opds/src/auth.rs | 2 +- crates/citadel-opds/src/network.rs | 54 +++ crates/citadel-opds/src/service.rs | 17 +- crates/citadel-server/Cargo.toml | 20 ++ crates/citadel-server/example-config.toml | 13 + crates/citadel-server/src/lib.rs | 333 ++++++++++++++++++ crates/citadel-server/src/main.rs | 18 + .../citadel-server/tests/headless_process.rs | 169 +++++++++ docs/headless-server.md | 41 +++ src-tauri/Cargo.toml | 5 +- src-tauri/src/opds/commands.rs | 8 +- src/bindings.ts | 2 +- src/components/organisms/SharingSettings.tsx | 6 +- 15 files changed, 712 insertions(+), 17 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 60f2e297..d5d9286d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -634,6 +634,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[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.2" @@ -713,6 +719,7 @@ dependencies = [ name = "citadel-rs" version = "0.6.1" dependencies = [ + "axum", "chrono", "citadel-core", "citadel-opds", @@ -722,7 +729,6 @@ dependencies = [ "libcalibre", "log", "mobi", - "netdev", "quick-xml 0.38.4", "regex", "reqwest 0.12.28", @@ -746,10 +752,25 @@ dependencies = [ "tempfile", "tokio", "urlencoding", - "uuid", "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" @@ -1719,6 +1740,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -3192,6 +3214,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.2", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nom" version = "8.0.0" @@ -4203,7 +4237,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/auth.rs b/crates/citadel-opds/src/auth.rs index 2ef14866..fece9ec7 100644 --- a/crates/citadel-opds/src/auth.rs +++ b/crates/citadel-opds/src/auth.rs @@ -131,7 +131,7 @@ impl AuthCache { } impl OpdsBasicAuth { - pub(crate) fn disabled() -> Self { + pub fn disabled() -> Self { Self { enabled: None } } diff --git a/crates/citadel-opds/src/network.rs b/crates/citadel-opds/src/network.rs index 6dc424a1..3ae422e3 100644 --- a/crates/citadel-opds/src/network.rs +++ b/crates/citadel-opds/src/network.rs @@ -295,6 +295,8 @@ pub(crate) enum WaitingReason { SelectedInterfaceDown(String), SelectedInterfaceHasNoUsableAddress(String), LoopbackCannotBeShared(String), + NoConfiguredAddress, + InvalidConfiguredAddress(String), } impl WaitingReason { @@ -315,6 +317,10 @@ impl WaitingReason { Self::LoopbackCannotBeShared(id) => { format!("The selected interface ({id}) is loopback-only and cannot be shared.") } + Self::NoConfiguredAddress => "No explicit listener address was configured.".to_string(), + Self::InvalidConfiguredAddress(address) => { + format!("The configured listener address ({address}) is invalid.") + } } } } @@ -326,6 +332,7 @@ impl WaitingReason { pub enum OpdsBindTarget { AllLocalNetworks, Interface { id: String }, + Addresses { addresses: Vec }, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -342,6 +349,20 @@ pub(crate) fn plan_bindings( target: &OpdsBindTarget, port: u16, ) -> BindPlan { + if let OpdsBindTarget::Addresses { addresses } = target { + if addresses.is_empty() { + return BindPlan::Wait(WaitingReason::NoConfiguredAddress); + } + let mut sockets = BTreeSet::new(); + for address in addresses { + let Ok(address) = address.parse::() else { + return BindPlan::Wait(WaitingReason::InvalidConfiguredAddress(address.clone())); + }; + sockets.insert(SocketAddr::new(address, port)); + } + return BindPlan::Listen(sockets); + } + let selected = match target { OpdsBindTarget::AllLocalNetworks => interfaces .iter() @@ -362,6 +383,7 @@ pub(crate) fn plan_bindings( } vec![interface] } + OpdsBindTarget::Addresses { .. } => unreachable!(), }; let addresses = selected @@ -374,6 +396,7 @@ pub(crate) fn plan_bindings( OpdsBindTarget::Interface { id } => { WaitingReason::SelectedInterfaceHasNoUsableAddress(id.clone()) } + OpdsBindTarget::Addresses { .. } => unreachable!(), }) } else { BindPlan::Listen(addresses) @@ -621,6 +644,37 @@ mod tests { ); } + #[test] + fn explicit_addresses_do_not_depend_on_interface_enumeration() { + let plan = plan_bindings( + &[], + &OpdsBindTarget::Addresses { + addresses: vec!["127.0.0.1".to_string(), "::1".to_string()], + }, + 8080, + ); + assert_eq!( + plan, + BindPlan::Listen(BTreeSet::from([ + "127.0.0.1:8080".parse().unwrap(), + "[::1]:8080".parse().unwrap(), + ])) + ); + + assert_eq!( + plan_bindings( + &[], + &OpdsBindTarget::Addresses { + addresses: vec!["not-an-address".to_string()], + }, + 8080, + ), + BindPlan::Wait(WaitingReason::InvalidConfiguredAddress( + "not-an-address".to_string() + )) + ); + } + #[test] fn public_interface_exposes_bindable_addresses_and_shareability() { let public = interface( diff --git a/crates/citadel-opds/src/service.rs b/crates/citadel-opds/src/service.rs index 14f5f9ce..d55052d5 100644 --- a/crates/citadel-opds/src/service.rs +++ b/crates/citadel-opds/src/service.rs @@ -401,7 +401,9 @@ impl OpdsService { }; let mut servers = BTreeMap::new(); - match snapshot_interfaces(self.inner.dependencies.interfaces.clone()).await { + match snapshot_target_interfaces(self.inner.dependencies.interfaces.clone(), &config.target) + .await + { Ok(interfaces) => { apply_plan( &interfaces, @@ -587,6 +589,17 @@ async fn snapshot_interfaces( .map_err(|_| io::Error::other("interface snapshot task failed"))? } +async fn snapshot_target_interfaces( + interfaces: Arc, + target: &OpdsBindTarget, +) -> io::Result> { + if matches!(target, OpdsBindTarget::Addresses { .. }) { + Ok(Vec::new()) + } else { + snapshot_interfaces(interfaces).await + } +} + async fn active_library_id(source: Arc) -> Option { tokio::task::spawn_blocking(move || source.active_library_id().ok()) .await @@ -725,7 +738,7 @@ async fn monitor_service( continue; } - let snapshot = match snapshot_interfaces(interfaces.clone()).await { + let snapshot = match snapshot_target_interfaces(interfaces.clone(), &config.target).await { Ok(snapshot) => snapshot, Err(_) => { shutdown_servers(&mut servers).await; 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..e18f4548 --- /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] +type = "allLocalNetworks" + +[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..2145e6bc --- /dev/null +++ b/crates/citadel-server/src/lib.rs @@ -0,0 +1,333 @@ +use std::{ + ffi::OsString, + net::IpAddr, + path::{Path, PathBuf}, + sync::{Arc, Mutex}, + time::Duration, +}; + +use chrono::NaiveDateTime; +use citadel_opds::{ + CatalogSource, OpdsErrorCode, 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 { + if addresses.is_empty() { + return Err("sharing.target.addresses must not be empty".to_string()); + } + for address in addresses { + let address = address + .parse::() + .map_err(|_| format!("invalid sharing target address: {address}"))?; + if address.is_unspecified() { + return Err("wildcard sharing target addresses are not allowed".to_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> { + 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) + .await + .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.error.as_ref().map(|error| &error.code), + Some( + OpdsErrorCode::InvalidPort + | OpdsErrorCode::LibraryNotReady + | OpdsErrorCode::InvalidCredentials + | OpdsErrorCode::CredentialsRequired + | OpdsErrorCode::CredentialStorageFailed + | OpdsErrorCode::ConfigurationConflict + | OpdsErrorCode::Unexpected + ) + ) || status.state == 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 + .port + .map(|port| 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 +[sharing.target] +type = "allLocalNetworks" +"#, + r#"libraryPath = "/tmp/library" +stateDirectory = "/tmp/state" +unknown = true +[sharing] +port = 8080 +authenticationEnabled = false +[sharing.target] +type = "allLocalNetworks" +"#, + r#"libraryPath = "/tmp/library" +stateDirectory = "/tmp/state" +[sharing] +port = 0 +authenticationEnabled = false +[sharing.target] +type = "allLocalNetworks" +"#, + ] { + 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..03354134 --- /dev/null +++ b/crates/citadel-server/tests/headless_process.rs @@ -0,0 +1,169 @@ +#![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, +}; + +#[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] +type = "addresses" +addresses = ["127.0.0.1"] + +[credentials] +username = "reader" +passwordEnvironment = "CITADEL_TEST_OPDS_PASSWORD" +"#, + library_path.display(), + state_path.display() + ), + ) + .unwrap(); + + let mut child = 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 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("$argon2id$")); + assert!(!credential_file.contains("secret")); +} diff --git a/docs/headless-server.md b/docs/headless-server.md new file mode 100644 index 00000000..b483541c --- /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 only the username and Argon2id verifier beneath the +configured state directory. 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. +Interface selections use the same stable system interface names and IPv4/IPv6 +policy as desktop sharing. + +For a reverse proxy on the same host, the target can instead use explicit +concrete addresses without depending on interface discovery: + +```toml +[sharing.target] +type = "addresses" +addresses = ["127.0.0.1", "::1"] +``` + +Wildcard addresses such as `0.0.0.0` and `::` are rejected. 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-tauri/Cargo.toml b/src-tauri/Cargo.toml index 48023ad5..cac9e5eb 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "citadel-rs" version = "0.6.1" -description = "Backend for Citadel, embedded into the Tauri app or run stand-alone." +description = "Backend for the Citadel desktop app." authors = ["Phil Denhoff"] license = "MIT" repository = "https://github.com/every-day-things/citadel" @@ -19,7 +19,6 @@ chrono = { version = "0.4.31", features = ["serde"] } diesel = { version = "2.1.0", features = ["sqlite", "chrono", "returning_clauses_for_sqlite_3_35"] } epub = "2.1.1" mobi = "0.8.0" -netdev = { version = "=0.45.0", default-features = false } citadel-core = { path = "../crates/citadel-core" } citadel-opds = { path = "../crates/citadel-opds" } libcalibre = { path = "../crates/libcalibre" } @@ -36,7 +35,6 @@ tauri-plugin-drag = "2.1.0" tauri-plugin-persisted-scope = { features = ["protocol-asset"] , version = "2" } tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] } urlencoding = "2.1.3" -uuid = { version = "1.6.1", features = [ "v4", "fast-rng", ] } zip = "0.6" tauri-plugin-dialog = "2" tauri-plugin-fs = "2" @@ -50,6 +48,7 @@ image = { version = "0.25.6", default-features = false, features = ["jpeg", "png tokio = { version = "1.52.3", features = ["macros", "net", "rt-multi-thread", "sync", "time"] } [dev-dependencies] +axum = "0.8.9" tempfile = "3.8" [features] diff --git a/src-tauri/src/opds/commands.rs b/src-tauri/src/opds/commands.rs index f40cecc3..ee44267d 100644 --- a/src-tauri/src/opds/commands.rs +++ b/src-tauri/src/opds/commands.rs @@ -1,8 +1,6 @@ -use citadel_opds::{ - credentials::{GeneratedOpdsCredentials, OpdsCredentialStatus}, - network::OpdsNetworkInterface, - service::{OpdsServiceStatus, OpdsStartConfig, OpdsStatusError}, - OpdsService, +use super::{ + GeneratedOpdsCredentials, OpdsCredentialStatus, OpdsNetworkInterface, OpdsService, + OpdsServiceStatus, OpdsStartConfig, OpdsStatusError, }; #[tauri::command] diff --git a/src/bindings.ts b/src/bindings.ts index 57624f64..9edde3da 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -555,7 +555,7 @@ export type LocalOrRemoteUrl = { kind: LocalOrRemote; url: string; local_path: s */ export type MetadataProvider = "hardcover" | "loc" | "dnb" | "k10plus" | "openlibrary" export type NewAuthor = { name: string; sortable_name: string | null } -export type OpdsBindTarget = { type: "allLocalNetworks" } | { type: "interface"; id: string } +export type OpdsBindTarget = { type: "allLocalNetworks" } | { type: "interface"; id: string } | { type: "addresses"; addresses: string[] } export type OpdsCredentialStatus = { configured: boolean; username: string | null } export type OpdsErrorCode = "invalidPort" | "libraryNotReady" | "configurationConflict" | "interfaceUnavailable" | "interfaceEnumerationFailed" | "portUnavailable" | "listenerFailed" | "invalidCredentials" | "credentialsRequired" | "credentialStorageFailed" | "unexpected" export type OpdsInterfaceKind = "lan" | "vpn" | "loopback" | "other" diff --git a/src/components/organisms/SharingSettings.tsx b/src/components/organisms/SharingSettings.tsx index e337d2e2..898fd93d 100644 --- a/src/components/organisms/SharingSettings.tsx +++ b/src/components/organisms/SharingSettings.tsx @@ -29,9 +29,9 @@ const STATUS_LABELS: Record = { const targetLabel = (status: OpdsServiceStatus): string => { if (!status.target) return "Not selected"; - return status.target.type === "allLocalNetworks" - ? "All local networks" - : status.target.id; + if (status.target.type === "allLocalNetworks") return "All local networks"; + if (status.target.type === "interface") return status.target.id; + return status.target.addresses.join(", "); }; export const SharingSettings = () => {