Zum Inhalt

Konfigurations-Referenz

Diese Seite beschreibt die Struktur der YAML-Konfiguration des DICOM-Routers.

Die massgebliche, vollständig kommentierte Referenz ist die Vorlagendatei selbst — in einer Installation liegt sie als share/dicom-router.yaml.example bei (im Quell-Repository: dicom-router-core/src/main/resources/.../config/default-dicom-router.yaml).

Diese Datei ist die massgebliche Quelle — sie enthält Inline-Kommentare zu jedem Feld und realistische Beispielwerte. Dieses Dokument erklärt den Grobaufbau und die Bedeutung jedes Abschnitts, dupliziert aber nicht jede Zeile. Im Zweifel gilt: die YAML lesen.

Verwandte Doku: User-Guide · Plugin-Referenz · Management & Drain · Deployment


Top-Level-Struktur

Eine Konfigurationsdatei hat diese Top-Level-Abschnitte:

Abschnitt Zweck
router Listener (host/port/aet) und allgemeine Router-Optionen
management Management- & HAProxy-Agent-Check-TCP-Server
audit Datenbank-Audit-Trail (Associations + DIMSE-Operationen)
proxyprotocol Welche eingehenden Verbindungen einen PROXY-Protocol-Header tragen
filter Geordnete Routing-Regeln (first match wins)
actions Benannte Plugin-Instanzen, referenziert über filter[].actions
destinations Benannte forward-/reject-/qr-bridge-Ziele, referenziert über filter[].destination
haproxy-return Globale Default-VIP und port-range für HAProxy-Rückwege (return-port an Destinations)

Unbekannte Schlüssel und Schreibweise

Jeder Schlüssel in der YAML muss auf ein Feld passen. Tut er das nicht, ist das ein Konfigurationsfehler: der Start bricht ab, ein reload wird abgelehnt. Das gilt für jeden Abschnitt, für die config:-Blöcke der Plugins und für einen vertippten Top-Level-Abschnitt (managment:) genauso.

Bis 1.16.0 wurde ein solcher Schlüssel stillschweigend verworfen und der Code-Default griff. Drei Beispiele:

  • tokenFile: statt token-file: — der Management-Port lief ohne Token-Auth weiter.
  • returnPort: statt return-port: — der Router wählte das Gerät direkt an, am HAProxy-Rückweg vorbei.
  • managment: statt management: — der ganze Abschnitt fiel auf seine Defaults zurück.

Derselbe Schlüssel zweimal. Ein Schlüssel, der in einem Block doppelt vorkommt, ist ebenfalls ein Startfehler. YAML erlaubt das und die letzte Zeile gewinnt — ein korrekt geschriebener Wert greift also nie, dieselbe Fehlerklasse wie ein toter Schlüssel, nur von der anderen Seite. Der typische Fall: die neue Zeile wird neben die alte geschrieben statt an ihre Stelle. Die Meldung nennt Schlüssel, Abschnitt und Zeile. Sie kommt aus dem Parser und nicht aus dem Validator, bricht also beim ersten Fund ab, statt alle Fehler der Datei zu sammeln.

Genau ein Dokument pro Datei. YAML erlaubt mehrere Dokumente in einer Datei, getrennt durch ---. Der Router liest genau eines; alles hinter einem zweiten --- wurde bisher kommentarlos verworfen — dieselbe Fehlerklasse wie ein toter oder doppelter Schlüssel, nur im Grossformat: eine per Copy-Paste zusammengesetzte Datei verliert ihre ganze zweite Hälfte. Ein zweites Dokument ist deshalb ein Startfehler (config.file: multiple YAML documents). Ein einzelnes Dokument mit einleitendem --- oder abschliessendem ... bleibt gültig — beides sind Marker desselben Dokuments. Ein --- am Dateiende, hinter dem nichts mehr folgt, zählt dagegen als zweites (leeres) Dokument und wird ebenfalls als multiple YAML documents gemeldet; die Zeile ersatzlos löschen.

Eine Datei ohne jeden Inhalt ist ebenfalls ein Startfehler (config.file: empty configuration document): leer, nur Kommentare, oder ein Dokument, das nur aus --- bzw. null besteht. Vorher endete das im Boot als roher Stacktrace und beim reload als Fehlermeldung ohne Aussage.

Schreibweise. Ein Feld heisst entweder kebab-case oder camelCase, nie beides. Die zweite Schreibweise ist schlicht ein unbekannter Schlüssel. Die Vorlagendatei zeigt für jedes Feld die richtige Form. camelCase sind die Filter- und proxyprotocol-Felder (clientLocalIp, callingAET, logLevel, proxyPort, …) sowie socketTimeout, connectTimeout und tcplog unter router.listener; kebab-case ist alles Übrige unter router, management, audit, haproxy-return und den Destinations. In den config:-Blöcken der Plugins entscheidet das jeweilige Plugin — die Plugin-Referenz nennt für jedes Feld die Schreibweise.

Prüfweg vor dem Ausrollen. --validate-config läuft dieselbe Kette wie Start und Reload, ohne einen Port zu binden oder die Datenbank anzufassen. Eine Ausnahme gibt es: router.listener.host wird bewusst nicht aufgelöst, der Dry-Run macht also keine DNS-Abfrage. Ein grüner Lauf kann daher an einem unauflösbaren Hostnamen trotzdem scheitern — das zeigt sich erst beim Binden des Listeners.

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

Der Validator sammelt alle Fehler einer Datei und meldet sie mit Abschnitt und Feld (filter[2].logLevl, destinations.pacs1.returnPort) — er bricht nicht beim ersten ab. Bestehende Konfigurationen mit toten Schlüsseln fallen beim Upgrade also in einem Durchgang auf, nicht einzeln nacheinander.

Zwei Fälle bekommen bewusst eine genauere Meldung als „unbekannter Schlüssel": ein Feld, das es an einem anderen Destination-Typ gibt (store: an einer forward-Destination), und die drei Schlüssel unter timeouts: einer qr-bridge. Dort nennt die Meldung, wohin das Feld gehört bzw. was erlaubt ist.


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   # weglassen → folgt memory-threshold
  max-elements: 100000
  max-pending-assemblies: 8
  assembly-idle-timeout: 120000
  max-object-bytes: 1073741824

router.listener

Feld Typ Default Beschreibung
host string Bind-Adresse für den DICOM-Listener. 0.0.0.0 lauscht auf allen Interfaces, jede andere Adresse beschränkt den Listener darauf. Ein Hostname ist erlaubt und wird beim Start aufgelöst; lässt er sich nicht auflösen, bricht der Start ab (statt still auf 0.0.0.0 zurückzufallen).
port int Port, auf dem gelauscht wird.
aet string Der eigene Application Entity Title des Routers.
tcplog boolean false Hexdump jedes TCP-Chunks (Hex und ASCII, beide Richtungen) ins Application-Log, auf INFO. Reines Diagnose-Werkzeug — siehe den PHI-Hinweis unter der Tabelle. Hot-reloadbar.
socketTimeout int 60000 Zwei Rollen, ein Wert. (1) Read-Timeout einer Verbindung: kommt so lange nichts an, wird sie beendet (SO_TIMEOUT). (2) Seit 1.17.0 zusätzlich das Budget für einen einzelnen blockierenden Write Richtung Client oder Ziel — SO_TIMEOUT deckt Writes nicht ab, und ohne Schranke parkt eine Gegenstelle mit zugefahrenem TCP-Fenster den schreibenden Thread unbegrenzt. Braucht ein Write länger, wird das Socket geschlossen und die Association beendet. Wer den Wert sehr klein setzt, verkürzt damit auch dieses Budget — bei der Vorlage (120000) ist das unkritisch.
connectTimeout int 10000 Zeitbudget in Millisekunden für das Erreichen einer Forward-Destination: Namensauflösung und TCP-Handshake zusammen, nicht je einzeln. Ohne dieses Budget lief die Auflösung ohne jedes Zeitlimit auf dem Inbound-Thread der eingehenden Association, und der Handshake mit dem OS-Default (unter Linux bis ~130 s gegen ein geschlucktes SYN) — die Gegenstelle wartete die ganze Zeit ohne Antwort. Eine Destination mit IP-Literal löst nichts auf und verbraucht das Budget vollständig für den Handshake. Muss > 0 sein; hot-reloadbar.

tcplog nur temporär aktivieren. Der Hexdump gibt den Chunk vollständig wieder, und die ASCII-Spalte enthält damit die P-DATA-Nutzlast im Klartext — PatientName und PatientID inklusive. Das Application-Log ist genau das Log, aus dem die PDU-Nutzlast sonst herausgehalten wird (siehe Logging), und es rotiert nach Grösse und Alter, statt betreiberseitig aufbewahrt und gelöscht zu werden. Dazu kommt das Volumen: ein Dump pro TCP-Chunk lässt die 100-MB/30-Tage-Rotation auf einem belasteten Listener innerhalb von Minuten durchlaufen. Die ausgelieferte Vorlage setzt deshalb tcplog: false. Zum Analysieren einschalten (hot-reloadbar), danach wieder ausschalten — und die Logdateien der Zeit dazwischen wie PHI behandeln. Für die dauerhafte Nachvollziehbarkeit ist der Datenbank-Audit-Trail (audit) da.

router – allgemeine Optionen

Feld Typ Default Beschreibung
max-pdu-length int 16384 (Code); die ausgelieferte Vorlage setzt 131072 Maximale ausgehandelte/verarbeitete DICOM-PDU-Länge, erlaubter Bereich 40964194304 (4 MB). PS3.8-Maximum-Length-Semantik: begrenzt das PDU-Längen-Feld (Datenfeld, ohne den 6-Byte-PDU-Header) — derselbe Wert, den der Router im Maximum Length Sub-Item anbietet/deckelt. Werte über der 1-MB-Speicherschwelle erzwingen Disk-Pufferung pro PDU — nicht empfohlen.
storage-path string /tmp Verzeichnis, das als DICOM-Cache / Storage-Path genutzt wird. Spill-Dateien werden im laufenden Betrieb deterministisch gelöscht; nach einem harten Prozessende (SIGKILL, OOM-Kill, Stromausfall) bleiben sie liegen. Beim Start räumt der Router die Reste seiner vier Namensmuster (dicom-buffer*.bin, dicom-assembler*.tmp, dicom-data-*.bin, dicom-encode-*.tmp) weg, sofern sie älter als eine Stunde sind, und meldet Anzahl und Bytes auf INFO. Dateien ausserhalb der vier Namensmuster werden nicht angefasst; die Zuordnung läuft rein über Präfix und Endung, eine Betreiber-Datei wie dicom-buffer-backup.bin fällt also darunter.
log-folder string /tmp Verzeichnis für binäre DICOM-PDU-Dumps (nur wenn ein Filter-logLevel 5/6/7 ist). Wie storage-path darf der Schlüssel nicht leer geschrieben werden (Startfehler seit 1.17.0) — vorher band der leere Wert null und die erste Association auf einer Dump-Regel brach ab. Weglassen behält /tmp.
memory-threshold long (Bytes) 1048576 (1 MiB) Spill-Schwelle: ein assembliertes Objekt oder eine sich reassemblierende PDU, die grösser ist, wird auf einer Temp-Datei (unter storage-path/System-Temp) statt im RAM gepuffert. Der eine konsolidierte RAM/Disk-Regler.
materialization-threshold long (Bytes) = memory-threshold Grösster einzelner Element-Wert, den ein Plugin in ein byte[]/String ziehen darf; darüber ist der Wert nur streambar (ValueTooLargeException). Weglassen, um memory-threshold zu folgen (ein gespillter Wert wird nicht zurück in den RAM geholt).
max-elements long 100000 Harte Obergrenze für indizierte Top-Level-Elemente vor einem Fail-Fast (TooManyElementsException, DoS-Backstop; ein echtes DICOM-Objekt hat deutlich unter 10k Top-Level-Elemente).
max-pending-assemblies int 8 Obergrenze für gleichzeitig halb-offene P-DATA-Fragment-Assemblies pro Verbindungsrichtung. Legitimer DICOM-Verkehr hat genau eine (Nachrichten-Fragmente dürfen nicht verschränkt werden, PS3.7); eine Gegenstelle, die mehr stapelt, erhält ein A-ABORT und aller ausstehende Zustand (RAM, Spill-Dateien) wird freigegeben.
assembly-idle-timeout long (ms) 120000 (2 min) Idle-Timeout für eine halb-offene Fragment-Assembly: kommt so lange kein Fragment — während die Verbindung sonst aktiv bleibt —, wird die Association abgebrochen und der Zustand freigegeben. 0 deaktiviert; komplett inaktive Verbindungen deckt listener.socketTimeout ab.
max-object-bytes long (Bytes) 1073741824 (1 GiB) Harte Obergrenze für ein einzelnes assembliertes Data Set. Die Fragment-Assemblierung puffert eine Nachricht bis memory-threshold im RAM und darüber auf einer Spill-Datei unter storage-path — ohne diesen Deckel kann eine einzige, nie abgeschlossene Nachricht das Dateisystem füllen. Wird er überschritten, bricht die Association mit A-ABORT ab und die Spill-Datei wird freigegeben. Erlaubter Bereich 12147483647 (2 GiB; das assemblierte Objekt wandert als 32-Bit-Länge weiter) und mindestens max-pdu-length. Ein „unbegrenzt" gibt es nicht. Command Sets haben ihren eigenen, deutlich engeren Deckel.

Die obigen Schwellen und Assembly-Limits sind hot-reloadbar: ein reload wendet sie auf neue Verbindungen an, während in-flight-Verbindungen den Snapshot behalten, mit dem sie gestartet sind.

Hinweis: log-folder ist nicht für die normalen Anwendungslogs. SLF4J-/ Anwendungs-Logging wird über logback.xml konfiguriert (interner Default im JAR, überschreibbar mit config/logback.xml). Siehe den Logging-Abschnitt im Projekt-README.


management

Der management-Abschnitt konfiguriert die beiden zusätzlichen TCP-Listener für Zero-Downtime-Betrieb hinter 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
Feld Typ Default Beschreibung
enabled boolean true Aktiviert die Management- & Agent-Check-Server.
agent-check-port int 8404 Port, den HAProxy für den Zustand pollt (ready/drain/down).
agent-check-bind-address string "0.0.0.0" Bind-Adresse für Agent-Check (netzwerk-erreichbar).
management-port int 8405 Port für Operator-Kommandos.
management-bind-address string "127.0.0.1" Bind-Adresse für den Management-Port (nur lokal).
allow-remote-management boolean false Opt-in für eine Nicht-Loopback-management-bind-address; andernfalls wird eine Nicht-Loopback-Bind-Adresse beim Start abgelehnt. Das Opt-in hebt die Loopback-Pflicht auf, nicht die Authentisierung: zusammen mit einer Nicht-Loopback-Adresse verlangt es seit 1.17.0 eine gesetzte token-file, sonst bricht der Start ab.
token-file string (nicht gesetzt) Pfad zu einer Datei mit dem Shared-Secret-Token, das die state-changing Kommandos drain/resume/reload/shutdown verlangen (status/connections bleiben tokenfrei). Nicht gesetzt = keine Authentifizierung (WARN beim Start). Der Token ist die erste non-blank Zeile der Datei, getrimmt. Muss beim Start existieren, lesbar, ≤ 64 KB und nicht leer sein; der Token-Wert wird pro Kommando neu gelesen, rotiert also ohne Restart (der Pfad ist restart-pflichtig). Nur für den Eigentümer lesbar halten (chmod 600) — group/other-lesbare Dateien lösen einen WARN beim Start aus.

Ein Schlüssel mit leerem Wert (enabled: ohne Angabe, ein leerer Port) ist seit 1.17.0 ein Startfehler — vorher band er still auf false bzw. 0, und die beiden Listener gingen kommentarlos nie auf. Den Schlüssel entweder weglassen (der Default greift) oder einen Wert hinschreiben; das gilt für alle An/Aus- und Zahlen-Schlüssel dieses Abschnitts.

Das gilt auch für den Sektions-Header selbst: ein management: ohne Block darunter ist ein Startfehler. Vorher fiel die ganze Sektion still auf ihre Defaults zurück — wer beim Härten alle Zeilen auskommentiert und den Header stehen lässt, bekam einen offenen Management-Port ohne die konfigurierte token-file. Den Header ganz weglassen behält wie dokumentiert die Defaults.

Die verfügbaren Kommandos (status, connections, drain, resume, reload, shutdown), die Begründung für Loopback-Zwang und Token-Authentifizierung sowie die Regeln hot-reload vs. restart-required sind in Management & Drain dokumentiert.


audit

Konfiguriert den Datenbank-Audit-Trail: je eine Zeile pro Association und pro DIMSE-Operation in der gemeinsamen PostgreSQL-Datenbank, verlustfrei über einen lokalen Disk-Spool geschrieben.

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
Feld Typ Default Beschreibung
enabled boolean true Ob überhaupt Audit-Events erfasst werden. Auch effektiv aus, wenn DB_ENABLED=false.
spool-path string /var/imconnect/audit-spool Verzeichnis für den lokalen Disk-Spool (Outbox zwischen Router und DB). Muss beschreibbar und exklusiv pro Router-Instanz sein — zwei Prozesse, die sich ein Spool-Verzeichnis teilen, kollidieren, und der Audit-Trail deaktiviert sich selbst (lautstark). Die Spool-Dateien sind unverschlüsseltes JSONL mit Patienten-ID, Study-/SOP-Instance-UIDs und Accession Number (mit capture-patient-name zusätzlich dem Patientennamen) — sie entstehen owner-only, für das Restrisiko auf dem Datenträger gelten die Verschlüsselungs-Hinweise im Deployment.
spool-max-mb int 512 Obergrenze der Spool-Grösse in MiB. Darüber werden neue Events verworfen und gezählt — der häufigste, aber nicht der einzige Audit-Verlustpfad (die übrigen siehe audit-dropped in Management), sichtbar in status und als WARN-Log.
batch-size int 100 Maximale Anzahl Events pro Datenbank-Transaktion.
flush-interval string 1s Maximale Verzögerung Queue → dauerhafter Spool (gruppierter fsync). Akzeptiert 500ms, 1s, 2m; eine blosse Zahl sind Millisekunden. Ein Prozessabsturz kann höchstens dieses Fenster verlieren.
capture-patient-name boolean false Erfasst den Patientennamen (PHI!). Standardmässig aus — Patienten-ID und UIDs sind pseudonyme Identifikatoren und für Support-Korrelation ausreichend. Gatet nur die DIMSE-Erfassung (DimseAuditStagedimse_operations); auf audit.events/router_events hat der Schalter keinen Einfluss.

Restart-required: alle Felder ausser capture-patient-name (Spool-Writer und Shipper werden beim Start gebunden, analog zu management.*). capture-patient-name wird pro Erfassung aus dem Config-Snapshot der Verbindung gelesen und hot-reloadt.

enabled:, capture-patient-name: oder events.enabled: mit leerem Wert ist seit 1.17.0 ein Startfehler — vorher band ein beim Editieren stehengebliebenes enabled: still auf false, und der Audit-Trail lief kommentarlos nicht. Ebenso abgelehnt: ein leeres events: und ein leeres audit: (Schlüssel ohne Block darunter) — sonst kommt ein ausdrücklich abgeschalteter Audit-Trail beim Editieren still wieder hoch. Weggelassene Schlüssel behalten wie überall ihre Defaults.

audit.events

Schreibt WARN/ERROR-Ereignisse aller Nodes zentral in die Datenbank — „welcher Node hatte den Fehler, zu welcher Verbindung gehörte er?“ wird damit zur Datenbankabfrage statt zum Log-Grep über N Hosts. Die Erfassung hängt sich beim Start programmatisch in das Logging ein; die logback.xml des Betreibers muss dafür nicht angepasst werden.

Bewusst kein Log-Spiegel: erfasst wird nur ab WARN, die Meldung wird auf 2000 Zeichen gekürzt und von einer Exception nur der Klassenname gespeichert. Die Logdateien des Nodes bleiben die vollständige Quelle (DEBUG, volle Stacktraces).

PHI: audit.capture-patient-name gatet ausschliesslich die DIMSE-Erfassung, nicht router_events — gespeichert wird, was die WARN/ERROR-Meldung sagt. Die Klassennamen-Bremse gilt nur für die angehängte Exception; eine in den Meldungstext interpolierte Exception-Message landet wortwörtlich in der Datenbank. WARN/ERROR-Meldungen von Plugins müssen deshalb PHI-frei formuliert sein — siehe die verbindliche Policy samt phiFreeCause-Muster in docs/plugins.md im Repository.

Bewusst best-effort, anders als der Audit-Trail: eine begrenzte Warteschlange und ein Batch-Writer, kein Disk-Spool, kein Retry. Läuft die Warteschlange voll oder scheitert ein Batch, werden die Ereignisse verworfen und gezählt. Ein Gerät in einer Reconnect-Schleife erzeugt Fehler schneller, als eine Datenbank sie aufnimmt — dieser Verlust ist günstiger als ein Fehlersturm, der Heap, Routing und den Audit-Spool gefährdet.

audit:
  enabled: true          # zwingend: die Events teilen sich die DB-Anbindung des Audit-Trails
  events:
    enabled: false
    level: WARN
    queue-size: 1000
Feld Typ Default Beschreibung
enabled boolean false Ob WARN/ERROR-Ereignisse in die Tabelle router_events geschrieben werden. Erfordert audit.enabled: true — die Kombination audit.enabled: false mit audit.events.enabled: true ist ein Konfigurationsfehler (der Writer teilt sich DB-Anbindung und Schema-Verwaltung des Audit-Trails, und die Association-Korrelation liefe ohne die Audit-Tabellen ins Leere).
level string WARN Erfassungsschwelle: WARN oder ERROR (Gross-/Kleinschreibung egal). Niedrigere Level werden abgelehnt statt stillschweigend angehoben — eine Spiegelung von INFO/DEBUG in die Datenbank ist ausdrücklich nicht das Ziel.
queue-size int 1000 Grösse der begrenzten Übergabe-Warteschlange, erlaubter Bereich 1–100000. Darüber hinaus werden Ereignisse verworfen und gezählt (periodische WARN-Meldung im Log). Ein grösserer Wert puffert längere Datenbank-Aussetzer, kostet aber Heap: ein geclamptes Ereignis belegt im schlechtesten Fall ~3–5.5 KB, der Default also ~3–5.5 MB. Die Obergrenze ist Absicht — eine unbegrenzte Warteschlange würde genau den Fehlersturm zum Heap-Problem machen, den dieser Pfad vermeiden soll.

Restart-required: alle drei Felder (Warteschlange, Writer-Thread und der eingehängte Appender werden beim Start gebunden).

Tabellenaufbau, Join-Beispiele, die Grenzen der Korrelation und die Aufbewahrung über logged_at stehen in docs/database.md im Repository.


proxyprotocol

Eine Liste von Matchern, die beschreiben, welche eingehenden Verbindungen einen PROXY-Protocol-Header tragen sollen. Fehlt dieser Abschnitt oder ist er leer, wird keine Verbindung auf das PROXY-Protocol geprüft.

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
Feld Typ „Beliebig"-Wert Beschreibung
proxyIp string 0.0.0.0/0 IP, von der der Proxy sich verbindet (CIDR erlaubt).
proxyPort int 0 Quell-Port der Proxy-Verbindung (0 = wird nicht berücksichtigt).
routerLocalIp string 0.0.0.0/0 Lokale Router-IP, auf der die Verbindung empfangen wurde.
routerLocalPort int 0 Lokaler Router-Port, auf dem die Verbindung empfangen wurde.

IPs dürfen CIDR-Notation verwenden; ohne Suffix wird die Adresse als /32 behandelt. Port 0 bedeutet, dass der Port nicht berücksichtigt wird. Jeder andere Wert muss im Bereich 1–65535 liegen — ein Port ausserhalb kann keinem realen Socket-Port entsprechen, die Regel würde also nie greifen.

Das PROXY-Protocol nur für Ports aktivieren, auf denen HAProxy tatsächlich PROXY-Header sendet.

Der Router parst v1-Header strikt: ein fehlerhafter Header (zu wenige Felder, Adressfamilie passt nicht zu TCP4/TCP6, Port ausserhalb 0–65535, doppelte Trennzeichen) schliesst die Verbindung — vorher lief sie in einigen dieser Fälle mit der Proxy-Adresse als Client-Adresse weiter, und IP-basierte Filter-Regeln entschieden auf der falschen Adresse. PROXY UNKNOWN wird gemäss Spezifikation konsumiert, ohne Adressen zu übernehmen — genauso wie das v2-Pendant LOCAL, das HAProxy für Health-Checks sendet, sobald check-send-proxy konfiguriert ist: die Verbindung behält in beiden Fällen die reale Peer-Adresse. Hinter HAProxy ist das die Adresse des Proxys — IP-basierte Filterregeln entscheiden dann auf dieser, nicht auf einer Client-Adresse.


filter

Eine geordnete Liste von Routing-Regeln. Das Matching ist first-match-wins: sobald eine Regel auf eine Verbindung passt, wird sie angewendet und die Auswertung stoppt. Eine Auffang-Regel gehört ans Ende.

Fehlt der filter-Abschnitt oder ist er leer, meldet der Router beim Laden eine Warnung. Ohne Regeln nimmt er zwar jede Association an, findet danach aber zu keiner Verbindung ein Ziel: der Client hängt an einer offenen Verbindung, die keine Antwort mehr bekommt. Es ist bewusst keine Fehlermeldung — die Setup-Modi --allocate-return-ports und --render-haproxy arbeiten ausschliesslich auf destinations und laufen legitim auf einer Konfiguration, deren Filterregeln noch nicht geschrieben sind. Beim Neuaufbau einer Konfiguration ist genau das die übliche Reihenfolge.

filter:
  - clientLocalIp: 0.0.0.0/0
    clientLocalPort: 0
    callingAET: US1
    calledAET: PACS
    logLevel: 3
    actions:
      - "aetRewrite_ultrasound"
    destination:
      - "pacs1"

  # Auffang-Regel: alles Nicht-Passende ablehnen
  - callingAET: "*"
    calledAET: "*"
    destination:
      - "reject_unknown"
Feld Typ Default Beschreibung
clientLocalIp string 0.0.0.0/0 IP des Clients (CIDR erlaubt).
clientLocalPort int 0 Quell-Port, von dem der Client sich verbindet (0 = beliebig).
clientRemoteIp string 0.0.0.0/0 Ziel-IP, die am Client konfiguriert ist (z.B. HAProxy/Router).
clientRemotePort int 0 Ziel-Port, der am Client konfiguriert ist.
routerLocalIp string 0.0.0.0/0 Lokale Router-IP, die die Verbindung empfangen hat.
routerLocalPort int 0 Lokaler Router-Port, der die Verbindung empfangen hat.
callingAET string * Calling-AET des Clients (* oder weggelassen = beliebig). Exakter Vergleich oder Regex mit regex:-Präfix (siehe unten).
calledAET string * Called-AET, den der Client anfragt (* oder weggelassen = beliebig). Exakter Vergleich oder Regex mit regex:-Präfix (siehe unten).
logLevel int 0 PDU-Logging-Stufe (siehe Tabelle unten).
actions list Namen von actions-Einträgen, die für diese Regel ausgeführt werden (Plugin-Instanzen).
destination list Das Ziel der Regel: ein Name aus destinations. Das Feld nimmt eine Liste, aber es wird genau ein Ziel unterstützt — nur der erste Eintrag wird verwendet.

Der YAML-Schlüssel heisst destination (Singular) und akzeptiert eine Liste — der Router verwendet aber nur den ersten Eintrag; es gibt keinen Fan-out auf mehrere Ziele. Die Listenform hält die Config-Struktur stabil, falls künftig mehrere Ziele unterstützt werden. Immer genau einen Eintrag angeben, z.B. destination: ["pacs1"].

Wird eine IP oder ein Port weggelassen, gilt „beliebig" (IP 0.0.0.0/0, Port 0). Ein gesetzter Port muss im Bereich 1–65535 liegen: clientRemotePort: 1044 statt 104 — oder eine Ziffer zu viel in routerLocalPort — kann keinem realen Socket-Port entsprechen. Die Regel griffe nie, und die Verbindung fiele stillschweigend in die Auffang-Regel (typisch: reject). Deshalb ist das jetzt ein Konfigurationsfehler.

Zu IPv6: IPv6-Werte sind in den IP-Feldern unzulässig (der Validator lehnt sie ab), die Filter-CIDRs sind also immer IPv4. Für einen IPv6-Peer (erreichbar über router.listener.host: "::" oder PROXY-Protocol-Quelladressen) gilt darum: ein echtes IPv4-Präfix (/1 bis /32, auch die /32-Form einer einzelnen IP) matcht ihn nie — nur ein /0 (0.0.0.0/0, der Default und die übliche Auffang-Regel) ist eine familienunabhängige Wildcard und matcht jeden Peer. IPv4-Clients auf einem "::"-Listener sind davon unberührt, ihre v4-mapped-Adressen zählen als IPv4. Ausserdem ein Konfigurationsfehler: eine führende Null in einem IPv4-Oktett (010.1.1.1) — der Wert ist mehrdeutig (oktal vs. dezimal gelesen); das Oktett ohne Null schreiben (10.1.1.1).

AE-Titel-Vergleich: exakt oder Regex

callingAET/calledAET-Werte werden exakt verglichen (case-sensitive); nur das literale * bedeutet „beliebig". Mit dem regex:-Präfix wird der Wert stattdessen als Java-Regex ausgewertet:

filter:
  - callingAET: "regex:^US[0-9]+$"   # eine Regel für alle Geräte US1, US2, ...
    destination: ["pacs1"]
  - callingAET: "CT.1"               # literal — passt nur auf den AET "CT.1", nicht "CTX1"
    destination: ["pacs2"]
  • Das Muster muss den ganzen AE-Titel abdecken (Full-Match, keine Teilstring-Suche) und ist case-sensitive (AE-Titel sind case-sensitive).
  • Werte ohne Präfix werden nie als Muster interpretiert — AE-Titel dürfen legal Regex-Metazeichen wie . oder + enthalten.
  • Ein ungültiges Muster ist ein Konfigurationsfehler: der Start bricht ab, ein reload wird abgelehnt.
  • Sonderfall: ein AET, dessen literaler Wert selbst mit regex: beginnt (laut DICOM legal, : ist ein erlaubtes Zeichen), lässt sich nicht mehr als reines Literal schreiben. Stattdessen mit einem zitierten Muster matchen: callingAET: "regex:\\Qregex:ABC\\E" (\Q…\E behandelt alles dazwischen literal).

logLevel-Werte

Wert Bedeutung
0 keine Protokollierung
1 eingehend protokollieren
2 ausgehend protokollieren
3 ein- und ausgehend protokollieren
5 eingehend als Datei mitschneiden
6 ausgehend als Datei mitschneiden
7 ein- und ausgehend als Datei mitschneiden

Die Stufen 5/6/7 schreiben binäre PDU-Dumps in router.log-folder.

4 ist der einzige Wert im Bereich, den kein Aufzeichnungspfad umsetzt, und wird seit 1.17.0 abgelehnt statt stillschweigend als „kein Logging" behandelt. Ebenso abgelehnt: ein leeres logLevel: (Schlüssel ohne Wert) — das überschrieb den Default mit null und liess die Association scheitern. Den Schlüssel entweder weglassen (bedeutet 0) oder eine Stufe hinschreiben.


actions

Jeder Eintrag unter actions ist eine benannte Action, deren Wert eine Liste von Plugin-Einträgen ist. Eine einzelne benannte Action kann mehrere Plugin-Einträge verketten (in Reihenfolge ausgeführt). Die Parameter je Plugin stehen im 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

Die real ausgelieferten/in der Default-Config genutzten Plugins sind (Verwendung und Config-Felder: Plugin-Referenz):

  • 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

Der Inhalt des config:-Blocks ist plugin-spezifisch — jedes ausgelieferte Plugin ist mit seinen Feldern und Beispielen in der Plugin-Referenz dokumentiert. Auch hier gilt strikte Bindung: ein Schlüssel im config:-Block, den das Plugin nicht kennt, ist ein Konfigurationsfehler; die Meldung nennt den Schlüssel und die, die das Plugin tatsächlich hat.

Dasselbe gilt seit 1.17.0 für leere Werte: ein Schlüssel im config:-Block ohne Wert (enabled: ohne Angabe, etwa beim Editieren stehengeblieben) ist ein Konfigurationsfehler, gleich welchen Typs das Feld ist — vorher band ein An/Aus- oder Zahlen-Schlüssel still auf false bzw. 0 (ein leeres enabled: schaltete das Plugin also trotz Default true ab), andere Felder auf „nicht gesetzt". Das gilt auch für die zitierte Form (quotaMb: "", ebenso ein Wert nur aus Leerraum) — mit einer Ausnahme: an einem String-Feld ist der leere String ein gültiger Wert und bindet als solcher. Ein weggelassener Schlüssel behält wie überall den Plugin-Default; die Meldung nennt den betroffenen Schlüssel, auch verschachtelt (actions.x[0].config.enabled).

Auch der config:-Block selbst folgt dieser Regel: ein config: ohne Inhalt (etwa stehengeblieben, nachdem alle Zeilen darunter auskommentiert wurden) ist ein Konfigurationsfehler — das Plugin liefe sonst still auf allen Defaults. Wer die Defaults bewusst will, schreibt config: {} (gültig, verhält sich wie ein weggelassener Block) oder entfernt den Block; die Fehlermeldung nennt beide Auswege. config: {} ist auch an einem Plugin gültig, das keine Config-Schlüssel bindet — ein leerer Block konfiguriert nichts, anders als der nicht-leere Block im nächsten Absatz.

Ein config:-Block an einem Plugin, das gar keine Config-Schlüssel bindet (es implementiert das ConfigurablePlugin-Interface nicht, oder es implementiert es zwar, meldet aber keinen Config-Typ — getConfigurationType() liefert null), ist ebenfalls ein Konfigurationsfehler: jeder Schlüssel darin wäre wirkungslos, die Meldung sagt, den Block zu entfernen. Das betrifft nur Dritt-Plugins — alle ausgelieferten Plugins sind konfigurierbar.

Eine Action läuft nur, wenn eine Filter-Regel sie referenziert

Es gibt kein Auto-Laden: ActionProcessor löst genau die Actions auf, die die passende Filter-Regel unter filter[].actions nennt. Eine Action, die keine Regel referenziert, ist tote Konfiguration — der Router meldet das bei Start und Reload mit einer Warnung, die den Namen nennt. Für eine CMoveTrackingStage ist das besonders folgenreich: sie schreibt dann nichts, und die Q/R-Rück-Association findet Stunden später zu keinem Objekt einen Eintrag. Die Store-Kreuzprüfung zwischen CMoveTrackingStage und qr-bridge-Destination zählt deshalb nur referenzierte Actions.

Aus demselben Grund sind die Plugin-Beispiele in der ausgelieferten Vorlage auskommentiert: sie sind Dokumentation, keine aktive Konfiguration. Zum Aktivieren beides tun — Block einkommentieren und den Namen in die Filter-Regel aufnehmen.

Jeder Eintrag braucht ein plugin:

Ein Eintrag ohne plugin:-Schlüssel wird abgelehnt. Es gibt keinen eingebauten Action-Typ: plugin ist der einzige. Ein vertipptes plugn: erzeugte früher einen Eintrag ohne Plugin-Klasse, den die Laufzeit stillschweigend übersprang — ein Tippfehler im wichtigsten Schlüssel deaktivierte damit z.B. eine Anonymisierung.

Ein Plugin, das sich nicht instanziieren lässt, lehnt die Verbindung ab

Scheitert zur Verbindungszeit die Instanziierung eines von der Regel referenzierten Plugins (Klasse nicht ladbar, Konstruktor wirft, Config nicht anwendbar, inkompatibles Plugin-JAR), wird die Association abgebrochen statt ohne diese Action weitergeleitet. Der Grund: eine Verbindung ohne ihre konfigurierte Action ist keine eingeschränkte Verbindung, sondern eine, die etwas tut, was die Konfiguration verbietet — ohne Rewrite, ohne Filterung, ohne Tracking. Der Fehler steht als ERROR im Log und nennt Plugin und Action. Der Prüfweg davor bleibt --validate-config: es lädt jede referenzierte Plugin-Klasse und jeden config:-Block.


destinations

Eine Map benannter Destinations. Jede Destination ist über ihr type-Feld polymorph. Ein Destination-Name wird aus einer Filter-Regel über filter[].destination referenziert.

Der Name darf höchstens 64 Zeichen lang sein. Er ist nicht nur eine Beschriftung: die Q/R-Bridge merkt ihn sich als Rückweg im Tracking-Eintrag und löst ihn Stunden später wieder gegen destinations: auf. Ein längerer Name passt dort nicht hinein, also lehnt die Konfigurationsprüfung ihn ab, statt später einen abgeschnittenen Namen zu speichern, den nichts mehr auflösen kann.

type Verhalten
forward Weiterleitung an ein Netzwerk-Ziel (Standard, type darf fehlen).
reject A-ASSOCIATE-RJ an den Client, keine Weiterleitung.
qr-bridge Der Router terminiert die Association selbst und routet jeden C-STORE anhand des C-MOVE-Trackings (Q/R-Rückweg; keine Adresse, kein ausgehender Socket).

type wird exakt verglichen — Kleinschreibung, keine umgebenden Leerzeichen. Ein abweichend geschriebener Wert (QR-BRIDGE, Reject) ist ein Validierungsfehler und bricht Start bzw. Reload ab, statt still als forward-Destination zu binden. Fehlt type ganz, gilt weiterhin forward.

Felder gehören genau einem Typ. Ein Feld, das zu einer anderen Destination-Art gehört, ist ein Validierungsfehler statt tote Config:

  • store, calling-aet, allowed-calling-aet, presentation-contexts, max-drain-bytes, timeouts nur an qr-bridge;
  • host, port, aet nur an forward (eine reject- oder qr-bridge-Destination baut nie eine Verbindung auf);
  • result, source, reason, message nur an reject;
  • return-port/return-vip nur an forward (siehe haproxy-return).

Der praktisch wichtigste Fall: eine Destination mit host/port und result/source/reason, bei der type: reject vergessen wurde, leitete bisher still weiter, statt abzulehnen.

type: forward (Standard)

Leitet die Association an ein Netzwerk-Ziel weiter. type darf weggelassen werden — forward ist der Standard.

destinations:
  pacs1:
    type: forward        # optional, das ist der Standard
    host: pacs1.example.org
    port: 104
    aet: PACS
Feld Typ Default Beschreibung
type string forward Destination-Typ.
host string Ziel-Host — immer die echte Geräteadresse (mit return-port das HAProxy-Backend-Ziel).
port int Ziel-Port.
aet string Application Entity Title des Ziels. Optional: der reguläre Forward-Pfad reicht die eingehende A-ASSOCIATE-RQ durch und liest den Wert nie — gelesen wird er von den Pfaden, die selbst eine Association aufbauen (Q/R-Rückweg, Tap-Forward ohne eigenen calledAet). Wenn er geschrieben ist, muss er 1–16 Zeichen haben: leer oder länger wird beim Start und beim Reload abgelehnt, weil PS3.8 9.3.2 sonst auf 16 Byte auffüllt bzw. abschneidet und der Fehler erst als Ablehnung der Gegenstelle sichtbar würde.
return-port int null HAProxy-Rückweg (siehe haproxy-return): wenn gesetzt, wählt der Router vip:return-port statt host:port an. 0 ist ein Allokations-Marker: --allocate-return-ports ersetzt ihn durch den kleinsten freien Port der port-range.
return-vip string null Per-Destination-VIP-Override; ohne Angabe gilt die globale haproxy-return.vip. Nur zusammen mit return-port erlaubt.

type: reject

Sendet dem Client ein A-ASSOCIATE-RJ-PDU, statt weiterzuleiten.

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"
Feld Typ Default Beschreibung
type string reject Muss reject sein.
result int 1 1 = rejected-permanent, 2 = rejected-transient.
source int 1 1 = service-user, 2 = ACSE, 3 = Presentation.
reason int 1 Reason-/Diagnose-Code; Bedeutung hängt von source ab.
message string null Optionale beschreibende Nachricht (nur fürs Logging).

Reason-Codes (laut 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: mit leerem Wert sind seit 1.17.0 ein Startfehler — vorher banden sie still auf 0, und die Fehlermeldung nannte einen Code, den niemand geschrieben hatte. Weggelassen gelten die Defaults 1/1/1.

Filter-Regeln können unautorisierte Verbindungen an eine reject-Destination leiten, um sie mit einem korrekten DICOM-Ablehnungscode abzuweisen (siehe die Auffang-Regel oben).

type: qr-bridge

Der Rückweg der Late-Binding-Q/R-Bridge (Design: docs/design/archive/routing-c-move.md im Repository). Statt weiterzuleiten terminiert der Router die Association selbst: er beantwortet das A-ASSOCIATE-RQ, liest die MoveOriginatorMessageID aus jedem C-STORE-Command-Set und leitet das Objekt an das Gerät weiter, dessen C-MOVE den Tracking-Eintrag hinterlassen hat (CMoveTrackingStage, siehe plugin-reference.md). Fehlt die MoveOriginatorMessageID oder passt sie zu keinem Eintrag — nicht jedes PACS füllt das Feld, manche tragen einen eigenen Wert ein —, ordnet die Bridge das Objekt ersatzweise über die StudyInstanceUID des Data-Sets dem jüngsten passenden Tracking-Eintrag zu. Deshalb hat diese Destination kein host/port — das Ziel steht erst pro Message fest. Zu der qr-bridge-Destination selbst baut der Router deshalb nie eine Verbindung auf; die ausgehende Association geht an die im Tracking-Eintrag hinterlegte normale forward-Destination des Geräts (siehe „Weiterleitung ans Zielgerät" unten), also an deren host/port bzw. deren HAProxy-Rückweg (return-port).

destinations:
  qr_bridge:
    type: qr-bridge
    store: memory                 # muss mit dem store: der cmoveTracking-Action übereinstimmen
    calling-aet: ROUTER           # CallingAET des Beins zum Gerät (Default: Listener-AET)
    allowed-calling-aet: [PACS]   # optionale Allowlist für die Rück-Association (leer = keine Prüfung)
    max-drain-bytes: 268435456    # Notbremse: verworfene Bytes pro Association (0 = unbegrenzt)
    timeouts:                     # Timeouts des Beins zum Gerät (alle optional, Millisekunden)
      connect: 30000
      response: 30000
      release: 5000
    presentation-contexts:        # kuratierte Liste; fehlt sie: nur Implicit VR LE
      - 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"]
Feld Typ Default Beschreibung
type string Muss qr-bridge sein.
store string memory Tracking-Store, aus dem die Bridge liest: memory (Einzelknoten) oder database (Cluster). Restart-pflichtig.
calling-aet string Listener-AET CallingAET des Beins zum Zielgerät (für Geräte mit AE-ACL).
allowed-calling-aet Liste leer Allowlist für die eingehende Rück-Association: nur diese CallingAETs werden angenommen, alles andere bekommt A-ASSOCIATE-RJ (Result 1, Source 1, Reason 3 = calling-aet-not-recognized). Leer oder weggelassen = keine Prüfung, d. h. jede Verbindung, die eine Filter-Regel hierher routet (siehe „Zugangskontrolle" unten).
max-drain-bytes long 0 Notbremse: Summe der Data-Set-Bytes, die die Bridge auf einer Association verwerfen darf, bevor sie mit A-ABORT abbricht. 0 = unbegrenzt (siehe „Notbremse" unten).
timeouts.connect int 30000 Millisekunden für den Aufbau der Association zum Gerät. Der Wert gilt je Phase — einmal für den TCP-Connect und einmal für das Warten auf das A-ASSOCIATE-AC. Ein Gerät, das die Verbindung annimmt und dann schweigt, kostet also im Worst Case das Doppelte. Die Connect-Phase schliesst die Namensauflösung des Ziel-Hosts mit ein: Auflösung und TCP-Handshake teilen sich dieses Budget, ein nicht erreichbarer DNS-Resolver kann die Bridge also nicht länger blockieren als hier konfiguriert. Ist der Host als IP-Adresse konfiguriert, entfällt die Auflösung ganz. Der Wert gilt je Verbindungsversuch, und die Bridge gibt nach drei aufeinanderfolgenden Versuchen ohne Antwort des Geräts auf — ein dauerhaft nicht erreichbares Gerät kostet das PACS also bis zu 3 × diesen Wert.
timeouts.response int 30000 Millisekunden pro relayter Message: Warten auf das C-STORE-RSP und jeder einzelne PDU-Write.
timeouts.release int 5000 Millisekunden für das A-RELEASE-RP des Geräte-Beins — bewusst kurz, weil das PACS währenddessen auf seine eigene Release-Bestätigung wartet.
presentation-contexts Liste leer Kuratierte Accept-Liste aus abstract-syntax + transfer-syntaxes (Reihenfolge = Operator-Präferenz).

Presentation-Context-Policy. Weil der Router antworten muss, bevor das Zielgerät feststeht, handeln die Endpunkte ihre Kontexte nicht mehr direkt miteinander aus:

  • Ohne presentation-contexts: akzeptiert die Bridge jede angebotene Abstract Syntax, aber nur mit Implicit VR Little Endian (sicherer Default; Kompression geht verloren, das PACS dekomprimiert).
  • Mit presentation-contexts: muss die Abstract Syntax konfiguriert sein (sonst Ablehnung mit Result 3 = abstract-syntax-not-supported); gewählt wird die erste konfigurierte Transfer Syntax, die das PACS ebenfalls anbietet (sonst Result 4 = transfer-syntaxes-not-supported).
  • Nicht passende Kontexte werden einzeln abgelehnt — das PACS zählt die betroffenen Objekte selbst als failed sub-operations. Ein A-ASSOCIATE-RJ gibt es nur, wenn kein Kontext akzeptabel ist. Sendet ein PACS trotzdem auf einem abgelehnten Kontext, wird die Message mit C000 beantwortet und nicht weitergeleitet: für diesen Kontext wurde nie eine Transfer Syntax vereinbart, unter der man die Bytes etikettieren dürfte. Diese Antwort geht dabei auf demselben abgelehnten Kontext hinaus — eine bewusste Abweichung von PS3.8, die verlangt, dass P-DATA nur auf akzeptierten Kontexten läuft. Der Standard sähe hier einen A-ABORT vor; der würde aber den ganzen Retrieve beenden, auch die Objekte auf den akzeptierten Kontexten. Die Message-weise Failure verliert nur die Objekte, die ohnehin nicht zustellbar sind, und der Anrufer hat die Regel zuerst gebrochen. Weitergeleitet wird in keinem Fall etwas.
  • Schlägt ein PACS dieselbe Context-ID mehrfach vor (nicht normkonform, PS3.8 9.3.2.2 verlangt eindeutige ungerade IDs), wird jedes Vorkommen abgelehnt. Eines davon anzunehmen ergäbe ein A-ASSOCIATE-AC, das sich für diese ID selbst widerspricht.
  • Verification (C-ECHO) wird immer akzeptiert, auch im Implicit-only-Default; die Bridge beantwortet den Ping selbst. Ausnahme: Wird 1.2.840.10008.1.1 selbst in presentation-contexts: kuratiert, gilt für ihn die kuratierte Regel wie für jede andere Abstract Syntax — wer den Kontext ausbuchstabiert, meint es so, und dann kann C-ECHO auch auf Result 4 laufen.

Fehlersemantik pro Message (kein A-ABORT — einzige Ausnahme ist die Notbremse max-drain-bytes weiter unten; das Data-Set wird immer vollständig konsumiert, bevor geantwortet wird, damit die Association weiter nutzbar bleibt):

Fall RSP-Status
Zielgerät nicht erreichbar oder nicht wählbar (unvollständige Destination), Association abgelehnt, Timeout, Abbruch mitten in der Message, Wiederverbindungs-Deckel erreicht A700
Lookup-Miss, unbekannter Destination-Name nach Reload, zweite Destination auf derselben Association C000
C-STORE ohne lesbare SOP-Class-/Instance-UID, leeres Data-Set, nicht lesbares Command-Set oder Data-Set, C-STORE aus einem korrupten Tracking-Eintrag nicht baubar C000
Message auf einem Presentation Context, den die Bridge abgelehnt hat (siehe Policy oben) C000
Command-Set ohne lesbares CommandField, sowie jeder DIMSE-Dienst, den die Bridge nicht anbietet (alles ausser C-STORE, C-ECHO und C-CANCEL) 0211
Zielgerät akzeptiert den benötigten Presentation Context nicht 0122

Ein C-CANCEL-RQ ist die einzige Nachricht, die keine Antwort bekommt: PS3.7 9.3.4 sieht für ihn keine Response vor, und die Bridge hat keine eigene ausstehende Operation abzubrechen. Er wird protokolliert und verworfen — weitergeleitet wird er ebenfalls nicht, weil eine Bridge-Association gar keinen ausgehenden Socket hat. Kündigt er — protokollwidrig, PS3.7 9.3.4 sieht für einen C-CANCEL kein Data-Set vor — trotzdem eines an, wird auch das Data-Set gelesen, auf max-drain-bytes verrechnet und ohne Antwort verworfen.

Antwortet das Zielgerät selbst, wird sein Status unverändert durchgereicht — auch 0000 und Warnungen (B000/B006/B007), die das PACS als warning sub-operations zählt.

Notbremse max-drain-bytes. Eine Failure-Antwort entbindet nicht vom Drain: die restliche Message wird immer zu Ende gelesen und verworfen. Bei einem dauerhaften Fehler — eine per Reload entfernte Destination, ein Gerät, das auch nach dem Wiederverbindungs- Deckel nicht erreichbar bleibt — schiebt ein PACS damit unter Umständen eine ganze Studie ins Leere. max-drain-bytes begrenzt das: übersteigt die Summe der verworfenen Data-Set-Bytes auf einer Association den Wert, wird die Association mit A-ABORT beendet. Die Schwelle greift immer zwischen zwei Messages — eine laufende Message wird nie abgeschnitten (dafür gibt es keinen Flow-Control-Mechanismus). Die Message, die das Limit reisst, bekommt ihren Failure-RSP noch vollständig; abgebrochen wird erst, wenn das PACS die nächste Message beginnt — gleich ob diese mit einem Command-Set anfängt oder, bei einem reinen Data-Set-Strom, mit einem Data-Set. Gezählt werden nur Bytes, die die Bridge selbst nirgends hinbringen konnte — u. a. Lookup-Miss, unbekannte Destination, Multiplex-Mismatch, Gerät nicht erreichbar, Presentation-Context-Mismatch, Transportfehler, ein C-STORE ohne lesbare SOP-UID oder mit leerem Data-Set, und ein Data-Set, zu dem gar kein Command dieser Bridge vorausging (sonst wäre genau das der eine Weg, unbegrenzt Bytes ins Leere zu schieben, ohne die Bremse je auszulösen). Eine Message, die das Zielgerät quittiert hat, zählt nie mit — unabhängig vom Status: auch ein A900 oder A700 des Geräts ist dessen Urteil über ein vollständig empfangenes Objekt, und würde man das mitzählen, risse ein Retrieve gegen ein wählerisches Gerät die Notbremse und verlöre genau die Objekte, die das Gerät angenommen hätte. Default 0 = unbegrenzt, also unverändertes Verhalten.

Weiterleitung ans Zielgerät. Die Bridge öffnet die Association zum Gerät bei der ersten Message, deren Routing aufgeht, und nutzt sie für den Rest der Association weiter (eine gebundene Destination pro eingehender Association). Dabei gilt:

  • Der C-STORE wird neu gebaut, nicht byteweise repliziert: MoveOriginatorMessageID und MoveOriginatorAET tragen wieder die Originalwerte des Geräts aus dem Tracking-Eintrag, damit ein Gerät, das den Abschluss seines C-MOVE über (0000,1031) korreliert, korrekt funktioniert. Das Data-Set geht byte-identisch über die Leitung (kein Transcoding) und wird auf die Max-PDU-Länge des Geräts neu fragmentiert.
  • Pro benötigter Abstract Syntax wird genau die Transfer Syntax proponiert, die auf dem PACS-Bein ausgehandelt wurde — ohne Transcoding müssen beide Beine dieselbe Syntax benutzen. Lehnt das Gerät sie ab, ist die Message 0122; die übrigen laufen weiter.
  • Bricht die Verbindung zum Gerät ab, wird sie verworfen; die nächste Message öffnet eine neue. Passiert das als Transportfehler auf einer bereits genutzten Association — die Signatur einer geräteseitigen Rotation, nicht eines ausgefallenen Geräts —, wird die betroffene Message genau einmal auf einer frischen Association wiederholt, statt sie als fehlgeschlagene Sub-Operation zu verlieren. Nur der Transportfehler ist wiederholbar: ein A-ABORT des Geräts mitten in der Message und eine protokollwidrige Antwort werden nicht wiederholt (A700), weil die Wiederholung dasselbe Objekt sendet, um genauso beantwortet zu werden. Genau einmal, weil derselbe Abbruch auch bedeuten kann, dass das Gerät das Objekt bereits vollständig empfangen hat und nur seine RSP ausblieb: die Wiederholung erzeugt dann ein Duplikat derselben SOP-Instanz, das ein Archiv in der Praxis überschreibt. Auf einer gerade erst geöffneten Association wird nicht wiederholt — dort ist der Fehlschlag keine Rotation, sondern ein Gerät, das schon beim ersten Objekt versagt.
  • Der Deckel für das Neuöffnen liegt bei drei aufeinanderfolgenden Association-Versuchen, die zu keiner Antwort des Geräts geführt haben — ein einzelner Netzunterbruch kostet damit nicht den Rest des Retrieves, ein dauerhaft nicht erreichbares Gerät aber auch nicht pro Bild einen Verbindungs-Timeout: danach wird jede weitere Message sofort mit A700 beantwortet. Verbraucht wird das Budget nicht nur von Verbindungen, die gar nicht zustande kamen, sondern auch von solchen, die sauber aufgebaut und dann wegen eines Protokollverstosses des Geräts verworfen wurden. Gezählt wird nur aufeinanderfolgend: Sobald das Gerät wieder geantwortet hat — mit welchem Status auch immer, eine Ablehnung zählt also ebenfalls —, beginnt der Deckel von vorn. Ein gesundes Gerät, das seine Associations selbst beendet (Association-Limit, Idle-Timeout, NAT-Reset), liefert einen Retrieve damit vollständig aus — weder bricht es nach der dritten Association ab, noch kostet eine Rotation ein Bild.
  • Beim A-RELEASE-RQ des PACS wird zuerst das Geräte-Bein ordentlich released, dann das A-RELEASE-RP gesendet; bricht das PACS ab, wird das Geräte-Bein ebenfalls abgebrochen.

Im Audit-Trail erscheint das Bein zum Gerät als eigene Association-Zeile mit router_role = 'forward' und parent_association_id der PACS-Association — beide Beine eines Retrieves sind damit direkt joinbar (Details zu den Spalten: docs/database.md im Repository).

Validierung. Die Store-Angaben beider Hälften müssen übereinstimmen: jeden Store, aus dem eine Bridge liest, muss auf diesem Knoten auch eine CMoveTrackingStage-Action beschreiben. Es genügt also nicht, dass der Store einer Bridge irgendwo unter den Actions vorkommt: schreiben fünf Actions nach database und eine sechste (mit vergessenem store:, Default memory) in den Speicher, ist das ein Fehler, denn genau die Geräte dieser einen Action verlören jedes zurückkommende Bild — der Speicher-Store ist knotenlokal, was dort landet und hier niemand liest, kann niemand lesen. Umgekehrt gilt die Regel deshalb nur für store: memory: eine Action mit store: database, für die es hier keine Bridge gibt, ist der dokumentierte Cluster-Fall (die lesende Bridge läuft auf einer anderen Instanz) und zulässig — auch neben einer lokalen memory-Bridge. Gibt es auf diesem Knoten gar keine CMoveTrackingStage-Action, ist die Bridge trotzdem zulässig: das ist der reine Lese-Knoten im Cluster, dessen Einträge eine andere Instanz in die gemeinsame Datenbank schreibt; der Spiegelfall (Actions ohne Bridge) ist der Schreib-Knoten und ebenso zulässig. Ob dieser Knoten überhaupt eine Datenbank hat, prüft die Konfigurationsprüfung nicht — das ist eine Eigenschaft des Rechners, nicht der Datei, und dieselbe Datei ist auf einem anderen Knoten gültig. Entschieden wird es beim Start und beim reload: entsteht trotz gültiger Konfiguration kein Datenbank-Store — weil die Datenbank abgeschaltet ist oder die JDBC-Verkabelung scheitert —, startet der Router nicht, statt still auf den Speicher-Store auszuweichen; verlangt eine Ergänzung im reload store: database auf einem Knoten ohne datenbank-gestützten Tracking-Store, wird der reload abgelehnt und die bisherige Konfiguration bleibt aktiv (siehe Management). Ein Knoten, der lokal trackt, während seine Cluster-Partner in der gemeinsamen Datenbank nachschlagen, verlöre jeden Retrieve, dessen Rück-Association woanders landet. Die Fehlermeldung nennt beide Auswege (Datenbank reparieren oder überall auf store: memory umstellen). UIDs werden auf die DICOM-Syntax geprüft (nur Ziffern und Punkte, keine leere Komponente, keine führende Null in einer mehrstelligen Komponente, höchstens 64 Zeichen — ein ausgeschriebener SOP-Class-Name oder eine UID mit angehängtem Leerzeichen wird abgelehnt, weil sie zur Laufzeit nie träfe), eine Abstract Syntax darf nur einmal vorkommen, und eine Transfer Syntax, die der Router nicht dekodieren kann, wird abgelehnt. calling-aet muss ein gültiger AE-Titel sein (1–16 Zeichen), max-drain-bytes muss >= 0 sein, jeder gesetzte Wert unter timeouts: muss > 0 sein. Unter timeouts: sind ausschliesslich connect, response und release erlaubt — ein Tippfehler ist ein normaler Konfigurationsfehler mit Sektion und Feld (destinations.<name>.timeouts.<key>), nicht ein stiller Rückfall auf den Default. Ausser store sind alle Felder dieser Destination hot-reloadbar.

Zugangskontrolle: die Filter-Regel und optional allowed-calling-aet. Die Bridge prüft von sich aus nicht, ob die eingehende Association zu dem PACS gehört, dessen C-MOVE den Tracking-Eintrag angelegt hat. Sie routet nach der MoveOriginatorMessageID aus dem Command-Set und ersatzweise nach der StudyInstanceUID des Data-Sets (siehe oben) — wer eine Association zur Bridge aufbauen darf und eine gültige Nummer kennt oder trifft (der Raum ist 16 Bit gross) oder die StudyInstanceUID eines laufenden Retrieves kennt, lässt ein Objekt an das zugehörige Gerät zustellen. Die Filter-Regel, die auf die qr-bridge-Destination zeigt, ist damit die Zugangskontrolle des Rückwegs und sollte so eng gefasst sein wie möglich:

  • clientLocalIp auf die Adresse oder das Netz des PACS einschränken. Das ist das einzige Kriterium der Regel, das ein Anrufer nicht selbst bestimmt, und damit die einzige wirksame Zugangsbeschränkung. Hinter HAProxy nur zusammen mit dem PROXY-Protocol sinnvoll — sonst sieht der Router die Adresse des Proxys;
  • callingAET auf den AE-Titel des PACS festnageln — kein * und kein weites regex:;
  • calledAET auf den AE-Titel, unter dem der Router im PACS als DICOM-Knoten eingetragen ist (im Beispiel unten ROUTER), statt ihn offen zu lassen;
  • die Regel nach den Geräte-Regeln einsortieren, damit kein Gerät versehentlich auf ihr landet (die erste passende Regel gewinnt).

Beide AE-Titel stammen aus der A-ASSOCIATE-RQ des Anrufers, sind also unauthentifiziert: wer den Listener-Port erreicht, trägt dort ein, was er will. Sie halten Fehlkonfiguration und versehentliche Verbindungen ab, keinen Angreifer. Verlassen Sie sich für die Zugangskontrolle des Rückwegs daher auf clientLocalIp und auf die Netzwerksegmentierung davor, nicht auf die AE-Titel.

allowed-calling-aet an der Destination ist die zweite, engere Schranke: sie wird beim A-ASSOCIATE-RQ geprüft, also bevor überhaupt eine Message ankommt, und lehnt einen nicht gelisteten CallingAET mit A-ASSOCIATE-RJ (calling-aet-not-recognized) ab. Der AE-Titel bleibt dabei ein unauthentifizierter Wert aus der A-ASSOCIATE-RQ — die Liste ersetzt clientLocalIp also nicht, sie ergänzt sie. Ohne Eintrag verhält sich die Bridge wie bisher und nimmt jede Verbindung an, die eine Filter-Regel hierher routet; das ist der Default, damit ein Update keine bestehende Installation stilllegt. Der AE-Titel-Vergleich ist exakt und case-sensitiv (PS3.5 6.2).

Bei einem geclusterten PACS ruft nicht zwingend derselbe Knoten zurück, der den C-MOVE angenommen hat, und die Knoten treten oft unter eigenen AE-Titeln auf. In die Liste gehören dann alle AE-Titel, unter denen das PACS eine Association aufbauen kann — ein fehlender lässt genau die Retrieves scheitern, die über diesen Knoten zurückkommen, und zwar mit einem A-ASSOCIATE-RJ statt mit einer Fehlermeldung pro Bild.

Kapazität des Tracking-Raums. Ein Tracking-Eintrag belegt eine DICOM-MessageID, also eine von 65 536 Nummern — je Store, nicht je Gerät oder Association. Ein Eintrag wird nicht frei, wenn der Retrieve fertig ist, sondern erst mit seiner TTL (ttl: der CMoveTrackingStage-Action, Default 24 h); und auch dann räumt der Router nur beiläufig auf: der Memory-Store beim nächsten Zugriff auf dieselbe Nummer und beiläufig alle paar Zuteilungen über den ganzen Bestand, die Datenbank zusätzlich über das Retention-Skript (share/audit-retention.sql.example). Ab 50 % Belegung schreibt der Memory-Store eine WARN-, ab 90 % eine ERROR-Zeile — je einmal pro Prozess, damit die Sättigung sichtbar wird, bevor sie greift, ohne das Log zu fluten. Läuft der Raum voll, findet die Zuteilung keine freie Nummer mehr: der C-MOVE wird dann unverändert weitergeleitet — kein Abbruch, aber auch keine Bridge, die Bilder gehen dorthin, wohin das Gerät sie ursprünglich adressiert hatte — und im Log steht ein ERROR.

Für die Wahl der TTL heisst das: lang genug für den langsamsten erwarteten Retrieve, aber nicht länger. 65 536 ist die Obergrenze, kein Richtwert — schon lange davor wird die Zuteilung teuer, weil sie für jede neue Nummer immer länger nach einer freien sucht. Als Faustregel: „TTL (in Stunden) × C-MOVE-RQs pro Stunde" soll höchstens rund ein Viertel des Raums belegen, also ~16 000 gleichzeitig offene Einträge. Bei der Default-TTL von 24 h sind das rund 680 C-MOVEs pro Stunde über alle Geräte dieses Stores (die Sättigung läge bei 2730 — dort ist der Raum zu 100 % belegt). Wer darüber liegt, setzt die TTL entsprechend kürzer: mit ttl: 2h trägt derselbe Raum rund 8000 C-MOVEs pro Stunde.


haproxy-return

Optionale globale Sektion für HAProxy-Rückwege: Geräte whitelisten anrufende IPs. Damit nicht jeder Router-Knoten einen eigenen Eintrag auf dem Gerät braucht, betreibt HAProxy pro Gerät einen Rückweg (VIP:return-port → Geräte-IP:Port). Eine forward-Destination mit return-port wird vom Router automatisch über return-vip:return-port (Per-Destination-Override) bzw. vip:return-port (globaler Default) angewählt statt über ihre echte host:port-Adresse — das gilt für das Routing (ForwardStage) und für den Tap-Shipper (Adresse wird beim Spoolen aufgelöst).

haproxy-return:            # global, optional
  vip: 10.0.0.10           # Default-VIP
  port-range: "5000-5999"  # Pool für die automatische Port-Vergabe + Render-Grenze

destinations:
  ct_a:
    host: 192.168.10.5     # ECHTE Geräteadresse (HAProxy-Backend-Ziel)
    port: 104
    aet: CT_A
    return-port: 5013      # → Router wählt vip:5013 an
    # return-vip: 10.0.0.11  # optionaler Per-Destination-Override
  ct_b:
    host: 192.168.10.6
    port: 104
    aet: CT_B
    return-port: 0         # Marker: --allocate-return-ports vergibt den Port
Feld Typ Default Beschreibung
vip string Default-VIP für Destinations mit return-port ohne eigene return-vip.
port-range string null Port-Pool "from-to" (beide Grenzen inklusiv, 1–65535, from ≤ to) für die automatische Vergabe: --allocate-return-ports ersetzt jeden return-port: 0-Marker durch den kleinsten freien Port der Range bzgl. der effektiven VIP der Destination. Ohne port-range sind Marker ein Validierungsfehler. Die Range ist zugleich die Grenze für --render-haproxy: nur Destinations mit return-port innerhalb der Range werden gerendert (siehe unten).

Automatische Port-Vergabe (return-port: 0): neue Destinations werden mit return-port: 0 markiert; das Subkommando --allocate-return-ports (siehe management.md, von dicom-router-sync automatisch ausgeführt) ersetzt jeden Marker durch den kleinsten freien Port der port-range — bezogen auf die effektive VIP der Destination (return-vip-Override oder globale vip; Eindeutigkeit gilt pro (vip, port)-Paar, zwei VIPs können denselben Port tragen). Einmal vergebene Ports werden nie neu berechnet (sticky) — nur Marker werden ersetzt. port-range ohne vip ist erlaubt, wenn alle betroffenen Destinations eine eigene return-vip setzen.

Die passende HAProxy-Konfiguration (ein listen-Block pro Rückweg) generiert der Router selbst aus der YAML: siehe --render-haproxy in management.md; Verteilung + Reload übernimmt dicom-router-haproxy-sync (ebenfalls management.md). Wie destinations ist die Sektion hot-reloadbar.

port-range als Grenze zum handgepflegten Teil: --render-haproxy rendert nur Destinations, deren return-port innerhalb der port-range liegt. Die generierte Datei wird bei jedem Sync-Lauf komplett überschrieben; ein Rückweg, den der Betreiber in seiner eigenen HAProxy-Config führt, muss deshalb einen Port ausserhalb der Range benutzen, sonst kollidieren zwei listen-Blöcke auf demselben bind. Ein return-port ausserhalb der Range bleibt gültige Konfiguration und wird zur Laufzeit unverändert über vip:return-port angewählt — er erscheint in der generierten Datei nur als Kommentar. Die Prüfung ist rein portbasiert und VIP-unabhängig. Ohne konfigurierte port-range gibt es keine Grenze: dann wird jede forward-Destination mit return-port gerendert.

Validierung (load-strikt — Fehler brechen Start/Reload ab):

  • return-port ausserhalb 1–65535 → Fehler.
  • return-port: 0 (unallozierter Marker) → Fehler mit Hinweis auf --allocate-return-ports — eine unaufgelöste Config kann nie live gehen.
  • return-port: 0 ohne konfigurierte port-range → eigener Fehler (Marker nicht auflösbar).
  • port-range nicht im Format "from-to" mit 1 ≤ from ≤ to ≤ 65535 → Fehler.
  • return-port gesetzt, aber weder return-vip noch haproxy-return.vip → Fehler.
  • return-vip ohne return-port → Fehler (tote Config).
  • Doppeltes effektives (vip, return-port)-Paar über Destinations hinweg → Fehler (HAProxy-Bind-Kollision — jedes Gerät braucht seinen eigenen Rückweg).
  • haproxy-return: vorhanden, aber weder vip noch port-range gesetzt → Fehler (vip: "" explizit leer bleibt auch mit port-range ein Fehler).
  • return-port/return-vip an einer type: reject-Destination → Fehler.
  • vip/return-vip — und ebenso host jeder forward-Destination — mit Zeichen ausserhalb des Hostname/IP-Zeichensatzes A-Za-z0-9._:- plus [] (IPv6-Klammern) und % (IPv6-Zone-IDs) → Fehler: die Werte landen wörtlich in den bind-/server-Zeilen der generierten HAProxy-Config; insbesondere Whitespace/Zeilenumbrüche werden abgelehnt.

Durchgespieltes Beispiel: C-MOVE Round Trip (beide Associations)

Ein C-MOVE besteht aus zwei unabhängigen Associations:

  1. Association 1 — Gerät → Router → PACS: das C-MOVE-RQ.
  2. Association 2 — PACS → Router → Gerät: das PACS öffnet eine neue eingehende Verbindung und sendet die Bilder per C-STORE an die im Request genannte MoveDestination.

Auf TCP-Ebene haben die beiden nichts miteinander zu tun — der Router muss die zweite Verbindung also anhand ihres Inhalts wieder der ersten zuordnen. Dafür gibt es zwei Wege, die sich gegenseitig ausschliessen:

Weg A — AET-Encoding Weg B — Q/R-Bridge
Zuordnung über den CalledAET von Association 2 (steht schon im A-ASSOCIATE-RQ) den Tracking-Eintrag zur MoveOriginatorMessageID im ersten C-STORE-Command-Set
Router-Rolle auf Association 2 transparenter Proxy SCP-Terminierung (type: qr-bridge)
Einträge im PACS ein DICOM-Knoten pro Gerät(-egruppe) ein einziger DICOM-Knoten
Filter-Regeln für Association 2 eine pro kodiertem AET genau eine, für alle Geräte
Presentation Contexts Gerät und PACS handeln direkt aus (Kompression bleibt erhalten) der Router antwortet, bevor das Gerät feststeht — kuratierte Liste, Default Implicit VR LE
Cluster (mehrere Router-Knoten) zustandslos store: database (gemeinsame Datenbank)

Wann welcher Weg? Weg A ist der einfachere und behält die Ende-zu-Ende-Aushandlung der Presentation Contexts — solange das PACS pro Gerät einen Knoten-Eintrag tragen darf, ist er die erste Wahl. Weg B ist die Antwort auf genau eine Anforderung: das PACS soll einen einzigen Eintrag für den ganzen Router bekommen. Dafür entfällt die Ende-zu-Ende-Aushandlung: die akzeptierten Kontexte und Transfer-Syntaxen werden am Router konfiguriert (presentation-contexts, siehe type: qr-bridge) und müssen die Schnittmenge der Geräteflotte treffen; ohne Konfiguration läuft der Rückweg unkomprimiert in Implicit VR LE.

Für beide Wege gilt: beide Richtungen brauchen eine Filter-Regel, und die MoveDestination im C-MOVE-RQ muss ein AE-Titel sein, den das PACS tatsächlich erreichen kann. Zu beachten: das aet:-Feld einer Destination schreibt nichts auf dem Draht um (der Router leitet das ursprüngliche A-ASSOCIATE-RQ transparent weiter); jede AE-Titel-Änderung ist eine explizite AETitleRewriteStage-Action.

Weg A: AET-Encoding (transparenter Proxy)

Die MoveDestination wird auf einen AE-Titel umgeschrieben, den das PACS kennt (CMoveDestinationRewriteStage). Das PACS öffnet Association 2 mit genau diesem CalledAET — und der steht bereits im A-ASSOCIATE-RQ, also bevor ein Byte P-DATA fliesst. Der Router bleibt damit transparenter Proxy.

Vollständige Konfiguration für ein Gerät (US1) und ein PACS:

filter:
  # --- Association 1: Gerät US1 -> PACS (C-MOVE-RQ) ---
  - callingAET: US1
    actions:
      - "aetRewrite_us"        # PACS sieht CallingAET=ROUTER statt US1
      - "cmoveRewrite_us"      # ...und MoveDestination=US_POOL statt US1
    destination:
      - "pacs1"

  # --- Association 2: PACS -> Gerät US1 (C-STOREs an die MoveDestination) ---
  - callingAET: PACS
    calledAET: US_POOL
    actions:
      - "aetRewriteReturn_us"  # Gerät sieht CalledAET=US1, wie erwartet
    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

Auf der PACS-Seite nötig: ein DICOM-Knoten-Eintrag US_POOL (AET US_POOL, Host/Port = der Router oder sein HAProxy-Frontend). Wenn das PACS die umgeschriebene MoveDestination=US_POOL auflöst, verbindet es sich zu diesem Knoten — also zurück zum Router — mit CalledAET=US_POOL, worauf die zweite Filter-Regel passt.

Mehrere Geräte hinter einer Regel: ein fester destinationOverride bildet alle Geräte auf eine Rückroute ab. Um den Rückweg pro Gerät wieder aufzufächern, destinationMapping verwenden mit einem kodierten AE-Titel pro Gerät (US1: "RTR_US1", US2: "RTR_US2", …), einem PACS-Knoten-Eintrag pro kodiertem AET und einer Association-2-Filter-Regel pro calledAET — der Ansatz „Unique AET Encoding". Ein alternativer Ansatz (portbasiert, ein Router-/HAProxy-Port pro Gerätegruppe, gematcht über routerLocalPort) funktioniert ohne AET-Kodierung; hinter HAProxy verlangt er das PROXY-Protocol, damit der Router weiterhin den ursprünglichen Frontend-Port sieht.

Weg B: Q/R-Bridge (ein einziger PACS-Eintrag)

Alle Geräte schreiben ihre MoveDestination auf denselben AE-Titel um (ROUTER), und das PACS trägt dafür genau einen DICOM-Knoten. Unterscheidbar bleiben die Geräte über den Tracking-Eintrag, den CMoveTrackingStage auf Association 1 anlegt: Es ersetzt die MessageID des C-MOVE-RQ durch eine router-eigene, cluster-weit eindeutige Nummer und merkt sich, zu welcher Destination die Bilder gehören. Auf Association 2 terminiert der Router die Verbindung selbst (type: qr-bridge), liest die Nummer als MoveOriginatorMessageID aus jedem C-STORE-Command-Set und sendet das Bild an das zugehörige Gerät weiter.

Dieselben zwei Geräte (US1, US2) und dasselbe PACS — ohne zweiten PACS-Eintrag:

filter:
  # --- Association 1: Geräte -> PACS (C-MOVE-RQ), eine Regel pro Gerät ---
  - callingAET: US1
    actions:
      - "aetRewrite"             # PACS sieht CallingAET=ROUTER statt US1
      - "cmoveTracking_us1"      # MoveDestination=ROUTER, MessageID-Rewrite, Tracking
    destination:
      - "pacs1"

  - callingAET: US2
    actions:
      - "aetRewrite"
      - "cmoveTracking_us2"
    destination:
      - "pacs1"

  # --- Association 2: PACS -> Router, EINE Regel für alle Geräte ---
  - 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"   # dieselbe MoveDestination für alle Geräte
        returnDestination: "geraet_us1" # forward-Destination — dorthin gehen die Bilder
        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                 # muss mit dem store: der cmoveTracking-Actions übereinstimmen
    calling-aet: ROUTER           # CallingAET des Beins zum Gerät
    # ohne presentation-contexts: akzeptiert die Bridge nur Implicit VR LE

  geraet_us1:
    host: us1.example.org
    port: 104
    aet: US1
  geraet_us2:
    host: us2.example.org
    port: 104
    aet: US2

Auf der PACS-Seite nötig: ein einziger DICOM-Knoten-Eintrag ROUTER (AET ROUTER, Host/Port = der Router oder sein HAProxy-Frontend) — unabhängig davon, wie viele Geräte über den Router retrieven. Beim Gerät ändert sich nichts: Es sieht in den C-STOREs wieder seine eigene MessageID und seinen eigenen AE-Titel als MoveOriginator, weil die Bridge beide aus dem Tracking-Eintrag zurückschreibt.

Zu beachten:

  • Eine Regel für Association 2 genügt, und sie referenziert keine Rewrite-Action — das Ziel steht erst pro Bild fest, nicht beim A-ASSOCIATE-RQ.
  • store: muss an der Action und an der Bridge-Destination übereinstimmen; die Konfigurationsprüfung lehnt einen Mismatch ab. Im Cluster (Association 2 kann auf einer anderen Router-Instanz landen als Association 1) steht überall store: database.
  • Ohne presentation-contexts: läuft der Rückweg in Implicit VR Little Endian, das PACS dekomprimiert also. Wer Kompression erhalten will, konfiguriert die Schnittmenge der Geräteflotte — siehe type: qr-bridge.
  • Ein Gerät, das die Bilder nur empfängt (kein eigenes C-MOVE), braucht wie bisher eine normale forward-Destination — geraet_us1/geraet_us2 sind genau das und werden von der Bridge über ihren Namen aus dem Tracking-Eintrag aufgelöst.

Config-Sync — die zwei YAML-Kopien

Es gibt zwei Kopien dieser Konfigurationsdatei im Repository:

  • Master (massgebliche Quelle): dicom-router-core/src/main/resources/ch/immeditech/imconnect/dicomrouter/config/default-dicom-router.yaml
  • Beispiel-Kopie (plugins-Modul): dicom-router-plugins/src/main/resources/example-dicom-router.yaml

Das Skript sync-config.sh kopiert die Master-Datei über die Beispiel-Kopie und prüft, dass beide Dateien byte-identisch sind (cmp). Es ist eine Einweg-Kopie (master → example); es liest die Beispiel-Datei nie zurück in den Master.

./sync-config.sh

Nach jeder Änderung an der Master-Config ausführen — während der Entwicklung vor Plugin-Tests und vor dem Commit —, damit die Beispiel-Kopie aktuell bleibt. Das Skript ist eine Einweg-Kopie master → example und prüft das Ergebnis mit cmp.