Skip to content

IMConnect DICOM Router – Operations Manual

This manual is aimed at users who configure and operate the DICOM Router – not at developers. It explains the structure of the configuration file dicom-router.yaml step by step, as well as day-to-day operation.

Further detail configuration of individual plugins: Plugin Reference.


1. Overview

The DICOM Router accepts incoming DICOM connections and forwards them to defined destinations based on filter rules. Optionally it can rewrite messages along the way, branch them off to an additional receiver, or log them.

Four core terms describe how the router works:

Term Description
Listener The port and AE title under which the router itself is reachable.
Filter Ordered rules that decide what happens to a connection.
Actions Optional processing steps (plugins) triggered by a rule.
Destinations Named destinations: either forward or reject the connection.

Simplified data flow of a connection:

   Device (modality/PACS)
          │  DICOM connection
   ┌──────────────┐
   │   Listener   │   accepts the connection
   └──────┬───────┘
   ┌──────────────┐
   │    Filter    │   first matching rule wins
   └──────┬───────┘
     ┌────┴─────┐
     ▼          ▼
  Actions    Destination
 (optional)   ┌──────────────┬───────────────┐
              ▼              ▼
          forward          reject
     (forward to target) (reject connection)
        Target system (e.g. PACS)

For each connection the router finds the first matching filter rule, executes its actions (if configured), and forwards the connection to the destination named in the rule – or cleanly rejects it.


2. Installation & first steps

This section is deliberately short. The complete step-by-step guide (dedicated user, directories, permissions) is in the header of the bundled systemd unit and in the deployment documentation.

The router is shipped as a .tar.gz. Rough outline of a first installation:

  1. Extract into the app directory (e.g. /opt/imconnect):
    tar -xzf dicom-router-X.Y.Z.tar.gz --strip-components=1 -C /opt/imconnect
    
  2. Create the configuration: The config/ directory belongs to the operator and is never overwritten during updates. The templates are located under share/ with the .example suffix and are copied once:
    mkdir -p /opt/imconnect/config
    cp /opt/imconnect/share/dicom-router.yaml.example /opt/imconnect/config/dicom-router.yaml
    cp /opt/imconnect/share/logback.xml.example       /opt/imconnect/config/logback.xml
    cp /opt/imconnect/share/dicom-router.env.example  /opt/imconnect/config/dicom-router.env
    
    Then adapt dicom-router.yaml to your environment (see section 3). Database credentials live in dicom-router.env (chmod 600).
  3. Install the systemd unit (template dicom-router.service.example) and start it:
    systemctl enable --now dicom-router
    
  4. Check that the router is running:
    dicom-router-ctl status
    

3. The configuration file dicom-router.yaml

The active configuration lives at /opt/imconnect/config/dicom-router.yaml. It is divided into sections. For normal operation the most relevant ones are router, destinations, filter, and actions.

3.1 router – Listener and general settings

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

router.listener – how the router itself is reachable:

Field Description
host Address to listen on. 0.0.0.0 = all network interfaces.
port Port on which DICOM connections are accepted (default 11112).
aet The router's own AE title.
tcplog Writes every TCP packet this listener receives and sends into the application log in full (true/false, template: false). For troubleshooting only — see the note below the table.
socketTimeout Time limit for an inactive connection in milliseconds (if the field is missing, 60000 applies; the template file sets 120000). The same value also bounds how long a single write toward the client or the destination may take — a peer that stops consuming would otherwise block the router indefinitely.
connectTimeout Time limit in milliseconds for opening a connection to a destination — name lookup and TCP handshake together (if the field is missing, 10000 applies). If a destination is unreachable the router gives up after this time instead of waiting for the operating system's own limit (up to two minutes on Linux) while the peer waits without an answer.

Switch tcplog on for troubleshooting only. The output contains the packet contents in full, and therefore patient data in the clear (name, patient ID). The application log is rotated by size and age and is not meant as a store for patient data; on top of that, one entry per TCP packet fills the log files within minutes under load. Switch it on to analyse, switch it off afterwards (neither needs a restart), and handle the log files of the time in between accordingly. For lasting traceability the database audit trail is the tool.

Additional router settings an operator may need to adjust:

Field Description
max-pdu-length Maximum DICOM packet size (PDU) the router offers to and enforces on its peers. Allowed values range from 4096 to 4194304 (4 MB); the template file ships 131072 (128 KB). Larger values reduce protocol overhead for large objects; values above 1 MB are not recommended (the router buffers such packets on disk instead of in memory). The peer negotiates its own maximum independently — effectively the smaller value applies in each direction.
storage-path Directory for the internal buffer for large objects. Must be writable.
log-folder Directory for binary PDU captures (only with logLevel 5/6/7 enabled, see section 5). Not the regular application logs.

The remaining router fields (memory thresholds etc.) are technical fine-tuning and are commented in the template file – normally leave them unchanged.

3.2 destinations – Destinations

Destinations are named and are referenced from filter rules by their name. There are two types.

Forward (type: forward) – the normal case. type may be omitted, since forward is the default:

destinations:
  pacs1:
    host: pacs1.example.org
    port: 104
    aet: PACS
Field Description
host Target machine (hostname or IP).
port Target port.
aet AE title of the target system.

Reject (type: reject) – cleanly refuses the connection with a DICOM rejection (A-ASSOCIATE-RJ) instead of forwarding it:

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"

The fields result, source, and reason are DICOM codes. For most cases one of these common combinations is sufficient:

Purpose result source reason
Unknown target AE title (called AET) 1 1 7
Unknown sender AE title (calling AET) 1 1 3
General rejection without a specific reason 1 1 1
Temporarily no capacity (transient) 2 3 1
  • result: 1 = rejected permanently, 2 = rejected temporarily.
  • source: 1 = service user, 2 = ACSE, 3 = Presentation.
  • reason: meaning depends on source (see codes above).
  • message: free text, appears only in the log.

Choose the reason to match the reason for rejection: when filtering by the sender (unknown device, catch-all rule via callingAET), reason: 3 is the correct code; reason: 7 fits when the requested AE title does not exist; when in doubt, reason: 1 (no specific reason) is always acceptable.

3.3 filter – Routing rules

filter is an ordered list. The router evaluates the rules top to bottom and applies the first one that matches (first match wins). After that no further rules are considered. Therefore: specific rules at the top, a catch-all rule at the very bottom.

The most important fields of a rule:

Field Description
callingAET AE title of the sender (device). * or omitted = any.
calledAET Requested target AE title. * or omitted = any.
logLevel Logging level for this connection (see section 5).
actions List of action names executed for this rule (optional).
destination The destination of the rule: a name from destinations: (forward or reject).

Note: The YAML field destination formally accepts a list, but supports exactly one destination — only the first entry is used. The list form exists so that the configuration structure stays stable in case multiple destinations become possible in the future. So: always specify exactly one entry, e.g. destination: ["pacs1"].

In addition, a rule can be restricted by network characteristics (clientLocalIp, routerLocalPort etc.); if omitted, each defaults to "any" (IP 0.0.0.0/0, port 0).

AE title matching: Values are compared exactly by default (case-sensitive); only the value * means "any". With the prefix regex: the value is instead evaluated as a regular expression and must cover the entire AE title:

callingAET: "regex:^US[0-9]+$"   # one rule for all devices US1, US2, US3, ...

An invalid expression is a configuration error and prevents startup or reload.

Example 1 – one device to a PACS, reject everything else:

filter:
  - callingAET: US1
    calledAET: PACS
    destination:
      - "pacs1"

  # Catch-all rule: reject everything else
  - callingAET: "*"
    calledAET: "*"
    destination:
      - "reject_unknown"

Example 2 – several similar devices via a regular expression:

filter:
  - callingAET: "regex:^US[0-9]+$"   # US1, US2, ...
    calledAET: PACS
    destination:
      - "pacs1"
  - callingAET: "*"
    destination:
      - "reject_unknown"

Example 3 – rule with AE title rewriting before forwarding:

filter:
  - callingAET: MRI1
    calledAET: PACS
    actions:
      - "aetRewrite_mri"   # defined in actions, see below
    destination:
      - "pacs1"

3.4 actions – Processing steps (plugins)

Under actions, named processing steps are defined. A name refers to a list of plugin entries; a plugin's parameters go in its config: block. A rule activates an action by naming it under actions (see example 3 above).

Basic structure:

actions:
  aetRewrite_mri:
    - plugin: <plugin identifier – see Plugin Reference>
      config:
        # plugin-specific parameters
        enabled: true

Which plugins are available and which parameters their config: block accepts (e.g. AE title rewriting, protocol capture, Q/R field adjustment) is described in the Plugin Reference. Plain forwarding without rewriting requires no actions – destination alone is sufficient.

An action only runs when a rule names it. There is no automatic loading: if no filter rule references an action name under actions, nothing happens — defining it is not enough. The router points this out with a warning in the log at start and on every reload, naming the action. So always do both: define the action and enter it in the rule it belongs to.

3.5 management and audit

These two sections are usually correct with their default values and rarely need to be touched.

management controls the two additional ports for operations and monitoring:

management:
  enabled: true
  agent-check-port: 8404            # health query by HAProxy (network-accessible)
  agent-check-bind-address: "0.0.0.0"
  management-port: 8405             # operator commands (local only, 127.0.0.1)
  management-bind-address: "127.0.0.1"

The management port is deliberately reachable only locally (127.0.0.1). Without additional protection it is not authenticated – i.e. any local process could stop the router. Optionally, a password can be required via a token file; details in the management documentation.

audit writes an audit trail to the database: one row per connection and per DICOM operation, without data loss via a local spool (sole exception: if the spool exceeds its spool-max-mb limit, for example during a very long database outage, further entries are discarded and counted). A database outage does not cause data loss and does not affect routing.

audit:
  enabled: true
  spool-path: /var/imconnect/audit-spool
  spool-max-mb: 512
  batch-size: 100
  flush-interval: 1s
  capture-patient-name: false        # patient name (PHI!) – off by default

capture-patient-name should only be enabled for good reason, since it stores patient data (PHI).

3.6 Which changes a reload picks up

Many changes take effect without a restart via the reload command (see section 4): New connections then use the new configuration, while connections already in progress keep their previous one. Some fundamental fields, however, are only bound at startup and require a restart.

Applied immediately via reload Effective only after a restart
filter (routing rules) router.listener.host / port / aet
destinations (forward/reject/qr-bridge destinations) – exception: store: of a qr-bridge destination the entire management block
actions (plugins and their config:) – exception: store: of CMoveTrackingStage the entire audit block – exception: capture-patient-name
proxyprotocol store: of a CMoveTrackingStage action
router.listener.socketTimeout / connectTimeout / tcplog store: of a qr-bridge destination
audit.capture-patient-name
the router memory thresholds

For the two store: entries this covers more than the value: if such an entry vanishes (removed or renamed) and no entry with the same store takes its place in the same reload, that reload is restart-required as well. The store instances are created at startup, and a renamed entry cannot be told apart from a removed one. Pure additions stay reloadable.

If a reload attempts to change a restart-required field, it is rejected with requires restart and the previous configuration stays active. The complete field list is in the management documentation.


4. Operation

The running router is controlled via the bundled CLI tool dicom-router-ctl (under /opt/imconnect/bin/).

Command Effect
dicom-router-ctl status State, number of active connections, uptime, audit status.
dicom-router-ctl connections List of active DICOM connections.
dicom-router-ctl drain Stop accepting new connections; existing ones continue.
dicom-router-ctl resume Accept connections again after a drain.
dicom-router-ctl reload Re-read the configuration (see section 3.6).
dicom-router-ctl shutdown Immediate orderly stop.
dicom-router-ctl drain-and-shutdown First drain, wait for 0 connections, then stop.

Stopping: shutdown vs. drain-and-shutdown

The difference matters:

  • shutdown stops the router immediately. Transfers still in progress are disconnected.
  • drain-and-shutdown first stops accepting new connections, waits until all ongoing transfers have completed, and stops only then. This is the gentle way for a maintenance window or update.

Restarting via systemd

A restart is done via systemd:

systemctl restart dicom-router

This is automatically gentle: when stopping, the systemd unit internally calls drain-and-shutdown, so it first waits for all ongoing connections before it terminates the process. A generous time limit is set for the wait. After an unexpected crash the service restarts automatically; a clean stop stays stopped.

Changing the configuration (workflow)

  1. Edit /opt/imconnect/config/dicom-router.yaml.
  2. Optionally validate first, without touching the running router at all:
    cd /opt/imconnect
    java -jar dicom-router-core-*.jar --validate-config config/dicom-router.yaml
    
    Reports OK or lists the errors (same check as on reload). That includes every key that does not exist: a typo or the wrong spelling (tokenFile instead of token-file) fails the check naming section and field, instead of silently discarding the setting. All errors of a file are reported in one pass. Important: run the command in the installation directory (or set chdir in automation such as Ansible) — plugin classes in plugins/ are looked up relative to the working directory; from any other directory the check would falsely report errors when the configuration uses external plugins.
  3. Apply: dicom-router-ctl reload.
  4. Check the response:
  5. OK config reloaded – the change is active.
  6. ERROR ... requires restart – a restart-required field was changed; use systemctl restart dicom-router instead.
  7. Other ERROR ... – the configuration contains an error; the old configuration stays active until the error is fixed and another reload succeeds.

Multiple hosts: distributing the configuration (dicom-router-sync)

If the router runs on multiple hosts (e.g. behind HAProxy), the configuration is edited on one host and distributed to all others with dicom-router-sync:

  1. Enter the other hosts once in /opt/imconnect/config/cluster-hosts (template: share/cluster-hosts.example; one line per host, user@host allowed). Prerequisite is passwordless ssh access from the editing host to each target.
  2. After editing the YAML: run dicom-router-sync. The tool
  3. automatically backs up the state to config/config-history/ (history for later reference and rollback — simply copy an old state back and distribute it again),
  4. reloads the local router first as a probe: if the configuration is rejected, the tool aborts before any other host is touched — the old configuration stays active everywhere,
  5. then copies the file to all hosts, reloads them, and verifies via checksum that the same state is present everywhere — where the router reports it, additionally against the config-hash from status, i.e. the state actually loaded (result table at the end, MATCH (loaded)).
  6. If the probe reports requires restart, the tool distributes the file, skips the reloads, and shows the instructions for the rolling restart (systemctl restart dicom-router, host by host).

Additional modes: dicom-router-sync --check only compares the state of all hosts (useful as a drift check, changes nothing); --no-reload only distributes.

The result table distinguishes what went wrong: UNREACHABLE means the host could not be reached over ssh, while MISSING means the ssh connection worked but the host has no configuration file (yet) — typical for a freshly set up node. NO-SHA256SUM reports a host without a sha256sum command, where the state cannot be verified.

New destination with an automatic return port (return-port: 0)

If you use HAProxy return paths (haproxy-return with a configured port-range), return ports no longer need to be assigned by hand:

  1. Create the new destination with the marker return-port: 0:
    destinations:
      ct_new:
        host: 192.168.10.7
        port: 104
        aet: CT_NEW
        return-port: 0        # port is assigned automatically
    
  2. Have the marker replaced locally:
    cd /opt/imconnect
    java -jar dicom-router-core-*.jar --allocate-return-ports config/dicom-router.yaml
    
    The subcommand assigns the smallest free port of the port-range and prints the assignment (e.g. ct_new: return-port 5014); ports already assigned to other destinations always stay unchanged. (Required before step 3 — dicom-router-haproxy-sync aborts on an unallocated marker.)
  3. Activate the new return path on the HAProxy hosts: dicom-router-haproxy-sync (next section).
  4. Only now distribute the router configuration: dicom-router-sync. In this order the router never dials a port HAProxy does not know yet. Without a cluster (single host), use dicom-router-ctl reload instead.

A forgotten marker can never go live unnoticed: start, reload and --validate-config reject a configuration containing return-port: 0 with a clear error message.

Return paths kept by hand in the HAProxy config: the generated HAProxy file only contains destinations whose return-port lies inside the port-range. If you keep a return path in your own HAProxy configuration (fixed port, different backend setup, legacy entry), give that destination a return-port outside the range. The router still dials it via vip:return-port, but the return path no longer appears in the generated file — otherwise there would be two listen blocks on the same bind and HAProxy would refuse to start. Skipped destinations are listed as comments in the generated file:

# skipped (return-port outside port-range 5000-5999 — manual HAProxy entry):
#   ct_b  bind 10.0.0.10:4711  -> 192.168.10.9:104

This applies retroactively when updating to this version: if you have a port-range configured and destinations with a return-port outside it, their listen blocks disappear from the generated file on the next dicom-router-haproxy-sync run. dicom-router-haproxy-sync --check shows this beforehand and names the blocks explicitly as still configured ("target has listen block(s) for destination(s) STILL in the YAML … do NOT remove them") — move them into your own HAProxy configuration first.

Distributing HAProxy return paths (dicom-router-haproxy-sync)

If you use HAProxy return paths, the generated HAProxy file no longer has to be brought to the HAProxy hosts by hand:

  1. Enter the HAProxy hosts once in /opt/imconnect/config/haproxy-hosts (template: share/haproxy-hosts.example; one line per host, optionally with a target path, default /etc/haproxy/conf.d/dicom-return.cfg). Prerequisites: passwordless ssh access, write access to the target path and a sudo rule for systemctl reload haproxy (details: deployment documentation).
  2. After every change to the return paths: run dicom-router-haproxy-sync. The tool generates the file from the YAML, copies it to all hosts (on unchanged hosts copy and validation are skipped, the reload still runs — so a repeated run catches up on a previously failed reload), checks it there with haproxy -c before it becomes active (on a failure the host keeps its previous state untouched), reloads HAProxy seamlessly (running transfers survive) and verifies via checksum that the same state is present everywhere (result table at the end). It manages only this one file — never HAProxy's main configuration.

Order rule:

  • Destination added → first dicom-router-haproxy-sync, then dicom-router-sync — a new listen block is harmless until someone dials it; the other way round the router would dial a port HAProxy does not know yet.
  • Destination removed → the other way round: first dicom-router-sync, then dicom-router-haproxy-sync.
  • return-port changed to a value outside the port-range (the return path is kept by hand, see the section above) → first move the listen block into your own HAProxy configuration, then run dicom-router-haproxy-sync — otherwise the return path is unreachable in between.

The tool prints all three rules as a hint itself — the third one included: on the HAProxy host it looks like a removed destination, but the script recognizes it by the skip list of the generated file and then states explicitly that the block is not stale and must not be removed. The comparison works on block names: if such a destination is renamed on top of that, the script reports the old block as stale even though its return path is still in use. So check the skip list of the generated file before removing a reported block — the hint text says so as well.

Additional modes: dicom-router-haproxy-sync --check only compares the state of all hosts (changes nothing); --no-reload distributes and checks but does not reload.


5. Troubleshooting

Where are the logs?

There are two independent logging mechanisms – easy to confuse:

What Where / how Content
Application log File dicom-router.log in the log directory (default /var/log/imconnect), configured via logback.xml regular messages (INFO/WARN/ERROR) – the first place to look
Binary PDU captures Directory from router.log-folder, enabled via logLevel 5/6/7 in a filter rule complete DICOM packets as binary files – for diagnosis only

The regular application log is rotated automatically. The PDU captures are a diagnostic tool for difficult cases: they contain complete patient data (PHI), are not rotated or automatically deleted, and should only be enabled temporarily. After the analysis, reset the logLevel and remove the captures.

Setting logLevel in the filter rule is sufficient — the capture function is built in, no action is needed. logLevel is hot-reloadable: change the value, dicom-router-ctl reload, done — and to disable it, set it back to 0 the same way. (The plugin PduLoggingStage in the Plugin Reference merely adds additional named loggers.)

logLevel levels in a filter rule:

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

Inspecting the running state

dicom-router-ctl status        # state, connection count, uptime, audit
dicom-router-ctl connections   # which connections are currently open

Typical problems

  • A connection is rejected. First check dicom-router-ctl status: if the router is in the drain state, it is deliberately not accepting new connections (dicom-router-ctl resume fixes that). If the state is ready, a reject destination is probably matching – usually the catch-all rule at the end of the filter list (its message appears in the application log). Check: is there a matching rule before the catch-all rule? Do callingAET/calledAET match exactly (case, no spaces)? Is the correct port being used?

  • Connections are aborted since an action was added. If a plugin entered in a rule cannot be loaded or cannot start with its configuration, the router aborts the affected association instead of forwarding it without that processing step — a connection without its configured anonymisation or rewriting must not run unnoticed. The application log carries an ERROR line naming plugin and action. To find this beforehand, use --validate-config (see section 4): the check loads every plugin class entered and every config: block.

  • Destination unreachable / forwarding fails. Check host, port, and aet of the forward destination against the target system. Is the target system reachable over the network, and does it accept the AE title used by the router?

  • reload is rejected.

  • requires restart: a restart-required field was changed (listener, management, audit, or a store: — including by removing or renaming a tracking entry or a qr-bridge destination, see section 3.6) → systemctl restart dicom-router.
  • other ERROR: the YAML contains an error (typo, invalid regular expression, unknown destination) or the file is missing (nothing is created anew in that case). The router keeps running with the old configuration until a reload completes without errors.