diff --git a/CHANGELOG.md b/CHANGELOG.md index 766dabc..6d9ec0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- AEs can be registered and removed at runtime with `POST /aets` and `DELETE /aets/{aet}` (#35). + Disabled by default; enable it with `server.dynamic-registration`, which requires a bearer token and + a list of allowed hosts. An AET can only be registered once: identical re-registrations are a no-op, + different configurations are rejected with `409 Conflict`. Registrations are not persisted. + ### Changed +- Startup fails if an AET is configured multiple times or its host cannot be resolved, instead of + silently using the last entry or panicking. +- `GET /aets` returns the AETs in alphabetical order. + ### Fixed ## [0.3.3] - 2026-09-28 diff --git a/Cargo.lock b/Cargo.lock index ade1275..2681c21 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1720,9 +1720,11 @@ dependencies = [ "futures", "http-body-util", "image", + "ipnet", "mimalloc", "mime", "multer", + "reqwest 0.12.28", "sentry", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 9215765..8919d85 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,7 @@ mime = "0.3.17" tower-http = { version = "0.6.6", features = ["trace", "cors", "timeout", "normalize-path"] } tower = { version = "0.5.2", features = ["limit"] } url = "2.5.7" +ipnet = "2.11.0" async-trait = "0.1.89" async-stream = "0.3.6" uuid = { version = "1.18.1", features = ["v4"] } @@ -65,6 +66,7 @@ aws-credential-types = { version = "1.2.13", optional = true } testcontainers = "0.27.3" dicom-test-files = "0.4.0" dicom-web = "0.6.0" +reqwest = { version = "0.12.28", default-features = false, features = ["json"] } [lints.rust] unsafe_code = "forbid" diff --git a/README.md b/README.md index 22adc2e..0f3db10 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,37 @@ Returns a simple OK if the connection is still healthy. | ------------ | ------------- | | Health Check | `/aets/{aet}` | +### Dynamic AE Registration + +Registers and removes AEs at runtime. Disabled by default. +Registered AEs are kept in memory only and are lost after a restart. + +| Resource | Method | URI Template | +| ----------- | -------- | ------------- | +| Register AE | `POST` | `/aets` | +| Remove AE | `DELETE` | `/aets/{aet}` | + +Requests must send a bearer token, and AEs may only point to allowed hosts. +Allowed hosts can be IP addresses, CIDR ranges or hostnames, optionally restricted to a single port: + +```yaml +server: + dynamic-registration: + enabled: true + token-env: DICOM_RST_ADMIN_TOKEN # environment variable that contains the bearer token + allowed-hosts: + - host: 10.0.0.0/8 # any address from 10.0.0.0 to 10.255.255.255 + - host: 192.168.10.0/24 # any address from 192.168.10.0 to 192.168.10.255, but only port 104 + port: 104 + - host: fd00::/8 # IPv6 unique local addresses + - host: pacs.internal # exact hostname +``` + +To allow any host, use `0.0.0.0/0` and `::/0`. +Each AET can only be registered once. Registering it again with the same configuration has no effect, +a different configuration is rejected with `409 Conflict`. +See the [configuration documentation](docs/topics/configuration.md) for details. + [^1]: The [DICOM-RST logo](./dicom-rst-icon.png) is adapted from the [Rust logo](https://github.com/rust-lang/rust-artwork) diff --git a/docs/resources/openapi.yaml b/docs/resources/openapi.yaml index 78d0afd..69a9d9d 100644 --- a/docs/resources/openapi.yaml +++ b/docs/resources/openapi.yaml @@ -24,6 +24,10 @@ tags: description: DICOM PS3.18 10.5 url: https://dicom.nema.org/medical/dicom/current/output/chtml/part18/sect_10.5.html components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer parameters: aet: in: path @@ -79,6 +83,33 @@ paths: schema: type: array example: [ "CLINICAL", "RESEARCH" ] + post: + summary: Register an AET at runtime + description: > + Only available if `server.dynamic-registration.enabled` is set. + Registered AETs are kept in memory and are lost after a restart. + tags: + - DICOM-RST + security: + - bearerAuth: [ ] + requestBody: + required: true + content: + application/json: + example: { "aet": "PACS", "backend": "DIMSE", "host": "10.1.2.3", "port": 104, "wado-rs": { "timeout": 60000, "receivers": [ "DICOM-RST" ] } } + responses: + 201: + description: The AET was registered. Returns the normalized configuration. + 200: + description: The AET is already registered with an identical configuration. Nothing was changed. + 401: + description: Missing or invalid bearer token. + 403: + description: The host is not allowed by `server.dynamic-registration.allowed-hosts`. + 409: + description: The AET is already registered with a different configuration or is statically configured. + 422: + description: The configuration is invalid, uses an unsupported backend or the host cannot be resolved. /aets/{aet}: get: summary: AET Healthcheck @@ -92,6 +123,24 @@ paths: content: text/plain: example: "Connection is healthy" + delete: + summary: Remove an AET registered at runtime + description: Only available if `server.dynamic-registration.enabled` is set. + tags: + - DICOM-RST + security: + - bearerAuth: [ ] + parameters: + - $ref: "#/components/parameters/aet" + responses: + 204: + description: The AET was removed. + 401: + description: Missing or invalid bearer token. + 404: + description: The AET is unknown. + 409: + description: The AET is statically configured and cannot be removed. /aets/{aet}/studies: get: tags: diff --git a/docs/topics/configuration.md b/docs/topics/configuration.md index 547443f..d4e76ae 100644 --- a/docs/topics/configuration.md +++ b/docs/topics/configuration.md @@ -184,6 +184,61 @@ server: +## Dynamic Registration Config + +AEs can be registered and removed at runtime with `POST /aets` and `DELETE /aets/{aet}`. +This is disabled by default. + +```yaml +server: + dynamic-registration: + enabled: true + token-env: DICOM_RST_ADMIN_TOKEN + allowed-hosts: + - host: 10.0.0.0/8 + - host: pacs.internal + port: 4242 +``` + +The request body of `POST /aets` uses the same format as an entry in `aets` (see below), serialized as JSON: + +```shell +curl -X POST http://localhost:8080/aets \ + -H "Authorization: Bearer $DICOM_RST_ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"aet": "MY-PACS", "backend": "DIMSE", "host": "10.1.2.3", "port": 104, "wado-rs": {"timeout": 60000, "receivers": ["DICOM-RST"]}}' +``` + +Each AET can only be registered once: + +- Registering an AET again with an identical configuration is a no-op and returns `200 OK`, so clients can safely retry. +- Registering an AET with a different configuration returns `409 Conflict`. Remove the AE first to change it. +- AEs from the configuration file cannot be modified or removed. + + +Dynamically registered AEs are only kept in memory and are lost when %product% restarts. +The client that registers AEs is responsible for registering them again after a restart. + + + + + Enables the endpoints for registering and removing AEs at runtime. Disabled by default. + + + The name of the environment variable that contains the bearer token. + Requests must send this token in the Authorization: Bearer header. + Required if dynamic registration is enabled. + + + The hosts that dynamically registered AEs may point to. + host is an IP address, a CIDR range or a hostname, which is compared with the host in the request. + port optionally restricts the entry to a single port. + An empty list allows no hosts. + + + +Only the DIMSE backend can be registered dynamically. + ## DICOMweb Config ```yaml diff --git a/src/api/aets.rs b/src/api/aets.rs index da7d210..2d48d74 100644 --- a/src/api/aets.rs +++ b/src/api/aets.rs @@ -1,26 +1,115 @@ +use crate::config::ApplicationEntityConfig; +use crate::registry::{Registration, RegistryError}; use crate::AppState; -use axum::extract::{Path, State}; -use axum::http::StatusCode; -use axum::response::IntoResponse; -use axum::routing::get; +use axum::extract::{Path, Request, State}; +use axum::http::{header, StatusCode}; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; +use axum::routing::{delete, get, post}; use axum::{Json, Router}; +use std::sync::Arc; -pub fn routes() -> Router { - Router::new() +/// Creates the routes for AE management. +/// If `admin_token` is set, AEs can be registered and removed by requests with that bearer token. +pub fn routes(admin_token: Option) -> Router { + let router = Router::new() .route("/aets", get(all_aets)) - .route("/aets/{aet}", get(aet_health)) + .route("/aets/{aet}", get(aet_health)); + + let Some(admin_token) = admin_token else { + return router; + }; + let admin_token: Arc = Arc::from(admin_token); + let admin_router = Router::new() + .route("/aets", post(register_aet)) + .route("/aets/{aet}", delete(unregister_aet)) + .route_layer(axum::middleware::from_fn(move |request, next| { + let admin_token = Arc::clone(&admin_token); + async move { authorize(&admin_token, request, next).await } + })); + + router.merge(admin_router) } async fn all_aets(state: State) -> impl IntoResponse { - let aets = &state.config.aets; - - Json(serde_json::Value::Array( - aets.iter() - .map(|ae| serde_json::Value::String(ae.aet.clone())) - .collect::>(), - )) + Json(state.registry.list()) } async fn aet_health(Path(aet): Path) -> impl IntoResponse { (StatusCode::OK, format!("{aet} is healthy")).into_response() } + +async fn register_aet( + state: State, + Json(config): Json, +) -> Result { + let (registration, registered_ae) = state.registry.register(config).await?; + let status = match registration { + Registration::Created => StatusCode::CREATED, + Registration::Unchanged => StatusCode::OK, + }; + Ok((status, Json(registered_ae.config.clone())).into_response()) +} + +async fn unregister_aet( + state: State, + Path(aet): Path, +) -> Result { + state.registry.unregister(&aet)?; + Ok(StatusCode::NO_CONTENT) +} + +/// Rejects requests without the expected bearer token. +async fn authorize(admin_token: &str, request: Request, next: Next) -> Response { + let authorized = request + .headers() + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")) + .is_some_and(|token| constant_time_eq(token.trim().as_bytes(), admin_token.as_bytes())); + + if authorized { + next.run(request).await + } else { + ( + StatusCode::UNAUTHORIZED, + [(header::WWW_AUTHENTICATE, "Bearer")], + "Missing or invalid bearer token", + ) + .into_response() + } +} + +/// Compares two byte slices in constant time to prevent timing attacks on the token. +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + a.len() == b.len() && a.iter().zip(b).fold(0, |acc, (x, y)| acc | (x ^ y)) == 0 +} + +impl IntoResponse for RegistryError { + fn into_response(self) -> Response { + let status = match self { + Self::InvalidAet(_) + | Self::ReservedAet(_) + | Self::UnsupportedBackend + | Self::InvalidConfig(_) + | Self::UnresolvableHost(_) => StatusCode::UNPROCESSABLE_ENTITY, + Self::HostNotAllowed { .. } => StatusCode::FORBIDDEN, + Self::Conflict(_) | Self::Static(_) => StatusCode::CONFLICT, + Self::NotFound(_) => StatusCode::NOT_FOUND, + }; + (status, self.to_string()).into_response() + } +} + +#[cfg(test)] +mod tests { + use super::constant_time_eq; + + #[test] + fn compares_tokens() { + assert!(constant_time_eq(b"secret", b"secret")); + assert!(!constant_time_eq(b"secret", b"secreT")); + assert!(!constant_time_eq(b"secret", b"secret2")); + assert!(!constant_time_eq(b"", b"secret")); + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 657f1db..1779ff9 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -17,10 +17,10 @@ pub mod qido; pub mod stow; pub mod wado; -pub fn routes(base_path: &str) -> Router { +pub fn routes(base_path: &str, admin_token: Option) -> Router { let router = Router::new() .merge(home::routes()) - .merge(aets::routes()) + .merge(aets::routes(admin_token)) .nest( "/aets/{aet}", Router::new() diff --git a/src/backend/dimse/association/pool.rs b/src/backend/dimse/association/pool.rs index fa28424..5a92d8b 100644 --- a/src/backend/dimse/association/pool.rs +++ b/src/backend/dimse/association/pool.rs @@ -1,9 +1,9 @@ use crate::backend::dimse::association; -use crate::config::{AppConfig, BackendConfig}; +use crate::config::PoolConfig; use crate::types::UI; use association::client::{ClientAssociation, ClientAssociationOptions}; -use std::collections::{HashMap, VecDeque}; -use std::net::{IpAddr, SocketAddr, ToSocketAddrs}; +use std::collections::VecDeque; +use std::net::SocketAddr; use std::ops::Deref; use futures::TryFutureExt; @@ -279,61 +279,22 @@ impl Manager for AssociationManager { pub type AssociationPool = Pool; -#[derive(Clone)] -pub struct AssociationPools(HashMap); - -impl AssociationPools { - pub fn new(config: &AppConfig) -> Self { - let mut pools = HashMap::with_capacity(config.server.dimse.len()); - for ae_config in &config.aets { - #[allow(irrefutable_let_patterns)] // the lint is not aware of the S3 feature - if let BackendConfig::Dimse(dimse_config) = &ae_config.backend { - let pool_size = dimse_config.pool.size; - - // Hacky: there is no function in the standard library to lookup host names. - // to_socket_addrs requires a port, so we're using a dummy port of :0. - // This port has no meaning and is removed when collecting the IP addresses. - let hosts: Vec = format!("{}:0", dimse_config.host) - .to_socket_addrs() - .unwrap_or_default() - .map(|addr| addr.ip()) - .collect(); - - let Some(host_ip) = hosts.first() else { - panic!("Could not resolve hostname {}", dimse_config.host) - }; - - let address = SocketAddr::new(*host_ip, dimse_config.port); - let mgr = AssociationManager { - calling_aet: config.server.aet.clone(), - address, - called_aet: ae_config.aet.clone(), - }; - - let pool = Pool::new( - mgr, - dimse_config.pool.size, - Duration::from_millis(dimse_config.pool.timeout), - ); - pools.insert(ae_config.aet.clone(), pool); - - info!( - aet = ae_config.aet, - pool_size, "Created new association pool" - ); - } - } - - Self(pools) - } - - #[inline] - pub fn get(&self, aet: &str) -> Option<&AssociationPool> { - self.0.get(aet) - } - - #[inline] - pub fn aets(&self) -> impl Iterator { - self.0.keys() - } +/// Creates an association pool for the AE `called_aet` that is reachable at `address`. +pub fn new_association_pool( + calling_aet: &str, + called_aet: &str, + address: SocketAddr, + config: &PoolConfig, +) -> AssociationPool { + let mgr = AssociationManager { + calling_aet: calling_aet.to_owned(), + address, + called_aet: called_aet.to_owned(), + }; + info!( + aet = called_aet, + pool_size = config.size, + "Created new association pool" + ); + Pool::new(mgr, config.size, Duration::from_millis(config.timeout)) } diff --git a/src/backend/dimse/cmove/mediator.rs b/src/backend/dimse/cmove/mediator.rs index 053bfd3..384fade 100644 --- a/src/backend/dimse/cmove/mediator.rs +++ b/src/backend/dimse/cmove/mediator.rs @@ -1,6 +1,5 @@ use crate::backend::dimse::cmove::movescu::MoveError; use crate::backend::dimse::cmove::MoveSubOperation; -use crate::config::{AppConfig, RetrieveMode}; use crate::types::{AE, US}; use std::collections::HashMap; use std::sync::{Arc, Weak}; @@ -12,6 +11,7 @@ use tracing::info; pub type Callback = Sender>; /// A mediator for the communication between the MOVE-SCU and STORE-SCP. +#[derive(Default)] pub struct MoveMediator { inner: Arc, } @@ -26,37 +26,36 @@ impl Clone for MoveMediator { #[derive(Default)] struct InnerMoveMediator { - semaphores: RwLock>>, + // std RwLock: the lock is never held across an await point and must be usable from + // synchronous code, e.g. while the AE registry holds its own lock. + semaphores: std::sync::RwLock>>, callbacks: RwLock>, } impl MoveMediator { - pub fn new(config: &AppConfig) -> Self { - let mut semaphores = HashMap::new(); - for ae in &config.aets { - if matches!(ae.wado.mode, RetrieveMode::Sequential) { - info!( - "Using Sequential Retrieve Mode for {} - Reduced performance is expected.", - ae.aet - ); - semaphores.insert(AE::from(&ae.aet), Arc::new(Semaphore::new(1))); - } - } - Self { - inner: Arc::new(InnerMoveMediator { - semaphores: RwLock::new(semaphores), - callbacks: RwLock::new(HashMap::new()), - }), - } + /// Limits retrieves from `ae` to a single concurrent C-MOVE operation. + pub fn enable_sequential_mode(&self, ae: &AE) { + info!("Using Sequential Retrieve Mode for {ae} - Reduced performance is expected."); + self.inner + .semaphores + .write() + .unwrap() + .insert(AE::from(ae), Arc::new(Semaphore::new(1))); + } + + /// Removes all state associated with `ae`, e.g. after it was unregistered. + pub fn remove(&self, ae: &AE) { + self.inner.semaphores.write().unwrap().remove(ae); } pub async fn subscribe(&self, topic: SubscriptionTopic, callback: Callback) -> Subscription { - let semaphore: Option> = { - let semaphores = self.inner.semaphores.read().await; - let semaphore = semaphores.get(&topic.originator).cloned(); - drop(semaphores); - semaphore - }; + let semaphore: Option> = self + .inner + .semaphores + .read() + .unwrap() + .get(&topic.originator) + .cloned(); let permit = if let Some(semaphore) = semaphore { let permit = semaphore.acquire_owned().await.unwrap(); diff --git a/src/backend/dimse/cstore/storescp.rs b/src/backend/dimse/cstore/storescp.rs index 7fac8e9..84b7774 100644 --- a/src/backend/dimse/cstore/storescp.rs +++ b/src/backend/dimse/cstore/storescp.rs @@ -7,6 +7,7 @@ use crate::backend::dimse::cstore::{ }; use crate::backend::dimse::{DicomMessageReader, DicomMessageWriter}; use crate::config::DimseServerConfig; +use crate::registry::AeRegistry; use crate::types::{AE, UI, US}; use anyhow::Context; use association::server::{ServerAssociation, ServerAssociationOptions}; @@ -26,16 +27,16 @@ pub struct StoreServiceClassProvider { struct InnerStoreServiceClassProvider { mediator: MoveMediator, - subscribers: Vec, + registry: AeRegistry, config: DimseServerConfig, } impl StoreServiceClassProvider { - pub fn new(mediator: MoveMediator, subscribers: Vec, config: DimseServerConfig) -> Self { + pub fn new(mediator: MoveMediator, registry: AeRegistry, config: DimseServerConfig) -> Self { Self { inner: Arc::new(InnerStoreServiceClassProvider { mediator, - subscribers, + registry, config, }), } @@ -166,7 +167,9 @@ impl StoreServiceClassProvider { ); let file = Arc::new(file); - for sub_aet in &inner.subscribers { + // Looked up for every instance, as AEs can be registered and removed at runtime. + let subscribers = inner.registry.receivers_of(&inner.config.aet); + for sub_aet in &subscribers { trace!("Publishing sub-operation result to subscriber {sub_aet}"); let topic = SubscriptionTopic::new(AE::from(sub_aet), move_originator_id); if let Err(err) = inner diff --git a/src/backend/mod.rs b/src/backend/mod.rs index 973e37c..3b81bcc 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -41,22 +41,24 @@ where let state = AppState::from_ref(state); - let ae_config = state - .config - .aets - .into_iter() - .find(|aet_config| aet_config.aet == aet) + let registered_ae = state + .registry + .get(&aet) .ok_or_else(|| (StatusCode::NOT_FOUND, format!("Unknown AET {aet}")))?; + let ae_config = ®istered_ae.config; // TODO: Use a singleton to avoid re-creating on every request. - let provider = match ae_config.backend { + let provider = match &ae_config.backend { BackendConfig::Dimse { .. } => { use crate::backend::dimse::mwl::DimseMwlService; use crate::backend::dimse::qido::DimseQidoService; use crate::backend::dimse::stow::DimseStowService; use crate::backend::dimse::wado::DimseWadoService; - let pool = state.pools.get(&ae_config.aet).expect("pool should exist"); + let pool = registered_ae + .pool + .as_ref() + .expect("DIMSE AEs should have an association pool"); Self { qido: Some(Box::new(DimseQidoService::new( @@ -85,7 +87,7 @@ where Self { qido: None, - wado: Some(Box::new(S3WadoService::new(&config))), + wado: Some(Box::new(S3WadoService::new(config))), stow: None, mwl: None, } diff --git a/src/config/mod.rs b/src/config/mod.rs index d7047ef..5b6fdb0 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,9 +1,10 @@ use crate::types::AE; use crate::DEFAULT_AET; +use ipnet::IpNet; use serde::de::Error; -use serde::{Deserialize, Deserializer}; -use std::net::IpAddr; +use serde::{Deserialize, Deserializer, Serialize}; +use std::net::{IpAddr, SocketAddr}; use std::str::FromStr; #[derive(Debug, Clone, Default, Deserialize)] @@ -17,7 +18,7 @@ pub struct AppConfig { pub aets: Vec, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub struct ApplicationEntityConfig { pub aet: String, @@ -33,7 +34,7 @@ pub struct ApplicationEntityConfig { pub mwl: MwlConfig, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "kebab-case", tag = "backend")] pub enum BackendConfig { #[serde(rename = "DIMSE")] @@ -43,7 +44,7 @@ pub enum BackendConfig { S3(S3Config), } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub struct DimseConfig { pub host: String, @@ -53,7 +54,7 @@ pub struct DimseConfig { } #[cfg(feature = "s3")] -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub struct S3Config { pub endpoint: String, @@ -61,14 +62,15 @@ pub struct S3Config { #[serde(default)] pub region: Option, pub concurrency: usize, - #[serde(default)] + // Never expose credentials when the configuration is serialized, e.g. in API responses. + #[serde(default, skip_serializing)] pub credentials: Option, #[serde(default)] pub endpoint_style: S3EndpointStyle, } #[cfg(feature = "s3")] -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "lowercase")] pub enum S3EndpointStyle { Path, @@ -83,7 +85,7 @@ impl Default for S3EndpointStyle { } #[cfg(feature = "s3")] -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] #[serde(untagged)] pub enum S3CredentialsConfig { #[serde(rename_all = "kebab-case")] @@ -130,7 +132,7 @@ impl S3CredentialsConfig { } } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub struct QidoConfig { pub timeout: u64, @@ -142,7 +144,7 @@ impl Default for QidoConfig { } } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub struct WadoConfig { pub timeout: u64, @@ -162,7 +164,7 @@ impl Default for WadoConfig { } } -#[derive(Debug, Clone, Copy, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] #[derive(Default)] pub enum RetrieveMode { @@ -171,7 +173,7 @@ pub enum RetrieveMode { Sequential, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub struct StowConfig { pub timeout: u64, @@ -183,7 +185,7 @@ impl Default for StowConfig { } } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub struct MwlConfig { pub timeout: u64, @@ -226,6 +228,8 @@ pub struct ServerConfig { pub aet: AE, pub http: HttpServerConfig, pub dimse: Vec, + #[serde(default)] + pub dynamic_registration: DynamicRegistrationConfig, } impl Default for ServerConfig { @@ -234,10 +238,70 @@ impl Default for ServerConfig { aet: AE::from(DEFAULT_AET), http: HttpServerConfig::default(), dimse: vec![DimseServerConfig::default()], + dynamic_registration: DynamicRegistrationConfig::default(), + } + } +} + +/// Configuration for registering and removing AEs at runtime via the HTTP API. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct DynamicRegistrationConfig { + #[serde(default)] + pub enabled: bool, + /// Name of the environment variable that contains the bearer token. + #[serde(default)] + pub token_env: Option, + /// Hosts that dynamically registered AEs may point to. An empty list allows no hosts. + #[serde(default)] + pub allowed_hosts: Vec, +} + +impl DynamicRegistrationConfig { + /// Returns the bearer token if dynamic registration is enabled. + /// # Errors + /// Returns an error if dynamic registration is enabled, but no token is configured. + pub fn resolve_token(&self) -> anyhow::Result> { + if !self.enabled { + return Ok(None); + } + let Some(token_env) = &self.token_env else { + anyhow::bail!("server.dynamic-registration.token-env is required when enabled"); + }; + match std::env::var(token_env) { + Ok(token) if !token.trim().is_empty() => Ok(Some(token.trim().to_owned())), + _ => anyhow::bail!("Environment variable {token_env} must contain the bearer token for dynamic registration"), } } } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct AllowedHost { + /// An IP address, a CIDR range or a hostname. + pub host: String, + /// Restricts the entry to a single port. All ports are allowed if not set. + #[serde(default)] + pub port: Option, +} + +impl AllowedHost { + /// Checks if an AE with the configured `host` that resolved to `address` is allowed. + pub fn permits(&self, host: &str, address: SocketAddr) -> bool { + if self.port.is_some_and(|port| port != address.port()) { + return false; + } + let pattern = self.host.trim(); + if let Ok(network) = pattern.parse::() { + return network.contains(&address.ip()); + } + if let Ok(ip) = pattern.parse::() { + return ip == address.ip(); + } + pattern.eq_ignore_ascii_case(host.trim()) + } +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "kebab-case")] pub struct HttpServerConfig { @@ -318,7 +382,7 @@ impl Default for DimseServerConfig { } } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub struct PoolConfig { pub size: usize, diff --git a/src/main.rs b/src/main.rs index 5d9b0cc..795b908 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,16 +3,15 @@ pub(crate) mod api; pub(crate) mod backend; pub(crate) mod config; +pub(crate) mod registry; pub(crate) mod rendering; pub(crate) mod types; pub(crate) mod utils; -use crate::backend::dimse::association; use crate::backend::dimse::cmove::MoveMediator; use crate::backend::dimse::StoreServiceClassProvider; use crate::config::{AppConfig, HttpServerConfig}; -use crate::types::AE; -use association::pool::AssociationPools; +use crate::registry::AeRegistry; use axum::extract::{DefaultBodyLimit, Request}; use axum::http::StatusCode; use axum::response::Response; @@ -65,8 +64,7 @@ fn init_logger(level: tracing::Level) { #[derive(Clone)] pub struct AppState { - pub config: AppConfig, - pub pools: AssociationPools, + pub registry: AeRegistry, pub mediator: MoveMediator, } @@ -109,27 +107,21 @@ fn main() -> Result<(), Box> { } async fn run(config: AppConfig) -> anyhow::Result<()> { - let mediator = MoveMediator::new(&config); - let pools = AssociationPools::new(&config); + let admin_token = config.server.dynamic_registration.resolve_token()?; + let mediator = MoveMediator::default(); + let registry = AeRegistry::new(&config, mediator.clone())?; let app_state = AppState { - config: config.clone(), + registry: registry.clone(), mediator: mediator.clone(), - pools, }; - for dimse_config in config.server.dimse { + for dimse_config in config.server.dimse.clone() { let mediator = mediator.clone(); - let subscribers: Vec = config - .aets - .iter() - .filter(|&ae| ae.wado.receivers.contains(&dimse_config.aet)) - .cloned() - .map(|ae| ae.aet) - .collect(); + let registry = registry.clone(); tokio::spawn(async move { - let storescp = StoreServiceClassProvider::new(mediator, subscribers, dimse_config); + let storescp = StoreServiceClassProvider::new(mediator, registry, dimse_config); if let Err(err) = storescp.spawn().await { error!("Failed to spawn STORE-SCP thread: {err}"); // Unrecoverable error - exit the process @@ -138,7 +130,7 @@ async fn run(config: AppConfig) -> anyhow::Result<()> { }); } - let app = api::routes(&config.server.http.base_path) + let app = api::routes(&config.server.http.base_path, admin_token) .layer(CorsLayer::permissive()) .layer(axum::middleware::from_fn(add_common_headers)) .layer( diff --git a/src/registry.rs b/src/registry.rs new file mode 100644 index 0000000..2a82d4f --- /dev/null +++ b/src/registry.rs @@ -0,0 +1,526 @@ +//! The registry of all AEs known to DICOM-RST. +//! +//! Static AEs are loaded from the configuration file at startup and cannot be changed. +//! Dynamic AEs are registered at runtime via the HTTP API and are lost after a restart. + +use crate::backend::dimse::association::pool::{new_association_pool, AssociationPool}; +use crate::backend::dimse::cmove::MoveMediator; +use crate::config::{AllowedHost, AppConfig, ApplicationEntityConfig, BackendConfig, RetrieveMode}; +use crate::types::AE; +use anyhow::Context; +use std::collections::hash_map::Entry; +use std::collections::HashMap; +use std::net::{SocketAddr, ToSocketAddrs}; +use std::sync::{Arc, RwLock}; +use thiserror::Error; +use tracing::info; + +/// Maximum length of an AE title, see the AE value representation in PS3.5. +const MAX_AET_LENGTH: usize = 16; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Origin { + /// Loaded from the configuration file. + Static, + /// Registered at runtime via the HTTP API. + Dynamic, +} + +pub struct RegisteredAe { + pub config: ApplicationEntityConfig, + pub origin: Origin, + /// The association pool. Always present for the DIMSE backend. + pub pool: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Registration { + /// The AE was registered. + Created, + /// An AE with an identical configuration was already registered. + Unchanged, +} + +#[derive(Debug, Error)] +pub enum RegistryError { + #[error("Invalid AET: {0}")] + InvalidAet(String), + #[error("AET {0} is reserved for DICOM-RST")] + ReservedAet(AE), + #[error("Only the DIMSE backend can be registered dynamically")] + UnsupportedBackend, + #[error("Invalid configuration: {0}")] + InvalidConfig(String), + #[error("Could not resolve host {0}")] + UnresolvableHost(String), + #[error("Host {host}:{port} is not allowed for dynamic registration")] + HostNotAllowed { host: String, port: u16 }, + #[error("AET {0} is already registered with a different configuration. Remove it first.")] + Conflict(AE), + #[error("AET {0} is statically configured and cannot be modified")] + Static(AE), + #[error("Unknown AET {0}")] + NotFound(AE), +} + +#[derive(Clone)] +pub struct AeRegistry { + inner: Arc, +} + +struct InnerAeRegistry { + // std RwLock: the lock is never held across an await point. + entries: RwLock>>, + mediator: MoveMediator, + /// The calling AET used for outgoing associations. + calling_aet: AE, + /// AETs of the STORE-SCPs of DICOM-RST. + listeners: Vec, + allowed_hosts: Vec, +} + +impl AeRegistry { + /// Creates a registry that contains all AEs from the configuration file. + /// # Errors + /// Returns an error if an AET is configured multiple times or a host cannot be resolved. + pub fn new(config: &AppConfig, mediator: MoveMediator) -> anyhow::Result { + let registry = Self { + inner: Arc::new(InnerAeRegistry { + entries: RwLock::new(HashMap::with_capacity(config.aets.len())), + mediator, + calling_aet: config.server.aet.clone(), + listeners: config + .server + .dimse + .iter() + .map(|dimse| dimse.aet.clone()) + .collect(), + allowed_hosts: config.server.dynamic_registration.allowed_hosts.clone(), + }), + }; + + let mut entries = HashMap::with_capacity(config.aets.len()); + for ae_config in &config.aets { + let mut ae_config = ae_config.clone(); + ae_config.aet = AE::from(ae_config.aet.trim()); + + #[allow(irrefutable_let_patterns)] // the lint is not aware of the S3 feature + let pool = if let BackendConfig::Dimse(dimse_config) = &ae_config.backend { + let address = (dimse_config.host.as_str(), dimse_config.port) + .to_socket_addrs() + .ok() + .and_then(|mut addresses| addresses.next()) + .with_context(|| format!("Could not resolve hostname {}", dimse_config.host))?; + Some(registry.new_pool(&ae_config, address)) + } else { + None + }; + + let Entry::Vacant(entry) = entries.entry(ae_config.aet.clone()) else { + anyhow::bail!("AET {} is configured multiple times", ae_config.aet); + }; + registry.enable_retrieve_mode(&ae_config); + entry.insert(Arc::new(RegisteredAe { + config: ae_config, + origin: Origin::Static, + pool, + })); + } + *registry.inner.entries.write().unwrap() = entries; + + Ok(registry) + } + + pub fn get(&self, aet: &str) -> Option> { + self.inner.entries.read().unwrap().get(aet).cloned() + } + + /// Returns all registered AETs in alphabetical order. + pub fn list(&self) -> Vec { + let mut aets: Vec = self.inner.entries.read().unwrap().keys().cloned().collect(); + aets.sort(); + aets + } + + /// Returns all AETs that use the STORE-SCP `listener_aet` as a receiver for retrieves. + pub fn receivers_of(&self, listener_aet: &str) -> Vec { + self.inner + .entries + .read() + .unwrap() + .values() + .filter(|ae| ae.config.wado.receivers.iter().any(|r| r == listener_aet)) + .map(|ae| ae.config.aet.clone()) + .collect() + } + + /// Registers a new AE at runtime. + /// + /// Registering the same AET with an identical configuration again is a no-op, so clients can + /// safely retry. A different configuration for an already registered AET is rejected. + /// # Errors + /// Returns a [`RegistryError`] if the configuration is invalid, the host is not allowed or the + /// AET is already registered with a different configuration. + pub async fn register( + &self, + mut config: ApplicationEntityConfig, + ) -> Result<(Registration, Arc), RegistryError> { + config.aet = validate_aet(&config.aet)?; + if config.aet == self.inner.calling_aet || self.inner.listeners.contains(&config.aet) { + return Err(RegistryError::ReservedAet(config.aet)); + } + if let Some(receiver) = config + .wado + .receivers + .iter() + .find(|receiver| !self.inner.listeners.contains(receiver)) + { + return Err(RegistryError::InvalidConfig(format!( + "receiver {receiver} does not match any server.dimse.aet" + ))); + } + + #[allow(irrefutable_let_patterns)] // the lint is not aware of the S3 feature + let BackendConfig::Dimse(dimse_config) = &mut config.backend + else { + return Err(RegistryError::UnsupportedBackend); + }; + dimse_config.host = dimse_config.host.trim().to_ascii_lowercase(); + if dimse_config.host.is_empty() { + return Err(RegistryError::InvalidConfig(String::from( + "host must not be empty", + ))); + } + if dimse_config.port == 0 { + return Err(RegistryError::InvalidConfig(String::from( + "port must not be 0", + ))); + } + if dimse_config.pool.size == 0 { + return Err(RegistryError::InvalidConfig(String::from( + "pool.size must be at least 1", + ))); + } + + let address = tokio::net::lookup_host((dimse_config.host.as_str(), dimse_config.port)) + .await + .ok() + .and_then(|mut addresses| addresses.next()) + .ok_or_else(|| RegistryError::UnresolvableHost(dimse_config.host.clone()))?; + + if !self + .inner + .allowed_hosts + .iter() + .any(|allowed| allowed.permits(&dimse_config.host, address)) + { + return Err(RegistryError::HostNotAllowed { + host: dimse_config.host.clone(), + port: dimse_config.port, + }); + } + + // Check and insert under a single write lock, so concurrent registrations of the same AET + // cannot both succeed. + let mut entries = self.inner.entries.write().unwrap(); + match entries.entry(config.aet.clone()) { + Entry::Vacant(entry) => { + let pool = self.new_pool(&config, address); + self.enable_retrieve_mode(&config); + info!( + aet = config.aet, + host = dimse_config_of(&config).host, + port = dimse_config_of(&config).port, + "Registered AE" + ); + let registered = Arc::new(RegisteredAe { + config, + origin: Origin::Dynamic, + pool: Some(pool), + }); + entry.insert(Arc::clone(®istered)); + Ok((Registration::Created, registered)) + } + Entry::Occupied(entry) => { + let existing = entry.get(); + match existing.origin { + Origin::Static => Err(RegistryError::Static(config.aet)), + Origin::Dynamic if existing.config == config => { + Ok((Registration::Unchanged, Arc::clone(existing))) + } + Origin::Dynamic => Err(RegistryError::Conflict(config.aet)), + } + } + } + } + + /// Removes a dynamically registered AE. + /// Requests that are already in progress keep their association pool and finish normally. + /// # Errors + /// Returns a [`RegistryError`] if the AET is unknown or statically configured. + pub fn unregister(&self, aet: &str) -> Result<(), RegistryError> { + let aet = AE::from(aet.trim()); + { + let mut entries = self.inner.entries.write().unwrap(); + match entries.get(&aet).map(|entry| entry.origin) { + None => return Err(RegistryError::NotFound(aet)), + Some(Origin::Static) => return Err(RegistryError::Static(aet)), + Some(Origin::Dynamic) => entries.remove(&aet), + }; + } + self.inner.mediator.remove(&aet); + info!(aet, "Unregistered AE"); + Ok(()) + } + + fn new_pool(&self, config: &ApplicationEntityConfig, address: SocketAddr) -> AssociationPool { + new_association_pool( + &self.inner.calling_aet, + &config.aet, + address, + &dimse_config_of(config).pool, + ) + } + + fn enable_retrieve_mode(&self, config: &ApplicationEntityConfig) { + if matches!(config.wado.mode, RetrieveMode::Sequential) { + self.inner.mediator.enable_sequential_mode(&config.aet); + } + } +} + +/// Returns the DIMSE configuration. Must only be called for AEs with the DIMSE backend. +fn dimse_config_of(config: &ApplicationEntityConfig) -> &crate::config::DimseConfig { + #[allow(irrefutable_let_patterns)] // the lint is not aware of the S3 feature + let BackendConfig::Dimse(dimse_config) = &config.backend + else { + unreachable!("AE {} does not use the DIMSE backend", config.aet); + }; + dimse_config +} + +/// Validates an AE title and returns it without leading and trailing spaces, +/// which are insignificant for the AE value representation. +fn validate_aet(aet: &str) -> Result { + let aet = aet.trim(); + if aet.is_empty() { + return Err(RegistryError::InvalidAet(String::from("must not be empty"))); + } + if aet.len() > MAX_AET_LENGTH { + return Err(RegistryError::InvalidAet(format!( + "must not be longer than {MAX_AET_LENGTH} characters" + ))); + } + if aet + .chars() + .any(|c| c == '\\' || c.is_control() || !c.is_ascii()) + { + return Err(RegistryError::InvalidAet(String::from( + "must only contain printable ASCII characters except backslash", + ))); + } + Ok(AE::from(aet)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{DimseConfig, DimseServerConfig, PoolConfig}; + + fn app_config(static_aets: &[&str]) -> AppConfig { + let mut config = AppConfig::default(); + config.server.dimse = vec![DimseServerConfig::default()]; + config.server.dynamic_registration.allowed_hosts = vec![ + AllowedHost { + host: String::from("127.0.0.0/8"), + port: None, + }, + AllowedHost { + host: String::from("localhost"), + port: Some(4242), + }, + ]; + config.aets = static_aets + .iter() + .map(|aet| ae_config(aet, "127.0.0.1", 4242)) + .collect(); + config + } + + fn ae_config(aet: &str, host: &str, port: u16) -> ApplicationEntityConfig { + ApplicationEntityConfig { + aet: AE::from(aet), + backend: BackendConfig::Dimse(DimseConfig { + host: String::from(host), + port, + pool: PoolConfig::default(), + }), + qido: crate::config::QidoConfig::default(), + wado: crate::config::WadoConfig::default(), + stow: crate::config::StowConfig::default(), + mwl: crate::config::MwlConfig::default(), + } + } + + fn registry(static_aets: &[&str]) -> AeRegistry { + AeRegistry::new(&app_config(static_aets), MoveMediator::default()).unwrap() + } + + #[tokio::test] + async fn identical_registration_is_idempotent() { + let registry = registry(&[]); + let (first, _) = registry + .register(ae_config("PACS", "127.0.0.1", 104)) + .await + .unwrap(); + let (second, _) = registry + .register(ae_config("PACS", "127.0.0.1", 104)) + .await + .unwrap(); + assert_eq!(first, Registration::Created); + assert_eq!(second, Registration::Unchanged); + assert_eq!(registry.list(), vec![AE::from("PACS")]); + } + + #[tokio::test] + async fn different_configuration_for_same_aet_conflicts() { + let registry = registry(&[]); + registry + .register(ae_config("PACS", "127.0.0.1", 104)) + .await + .unwrap(); + let result = registry.register(ae_config("PACS", "127.0.0.1", 105)).await; + assert!(matches!(result, Err(RegistryError::Conflict(_)))); + } + + #[tokio::test] + async fn aet_is_normalized_before_duplicate_check() { + let registry = registry(&[]); + registry + .register(ae_config("PACS", "127.0.0.1", 104)) + .await + .unwrap(); + let (registration, _) = registry + .register(ae_config(" PACS ", " 127.0.0.1 ", 104)) + .await + .unwrap(); + assert_eq!(registration, Registration::Unchanged); + assert_eq!(registry.list(), vec![AE::from("PACS")]); + } + + #[tokio::test] + async fn concurrent_registrations_create_only_one_entry() { + let registry = registry(&[]); + let (a, b) = tokio::join!( + registry.register(ae_config("PACS", "127.0.0.1", 104)), + registry.register(ae_config("PACS", "127.0.0.1", 104)), + ); + let mut registrations = [a.unwrap().0, b.unwrap().0]; + registrations.sort_by_key(|r| *r == Registration::Unchanged); + assert_eq!( + registrations, + [Registration::Created, Registration::Unchanged] + ); + } + + #[tokio::test] + async fn static_aes_cannot_be_modified() { + let registry = registry(&["ORTHANC"]); + let result = registry + .register(ae_config("ORTHANC", "127.0.0.1", 4242)) + .await; + assert!(matches!(result, Err(RegistryError::Static(_)))); + assert!(matches!( + registry.unregister("ORTHANC"), + Err(RegistryError::Static(_)) + )); + } + + #[test] + fn duplicate_static_aes_are_rejected() { + let result = AeRegistry::new( + &app_config(&["ORTHANC", "ORTHANC"]), + MoveMediator::default(), + ); + assert!(result.is_err()); + } + + #[tokio::test] + async fn unregister_removes_dynamic_ae() { + let registry = registry(&[]); + registry + .register(ae_config("PACS", "127.0.0.1", 104)) + .await + .unwrap(); + registry.unregister("PACS").unwrap(); + assert!(registry.get("PACS").is_none()); + assert!(matches!( + registry.unregister("PACS"), + Err(RegistryError::NotFound(_)) + )); + } + + #[tokio::test] + async fn rejects_invalid_and_reserved_aets() { + let registry = registry(&[]); + for aet in ["", " ", "THIS-AET-IS-TOO-LONG", "BACK\\SLASH"] { + let result = registry.register(ae_config(aet, "127.0.0.1", 104)).await; + assert!( + matches!(result, Err(RegistryError::InvalidAet(_))), + "{aet:?}" + ); + } + let result = registry + .register(ae_config("DICOM-RST", "127.0.0.1", 104)) + .await; + assert!(matches!(result, Err(RegistryError::ReservedAet(_)))); + } + + #[tokio::test] + async fn rejects_unknown_receivers() { + let registry = registry(&[]); + let mut config = ae_config("PACS", "127.0.0.1", 104); + config.wado.receivers = vec![AE::from("UNKNOWN")]; + let result = registry.register(config).await; + assert!(matches!(result, Err(RegistryError::InvalidConfig(_)))); + } + + #[tokio::test] + async fn enforces_allowed_hosts() { + let registry = registry(&[]); + // Not in 127.0.0.0/8 + let result = registry.register(ae_config("PACS", "10.0.0.1", 104)).await; + assert!(matches!(result, Err(RegistryError::HostNotAllowed { .. }))); + // Allowed by the hostname entry + let result = registry + .register(ae_config("PACS2", "LocalHost", 4242)) + .await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn receivers_include_dynamic_aes() { + let registry = registry(&[]); + let mut config = ae_config("PACS", "127.0.0.1", 104); + config.wado.receivers = vec![AE::from("DICOM-RST")]; + registry.register(config).await.unwrap(); + assert_eq!(registry.receivers_of("DICOM-RST"), vec![AE::from("PACS")]); + registry.unregister("PACS").unwrap(); + assert!(registry.receivers_of("DICOM-RST").is_empty()); + } + + #[test] + fn allowed_host_matching() { + let address: SocketAddr = "192.168.1.20:104".parse().unwrap(); + let allowed = |host: &str, port: Option| AllowedHost { + host: String::from(host), + port, + }; + assert!(allowed("192.168.1.0/24", None).permits("pacs", address)); + assert!(!allowed("192.168.2.0/24", None).permits("pacs", address)); + assert!(allowed("192.168.1.20", Some(104)).permits("pacs", address)); + assert!(!allowed("192.168.1.20", Some(4242)).permits("pacs", address)); + assert!(allowed("PACS.internal", None).permits("pacs.internal", address)); + assert!(!allowed("other.internal", None).permits("pacs.internal", address)); + } +} diff --git a/tests/aets.rs b/tests/aets.rs new file mode 100644 index 0000000..731e57f --- /dev/null +++ b/tests/aets.rs @@ -0,0 +1,136 @@ +mod common; + +use anyhow::Context; +use axum::http::StatusCode; +use common::*; +use dicom::object::open_file; +use dicom_web::DicomWebClient; +use serde_json::json; +use testcontainers::core::IntoContainerPort; + +const TOKEN_ENV: &str = "DICOM_RST_TEST_ADMIN_TOKEN"; +const TOKEN: &str = "test-token"; + +#[tokio::test] +async fn can_register_and_remove_aes_at_runtime() -> anyhow::Result<()> { + // Inherited by the spawned DICOM-RST process + std::env::set_var(TOKEN_ENV, TOKEN); + let config = format!( + " + server: + http: + port: 0 + dimse: + - aet: DICOM-RST + interface: 0.0.0.0 + port: 0 + dynamic-registration: + enabled: true + token-env: {TOKEN_ENV} + allowed-hosts: + - host: 127.0.0.1 + aets: [] + " + ); + + let orthanc = spawn_orthanc().await?; + let orthanc_port = orthanc + .get_host_port_ipv4(4242.tcp()) + .await + .context("failed to get mapped Orthanc DIMSE port")?; + let server = spawn_dicomrst(&config).await?; + let base_url = format!("http://localhost:{}", server.http_port()); + let http = reqwest::Client::new(); + + let orthanc_ae = json!({ + "aet": "ORTHANC", + "backend": "DIMSE", + "host": "127.0.0.1", + "port": orthanc_port, + }); + let register = |body: serde_json::Value, token: &str| { + http.post(format!("{base_url}/aets")) + .bearer_auth(token) + .json(&body) + .send() + }; + + // Requests without a valid token are rejected + let response = register(orthanc_ae.clone(), "wrong-token").await?; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + + // Hosts outside the allowlist are rejected + let mut forbidden_ae = orthanc_ae.clone(); + forbidden_ae["aet"] = json!("OTHER"); + forbidden_ae["host"] = json!("10.0.0.1"); + let response = register(forbidden_ae, TOKEN).await?; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + // The first registration creates the AE, an identical one is a no-op + let response = register(orthanc_ae.clone(), TOKEN).await?; + assert_eq!(response.status(), StatusCode::CREATED); + let response = register(orthanc_ae.clone(), TOKEN).await?; + assert_eq!(response.status(), StatusCode::OK); + + // A different configuration for the same AET conflicts + let mut conflicting_ae = orthanc_ae.clone(); + conflicting_ae["port"] = json!(orthanc_port.wrapping_add(1)); + let response = register(conflicting_ae, TOKEN).await?; + assert_eq!(response.status(), StatusCode::CONFLICT); + + let aets: Vec = http + .get(format!("{base_url}/aets")) + .send() + .await? + .json() + .await?; + assert_eq!(aets, vec![String::from("ORTHANC")]); + + // The registered AE can be used. + // QIDO-RS is not tested, as Orthanc rejects C-FIND requests from unknown modalities by default. + let client = DicomWebClient::with_single_url(&format!("{base_url}/aets/ORTHANC")); + let instance = open_file(dicom_test_files::path("pydicom/CT_small.dcm").unwrap())?; + client + .store_instances() + .with_instances(futures::stream::iter([instance])) + .run() + .await + .context("STOW-RS request failed")?; + + // Removing the AE makes it unavailable + let response = http + .delete(format!("{base_url}/aets/ORTHANC")) + .bearer_auth(TOKEN) + .send() + .await?; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + let response = http + .get(format!("{base_url}/aets/ORTHANC/studies")) + .send() + .await?; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + Ok(()) +} + +#[tokio::test] +async fn registration_is_disabled_by_default() -> anyhow::Result<()> { + let config = " + server: + http: + port: 0 + dimse: + - aet: DICOM-RST + interface: 0.0.0.0 + port: 0 + aets: [] + "; + let server = spawn_dicomrst(config).await?; + let response = reqwest::Client::new() + .post(format!("http://localhost:{}/aets", server.http_port())) + .json(&json!({"aet": "PACS", "backend": "DIMSE", "host": "127.0.0.1", "port": 104})) + .send() + .await?; + assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); + Ok(()) +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 7e74ab9..91bbed0 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -33,6 +33,10 @@ pub struct ServerProcess { } impl ServerProcess { + pub const fn http_port(&self) -> u16 { + self.http_port + } + fn spawn(config: &str) -> anyhow::Result { let workdir = std::env::temp_dir().join(format!("dicom-rst-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&workdir)?;