Skip to content

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 of token-file: — the management port kept running without token auth.
  • returnPort: instead of return-port: — the router dialled the device directly, past the HAProxy return path.
  • managment: instead of management: — 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.

java -jar dicom-router-core-*.jar --validate-config config/dicom-router.yaml

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 tcplog on 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 sets tcplog: 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 40964194304 (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 12147483647 (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 reload applies them to new connections, while in-flight connections keep the snapshot they started with.

Note: log-folder is not for the regular application logs. SLF4J/ application logging is configured via logback.xml (internal default in the JAR, overridable with config/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 (DimseAuditStagedimse_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 to management.*). capture-patient-name is 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-name gates only the DIMSE capture, not router_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 the phiFreeCause pattern in docs/plugins.md in 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 reload is 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…\E treats 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.AETitleRewriteStage
  • ch.immeditech.imconnect.dicomrouter.plugin.CMoveDestinationRewriteStage
  • ch.immeditech.imconnect.dicomrouter.plugin.WorklistAetRewriteStage
  • ch.immeditech.imconnect.dicomrouter.plugin.QrFieldRewriteStage
  • ch.immeditech.imconnect.dicomrouter.plugin.DicomTapStage
  • ch.immeditech.imconnect.dicomrouter.plugin.CMoveTrackingStage
  • ch.immeditech.imconnect.dicomrouter.plugin.charset.CharsetTranscodePlugin
  • ch.immeditech.imconnect.dicomrouter.plugin.charset.CharsetDeclarePlugin
  • ch.immeditech.imconnect.plugins.PduLoggingStagePlugin
  • ch.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, timeouts only on qr-bridge;
  • host, port, aet only on forward (a reject or qr-bridge destination never opens a connection);
  • result, source, reason, message only on reject;
  • return-port/return-vip only on forward (see haproxy-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-recognized
  • source = 2 (ACSE): 1 = no-reason, 2 = protocol-version-not-supported
  • source = 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-RJ only happens when no context is acceptable. If a PACS sends on a rejected context anyway, the message is answered C000 and 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.1 is itself curated in presentation-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: MoveOriginatorMessageID and MoveOriginatorAET carry 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 A700 immediately. 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 clientLocalIp to 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 callingAET to the AE title of the PACS — no * and no broad regex:;
  • pin calledAET to the AE title the router is registered under as a DICOM node in the PACS (ROUTER in 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-port outside 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: 0 without a configured port-range → dedicated error (the marker cannot be resolved).
  • port-range not of the form "from-to" with 1 ≤ from ≤ to ≤ 65535 → error.
  • return-port set but neither return-vip nor haproxy-return.vip → error.
  • return-vip without return-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 neither vip nor port-range set → error (an explicitly blank vip: "" stays an error even with a port-range).
  • return-port/return-vip on a type: reject destination → error.
  • vip/return-vip — and likewise the host of every forward destination — with characters outside the hostname/IP charset A-Za-z0-9._:- plus [] (IPv6 brackets) and % (IPv6 zone IDs) → error: the values are rendered verbatim into the bind/server lines 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:

  1. Association 1 — device → router → PACS: the C-MOVE-RQ.
  2. Association 2 — PACS → router → device: the PACS opens a new incoming connection and sends the images via C-STORE to the MoveDestination named 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: database is 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 — see type: 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_us2 are 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.

./sync-config.sh

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.