Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand All @@ -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"
Expand Down
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
49 changes: 49 additions & 0 deletions docs/resources/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
55 changes: 55 additions & 0 deletions docs/topics/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,61 @@ server:

</deflist>

## 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.

<warning>
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.
</warning>

<deflist>
<def title="server.dynamic-registration.enabled" id="server.dynamic-registration.enabled">
Enables the endpoints for registering and removing AEs at runtime. Disabled by default.
</def>
<def title="server.dynamic-registration.token-env" id="server.dynamic-registration.token-env">
The name of the environment variable that contains the bearer token.
Requests must send this token in the <code>Authorization: Bearer</code> header.
Required if dynamic registration is enabled.
</def>
<def title="server.dynamic-registration.allowed-hosts" id="server.dynamic-registration.allowed-hosts">
The hosts that dynamically registered AEs may point to.
<code>host</code> is an IP address, a CIDR range or a hostname, which is compared with the host in the request.
<code>port</code> optionally restricts the entry to a single port.
An empty list allows no hosts.
</def>
</deflist>

Only the DIMSE backend can be registered dynamically.

## DICOMweb Config

```yaml
Expand Down
117 changes: 103 additions & 14 deletions src/api/aets.rs
Original file line number Diff line number Diff line change
@@ -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<AppState> {
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<String>) -> Router<AppState> {
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<str> = 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<AppState>) -> impl IntoResponse {
let aets = &state.config.aets;

Json(serde_json::Value::Array(
aets.iter()
.map(|ae| serde_json::Value::String(ae.aet.clone()))
.collect::<Vec<serde_json::Value>>(),
))
Json(state.registry.list())
}

async fn aet_health(Path(aet): Path<String>) -> impl IntoResponse {
(StatusCode::OK, format!("{aet} is healthy")).into_response()
}

async fn register_aet(
state: State<AppState>,
Json(config): Json<ApplicationEntityConfig>,
) -> Result<Response, RegistryError> {
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<AppState>,
Path(aet): Path<String>,
) -> Result<StatusCode, RegistryError> {
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"));
}
}
4 changes: 2 additions & 2 deletions src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ pub mod qido;
pub mod stow;
pub mod wado;

pub fn routes(base_path: &str) -> Router<AppState> {
pub fn routes(base_path: &str, admin_token: Option<String>) -> Router<AppState> {
let router = Router::new()
.merge(home::routes())
.merge(aets::routes())
.merge(aets::routes(admin_token))
.nest(
"/aets/{aet}",
Router::new()
Expand Down
Loading
Loading