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