Configuration Reference¶
This page describes the structure of the DICOM Router's YAML configuration.
The authoritative, fully commented reference is the template file itself — in an installation it ships as
share/dicom-router.yaml.example(in the source repository:dicom-router-core/src/main/resources/.../config/default-dicom-router.yaml).That file is the authoritative source — it contains inline comments for every field and realistic example values. This document explains the overall layout and the purpose of each section, but does not duplicate every line. When in doubt: read the YAML.
Related documentation: User Guide · Plugin Reference · Management & Drain · Deployment
Top-level structure¶
A configuration file has these top-level sections:
| Section | Purpose |
|---|---|
router |
Listener (host/port/aet) and general router options |
management |
Management & HAProxy agent-check TCP servers |
audit |
Database audit trail (associations + DIMSE operations) |
proxyprotocol |
Which incoming connections carry a PROXY protocol header |
filter |
Ordered routing rules (first match wins) |
actions |
Named plugin instances, referenced via filter[].actions |
destinations |
Named forward/reject/qr-bridge destinations, referenced via filter[].destination |
haproxy-return |
Global default VIP and port-range for HAProxy return paths (return-port on destinations) |
Unknown keys and spelling¶
Every key in the YAML has to match a field. One that does not is a configuration error:
the start aborts, a reload is rejected. That applies to every section, to the plugins'
config: blocks, and to a mistyped top-level section (managment:) alike.
Up to 1.16.0 such a key was silently discarded and the code default took over. Three examples:
tokenFile:instead oftoken-file:— the management port kept running without token auth.returnPort:instead ofreturn-port:— the router dialled the device directly, past the HAProxy return path.managment:instead ofmanagement:— the whole section fell back to its defaults.
The same key twice. A key that appears twice in one block is a start error as well. YAML allows it and the last line wins — a correctly spelled value that never takes effect, the same failure class as a dead key, only from the other side. The typical case: the new line is written next to the old one instead of replacing it. The message names key, section and line. It comes from the parser rather than the validator, so it stops at the first occurrence instead of collecting all errors of the file.
Exactly one document per file. YAML allows several documents in one file, separated by
---. The router reads exactly one; everything behind a second --- used to be discarded
without a word — the same failure class as a dead or duplicate key, only at a larger scale:
a file assembled by copy-paste loses its entire second half. A second document is therefore
a start error (config.file: multiple YAML documents). A single document with a leading
--- or a trailing ... stays valid — both are markers of that one document. A --- at the
end of the file with nothing behind it counts as a second (empty) document and is reported
as multiple YAML documents as well; delete the line.
A file with no content at all is a start error as well (config.file: empty configuration
document): empty, comments only, or a document consisting of just --- or null. That
case used to end as a raw stack trace at start and as a meaningless message on reload.
Spelling. A field is either kebab-case or camelCase, never both. The other spelling is
simply an unknown key. The template file shows the correct form for every field. camelCase
covers the filter and proxyprotocol fields (clientLocalIp, callingAET, logLevel,
proxyPort, …) plus socketTimeout, connectTimeout and tcplog under
router.listener; kebab-case is
everything else under router, management, audit, haproxy-return and the
destinations. Inside a plugin's config: block the plugin decides — the
Plugin Reference gives the spelling for each field.
How to check before rolling out. --validate-config runs the same chain as start and
reload, without binding a port or touching the database. With one exception:
router.listener.host is deliberately not resolved, so the dry run makes no DNS query. A
green run can therefore still fail on an unresolvable host name — that only shows when the
listener is bound.
The validator collects all errors of a file and reports them with section and field
(filter[2].logLevl, destinations.pacs1.returnPort) — it does not abort at the first
one. Existing configurations with dead keys therefore surface in a single run on upgrade,
not one after the other.
Two cases deliberately get a more precise message than "unknown key": a field that exists
on another destination type (store: on a forward destination), and the three keys under
a qr-bridge's timeouts:. There the message names where the field belongs, or what is
allowed.
router¶
router:
listener:
host: 0.0.0.0
port: 11112
aet: IMCONNECT
tcplog: false
socketTimeout: 120000
connectTimeout: 10000
max-pdu-length: 131072
storage-path: /var/tmp/dicom-cache
log-folder: /var/log/imconnect/pdu-dumps
memory-threshold: 1048576
# materialization-threshold: 1048576 # omit → follows memory-threshold
max-elements: 100000
max-pending-assemblies: 8
assembly-idle-timeout: 120000
max-object-bytes: 1073741824
router.listener¶
| Field | Type | Default | Description |
|---|---|---|---|
host |
string | — | Bind address for the DICOM listener. 0.0.0.0 listens on every interface, any other address restricts the listener to it. A hostname is allowed and resolved at startup; if it cannot be resolved the start aborts (rather than silently falling back to 0.0.0.0). |
port |
int | — | Port to listen on. |
aet |
string | — | The router's own Application Entity Title. |
tcplog |
boolean | false |
Hex dump of every TCP chunk (hex and ASCII, both directions) into the application log, at INFO. A diagnostic tool only — see the PHI note below the table. Hot-reloadable. |
socketTimeout |
int | 60000 |
Two roles, one value. (1) Read timeout of a connection: if nothing arrives for this long, it is ended (SO_TIMEOUT). (2) Since 1.17.0 also the budget for a single blocking write toward the client or the destination — SO_TIMEOUT does not cover writes, and without a bound a peer with a closed TCP window parks the writing thread indefinitely. A write that takes longer closes the socket and ends the association. Setting the value very low shortens that budget too; with the shipped template (120000) this is uncritical. |
connectTimeout |
int | 10000 |
Budget in milliseconds for reaching a forward destination: name lookup and TCP handshake together, not each of them. Without it the lookup ran on the inbound thread of the incoming association with no time limit at all, and the handshake used the OS default (up to ~130 s on Linux against a blackholed SYN) — the peer waited for all of it without an answer. A destination given as an IP literal resolves nothing and spends the whole budget on the handshake. Must be > 0; hot-reloadable. |
Switch
tcplogon temporarily only. The hex dump reproduces the chunk in full, so its ASCII column carries the P-DATA payload in the clear — patient name and patient ID included. The application log is precisely the log the PDU payload is otherwise kept out of (see Logging), and it rotates by size and age instead of being retained and deleted by the operator. Then there is the volume: one dump per TCP chunk runs the 100 MB / 30 day rotation through within minutes on a busy listener. The shipped template therefore setstcplog: false. Switch it on to analyse (hot-reloadable), switch it off afterwards — and treat the log files of the time in between as PHI. For lasting traceability the database audit trail (audit) is the tool.
router – general options¶
| Field | Type | Default | Description |
|---|---|---|---|
max-pdu-length |
int | 16384 (code); the shipped template sets 131072 |
Maximum negotiated/processed DICOM PDU length, allowed range 4096–4194304 (4 MB). PS3.8 Maximum Length semantics: limits the PDU length field (data field, excluding the 6-byte PDU header) — the same value the router offers/caps in the Maximum Length sub-item. Values above the 1 MB memory threshold force per-PDU disk buffering — not recommended. |
storage-path |
string | /tmp |
Directory used as the DICOM cache / storage path. Spill files are deleted deterministically while the process runs; a hard process end (SIGKILL, OOM kill, power loss) leaves them behind. On startup the router removes leftovers of its own four name patterns (dicom-buffer*.bin, dicom-assembler*.tmp, dicom-data-*.bin, dicom-encode-*.tmp) older than one hour and reports the count and bytes at INFO. Files outside the four name patterns are not touched; the match is by prefix and suffix alone, so an operator file such as dicom-buffer-backup.bin does fall under it. |
log-folder |
string | /tmp |
Directory for binary DICOM PDU dumps (only when a filter's logLevel is 5/6/7). Like storage-path, the key must not be written empty (startup error since 1.17.0) — the empty value used to bind null and the first association on a dump rule aborted. Omitting it keeps /tmp. |
memory-threshold |
long (bytes) | 1048576 (1 MiB) |
Spill threshold: an assembled object or a reassembling PDU larger than this is buffered on a temp file (under storage-path/system temp) instead of in RAM. The one consolidated RAM/disk knob. |
materialization-threshold |
long (bytes) | = memory-threshold |
Largest single element value a plugin may pull into a byte[]/String; above that the value is only streamable (ValueTooLargeException). Omit to follow memory-threshold (a spilled value is not pulled back into RAM). |
max-elements |
long | 100000 |
Hard upper bound on indexed top-level elements before a fail-fast (TooManyElementsException, DoS backstop; a real DICOM object has well under 10k top-level elements). |
max-pending-assemblies |
int | 8 |
Upper bound on concurrently half-open P-DATA fragment assemblies per connection direction. Legitimate DICOM traffic has exactly one (message fragments must not be interleaved, PS3.7); a peer stacking more receives an A-ABORT and all pending state (RAM, spill files) is released. |
assembly-idle-timeout |
long (ms) | 120000 (2 min) |
Idle timeout for a half-open fragment assembly: if no fragment arrives for this long — while the connection otherwise stays active — the association is aborted and the state released. 0 disables it; completely inactive connections are covered by listener.socketTimeout. |
max-object-bytes |
long (bytes) | 1073741824 (1 GiB) |
Hard upper bound on a single assembled Data Set. Fragment assembly buffers a message in RAM up to memory-threshold and on a spill file under storage-path beyond it — without this cap a single message that never completes can fill the file system. Exceeding it aborts the association (A-ABORT) and releases the spill file. Allowed range 1–2147483647 (2 GiB; the assembled object is carried on as a 32-bit length) and at least max-pdu-length. There is no "unlimited". Command Sets have their own, much stricter cap. |
The thresholds and assembly limits above are hot-reloadable: a
reloadapplies them to new connections, while in-flight connections keep the snapshot they started with.Note:
log-folderis not for the regular application logs. SLF4J/ application logging is configured vialogback.xml(internal default in the JAR, overridable withconfig/logback.xml). See the Logging section in the project README.
management¶
The management section configures the two additional TCP listeners for zero-downtime operation behind HAProxy.
management:
enabled: true
agent-check-port: 8404
agent-check-bind-address: "0.0.0.0"
management-port: 8405
management-bind-address: "127.0.0.1"
# allow-remote-management: false
# token-file: /opt/imconnect/config/management.token
| Field | Type | Default | Description |
|---|---|---|---|
enabled |
boolean | true |
Enables the management & agent-check servers. |
agent-check-port |
int | 8404 |
Port HAProxy polls for the state (ready/drain/down). |
agent-check-bind-address |
string | "0.0.0.0" |
Bind address for agent check (network-accessible). |
management-port |
int | 8405 |
Port for operator commands. |
management-bind-address |
string | "127.0.0.1" |
Bind address for the management port (local only). |
allow-remote-management |
boolean | false |
Opt-in for a non-loopback management-bind-address; otherwise a non-loopback bind address is rejected at startup. The opt-in waives the loopback requirement, not authentication: combined with a non-loopback address it requires a token-file to be set since 1.17.0, otherwise startup aborts. |
token-file |
string | (not set) | Path to a file containing the shared-secret token required by the state-changing commands drain/resume/reload/shutdown (status/connections remain token-free). Not set = no authentication (WARN at startup). The token is the first non-blank line of the file, trimmed. Must exist at startup, be readable, ≤ 64 KB and non-empty; the token value is re-read per command, so it rotates without a restart (the path requires a restart). Keep it readable by the owner only (chmod 600) — group/other-readable files trigger a WARN at startup. |
A key with an empty value (enabled: with nothing after it, an empty port) is a
startup error since 1.17.0 — it used to bind silently to false or 0, and the two
listeners never opened without a word. Either omit the key (the default applies) or
write a value; this holds for every on/off and numeric key of this section.
It holds for the section header itself, too: a management: with no block beneath it is a
startup error. The whole section used to fall back to its defaults silently — commenting out
every line while leaving the header in place produced an open management port without the
configured token-file. Omitting the header entirely keeps the defaults, as documented.
The available commands (status, connections, drain, resume, reload,
shutdown), the rationale for the loopback requirement and token authentication, and the
hot-reload vs. restart-required rules are documented in
Management & Drain.
audit¶
Configures the database audit trail: one row per association and per DIMSE operation in the shared PostgreSQL database, written losslessly through a local disk spool.
audit:
enabled: true
spool-path: /var/imconnect/audit-spool
spool-max-mb: 512
batch-size: 100
flush-interval: 1s
capture-patient-name: false
events:
enabled: false
level: WARN
queue-size: 1000
| Field | Type | Default | Description |
|---|---|---|---|
enabled |
boolean | true |
Whether audit events are captured at all. Also effectively off when DB_ENABLED=false. |
spool-path |
string | /var/imconnect/audit-spool |
Directory for the local disk spool (outbox between router and DB). Must be writable and exclusive per router instance — two processes sharing a spool directory collide, and the audit trail disables itself (loudly). The spool files are unencrypted JSONL with patient ID, study/SOP instance UIDs and accession number (plus the patient name with capture-patient-name) — they are created owner-only; for the residual risk on the storage medium the encryption notes in Deployment apply. |
spool-max-mb |
int | 512 |
Upper bound on the spool size in MiB. Above it, new events are dropped and counted — the most common, but not the only audit loss path (for the others see audit-dropped in Management), visible in status and as a WARN log. |
batch-size |
int | 100 |
Maximum number of events per database transaction. |
flush-interval |
string | 1s |
Maximum delay queue → durable spool (grouped fsync). Accepts 500ms, 1s, 2m; a bare number means milliseconds. A process crash can lose at most this window. |
capture-patient-name |
boolean | false |
Captures the patient name (PHI!). Off by default — patient ID and UIDs are pseudonymous identifiers and sufficient for support correlation. Gates only the DIMSE capture (DimseAuditStage → dimse_operations); it has no influence on audit.events/router_events. |
Restart-required: all fields except
capture-patient-name(spool writer and shipper are bound at startup, analogous tomanagement.*).capture-patient-nameis read per capture from the connection's config snapshot and hot-reloads.
enabled:, capture-patient-name: or events.enabled: with an empty value is a
startup error since 1.17.0 — an enabled: left standing while editing used to bind
silently to false, and the audit trail simply did not run. Also rejected: an empty
events: and an empty audit: (the key without a block beneath it) — otherwise an
explicitly disabled audit trail comes back silently while editing. Omitted keys keep their
defaults, as everywhere.
audit.events¶
Writes the WARN/ERROR events of all nodes centrally into the database — "which node had
the error, and which connection did it belong to?" becomes a database query instead of a log
grep across N hosts. The capture hooks itself into the logging programmatically at startup;
the operator's logback.xml does not have to be touched.
Deliberately not a log mirror: only WARN and above is captured, the message is
truncated to 2000 characters and only the class name of an exception is stored. The node's
log files remain the complete record (DEBUG, full stack traces).
PHI:
audit.capture-patient-namegates only the DIMSE capture, notrouter_events— what a WARN/ERROR message says is what gets stored. The class-name brake applies only to the attached exception; an exception message interpolated into the message text lands in the database verbatim. WARN/ERROR messages from plugins must therefore be formulated PHI-free — see the binding policy including thephiFreeCausepattern indocs/plugins.mdin the repository.
Deliberately best-effort, unlike the audit trail: a bounded queue and a batch writer, no disk spool, no retry. If the queue fills up or a batch fails, the events are discarded and counted. A device in a reconnect loop produces errors faster than any database ingests them — that loss is cheaper than an error storm threatening heap, routing and the audit spool.
audit:
enabled: true # required: the events share the audit trail's database wiring
events:
enabled: false
level: WARN
queue-size: 1000
| Field | Type | Default | Description |
|---|---|---|---|
enabled |
boolean | false |
Whether WARN/ERROR events are written to the router_events table. Requires audit.enabled: true — the combination audit.enabled: false with audit.events.enabled: true is a configuration error (the writer shares the audit trail's database wiring and schema management, and the association correlation would have nothing to join against without the audit tables). |
level |
string | WARN |
Capture threshold: WARN or ERROR (case-insensitive). Lower levels are rejected rather than silently raised — mirroring INFO/DEBUG into the database is explicitly not the goal. |
queue-size |
int | 1000 |
Size of the bounded hand-off queue, allowed range 1–100000. Beyond it events are discarded and counted (periodic WARN message in the log). A larger value buffers longer database outages but costs heap: a clamped event takes ~3–5.5 KB worst case, so the default is ~3–5.5 MB. The upper bound is deliberate — an unbounded queue would turn exactly the error storm this path is meant to survive into a heap problem. |
Restart-required: all three fields (queue, writer thread and the attached appender are bound at startup).
Table layout, join examples, the limits of the correlation and retention over logged_at
are described in docs/database.md in the repository.
proxyprotocol¶
A list of matchers describing which incoming connections are expected to carry a PROXY protocol header. If this section is missing or empty, no connection is checked for the PROXY protocol.
proxyprotocol:
- proxyIp: 192.168.50.128/29
proxyPort: 0
routerLocalIp: 192.168.50.224
routerLocalPort: 11112
- proxyIp: 192.168.12.9
proxyPort: 0
routerLocalIp: 192.168.50.224
routerLocalPort: 0
| Field | Type | "Any" value | Description |
|---|---|---|---|
proxyIp |
string | 0.0.0.0/0 |
IP the proxy connects from (CIDR allowed). |
proxyPort |
int | 0 |
Source port of the proxy connection (0 = not considered). |
routerLocalIp |
string | 0.0.0.0/0 |
Local router IP the connection was received on. |
routerLocalPort |
int | 0 |
Local router port the connection was received on. |
IPs may use CIDR notation; without a suffix the address is treated as /32.
Port 0 means the port is not considered. Any other value has to be within 1–65535
— a port outside that range cannot correspond to a real socket port, so the rule
would never apply.
Only enable the PROXY protocol for ports where HAProxy actually sends PROXY headers.
The router parses v1 headers strictly: a malformed header (too few fields, address
family not matching TCP4/TCP6, port outside 0–65535, repeated separators) closes
the connection — previously the connection kept running in some of these cases with
the proxy's address as the client address, and IP-based filter rules decided on the
wrong address. PROXY UNKNOWN is consumed as the specification requires, without
adopting any addresses — and so is its v2 counterpart LOCAL, which HAProxy sends
for health checks once check-send-proxy is configured: in both cases the
connection keeps its real peer address. Behind HAProxy that is the proxy's own
address — IP-based filter rules then decide on that address, not on a client
address.
filter¶
An ordered list of routing rules. Matching is first-match-wins: as soon as a rule matches a connection, it is applied and evaluation stops. A catch-all rule belongs at the end.
If the filter section is missing or empty, the router logs a warning when the
configuration is loaded. Without rules it still accepts every association, but then
finds no destination for any connection: the client is left on an open connection
that never gets an answer. Deliberately not an error — the setup modes
--allocate-return-ports and --render-haproxy work on destinations only and are
legitimately run on a configuration whose filter rules do not exist yet. When
building a configuration from scratch, that is the usual order.
filter:
- clientLocalIp: 0.0.0.0/0
clientLocalPort: 0
callingAET: US1
calledAET: PACS
logLevel: 3
actions:
- "aetRewrite_ultrasound"
destination:
- "pacs1"
# Catch-all rule: reject everything that did not match
- callingAET: "*"
calledAET: "*"
destination:
- "reject_unknown"
| Field | Type | Default | Description |
|---|---|---|---|
clientLocalIp |
string | 0.0.0.0/0 |
IP of the client (CIDR allowed). |
clientLocalPort |
int | 0 |
Source port the client connects from (0 = any). |
clientRemoteIp |
string | 0.0.0.0/0 |
Destination IP configured on the client (e.g. HAProxy/router). |
clientRemotePort |
int | 0 |
Destination port configured on the client. |
routerLocalIp |
string | 0.0.0.0/0 |
Local router IP that received the connection. |
routerLocalPort |
int | 0 |
Local router port that received the connection. |
callingAET |
string | * |
Calling AET of the client (* or omitted = any). Exact comparison or regex with the regex: prefix (see below). |
calledAET |
string | * |
Called AET the client requests (* or omitted = any). Exact comparison or regex with the regex: prefix (see below). |
logLevel |
int | 0 |
PDU logging level (see table below). |
actions |
list |
— | Names of actions entries executed for this rule (plugin instances). |
destination |
list |
— | The rule's destination: a name from destinations. The field takes a list, but exactly one destination is supported — only the first entry is used. |
The YAML key is named
destination(singular) and accepts a list — but the router uses only the first entry; there is no fan-out to multiple destinations. The list form keeps the config structure stable in case multiple destinations are supported in the future. Always specify exactly one entry, e.g.destination: ["pacs1"].
If an IP or a port is omitted, "any" applies (IP 0.0.0.0/0, port 0).
A port that is set has to be within 1–65535: clientRemotePort: 1044 instead of
104 — or one digit too many in routerLocalPort — cannot correspond to a real socket
port. The rule would never apply and the connection would fall through to the catch-all
rule (typically a reject) without a word. Hence it is now a configuration error.
On IPv6: IPv6 values are not allowed in the IP fields (the validator rejects them), so
the filter CIDRs are always IPv4. For an IPv6 peer (reachable via
router.listener.host: "::" or PROXY protocol source addresses) this means: a real
IPv4 prefix (/1 to /32, including the /32 form of a single IP) never matches it —
only a /0 (0.0.0.0/0, the default and the usual catch-all rule) is a
family-independent wildcard and matches every peer. IPv4 clients on a "::" listener
are unaffected; their v4-mapped addresses count as IPv4. Also a configuration error: a
leading zero in an IPv4 octet (010.1.1.1) — the value is ambiguous (read as octal vs.
decimal); write the octet without the zero (10.1.1.1).
AE title comparison: exact or regex¶
callingAET/calledAET values are compared exactly (case-sensitive); only the
literal * means "any". With the regex: prefix, the value is instead evaluated as a
Java regex:
filter:
- callingAET: "regex:^US[0-9]+$" # one rule for all devices US1, US2, ...
destination: ["pacs1"]
- callingAET: "CT.1" # literal — matches only the AET "CT.1", not "CTX1"
destination: ["pacs2"]
- The pattern must cover the entire AE title (full match, no substring search) and is case-sensitive (AE titles are case-sensitive).
- Values without the prefix are never interpreted as patterns — AE titles may legally
contain regex metacharacters such as
.or+. - An invalid pattern is a configuration error: startup aborts, a
reloadis rejected. - Edge case: an AET whose literal value itself starts with
regex:(legal per DICOM,:is an allowed character) can no longer be written as a plain literal. Match it with a quoted pattern instead:callingAET: "regex:\\Qregex:ABC\\E"(\Q…\Etreats everything in between literally).
logLevel values¶
| Value | Description |
|---|---|
0 |
no logging |
1 |
log inbound |
2 |
log outbound |
3 |
log inbound and outbound |
5 |
capture inbound to file |
6 |
capture outbound to file |
7 |
capture inbound and outbound to file |
Levels 5/6/7 write binary PDU dumps to router.log-folder.
4 is the one value in range that no capture path implements, and since 1.17.0 it is
rejected rather than silently treated as "no logging". Rejected as well: an empty
logLevel: (key without a value) — that overwrote the default with null and made the
association fail. Either omit the key (which means 0) or write a level.
actions¶
Each entry under actions is a named action whose value is a list of
plugin entries. A single named action can chain multiple plugin entries
(executed in order). Per-plugin parameters live in the config: block.
actions:
aetRewrite_ultrasound:
- plugin: ch.immeditech.imconnect.dicomrouter.plugin.AETitleRewriteStage
config:
callingAetOverride: "US_GATEWAY"
calledAetOverride: "PACS_PROD"
logRewrites: true
enabled: true
protocolLogging:
- plugin: ch.immeditech.imconnect.plugins.PduLoggingStagePlugin
config:
stageName: "InboundProtocolLogger"
firstLogger: true
enabled: true
- plugin: ch.immeditech.imconnect.plugins.PduLoggingStagePlugin
config:
stageName: "OutboundProtocolLogger"
firstLogger: false
enabled: true
exampleActions:
- plugin: ch.immeditech.imconnect.plugins.ExamplePluginStage
config:
exampleString: "exampleValue"
exampleInteger: 42
exampleFlag: true
The plugins actually shipped/used in the default config are (usage and config fields: Plugin Reference):
ch.immeditech.imconnect.dicomrouter.plugin.AETitleRewriteStagech.immeditech.imconnect.dicomrouter.plugin.CMoveDestinationRewriteStagech.immeditech.imconnect.dicomrouter.plugin.WorklistAetRewriteStagech.immeditech.imconnect.dicomrouter.plugin.QrFieldRewriteStagech.immeditech.imconnect.dicomrouter.plugin.DicomTapStagech.immeditech.imconnect.dicomrouter.plugin.CMoveTrackingStagech.immeditech.imconnect.dicomrouter.plugin.charset.CharsetTranscodePluginch.immeditech.imconnect.dicomrouter.plugin.charset.CharsetDeclarePluginch.immeditech.imconnect.plugins.PduLoggingStagePluginch.immeditech.imconnect.plugins.ExamplePluginStage
The content of the config: block is plugin-specific — every shipped plugin is
documented with its fields and examples in the Plugin Reference.
Strict binding applies here too: a key in the config: block the plugin does not know is
a configuration error, and the message names both the key and the ones the plugin
actually has.
The same applies to empty values since 1.17.0: a key in the config: block written
without a value (enabled: with nothing after it, e.g. left standing while editing) is a
configuration error, whatever the field's type — an on/off or numeric key used to bind
silently to false or 0 (an empty enabled: switched the plugin off despite its
default of true), other fields to "not set". This includes the quoted form
(quotaMb: "", likewise a value of pure whitespace) — with one exception: on a String
field the empty string is a valid value and binds as such. An omitted key keeps the
plugin's default, as everywhere; the message names the affected key, nested paths
included (actions.x[0].config.enabled).
The config: block itself follows the same rule: a config: with nothing under it
(e.g. left standing after every line below it was commented out) is a configuration
error — the plugin would silently run on all its defaults. To state the defaults
deliberately, write config: {} (valid, behaves like an omitted block) or remove the
block; the error message names both remedies. config: {} is also valid on a plugin
that binds no config keys — an empty block configures nothing, unlike the non-empty
block in the next paragraph.
A config: block on a plugin that binds no config keys at all (it does not implement
the ConfigurablePlugin interface, or it implements it but reports no config type —
getConfigurationType() returns null) is a configuration error too: every key in it
would be ineffective, and the message says to remove the block. This only concerns third-party
plugins — all shipped plugins are configurable.
An action only runs when a filter rule references it¶
There is no autoloading: ActionProcessor resolves exactly the actions the matching
filter rule names under filter[].actions. An action no rule references is dead
configuration — the router says so at start and reload with a warning naming it. For a
CMoveTrackingStage that is particularly consequential: it then writes nothing, and the
Q/R return association finds no entry for any object hours later. The store cross-check
between CMoveTrackingStage and the qr-bridge destination therefore only counts
referenced actions.
For the same reason the plugin examples in the shipped template are commented out: they are documentation, not active configuration. To activate one, do both — uncomment the block and add its name to the filter rule.
Every entry needs a plugin:¶
An entry without a plugin: key is rejected. There is no built-in action type:
plugin is the only one. A mistyped plugn: used to produce an entry without a plugin
class, which the runtime skipped in silence — a typo in the most important key thereby
disabled, say, an anonymisation.
A plugin that cannot be instantiated makes the connection fail¶
If instantiating one of the plugins the rule references fails at connection time (class
not loadable, constructor throws, config not applicable, incompatible plugin JAR), the
association is aborted instead of forwarded without that action. The reason: a
connection without its configured action is not a restricted connection, it is one doing
something the configuration forbids — no rewrite, no filtering, no tracking. The failure
is logged as ERROR naming plugin and action. The check before that stays
--validate-config: it loads every referenced plugin class and every config: block.
destinations¶
A map of named destinations. Each destination is polymorphic via its type field.
A destination name is referenced from a filter rule via filter[].destination.
A name may be at most 64 characters long. It is more than a label: the Q/R bridge stores
it as the return path in the tracking entry and resolves it against destinations: again
hours later. A longer name does not fit there, so config validation rejects it instead of
storing a truncation nothing can resolve later.
type |
Behavior |
|---|---|
forward |
Forwards to a network destination (default, type may be omitted). |
reject |
A-ASSOCIATE-RJ to the client, no forwarding. |
qr-bridge |
The router terminates the association itself and routes every C-STORE based on the C-MOVE tracking (Q/R return path; no address, no outgoing socket). |
type is compared exactly — lower case, no surrounding whitespace. A differently spelled
value (QR-BRIDGE, Reject) is a validation error and aborts start or reload instead of
silently binding as a forward destination. An omitted type still means forward.
Fields belong to exactly one type. A field belonging to another destination type is a validation error rather than dead config:
store,calling-aet,allowed-calling-aet,presentation-contexts,max-drain-bytes,timeoutsonly onqr-bridge;host,port,aetonly onforward(arejectorqr-bridgedestination never opens a connection);result,source,reason,messageonly onreject;return-port/return-viponly onforward(seehaproxy-return).
The practically most important case: a destination carrying host/port and
result/source/reason whose type: reject was forgotten used to forward in silence
instead of rejecting.
type: forward (default)¶
Forwards the association to a network destination. type may be omitted —
forward is the default.
destinations:
pacs1:
type: forward # optional, this is the default
host: pacs1.example.org
port: 104
aet: PACS
| Field | Type | Default | Description |
|---|---|---|---|
type |
string | forward |
Destination type. |
host |
string | — | Destination host — always the real device address (the HAProxy backend target when return-port is set). |
port |
int | — | Destination port. |
aet |
string | — | Application Entity Title of the destination. Optional: the regular forward path proxies the inbound A-ASSOCIATE-RQ and never reads the value — it is read by the paths that open an association of their own (Q/R return leg, tap forward without its own calledAet). When it is written, it has to be 1–16 characters: blank or longer is rejected at startup and on reload, because PS3.8 9.3.2 would otherwise pad to 16 bytes or truncate, and the mistake would only surface as a rejection from the far end. |
return-port |
int | null |
HAProxy return path (see haproxy-return): when set, the router dials vip:return-port instead of host:port. 0 is an allocation marker: --allocate-return-ports replaces it with the smallest free port of the port-range. |
return-vip |
string | null |
Per-destination VIP override; without it the global haproxy-return.vip applies. Only allowed together with return-port. |
type: reject¶
Sends the client an A-ASSOCIATE-RJ PDU instead of forwarding.
destinations:
reject_unknown:
type: reject
result: 1 # rejected-permanent
source: 1 # service-user
reason: 7 # called-aet-not-recognized
message: "Unknown AE Title - connection rejected"
| Field | Type | Default | Description |
|---|---|---|---|
type |
string | reject |
Must be reject. |
result |
int | 1 |
1 = rejected-permanent, 2 = rejected-transient. |
source |
int | 1 |
1 = service-user, 2 = ACSE, 3 = Presentation. |
reason |
int | 1 |
Reason/diagnostic code; meaning depends on source. |
message |
string | null |
Optional descriptive message (logging only). |
Reason codes (per DICOM A-ASSOCIATE-RJ):
source = 1(service-user):1= no-reason,2= app-context-not-supported,3= calling-aet-not-recognized,7= called-aet-not-recognizedsource = 2(ACSE):1= no-reason,2= protocol-version-not-supportedsource = 3(Presentation):1= temporary-congestion,2= local-limit-exceeded
result:/source:/reason: with an empty value are a startup error since 1.17.0 —
they used to bind silently to 0, and the error message named a code nobody had
written. Omitted, the defaults 1/1/1 apply.
Filter rules can route unauthorized connections to a reject destination to
turn them away with a proper DICOM rejection code (see the catch-all rule above).
type: qr-bridge¶
The return path of the late-binding Q/R bridge (design: docs/design/archive/routing-c-move.md
in the repository). Instead of forwarding, the router terminates the association
itself: it answers the A-ASSOCIATE-RQ, reads the MoveOriginatorMessageID from every
C-STORE command set and forwards the object to the device whose C-MOVE left the tracking
entry behind (CMoveTrackingStage, see plugin-reference.md). If the
MoveOriginatorMessageID is missing or matches no entry — not every PACS fills the field,
and some fill in a value of their own — the bridge falls back to matching the object to
the most recent fitting tracking entry by the data set's StudyInstanceUID. That
is why this destination has no host/port — the target is only determined per
message. The router therefore never opens a connection to the qr-bridge destination
itself; the outgoing association goes to the device's regular forward destination
recorded in the tracking entry (see "Forwarding to the target device" below), i.e. to its
host/port or its HAProxy return path (return-port).
destinations:
qr_bridge:
type: qr-bridge
store: memory # must match the store: of the cmoveTracking action
calling-aet: ROUTER # CallingAET of the leg to the device (default: listener AET)
allowed-calling-aet: [PACS] # optional allowlist for the return association (empty = no check)
max-drain-bytes: 268435456 # emergency brake: discarded bytes per association (0 = unlimited)
timeouts: # timeouts of the leg to the device (all optional, milliseconds)
connect: 30000
response: 30000
release: 5000
presentation-contexts: # curated list; if absent: Implicit VR LE only
- abstract-syntax: "1.2.840.10008.5.1.4.1.1.2" # CT Image Storage
transfer-syntaxes: ["1.2.840.10008.1.2.1", "1.2.840.10008.1.2"]
| Field | Type | Default | Description |
|---|---|---|---|
type |
string | — | Must be qr-bridge. |
store |
string | memory |
Tracking store the bridge reads from: memory (single node) or database (cluster). Requires a restart. |
calling-aet |
string | listener AET | CallingAET of the leg to the target device (for devices with an AE ACL). |
allowed-calling-aet |
list | empty | Allowlist for the incoming return association: only these CallingAETs are accepted, everything else gets an A-ASSOCIATE-RJ (result 1, source 1, reason 3 = calling-aet-not-recognized). Empty or absent = no check, i.e. every connection a filter rule routes here (see "Access control" below). |
max-drain-bytes |
long | 0 |
Emergency brake: total data-set bytes the bridge may discard on one association before it aborts with A-ABORT. 0 = unlimited (see "Emergency brake" below). |
timeouts.connect |
int | 30000 |
Milliseconds for establishing the association to the device. The value applies per phase — once for the TCP connect and once for waiting on the A-ASSOCIATE-AC. A device that accepts the connection and then stays silent therefore costs twice that in the worst case. The connect phase includes the name resolution of the target host: the lookup and the TCP handshake share this budget, so an unreachable DNS resolver cannot block the bridge for longer than configured here. A host configured as an IP address is never looked up. The value applies per connection attempt, and the bridge gives up after three consecutive attempts without an answer from the device — a permanently unreachable device therefore costs the PACS up to 3 × this value. |
timeouts.response |
int | 30000 |
Milliseconds per relayed message: waiting for the C-STORE-RSP and every single PDU write. |
timeouts.release |
int | 5000 |
Milliseconds for the A-RELEASE-RP of the device leg — deliberately short, because the PACS is waiting for its own release confirmation in the meantime. |
presentation-contexts |
list | empty | Curated accept list of abstract-syntax + transfer-syntaxes (order = operator preference). |
Presentation context policy. Because the router has to answer before the target device is known, the endpoints no longer negotiate their contexts directly with each other:
- Without
presentation-contexts:the bridge accepts every offered abstract syntax, but only with Implicit VR Little Endian (safe default; compression is lost, the PACS decompresses). - With
presentation-contexts:the abstract syntax must be configured (otherwise rejection with result 3 = abstract-syntax-not-supported); the first configured transfer syntax that the PACS also offers is chosen (otherwise result 4 = transfer-syntaxes-not-supported). - Contexts that do not match are rejected individually — the PACS itself counts the
affected objects as failed sub-operations. An
A-ASSOCIATE-RJonly happens when no context is acceptable. If a PACS sends on a rejected context anyway, the message is answeredC000and never forwarded: no transfer syntax was ever agreed for that context, so there is no label under which those bytes could be passed on. That answer goes back out on the same rejected context — a deliberate deviation from PS3.8, which requires P-DATA to run on accepted contexts only. The standard's answer here would be an A-ABORT, but that would end the whole retrieve, including the objects travelling on the accepted contexts. The per-message failure loses only the objects that are undeliverable anyway, and the caller broke the rule first. Nothing is forwarded either way. - If a PACS proposes the same context id more than once (not standard-conformant — PS3.8 9.3.2.2 requires distinct odd ids), every occurrence is rejected. Accepting one of them would produce an A-ASSOCIATE-AC that contradicts itself for that id.
- Verification (C-ECHO) is always accepted, even in the implicit-only default; the
bridge answers the ping itself. Exception: if
1.2.840.10008.1.1is itself curated inpresentation-contexts:, the curated rule applies to it like to any other abstract syntax — an operator who spells the context out means it, and C-ECHO can then end up on result 4 as well.
Per-message error semantics (no A-ABORT — the only exception is the max-drain-bytes
emergency brake further down; the data set is always consumed completely before the
response is sent, so the association stays usable):
| Case | RSP status |
|---|---|
| Target device unreachable or not dialable (incomplete destination), association rejected, timeout, abort mid-message, reconnect cap reached | A700 |
| Lookup miss, unknown destination name after a reload, second destination on the same association | C000 |
| C-STORE without a readable SOP class/instance UID, empty data set, unreadable command set or data set, C-STORE not buildable from a corrupt tracking entry | C000 |
| Message on a presentation context the bridge rejected (see the policy above) | C000 |
Command set without a readable CommandField, and every DIMSE service the bridge does not offer (anything other than C-STORE, C-ECHO and C-CANCEL) |
0211 |
| Target device does not accept the required presentation context | 0122 |
A C-CANCEL-RQ is the one message that gets no answer: PS3.7 9.3.4 defines no response
for it, and the bridge has no outstanding operation of its own to cancel. It is logged and
discarded — nor is it forwarded, because a bridge association has no outbound socket at all.
If it announces a data set anyway — against PS3.7 9.3.4, which defines none for a C-CANCEL —
that data set is read as well, charged to max-drain-bytes and discarded without an answer.
If the target device answers itself, its status is passed through unchanged — including
0000 and warnings (B000/B006/B007), which the PACS counts as warning sub-operations.
Emergency brake max-drain-bytes. A failure response does not exempt the bridge from
draining: the rest of the message is always read to the end and discarded. With a
permanent error — a destination removed by a reload, a device that stays unreachable even after
the reconnect cap — a PACS may thereby push an entire study into the void.
max-drain-bytes limits that: if the sum of discarded data-set bytes on one
association exceeds the value, the association is terminated with A-ABORT. The threshold
always takes effect between two messages — a message in flight is never cut off (there
is no flow-control mechanism for that). The message that breaks the limit still gets its
failure RSP in full; the abort only happens when the PACS starts the next message —
whether that one begins with a command set or, in a stream of nothing but data sets, with a
data set.
Only bytes the bridge itself could not deliver anywhere are counted — among them a
lookup miss, an unknown destination, a multiplex mismatch, an unreachable device, a
presentation context mismatch, a transport error, a C-STORE without a readable SOP UID or
with an empty data set, and a data set for which no command of this bridge preceded it
(otherwise that would be the one way to push unlimited bytes into the void without ever
arming the brake). A message the target device has acknowledged never counts, regardless
of the status: even an A900 or A700 from the device is its verdict on a completely
received object, and counting that would make a retrieve against a picky device trip the
emergency brake and lose exactly those objects the device would have accepted. Default 0
= unlimited, i.e. unchanged behavior.
Forwarding to the target device. The bridge opens the association to the device on the first message whose routing succeeds and keeps using it for the rest of the association (one bound destination per incoming association). The following applies:
- The C-STORE is rebuilt, not replicated byte by byte:
MoveOriginatorMessageIDandMoveOriginatorAETcarry the device's original values from the tracking entry again, so a device that correlates the completion of its C-MOVE via (0000,1031) works correctly. The data set goes over the wire byte-identical (no transcoding) and is re-fragmented to the device's max PDU length. - For every required abstract syntax exactly the transfer syntax negotiated on the PACS
leg is proposed — without transcoding both legs have to use the same syntax. If the
device rejects it, the message is
0122; the remaining ones continue. - If the connection to the device breaks, it is discarded; the next message opens a new
one. When that happens as a transport failure on an already used association — the
signature of a device-side rotation rather than of a failed device — the affected message is
repeated exactly once on a fresh association instead of being lost as a failed
sub-operation. Only the transport failure is repeatable: an A-ABORT from the device mid-message
and a protocol violation in its response are not repeated (
A700), because the repeat would send the same object only to have it answered the same way. Exactly once, because the same breakdown may equally mean the device received the object in full and only its response was lost: the repeat then creates a duplicate of the same SOP instance, which an archive overwrites in practice. On an association that was only just opened there is no repeat — a failure there is not a rotation but a device failing on its very first object. - The cap for reopening is three consecutive association attempts that produced no answer
from the device — a single network interruption therefore does not cost the rest of the
retrieve, but a permanently unreachable device does not cost a connection timeout per image
either: after that, every further message is answered with
A700immediately. The budget is spent not only by connections that never came up, but also by ones that were established cleanly and then discarded over a protocol violation of the device. Only consecutive attempts count: as soon as the device has answered again — whatever the status, a rejection counts too — the cap starts over. A healthy device that ends its associations itself (association limit, idle timeout, NAT reset) therefore delivers a retrieve in full — it neither stops after the third association nor loses an image per rotation. - On the PACS's A-RELEASE-RQ the device leg is released properly first, then the A-RELEASE-RP is sent; if the PACS aborts, the device leg is aborted as well.
In the audit trail the leg to the device appears as its own association row with
router_role = 'forward' and the parent_association_id of the PACS association — both
legs of a retrieve are therefore directly joinable (details on the columns:
docs/database.md in the repository).
Validation. The store settings of both halves must agree: every store a bridge reads
from must also be written by a CMoveTrackingStage action on this node. It is therefore
not enough for a bridge's store to occur somewhere among the actions: if five actions write
to database and a sixth (with a forgotten store:, default memory) writes into memory,
that is an error, because exactly the devices of that one action would lose every returning
image — the memory store is node-local, so what lands there and is read by nobody here can be
read by nobody at all. Conversely, the rule therefore only applies to store: memory: an action with
store: database that has no bridge here is the documented cluster case (the reading
bridge runs on another instance) and is valid — including next to a local memory bridge. If
there is no CMoveTrackingStage action on this node at all, the bridge is still valid: that
is the pure read node in the cluster, whose entries another instance writes into the shared
database; the mirror case (actions without a bridge) is the write node and is equally
valid.
Whether this node has a database at all is not checked by config validation — that is a
property of the machine, not of the file, and the same file is valid on another node. It is
decided at startup and at reload: if no database store comes up despite a valid config —
because the database is switched off or the JDBC wiring fails — the router refuses to boot
instead of quietly falling back to the in-memory store; and if an addition in a reload asks
for store: database on a node without a database-backed tracking store, the reload is
rejected and the previous configuration stays active (see Management). A node that tracks locally while its cluster
peers look up the shared database would lose every retrieve whose return association lands
elsewhere. The error message names both ways out (fix the database, or switch everything to
store: memory).
UIDs are checked against the DICOM syntax (digits and dots only, no empty component, no
leading zero in a multi-digit component, at most 64 characters — a SOP class name written out
in words, or a UID with a trailing space, is rejected because it could never match at
runtime), an abstract syntax may only appear once, and a transfer syntax
the router cannot decode is rejected. calling-aet must be a valid AE title (1–16
characters), max-drain-bytes must be >= 0, every value set
under timeouts: must be > 0. Only connect, response and release are allowed under
timeouts: — a typo is an ordinary configuration error naming section and field
(destinations.<name>.timeouts.<key>), not a silent fallback to the default.
Except for store, all fields of this destination are
hot-reloadable.
Access control: the filter rule, and optionally allowed-calling-aet. By itself the
bridge does not check whether an incoming association belongs to the PACS whose C-MOVE
created the tracking entry. It routes
on the MoveOriginatorMessageID from the command set and, as a fallback, on the data set's
StudyInstanceUID (see above) — anyone allowed to open an association to the bridge who
knows or hits a valid number (the space is 16 bits), or who knows the StudyInstanceUID of
a retrieve in flight, can have an object delivered to the corresponding device. The filter rule pointing at the qr-bridge
destination is therefore the access control on the return path, and should be as narrow as it
can be:
- restrict
clientLocalIpto the address or network of the PACS. This is the only criterion of the rule the caller does not choose itself, and therefore the only effective access restriction. Behind HAProxy it is only meaningful together with the PROXY protocol — otherwise the router sees the proxy's address; - pin
callingAETto the AE title of the PACS — no*and no broadregex:; - pin
calledAETto the AE title the router is registered under as a DICOM node in the PACS (ROUTERin the example below) instead of leaving it open; - put the rule after the device rules, so no device can land on it by accident (the first matching rule wins).
Both AE titles come from the caller's A-ASSOCIATE-RQ and are therefore unauthenticated:
whoever reaches the listener port fills them in as they please. They keep misconfiguration
and accidental connections out, not an attacker. For access control on the return path, rely
on clientLocalIp and on the network segmentation in front of it, not on the AE titles.
allowed-calling-aet on the destination is the second, narrower barrier: it is checked on
the A-ASSOCIATE-RQ, i.e. before a single message arrives, and refuses an unlisted CallingAET
with an A-ASSOCIATE-RJ (calling-aet-not-recognized). The AE title remains an
unauthenticated value from the A-ASSOCIATE-RQ, so the list does not replace clientLocalIp
— it complements it. Without an entry the bridge behaves as before and accepts every
connection a filter rule routes here; that is the default, so an update cannot take an
existing installation off the air. The comparison is exact and case-sensitive (PS3.5 6.2).
With a clustered PACS the node that calls back is not necessarily the one that took the
C-MOVE, and such nodes often appear under AE titles of their own. The list then has to carry
every AE title the PACS may open an association under — a missing one fails exactly the
retrieves coming back through that node, and it fails them with an A-ASSOCIATE-RJ rather
than with a per-image error.
Capacity of the tracking space. A tracking entry occupies one DICOM MessageID, i.e. one
of 65,536 numbers — per store, not per device or association. An entry is not released
when the retrieve finishes, but only with its TTL (ttl: of the CMoveTrackingStage action,
default 24 h); and even then the router only cleans up incidentally: the in-memory store on
the next attempt to use the same number and, every few allocations, across the whole set; the
database additionally through the retention script (share/audit-retention.sql.example).
From 50 % occupancy the in-memory store logs a WARN and from 90 % an ERROR — once per process
each, so saturation becomes visible before it bites. Once the space fills up, allocation no longer
finds a free number: the C-MOVE is then forwarded unchanged — nothing aborts, but there is
no bridge either, the images go wherever the device originally addressed them — and the log
carries an ERROR.
For choosing the TTL that means: long enough for the slowest expected retrieve, and no longer.
65,536 is the ceiling, not a target — allocation gets expensive well before that, because
it has to search longer and longer for a free number. As a rule of thumb, "TTL (in hours) ×
C-MOVE-RQs per hour" should occupy at most about a quarter of the space, i.e. ~16,000
entries open at once. With the default TTL of 24 h that is roughly 680 C-MOVEs per hour
across all devices of this store (saturation would be 2,730 — that is 100 % occupancy). Above
that, shorten the TTL accordingly: with ttl: 2h the same space carries about 8,000 C-MOVEs
per hour.
haproxy-return¶
Optional global section for HAProxy return paths: devices whitelist calling IPs.
So router nodes don't each need a device-side entry, HAProxy runs one return path per
device (VIP:return-port → device IP:port). A forward destination with a
return-port is dialed by the router automatically via return-vip:return-port
(per-destination override) or vip:return-port (global default) instead of its real
host:port address — this applies to routing (ForwardStage) and to the tap
shipper (the address is resolved at spool time).
haproxy-return: # global, optional
vip: 10.0.0.10 # default VIP
port-range: "5000-5999" # pool for automatic port allocation + render boundary
destinations:
ct_a:
host: 192.168.10.5 # REAL device address (HAProxy backend target)
port: 104
aet: CT_A
return-port: 5013 # → router dials vip:5013
# return-vip: 10.0.0.11 # optional per-destination override
ct_b:
host: 192.168.10.6
port: 104
aet: CT_B
return-port: 0 # marker: --allocate-return-ports assigns the port
| Field | Type | Default | Description |
|---|---|---|---|
vip |
string | — | Default VIP for destinations with a return-port but no return-vip. |
port-range |
string | null |
Port pool "from-to" (both bounds inclusive, 1–65535, from ≤ to) for automatic allocation: --allocate-return-ports replaces every return-port: 0 marker with the smallest free port of the range for the destination's effective VIP. Without a port-range, markers are a validation error. The range is also the boundary for --render-haproxy: only destinations with a return-port inside the range are rendered (see below). |
Automatic port allocation (return-port: 0): new destinations are marked with
return-port: 0; the --allocate-return-ports subcommand (see
management.md, run automatically by dicom-router-sync) replaces
each marker with the smallest free port of the port-range — with respect to the
destination's effective VIP (return-vip override or global vip; uniqueness is per
(vip, port) pair, two VIPs may carry the same port). Once assigned, ports are
never recomputed (sticky) — only markers are replaced. A port-range without a
vip is allowed when every affected destination sets its own return-vip.
The matching HAProxy configuration (one listen block per return path) is generated
by the router itself from the YAML: see --render-haproxy in
management.md; distribution + reload is handled by
dicom-router-haproxy-sync (also management.md). Like
destinations, this section is hot-reloadable.
port-range as the boundary to the hand-maintained part: --render-haproxy only
renders destinations whose return-port lies inside the port-range. The generated
file is overwritten completely on every sync run, so a return path the operator keeps in
their own HAProxy config must use a port outside the range — otherwise two listen
blocks collide on the same bind. A return-port outside the range stays valid
configuration and is dialed at runtime via vip:return-port as before; it only appears
as a comment in the generated file. The check is purely port-based and VIP-independent.
With no port-range configured there is no boundary: every forward destination with a
return-port is rendered.
Validation (load-strict — errors abort start/reload):
return-portoutside 1–65535 → error.return-port: 0(unallocated marker) → error with a hint pointing at--allocate-return-ports— an unresolved config can never go live.return-port: 0without a configuredport-range→ dedicated error (the marker cannot be resolved).port-rangenot of the form"from-to"with 1 ≤ from ≤ to ≤ 65535 → error.return-portset but neitherreturn-vipnorhaproxy-return.vip→ error.return-vipwithoutreturn-port→ error (dead config).- Duplicate effective
(vip, return-port)pair across destinations → error (HAProxy bind collision — each device needs its own return path). haproxy-return:present but neithervipnorport-rangeset → error (an explicitly blankvip: ""stays an error even with aport-range).return-port/return-vipon atype: rejectdestination → error.vip/return-vip— and likewise thehostof every forward destination — with characters outside the hostname/IP charsetA-Za-z0-9._:-plus[](IPv6 brackets) and%(IPv6 zone IDs) → error: the values are rendered verbatim into thebind/serverlines of the generated HAProxy config; whitespace/newlines in particular are rejected.
Worked example: C-MOVE round trip (both associations)¶
A C-MOVE consists of two independent associations:
- Association 1 — device → router → PACS: the C-MOVE-RQ.
- Association 2 — PACS → router → device: the PACS opens a new incoming
connection and sends the images via C-STORE to the
MoveDestinationnamed in the request.
On the TCP level the two have nothing to do with each other — the router therefore has to re-associate the second connection with the first based on its content. There are two ways to do that, and they are mutually exclusive:
| Path A — AET encoding | Path B — Q/R bridge | |
|---|---|---|
| Association via | the CalledAET of association 2 (already present in the A-ASSOCIATE-RQ) |
the tracking entry for the MoveOriginatorMessageID in the first C-STORE command set |
| Router role on association 2 | transparent proxy | SCP termination (type: qr-bridge) |
| Entries in the PACS | one DICOM node per device (group) | a single DICOM node |
| Filter rules for association 2 | one per encoded AET | exactly one, for all devices |
| Presentation contexts | device and PACS negotiate directly (compression is preserved) | the router answers before the device is known — curated list, default Implicit VR LE |
| Cluster (multiple router nodes) | stateless | store: database (shared database) |
Which path when? Path A is the simpler one and keeps the end-to-end negotiation of the
presentation contexts — as long as the PACS is allowed to carry one node entry per device,
it is the first choice. Path B is the answer to exactly one requirement: the PACS is to get
a single entry for the whole router. In exchange the end-to-end negotiation is
dropped: the accepted contexts and transfer syntaxes are configured at the router
(presentation-contexts, see type: qr-bridge) and have to hit the
intersection of the device fleet; without configuration the return path runs uncompressed
in Implicit VR LE.
For both paths: both directions need a filter rule, and the MoveDestination in the
C-MOVE-RQ must be an AE title the PACS can actually reach. Note: a destination's aet:
field rewrites nothing on the wire (the router forwards the original A-ASSOCIATE-RQ
transparently); any AE title change is an explicit AETitleRewriteStage action.
Path A: AET encoding (transparent proxy)¶
The MoveDestination is rewritten to an AE title the PACS knows
(CMoveDestinationRewriteStage). The PACS opens association 2 with exactly that
CalledAET — and that is already present in the A-ASSOCIATE-RQ, i.e. before a single byte
of P-DATA flows. The router therefore stays a transparent proxy.
Complete configuration for one device (US1) and one PACS:
filter:
# --- Association 1: device US1 -> PACS (C-MOVE-RQ) ---
- callingAET: US1
actions:
- "aetRewrite_us" # PACS sees CallingAET=ROUTER instead of US1
- "cmoveRewrite_us" # ...and MoveDestination=US_POOL instead of US1
destination:
- "pacs1"
# --- Association 2: PACS -> device US1 (C-STOREs to the MoveDestination) ---
- callingAET: PACS
calledAET: US_POOL
actions:
- "aetRewriteReturn_us" # device sees CalledAET=US1, as expected
destination:
- "geraet_us1"
actions:
aetRewrite_us:
- plugin: ch.immeditech.imconnect.dicomrouter.plugin.AETitleRewriteStage
config:
callingAetOverride: "ROUTER"
cmoveRewrite_us:
- plugin: ch.immeditech.imconnect.dicomrouter.plugin.CMoveDestinationRewriteStage
config:
destinationOverride: "US_POOL"
aetRewriteReturn_us:
- plugin: ch.immeditech.imconnect.dicomrouter.plugin.AETitleRewriteStage
config:
calledAetOverride: "US1"
destinations:
pacs1:
host: pacs.example.org
port: 104
aet: PACS
geraet_us1:
host: us1.example.org
port: 104
aet: US1
Required on the PACS side: a DICOM node entry US_POOL (AET US_POOL,
host/port = the router or its HAProxy frontend). When the PACS resolves the rewritten
MoveDestination=US_POOL, it connects to that node — i.e. back
to the router — with CalledAET=US_POOL, which the second filter rule matches.
Multiple devices behind one rule: a fixed destinationOverride maps all
devices onto one return route. To fan the return path back out per device, use
destinationMapping with an encoded AE title per device (US1: "RTR_US1",
US2: "RTR_US2", …), one PACS node entry per encoded AET and one
association-2 filter rule per calledAET — the "unique AET encoding" approach. An
alternative approach (port-based, one router/HAProxy port per device group, matched
via routerLocalPort) works without AET encoding; behind HAProxy it requires the
PROXY protocol so the router still sees the original frontend port.
Path B: Q/R bridge (a single PACS entry)¶
All devices rewrite their MoveDestination to the same AE title (ROUTER), and the
PACS carries exactly one DICOM node for it. The devices remain distinguishable through
the tracking entry that CMoveTrackingStage creates on association 1: it replaces the
MessageID of the C-MOVE-RQ with a router-owned, cluster-wide unique number and remembers
which destination the images belong to. On association 2 the router terminates the
connection itself (type: qr-bridge), reads the number as MoveOriginatorMessageID from
every C-STORE command set and forwards the image to the corresponding device.
The same two devices (US1, US2) and the same PACS — without a second PACS entry:
filter:
# --- Association 1: devices -> PACS (C-MOVE-RQ), one rule per device ---
- callingAET: US1
actions:
- "aetRewrite" # PACS sees CallingAET=ROUTER instead of US1
- "cmoveTracking_us1" # MoveDestination=ROUTER, MessageID rewrite, tracking
destination:
- "pacs1"
- callingAET: US2
actions:
- "aetRewrite"
- "cmoveTracking_us2"
destination:
- "pacs1"
# --- Association 2: PACS -> router, ONE rule for all devices ---
- callingAET: PACS
calledAET: ROUTER
destination:
- "qr_bridge"
actions:
aetRewrite:
- plugin: ch.immeditech.imconnect.dicomrouter.plugin.AETitleRewriteStage
config:
callingAetOverride: "ROUTER"
cmoveTracking_us1:
- plugin: ch.immeditech.imconnect.dicomrouter.plugin.CMoveTrackingStage
config:
destinationOverride: "ROUTER" # the same MoveDestination for all devices
returnDestination: "geraet_us1" # forward destination — that is where the images go
store: memory
ttl: 24h
cmoveTracking_us2:
- plugin: ch.immeditech.imconnect.dicomrouter.plugin.CMoveTrackingStage
config:
destinationOverride: "ROUTER"
returnDestination: "geraet_us2"
store: memory
ttl: 24h
destinations:
pacs1:
host: pacs.example.org
port: 104
aet: PACS
qr_bridge:
type: qr-bridge
store: memory # must match the store: of the cmoveTracking actions
calling-aet: ROUTER # CallingAET of the leg to the device
# without presentation-contexts: the bridge only accepts Implicit VR LE
geraet_us1:
host: us1.example.org
port: 104
aet: US1
geraet_us2:
host: us2.example.org
port: 104
aet: US2
Required on the PACS side: a single DICOM node entry ROUTER (AET ROUTER,
host/port = the router or its HAProxy frontend) — no matter how many devices retrieve
through the router. Nothing changes at the device: in the C-STOREs it sees its own
MessageID and its own AE title as MoveOriginator again, because the bridge writes
both back from the tracking entry.
Things to note:
- One rule for association 2 is enough, and it references no rewrite action — the target is only determined per image, not at the A-ASSOCIATE-RQ.
store:must match between the action and the bridge destination; the configuration check rejects a mismatch. In a cluster (association 2 may land on a different router instance than association 1)store: databaseis used everywhere.- Without
presentation-contexts:the return path runs in Implicit VR Little Endian, so the PACS decompresses. To preserve compression, configure the intersection of the device fleet — seetype: qr-bridge. - A device that only receives the images (no C-MOVE of its own) needs a regular forward
destination as before —
geraet_us1/geraet_us2are exactly that and are resolved by the bridge via their name from the tracking entry.
Config sync — the two YAML copies¶
There are two copies of this configuration file in the repository:
- Master (authoritative source):
dicom-router-core/src/main/resources/ch/immeditech/imconnect/dicomrouter/config/default-dicom-router.yaml - Example copy (plugins module):
dicom-router-plugins/src/main/resources/example-dicom-router.yaml
The sync-config.sh script copies the master file over the example copy and
verifies that both files are byte-identical (cmp). It is a one-way copy
(master → example); it never reads the example file back into the master.
Run it after every change to the master config — during development before
plugin tests and before committing — so the example copy stays current. The script
is a one-way copy master → example and verifies the result with cmp.