diff --git a/Cargo.lock b/Cargo.lock index d5d9286d..d118915b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -711,6 +711,7 @@ dependencies = [ "tempfile", "tokio", "tower", + "tower-http", "urlencoding", "uuid", ] @@ -6057,7 +6058,9 @@ dependencies = [ "futures-util", "http", "http-body", + "http-body-util", "pin-project-lite", + "tokio", "tower", "tower-layer", "tower-service", diff --git a/README.md b/README.md index b09bdcda..e208122d 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,12 @@ Development builds are available from [GitHub actions](https://github.com/everyd Please report any issues or crashes you experience while using any version of Citadel! +### Sharing with KOReader + +The desktop app can share the active library as a read-only OPDS catalog on the +local network. See [Share a library with KOReader](docs/opds-sharing.md) for +setup, authentication, security limits, and troubleshooting. + ### Installing on macOS Download the `.dmg` from [Releases](https://github.com/everydaythingssoftware/citadel/releases), drag Citadel to Applications, and open it. diff --git a/crates/citadel-opds/Cargo.toml b/crates/citadel-opds/Cargo.toml index 7d47f855..18bea56b 100644 --- a/crates/citadel-opds/Cargo.toml +++ b/crates/citadel-opds/Cargo.toml @@ -23,6 +23,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" specta = { version = "=2.0.0-rc.22", features = ["chrono", "derive"] } tokio = { version = "1.52.3", features = ["macros", "net", "rt-multi-thread", "sync", "time"] } +tower-http = { version = "0.6", default-features = false, features = ["limit", "timeout"] } urlencoding = "2.1.3" uuid = { version = "1.6.1", features = ["v4", "fast-rng"] } diff --git a/crates/citadel-opds/src/auth.rs b/crates/citadel-opds/src/auth.rs index fece9ec7..b036c705 100644 --- a/crates/citadel-opds/src/auth.rs +++ b/crates/citadel-opds/src/auth.rs @@ -9,7 +9,10 @@ use argon2::{ }; use axum::{ extract::{Request, State}, - http::{header::AUTHORIZATION, header::WWW_AUTHENTICATE, HeaderValue, StatusCode}, + http::{ + header::{AUTHORIZATION, RETRY_AFTER, WWW_AUTHENTICATE}, + HeaderValue, StatusCode, + }, middleware::Next, response::{IntoResponse, Response}, }; @@ -18,11 +21,25 @@ use hmac::{Hmac, Mac}; use rand_core::{OsRng, RngCore}; use sha2::Sha256; use subtle::ConstantTimeEq; +use tokio::sync::Semaphore; const DEFAULT_CACHE_CAPACITY: usize = 256; const DEFAULT_CACHE_TTL: Duration = Duration::from_secs(30); const DEFAULT_TARGET_DURATION: Duration = Duration::from_millis(250); +const MAX_CONCURRENT_VERIFICATIONS: usize = 3; const BASIC_CHALLENGE: &str = "Basic realm=\"Citadel\""; +const MAX_AUTHORIZATION_BYTES: usize = 8 * 1024; +const RETRY_AFTER_SECONDS: &str = "1"; + +/// Result of authorizing complete Authorization header bytes. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum AuthOutcome { + Authorized, + Rejected, + /// Every password-verification permit is in use; the request was not + /// verified and must not count as a failed attempt. + Busy, +} #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct OpdsAuthCredentials { @@ -75,6 +92,7 @@ struct EnabledAuth { cache_key: [u8; 32], cache: Mutex, target_duration: Mutex, + verifier_permits: Arc, } #[derive(Clone, Copy)] @@ -141,6 +159,7 @@ impl OpdsBasicAuth { DEFAULT_CACHE_CAPACITY, DEFAULT_CACHE_TTL, DEFAULT_TARGET_DURATION, + MAX_CONCURRENT_VERIFICATIONS, ) } @@ -149,6 +168,7 @@ impl OpdsBasicAuth { cache_capacity: usize, cache_ttl: Duration, target_duration: Duration, + verifier_permits: usize, ) -> Result { let password_hash = PasswordHash::new(&credentials.verifier).map_err(|_| OpdsAuthError::InvalidVerifier)?; @@ -164,6 +184,7 @@ impl OpdsBasicAuth { cache_key, cache: Mutex::new(AuthCache::new(cache_capacity, cache_ttl)), target_duration: Mutex::new(target_duration), + verifier_permits: Arc::new(Semaphore::new(verifier_permits)), })), }) } @@ -173,13 +194,17 @@ impl OpdsBasicAuth { } /// Authorizes complete Authorization header bytes. Disabled authentication does not parse them. - async fn authorize(&self, authorization: Option<&[u8]>) -> bool { + async fn authorize(&self, authorization: Option<&[u8]>) -> AuthOutcome { let Some(enabled) = &self.enabled else { - return true; + return AuthOutcome::Authorized; }; let started_at = Instant::now(); let authorization = authorization.unwrap_or_default(); + if authorization.len() > MAX_AUTHORIZATION_BYTES { + pad_to_target(started_at, current_target_duration(enabled)).await; + return AuthOutcome::Rejected; + } let tag = opaque_tag(&enabled.cache_key, authorization); if let Some((outcome, cached_duration)) = enabled .cache @@ -189,32 +214,57 @@ impl OpdsBasicAuth { { let target_duration = current_target_duration(enabled).max(cached_duration); pad_to_target(started_at, target_duration).await; - return matches!(outcome, CachedOutcome::Authorized); + return match outcome { + CachedOutcome::Authorized => AuthOutcome::Authorized, + CachedOutcome::Rejected => AuthOutcome::Rejected, + }; } let parsed = parse_basic_credentials(authorization); - let authorized = match parsed { + let verification = match parsed { Some(credentials) if credentials.username == enabled.credentials.username => { + match enabled.verifier_permits.clone().try_acquire_owned() { + Ok(permit) => Some((credentials, permit)), + Err(_) => { + pad_to_target(started_at, current_target_duration(enabled)).await; + return AuthOutcome::Busy; + } + } + } + _ => None, + }; + let authorized = match verification { + Some((credentials, permit)) => { let verifier = enabled.credentials.verifier.clone(); - tokio::task::spawn_blocking(move || { + let verified = tokio::task::spawn_blocking(move || { verify_password(&verifier, &credentials.password) }) .await - .unwrap_or(false) + .unwrap_or(false); + drop(permit); + verified } - _ => false, + None => false, }; let target_duration = target_duration(enabled, started_at.elapsed()); let outcome = if authorized { - CachedOutcome::Authorized + AuthOutcome::Authorized } else { - CachedOutcome::Rejected + AuthOutcome::Rejected }; if let Ok(mut cache) = enabled.cache.lock() { - cache.insert(tag, outcome, target_duration); + cache.insert( + tag, + if authorized { + CachedOutcome::Authorized + } else { + CachedOutcome::Rejected + }, + target_duration, + ); } pad_to_target(started_at, target_duration).await; - authorized + outcome } } @@ -232,10 +282,10 @@ pub(crate) async fn require_basic_auth( .headers() .get(AUTHORIZATION) .map(|value| value.as_bytes().to_vec()); - if auth.authorize(authorization.as_deref()).await { - next.run(request).await - } else { - unauthorized_response() + match auth.authorize(authorization.as_deref()).await { + AuthOutcome::Authorized => next.run(request).await, + AuthOutcome::Busy => busy_response(), + AuthOutcome::Rejected => unauthorized_response(), } } @@ -307,6 +357,14 @@ fn unauthorized_response() -> Response { .into_response() } +fn busy_response() -> Response { + ( + StatusCode::SERVICE_UNAVAILABLE, + [(RETRY_AFTER, HeaderValue::from_static(RETRY_AFTER_SECONDS))], + ) + .into_response() +} + #[cfg(test)] mod tests { use axum::{ @@ -333,10 +391,19 @@ mod tests { 2, Duration::from_secs(1), target_duration, + MAX_CONCURRENT_VERIFICATIONS, ) .unwrap() } + async fn assert_outcome( + auth: &OpdsBasicAuth, + authorization: Option<&[u8]>, + expected: AuthOutcome, + ) { + assert_eq!(auth.authorize(authorization).await, expected); + } + fn app(auth: OpdsBasicAuth) -> Router { Router::new() .route("/opds", get(|| async { "catalog" })) @@ -372,7 +439,12 @@ mod tests { async fn disabled_auth_bypasses_even_malformed_authorization() { let auth = OpdsBasicAuth::disabled(); - assert!(auth.authorize(Some(b"not even close to Basic")).await); + assert_outcome( + &auth, + Some(b"not even close to Basic"), + AuthOutcome::Authorized, + ) + .await; assert_eq!( response(auth, Some(b"not even close to Basic".to_vec())) .await @@ -385,21 +457,25 @@ mod tests { async fn enabled_auth_accepts_only_the_configured_basic_credentials() { let auth = enabled_auth("reader", b"correct horse", Duration::ZERO); - assert!( - auth.authorize(Some(&basic_header("reader", b"correct horse"))) - .await - ); - assert!( - !auth - .authorize(Some(&basic_header("reader", b"wrong password"))) - .await - ); - assert!( - !auth - .authorize(Some(&basic_header("someone-else", b"correct horse"))) - .await - ); - assert!(!auth.authorize(None).await); + assert_outcome( + &auth, + Some(&basic_header("reader", b"correct horse")), + AuthOutcome::Authorized, + ) + .await; + assert_outcome( + &auth, + Some(&basic_header("reader", b"wrong password")), + AuthOutcome::Rejected, + ) + .await; + assert_outcome( + &auth, + Some(&basic_header("someone-else", b"correct horse")), + AuthOutcome::Rejected, + ) + .await; + assert_outcome(&auth, None, AuthOutcome::Rejected).await; } #[tokio::test] @@ -425,25 +501,82 @@ mod tests { } #[tokio::test] - async fn cache_records_and_reuses_the_authorization_outcome_without_raw_header_bytes() { + async fn oversized_authorization_is_rejected_without_hashing_or_caching_it() { let auth = enabled_auth("reader", b"correct horse", Duration::ZERO); - let header = basic_header("reader", b"correct horse"); + let oversized = vec![b'x'; MAX_AUTHORIZATION_BYTES + 1]; + + let response = response(auth.clone(), Some(oversized.clone())).await; - assert!(auth.authorize(Some(&header)).await); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_outcome(&auth, Some(&oversized), AuthOutcome::Rejected).await; + assert!(auth + .enabled + .as_ref() + .unwrap() + .cache + .lock() + .unwrap() + .entries + .is_empty()); + } + + #[tokio::test] + async fn busy_verifier_permits_reject_without_hashing_or_caching_the_header() { + let auth = enabled_auth("reader", b"correct horse", Duration::ZERO); let enabled = auth.enabled.as_ref().unwrap(); - let cache = enabled.cache.lock().unwrap(); - assert_eq!(cache.entries.len(), 1); + let permits = (0..MAX_CONCURRENT_VERIFICATIONS) + .map(|_| { + enabled + .verifier_permits + .clone() + .try_acquire_owned() + .expect("fresh auth should expose exactly its configured permits") + }) + .collect::>(); + let header = basic_header("reader", b"wrong password"); + assert_eq!( - cache.entries[0].tag, - opaque_tag(&enabled.cache_key, &header) + auth.authorize(Some(&header)).await, + AuthOutcome::Busy, + "requests with no free verification permit must be rejected without hashing" ); - assert!(matches!( - cache.entries[0].outcome, - CachedOutcome::Authorized - )); - drop(cache); + assert_eq!( + response(auth.clone(), Some(header)).await.status(), + StatusCode::SERVICE_UNAVAILABLE + ); + assert!(enabled.cache.lock().unwrap().entries.is_empty()); + + drop(permits); + assert_outcome( + &auth, + Some(&basic_header("reader", b"wrong password")), + AuthOutcome::Rejected, + ) + .await; + assert_eq!(enabled.cache.lock().unwrap().entries.len(), 1); + } + + #[tokio::test] + async fn cache_records_and_reuses_the_authorization_outcome_without_raw_header_bytes() { + let auth = enabled_auth("reader", b"correct horse", Duration::ZERO); + let header = basic_header("reader", b"correct horse"); + + assert_outcome(&auth, Some(&header), AuthOutcome::Authorized).await; + let enabled = auth.enabled.as_ref().unwrap(); + { + let cache = enabled.cache.lock().unwrap(); + assert_eq!(cache.entries.len(), 1); + assert_eq!( + cache.entries[0].tag, + opaque_tag(&enabled.cache_key, &header) + ); + assert!(matches!( + cache.entries[0].outcome, + CachedOutcome::Authorized + )); + } - assert!(auth.authorize(Some(&header)).await); + assert_outcome(&auth, Some(&header), AuthOutcome::Authorized).await; assert_eq!(enabled.cache.lock().unwrap().entries.len(), 1); } @@ -454,11 +587,11 @@ mod tests { let unknown_header = basic_header("unknown", b"correct horse"); let first_started_at = Instant::now(); - assert!(!auth.authorize(Some(&unknown_header)).await); + assert_outcome(&auth, Some(&unknown_header), AuthOutcome::Rejected).await; let first_elapsed = first_started_at.elapsed(); let cached_started_at = Instant::now(); - assert!(!auth.authorize(Some(&unknown_header)).await); + assert_outcome(&auth, Some(&unknown_header), AuthOutcome::Rejected).await; let cached_elapsed = cached_started_at.elapsed(); let minimum_padded_duration = target_duration - Duration::from_millis(2); diff --git a/crates/citadel-opds/src/catalog.rs b/crates/citadel-opds/src/catalog.rs index 3994a2cc..02d8dd7e 100644 --- a/crates/citadel-opds/src/catalog.rs +++ b/crates/citadel-opds/src/catalog.rs @@ -1,9 +1,10 @@ -use std::{io::Read, sync::Arc}; +use std::{io::Read, sync::Arc, time::Duration}; use axum::{ body::Body, - extract::{Path, Query, State}, + extract::{Path, Query, Request, State}, http::{header, HeaderValue, StatusCode}, + middleware::{self, Next}, response::{IntoResponse, Response}, routing::get, Router, @@ -14,6 +15,8 @@ use libcalibre::{ AuthorId, BookId, BookPage, BookQuery, BookSortOrder, CalibreError, ResolvedBookAsset, }; use serde::Deserialize; +use tokio::sync::Semaphore; +use tower_http::{limit::RequestBodyLimitLayer, timeout::TimeoutLayer}; use super::{ assets::{self, AssetMethod, AssetResponseError}, @@ -23,6 +26,9 @@ use crate::identity::{book_identity, library_identity, navigation_identity}; const PAGE_SIZE: u64 = 50; const MAX_SEARCH_LENGTH: usize = 200; +const FEED_TIMEOUT: Duration = Duration::from_secs(30); +const MAX_REQUEST_BODY_BYTES: usize = 16 * 1024; +const MAX_CONCURRENT_REQUESTS: usize = 64; const ACQUISITION_REL: &str = "http://opds-spec.org/acquisition"; pub(crate) const IMAGE_REL: &str = "http://opds-spec.org/image"; const ATOM_TYPE: &str = "application/atom+xml;profile=opds-catalog;kind=acquisition"; @@ -154,8 +160,62 @@ pub(crate) struct FeedEntry { pub(crate) content: Option, } +/// Bounds how many requests the catalog serves at once. Clones of the guard +/// share one permit pool across every listener and connection. +#[derive(Clone)] +pub(crate) struct RequestGuard { + permits: Arc, +} + +impl RequestGuard { + pub(crate) fn with_capacity(permits: usize) -> Self { + Self { + permits: Arc::new(Semaphore::new(permits)), + } + } + + fn try_acquire(&self) -> Result { + self.permits + .clone() + .try_acquire_owned() + .map_err(|_| StatusCode::SERVICE_UNAVAILABLE) + } +} + +async fn enforce_request_guard( + State(guard): State, + request: Request, + next: Next, +) -> Response { + match guard.try_acquire() { + Ok(permit) => { + let _permit = permit; + next.run(request).await + } + Err(status) => ( + status, + [(header::RETRY_AFTER, HeaderValue::from_static("1"))], + ) + .into_response(), + } +} + pub fn router(source: Arc, auth: OpdsBasicAuth) -> Router { - Router::new() + build_router( + source, + auth, + RequestGuard::with_capacity(MAX_CONCURRENT_REQUESTS), + FEED_TIMEOUT, + ) +} + +fn build_router( + source: Arc, + auth: OpdsBasicAuth, + guard: RequestGuard, + feed_timeout: Duration, +) -> Router { + let feeds = Router::new() .route("/opds", get(root_feed)) .route("/opds/all", get(all_books_feed)) .route("/opds/recent", get(recent_books_feed)) @@ -170,6 +230,18 @@ pub fn router(source: Arc, auth: OpdsBasicAuth) -> Router { .route("/opds/genres/{id}", get(genre_books_feed)) .route("/opds/search", get(search_feed)) .route("/opds/opensearch.xml", get(opensearch_description)) + .with_state(CatalogState { + source: source.clone(), + }) + .layer(TimeoutLayer::with_status_code( + StatusCode::SERVICE_UNAVAILABLE, + feed_timeout, + )) + .layer(middleware::from_fn_with_state( + auth.clone(), + require_basic_auth, + )); + let assets = Router::new() .route( "/opds/books/{book_id}/files/{format}/{filename}", get(book_file).head(book_file_head), @@ -179,10 +251,12 @@ pub fn router(source: Arc, auth: OpdsBasicAuth) -> Router { get(book_cover).head(book_cover_head), ) .with_state(CatalogState { source }) - .layer(axum::middleware::from_fn_with_state( - auth, - require_basic_auth, - )) + .layer(middleware::from_fn_with_state(auth, require_basic_auth)); + Router::new() + .merge(feeds) + .merge(assets) + .layer(RequestBodyLimitLayer::new(MAX_REQUEST_BODY_BYTES)) + .layer(middleware::from_fn_with_state(guard, enforce_request_guard)) } async fn root_feed(state: State, query: Query) -> Response { @@ -328,20 +402,20 @@ async fn search_feed(state: State, Query(query): Query) -> Response { - facet_handler(state, CatalogFacetKind::Authors).await +async fn authors_feed(state: State, query: Query) -> Response { + facet_handler(state, query, CatalogFacetKind::Authors).await } -async fn series_feed(state: State) -> Response { - facet_handler(state, CatalogFacetKind::Series).await +async fn series_feed(state: State, query: Query) -> Response { + facet_handler(state, query, CatalogFacetKind::Series).await } -async fn tags_feed(state: State) -> Response { - facet_handler(state, CatalogFacetKind::Tags).await +async fn tags_feed(state: State, query: Query) -> Response { + facet_handler(state, query, CatalogFacetKind::Tags).await } -async fn genres_feed(state: State) -> Response { - facet_handler(state, CatalogFacetKind::Genres).await +async fn genres_feed(state: State, query: Query) -> Response { + facet_handler(state, query, CatalogFacetKind::Genres).await } async fn opensearch_description() -> Response { @@ -431,7 +505,15 @@ impl CatalogFacetKind { } } -async fn facet_handler(State(state): State, kind: CatalogFacetKind) -> Response { +async fn facet_handler( + State(state): State, + Query(query): Query, + kind: CatalogFacetKind, +) -> Response { + let page_number = query.page.unwrap_or(1); + if page_number == 0 { + return public_error(StatusCode::BAD_REQUEST, "Invalid page"); + } let source = state.source.clone(); let result = tokio::task::spawn_blocking(move || { let library_uuid = source.active_library_id()?; @@ -449,7 +531,26 @@ async fn facet_handler(State(state): State, kind: CatalogFacetKind Ok(Err(error)) => return calibre_error(error), Err(_) => return public_error(StatusCode::INTERNAL_SERVER_ERROR, "Catalog unavailable"), }; - match facet_navigation_feed(&library_uuid, kind, &facets) { + let last_page = page_count(u64::try_from(facets.len()).unwrap_or(0)); + if page_number > last_page { + return public_error(StatusCode::NOT_FOUND, "Page not found"); + } + let offset = match page_number + .checked_sub(1) + .and_then(|page| page.checked_mul(PAGE_SIZE as u64)) + .and_then(|offset| usize::try_from(offset).ok()) + { + Some(offset) => offset, + None => return public_error(StatusCode::BAD_REQUEST, "Invalid page"), + }; + let facets = facets + .get(offset..) + .unwrap_or_default() + .iter() + .take(PAGE_SIZE as usize) + .cloned() + .collect::>(); + match facet_navigation_feed(&library_uuid, kind, &facets, page_number, last_page) { Ok(xml) => xml_response(NAVIGATION_CONTENT_TYPE, xml), Err(_) => public_error(StatusCode::INTERNAL_SERVER_ERROR, "Catalog unavailable"), } @@ -782,28 +883,45 @@ fn facet_navigation_feed( library_uuid: &str, kind: CatalogFacetKind, facets: &[CatalogFacet], + page_number: u64, + last_page: u64, ) -> Result, quick_xml::Error> { + let mut links = vec![ + FeedLink { + rel: "self", + href: page_href(kind.route(), page_number), + media_type: NAVIGATION_TYPE, + }, + FeedLink { + rel: "start", + href: "/opds".to_string(), + media_type: NAVIGATION_TYPE, + }, + FeedLink { + rel: "up", + href: "/opds".to_string(), + media_type: NAVIGATION_TYPE, + }, + ]; + if page_number > 1 { + links.push(FeedLink { + rel: "previous", + href: page_href(kind.route(), page_number - 1), + media_type: NAVIGATION_TYPE, + }); + } + if page_number < last_page { + links.push(FeedLink { + rel: "next", + href: page_href(kind.route(), page_number + 1), + media_type: NAVIGATION_TYPE, + }); + } let feed = Feed { - id: navigation_identity(library_uuid, kind.route()), + id: navigation_identity(library_uuid, &page_href(kind.route(), page_number)), title: format!("Citadel — {}", kind.title()), updated: feed_updated(None), - links: vec![ - FeedLink { - rel: "self", - href: kind.route().to_string(), - media_type: NAVIGATION_TYPE, - }, - FeedLink { - rel: "start", - href: "/opds".to_string(), - media_type: NAVIGATION_TYPE, - }, - FeedLink { - rel: "up", - href: "/opds".to_string(), - media_type: NAVIGATION_TYPE, - }, - ], + links, entries: facets .iter() .map(|facet| { @@ -976,6 +1094,10 @@ mod tests { books: Vec, } + struct FacetSource { + facets: Vec, + } + impl CatalogSource for MemorySource { fn active_library_id(&self) -> Result { Ok("550e8400-e29b-41d4-a716-446655440000".to_string()) @@ -1015,6 +1137,54 @@ mod tests { } } + impl CatalogSource for FacetSource { + fn active_library_id(&self) -> Result { + Ok("550e8400-e29b-41d4-a716-446655440000".to_string()) + } + + fn book_page( + &self, + _query: CatalogBookQuery, + ) -> Result<(String, Option, BookPage), CalibreError> { + Ok(( + "550e8400-e29b-41d4-a716-446655440000".to_string(), + None, + BookPage { + items: Vec::new(), + total: 0, + }, + )) + } + + fn authors(&self) -> Result, CalibreError> { + Ok(self.facets.clone()) + } + + fn series(&self) -> Result, CalibreError> { + Ok(self.facets.clone()) + } + + fn tags(&self) -> Result, CalibreError> { + Ok(self.facets.clone()) + } + + fn genres(&self) -> Result, CalibreError> { + Ok(self.facets.clone()) + } + + fn book_file( + &self, + book_id: BookId, + format: &str, + ) -> Result { + Err(CalibreError::BookFileNotFound(book_id, format.to_string())) + } + + fn book_cover(&self, book_id: BookId) -> Result { + Err(CalibreError::BookCoverNotFound(book_id)) + } + } + struct LibrarySource { library: Mutex, } @@ -1431,6 +1601,66 @@ mod tests { server.abort(); } + #[tokio::test] + async fn facet_navigation_routes_are_bounded_and_paginated() { + let facets = (1..=101) + .map(|id| CatalogFacet { + id, + title: format!("Facet {id:03}"), + book_count: Some(i64::from(id)), + }) + .collect(); + let (base, server) = loopback(Arc::new(FacetSource { facets })).await; + let client = reqwest::Client::new(); + + for route in [ + "/opds/authors", + "/opds/series", + "/opds/tags", + "/opds/genres", + ] { + let second_page = format!("{route}?page=2"); + let third_page = format!("{route}?page=3"); + let first = client.get(format!("{base}{route}")).send().await.unwrap(); + assert_eq!(first.status(), StatusCode::OK, "{route} page 1"); + let first = parsed_feed(&first.bytes().await.unwrap()); + assert_eq!(first.titles.len(), 50, "{route} page 1"); + assert!(first.previous.is_none(), "{route} page 1"); + assert_eq!(first.next.as_deref(), Some(second_page.as_str())); + + let second = client + .get(format!("{base}{route}?page=2")) + .send() + .await + .unwrap(); + assert_eq!(second.status(), StatusCode::OK, "{route} page 2"); + let second = parsed_feed(&second.bytes().await.unwrap()); + assert_eq!(second.titles.len(), 50, "{route} page 2"); + assert_eq!(second.previous.as_deref(), Some(route)); + assert_eq!(second.next.as_deref(), Some(third_page.as_str())); + + let third = client + .get(format!("{base}{route}?page=3")) + .send() + .await + .unwrap(); + assert_eq!(third.status(), StatusCode::OK, "{route} page 3"); + let third = parsed_feed(&third.bytes().await.unwrap()); + assert_eq!(third.titles, ["Facet 101"], "{route} page 3"); + assert_eq!(third.previous.as_deref(), Some(second_page.as_str())); + assert!(third.next.is_none(), "{route} page 3"); + + let missing = client + .get(format!("{base}{route}?page=4")) + .send() + .await + .unwrap(); + assert_eq!(missing.status(), StatusCode::NOT_FOUND, "{route} page 4"); + } + + server.abort(); + } + #[tokio::test] async fn search_is_limited_and_preserves_query_in_pagination_links() { let books = (0..51) @@ -1871,4 +2101,145 @@ mod tests { assert_eq!(response.text().await.unwrap(), "Catalog unavailable"); server.abort(); } + + async fn loopback_router(router: Router) -> (String, tokio::task::JoinHandle<()>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let task = tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + (format!("http://{address}"), task) + } + + struct DelayedSource { + inner: Arc, + feed_delay: Duration, + asset_delay: Duration, + } + + impl CatalogSource for DelayedSource { + fn active_library_id(&self) -> Result { + std::thread::sleep(self.feed_delay); + self.inner.active_library_id() + } + + fn book_page( + &self, + query: CatalogBookQuery, + ) -> Result<(String, Option, BookPage), CalibreError> { + self.inner.book_page(query) + } + + fn book_file( + &self, + book_id: BookId, + format: &str, + ) -> Result { + std::thread::sleep(self.asset_delay); + self.inner.book_file(book_id, format) + } + + fn book_cover(&self, book_id: BookId) -> Result { + std::thread::sleep(self.asset_delay); + self.inner.book_cover(book_id) + } + } + + #[tokio::test] + async fn exhausted_request_guard_answers_service_unavailable() { + let guard = RequestGuard::with_capacity(1); + let held_permit = guard.try_acquire().unwrap(); + let (base, server) = loopback_router(build_router( + Arc::new(NoLibrary), + OpdsBasicAuth::disabled(), + guard, + Duration::from_secs(30), + )) + .await; + + let response = reqwest::get(format!("{base}/opds")).await.unwrap(); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(response.headers().get(header::RETRY_AFTER).unwrap(), "1"); + drop(held_permit); + server.abort(); + } + + #[tokio::test] + async fn slow_feeds_time_out_while_slow_asset_routes_do_not() { + let source = Arc::new(DelayedSource { + inner: Arc::new(NoLibrary), + feed_delay: Duration::from_millis(200), + asset_delay: Duration::from_millis(100), + }); + let (base, server) = loopback_router(build_router( + source, + OpdsBasicAuth::disabled(), + RequestGuard::with_capacity(MAX_CONCURRENT_REQUESTS), + Duration::from_millis(10), + )) + .await; + let client = reqwest::Client::new(); + + let feed = client.get(format!("{base}/opds")).send().await.unwrap(); + assert_eq!(feed.status(), StatusCode::SERVICE_UNAVAILABLE); + + let asset = client + .get(format!("{base}/opds/books/1/cover")) + .send() + .await + .unwrap(); + assert_ne!( + asset.status(), + StatusCode::REQUEST_TIMEOUT, + "asset downloads must not be bound by the feed timeout" + ); + assert_eq!(asset.status(), StatusCode::SERVICE_UNAVAILABLE); + server.abort(); + } + + #[tokio::test] + async fn book_file_formats_can_never_traverse_paths() { + let (directory, mut library) = test_library(); + let source_path = directory.path().join("book.epub"); + std::fs::write(&source_path, b"book").unwrap(); + let added = library + .add_book(BookAdd { + title: "Traversal Target".to_string(), + author_names: vec!["Author".to_string()], + tags: None, + series: None, + series_index: None, + publisher: None, + publication_date: None, + rating: None, + comments: None, + identifiers: HashMap::new(), + language: None, + file_paths: vec![source_path], + }) + .unwrap(); + let (base, server) = loopback(Arc::new(LibrarySource { + library: Mutex::new(library), + })) + .await; + let client = reqwest::Client::new(); + for format in ["..%2f", "%2e%2e%2f%2e%2e%2fmetadata.db"] { + let response = client + .get(format!( + "{base}/opds/books/{}/files/{format}/book.epub", + added.id.as_i32() + )) + .send() + .await + .unwrap(); + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "format {format} must be rejected" + ); + assert_eq!(response.text().await.unwrap(), "Invalid asset request"); + } + server.abort(); + drop(directory); + } } diff --git a/crates/citadel-opds/src/network.rs b/crates/citadel-opds/src/network.rs index 3ae422e3..7bc47961 100644 --- a/crates/citadel-opds/src/network.rs +++ b/crates/citadel-opds/src/network.rs @@ -644,6 +644,37 @@ mod tests { ); } + #[test] + fn advertised_urls_list_ipv4_first_and_never_link_local() { + let interfaces = [interface( + "en0", + OpdsInterfaceKind::Lan, + OpdsInterfaceState::Up, + vec![ + ipv6("2001:db8::42"), + ipv6("fe80::42"), + ipv4([192, 168, 1, 42]), + ], + )]; + + let urls = match plan_bindings(&interfaces, &OpdsBindTarget::AllLocalNetworks, 8080) { + BindPlan::Listen(addresses) => addresses + .iter() + .copied() + .map(advertised_url) + .collect::>(), + BindPlan::Wait(reason) => panic!("expected listening plan, got {reason:?}"), + }; + + assert_eq!( + urls, + vec![ + "http://192.168.1.42:8080/opds".to_string(), + "http://[2001:db8::42]:8080/opds".to_string(), + ] + ); + } + #[test] fn explicit_addresses_do_not_depend_on_interface_enumeration() { let plan = plan_bindings( diff --git a/crates/citadel-opds/src/service.rs b/crates/citadel-opds/src/service.rs index 83069cf3..0faed3ff 100644 --- a/crates/citadel-opds/src/service.rs +++ b/crates/citadel-opds/src/service.rs @@ -64,6 +64,7 @@ pub enum OpdsErrorCode { InterfaceUnavailable, InterfaceEnumerationFailed, PortUnavailable, + PermissionDenied, ListenerFailed, InvalidCredentials, CredentialsRequired, @@ -899,16 +900,19 @@ fn interface_enumeration_error(retrying: bool) -> OpdsStatusError { } fn bind_error(error: io::Error) -> OpdsStatusError { - if error.kind() == io::ErrorKind::AddrInUse { - OpdsStatusError { + match error.kind() { + io::ErrorKind::AddrInUse => OpdsStatusError { code: OpdsErrorCode::PortUnavailable, message: "That port is already in use on the selected network interface.".to_string(), - } - } else { - OpdsStatusError { + }, + io::ErrorKind::PermissionDenied => OpdsStatusError { + code: OpdsErrorCode::PermissionDenied, + message: "The operating system denied access to the requested network listener. Check firewall and local-network permissions.".to_string(), + }, + _ => OpdsStatusError { code: OpdsErrorCode::Unexpected, message: "Citadel could not open the requested OPDS listener.".to_string(), - } + }, } } @@ -922,6 +926,15 @@ mod tests { use super::*; use crate::network::{AddressScope, InterfaceAddress, OpdsInterfaceKind, OpdsInterfaceState}; + #[test] + fn bind_permission_denial_is_actionable() { + let error = bind_error(io::Error::from(io::ErrorKind::PermissionDenied)); + + assert_eq!(error.code, OpdsErrorCode::PermissionDenied); + assert!(error.message.contains("firewall")); + assert!(error.message.contains("local-network permissions")); + } + struct TestSource { library_id: Option, } diff --git a/crates/citadel-server/src/lib.rs b/crates/citadel-server/src/lib.rs index 16463557..4642fe56 100644 --- a/crates/citadel-server/src/lib.rs +++ b/crates/citadel-server/src/lib.rs @@ -200,6 +200,15 @@ fn validate_config(config: &ServerConfig) -> Result<(), String> { if address.is_unspecified() { return Err("wildcard sharing target addresses are not allowed".to_string()); } + let link_local = match address { + IpAddr::V4(address) => address.is_link_local(), + IpAddr::V6(address) => (address.segments()[0] & 0xffc0) == 0xfe80, + }; + if link_local { + return Err(format!( + "link-local sharing target address is not allowed: {address}" + )); + } } } if let Some(credentials) = &config.credentials { @@ -291,6 +300,7 @@ fn is_fatal_startup(status: &OpdsServiceStatus) -> bool { | OpdsErrorCode::CredentialsRequired | OpdsErrorCode::CredentialStorageFailed | OpdsErrorCode::ConfigurationConflict + | OpdsErrorCode::PermissionDenied | OpdsErrorCode::Unexpected ) ) || status.state == OpdsLifecycleState::Stopped @@ -396,4 +406,20 @@ type = "allLocalNetworks" assert!(rejected); } } + + #[test] + fn config_rejects_link_local_explicit_addresses() { + let rejected = r#"libraryPath = "/tmp/library" +stateDirectory = "/tmp/state" +[sharing] +port = 8080 +authenticationEnabled = false +[sharing.target] +type = "addresses" +addresses = ["fe80::1"] +"#; + let config = toml::from_str::(rejected).unwrap(); + let error = validate_config(&config).unwrap_err(); + assert!(error.contains("link-local"), "unexpected error: {error}"); + } } diff --git a/docs/README.md b/docs/README.md index 933ae91b..f508d6b6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -18,6 +18,12 @@ browser engine installed on the user's system. - **[Updater and release operations](./updater-and-releases.md)** - Tauri updater setup, GitHub release flow, and verification checklist +## OPDS + +- **[Share a library with KOReader](./opds-sharing.md)** - Desktop setup, optional Basic authentication, network limits, and troubleshooting +- **[OPDS v1 validation record](./opds-validation.md)** - Automated evidence and the physical package/KOReader acceptance matrix +- **[Headless OPDS server](./headless-server.md)** - Run the Tauri-independent server process from an explicit configuration file + **Start here if you're:** - 🚀 **New to the project**: Read the Overview below, then [Architecture Recommendations](../ai-docs/ARCHITECTURE_RECOMMENDATIONS.md) - 🔧 **Adding a feature**: Check [Patterns Quick Reference](../ai-docs/PATTERNS_QUICK_REFERENCE.md) @@ -25,7 +31,9 @@ browser engine installed on the user's system. ## Overview -Citadel ships as a single bundled desktop app; a headless server & web app is exploratory only. +Citadel ships as a bundled desktop app. A separate headless OPDS server uses +the same core runtime without a WebView; a remotely managed web app remains +exploratory.
Diagram showing that the UI has a Calibre client that uses IPC to talk to the backend's calibre adapter, which calls out to libcalibre. Space is left open to demonstrate that other clients and adapters are possible. diff --git a/docs/opds-sharing.md b/docs/opds-sharing.md new file mode 100644 index 00000000..50970e3a --- /dev/null +++ b/docs/opds-sharing.md @@ -0,0 +1,87 @@ +# Share a library with KOReader + +Citadel can expose the active Calibre library as a read-only OPDS catalog while +the desktop app is running. Current KOReader is the supported v1 client; other +OPDS readers may work but are not yet part of Citadel's compatibility promise. + +## Start sharing + +1. Open Citadel's **Settings**, then **Sharing**. +2. Choose **All local networks** or the specific Wi-Fi/Ethernet interface that + should host the catalog. +3. Keep the default port, `8080`, unless it conflicts with another service. +4. Optionally enable **Require a password** and set a username and password. + Citadel can generate a password, but shows it only once. Save it before + leaving the pane. +5. Select **Start Sharing**. +6. Copy one of the concrete catalog URLs shown by Citadel. Do not add the + username or password to the URL. + +Only the current active library is shared. Switching libraries changes the +served catalog. Sharing stops when Citadel quits and must be started again +after every launch; the network, port, and authentication settings remain +saved. + +## Add the catalog to KOReader + +The KOReader device must be able to reach the selected computer interface. +Usually that means both devices are on the same local network. + +1. In KOReader's File Browser, open the top menu and choose **OPDS catalog**. +2. Choose **Add new OPDS catalog**. +3. Enter a name such as `Citadel` and paste the URL copied from Citadel. +4. If authentication is enabled, enter the username and password in KOReader's + credential fields. +5. Open the new catalog. + +The root contains All Books, Recently Modified, Unread, Authors, Series, Tags, +and Genres, plus search. Genre is separate from arbitrary Calibre tags and is +populated only after genres have been accepted into Citadel's `Genres` custom +column. Books and covers are downloaded directly from the active library; +OPDS cannot edit the library or synchronize reading progress. + +KOReader's current user guide documents the OPDS catalog entry point: +. + +## Network and security limits + +Citadel serves plain HTTP. A Basic-auth password prevents unauthenticated +browsing, but the credentials and downloaded books are not encrypted in +transit. Use sharing only on a network you trust. Citadel does not configure +the operating-system firewall and does not provide HTTPS, remote internet +exposure, Bonjour/mDNS discovery, or a QR code in v1. + +**All local networks** binds Citadel to the concrete addresses of eligible +local Wi-Fi/Ethernet interfaces; it does not use a wildcard listener. Choosing +one interface prevents Citadel from silently broadening to another interface. +If that interface disappears, Citadel waits for the same interface to return. + +Citadel advertises usable IPv4 and global/unique-local IPv6 addresses. It does +not advertise IPv6 link-local URLs because their zone identifiers are not +portable between the computer and reader. If a reader cannot route a displayed +IPv6 address, use the displayed IPv4 URL instead. + +## Troubleshooting + +- **KOReader cannot open the catalog:** confirm sharing still says **Sharing**, + use a URL currently displayed by Citadel, and check that both devices can + communicate on the selected network. Guest Wi-Fi often isolates devices. +- **Authentication keeps failing:** edit credentials only while sharing is + stopped, then restart sharing. Enter them in KOReader's username/password + fields, not in the catalog URL. +- **Citadel is waiting for the network:** reconnect the selected interface or + stop sharing and choose another interface. Citadel will not fall back to a + broader listener automatically. +- **The port is already in use:** stop the conflicting service or choose a + different port in Citadel before starting again. +- **macOS asks about incoming connections:** allow them for Citadel if the + catalog should be reachable. Signed and unsigned builds can receive different + firewall treatment. +- **Linux cannot be reached:** allow the selected TCP port in the host firewall. + Citadel intentionally does not modify firewall rules. +- **The wrong books appear:** stop sharing if needed, select the intended + library in Citadel, and reopen the catalog. Only one active library is served. + +Stopping sharing or quitting Citadel closes every listener. If a listener still +appears reachable after the app exits, record the Citadel version, platform, +selected target, and catalog URL when reporting the defect. diff --git a/docs/opds-validation.md b/docs/opds-validation.md new file mode 100644 index 00000000..57729e7f --- /dev/null +++ b/docs/opds-validation.md @@ -0,0 +1,87 @@ +# OPDS v1 validation record + +This record separates repeatable automated coverage from the physical package +and reader checks required by CDL-27. Do not mark an unexecuted platform or +client scenario as passing. Record exact versions, artifact identity, network +path, and fixture details when running the manual matrix. + +## Current evidence + +### Local macOS package inspection — 2026-08-02 + +| Field | Result | +| --- | --- | +| Host | macOS, Apple Silicon | +| Command | `bun run build` | +| Artifacts | `Citadel.app`, `Citadel_0.6.1_aarch64.dmg` | +| Signature | Ad-hoc/linker signature only; no Team ID | +| Gatekeeper | Rejected because the local artifact is not Developer ID signed | +| Notarization | Not tested; release credentials are unavailable to the local build | +| Bundled helpers | None; the app contains the Citadel executable, icon, and empty-library resource | +| WebDriver | Registration is guarded by `debug_assertions`; no WebDriver listener is started by release code | +| OPDS-specific WebView permission | None; management remains Tauri IPC and readers access the native Rust listener | +| Local-network usage description | None added; packaged listener behavior still requires physical verification on supported macOS releases | + +The release workflow supplies Apple signing and notarization credentials and is +the correct source for the signed/notarized artifact. A successful local bundle +does not substitute for testing that release artifact. + +### Existing real-reader evidence — 2026-08-02 + +The current branch was successfully opened from KOReader over the developer +machine's `en0` interface using HTTP Basic credentials. The KOReader version, +device/OS, Citadel package identity, authentication-disabled case, and full +navigation/download matrix were not recorded, so this is a useful v1 proof but +not a completed CDL-27 package result. + +## Automated coverage + +| Contract | Coverage | +| --- | --- | +| OPDS XML, escaping, optional metadata, search, and acquisition pagination | `crates/citadel-opds/src/catalog.rs` tests | +| Cover/book GET, HEAD, ranges, and bounded streaming | `crates/citadel-opds/src/assets.rs` tests | +| Library path containment and symlink escape rejection | `crates/libcalibre/tests/asset_resolution_test.rs` | +| Basic challenge, success/failure, verifier storage, bounded cache, and timing padding | `crates/citadel-opds/src/auth.rs` and `credentials.rs` tests | +| Interface selection, IPv4/IPv6 planning, loss/recovery, and no exposure broadening | `crates/citadel-opds/src/network.rs` and `service.rs` tests | +| Port conflict, stop/restart, shutdown timeout, and listener release | `crates/citadel-opds/src/service.rs` tests | +| Active-library switch during concurrent feed/download traffic | `src-tauri/src/state.rs` tests | +| Real headless process auth, catalog, acquisition, and signal shutdown | `crates/citadel-server/tests/headless_process.rs` | + +Automation does not prove OS firewall UI, package signing/notarization, +cross-device routing, or KOReader's behavior on a particular device build. + +## Physical package and KOReader matrix + +Use a representative library with more than one catalog page, Unicode and XML +metacharacters, multiple authors/series/tags/genres, mixed read state, missing +optional metadata, covers and missing covers, multiple formats, and a large +book. Record the fixture identity and whether it is disposable. + +| Platform/artifact | KOReader device/version | Target | Auth | Result/notes | +| --- | --- | --- | --- | --- | +| Signed/notarized macOS release | — | Specific Wi-Fi/Ethernet | Off | Not run | +| Signed/notarized macOS release | — | Specific Wi-Fi/Ethernet | Basic | Not run | +| Signed/notarized macOS release | — | All local networks | Off + Basic | Not run | +| Unsigned macOS development package | — | Specific Wi-Fi/Ethernet | Off + Basic | Authenticated `en0` smoke test only; exact versions missing | +| Ubuntu `.deb` | — | Specific Wi-Fi/Ethernet | Off + Basic | Not run | +| Ubuntu AppImage | — | All local networks | Off + Basic | Not run | + +For every applicable row, verify and record: + +- Manual URL entry and root navigation. +- All Books, Recently Modified, Unread, Authors, Series, Tags, Genres, and + KOReader search. +- First/middle/last-page navigation without duplicates or omissions. +- Cover display and download/open for every fixture format. +- Missing, incorrect, and correct credentials; copied URLs contain no secrets. +- Stop/start in one launch and restart-off behavior with settings preserved. +- Active-library switching while sharing. +- Selected-interface loss, return, and address change. +- Port conflict plus macOS privacy/firewall or Linux firewall denial. +- Concurrent browsing and large download responsiveness. +- Listener and port release after Stop Sharing and after quitting Citadel. + +Record in-scope defects with reproduction steps and add an automated regression +where practical. Propose excluded features separately; do not expand this +matrix to HTTPS, Bonjour/mDNS, QR codes, remote deployment, or additional client +support promises. diff --git a/src/bindings.ts b/src/bindings.ts index 1964330a..46f33af5 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -580,7 +580,7 @@ export type MetadataProvider = "hardcover" | "loc" | "dnb" | "k10plus" | "openli export type NewAuthor = { name: string; sortable_name: string | null } 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 OpdsErrorCode = "invalidPort" | "libraryNotReady" | "configurationConflict" | "interfaceUnavailable" | "interfaceEnumerationFailed" | "portUnavailable" | "permissionDenied" | "listenerFailed" | "invalidCredentials" | "credentialsRequired" | "credentialStorageFailed" | "unexpected" export type OpdsInterfaceKind = "lan" | "vpn" | "loopback" | "other" export type OpdsInterfaceState = "up" | "down" export type OpdsLifecycleState = "stopped" | "starting" | "running" | "waitingForInterface" | "error" | "stopping" diff --git a/src/components/organisms/SharingSettings.tsx b/src/components/organisms/SharingSettings.tsx index 898fd93d..2da26aaf 100644 --- a/src/components/organisms/SharingSettings.tsx +++ b/src/components/organisms/SharingSettings.tsx @@ -229,20 +229,26 @@ export const SharingSettings = () => { {generatedPassword && ( -
-
- Generated password - {generatedPassword} + <> +
+
+ Generated password + {generatedPassword} +
+
- -
+

+ This password is shown only once, right here. Copy it into + your reader now. It travels unencrypted on your local network. +

+ )}