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.examplebei (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:statttoken-file:— der Management-Port lief ohne Token-Auth weiter.returnPort:stattreturn-port:— der Router wählte das Gerät direkt an, am HAProxy-Rückweg vorbei.managment:stattmanagement:— 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.
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. |
tcplognur 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 deshalbtcplog: 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 4096–4194304 (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 1–2147483647 (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
reloadwendet sie auf neue Verbindungen an, während in-flight-Verbindungen den Snapshot behalten, mit dem sie gestartet sind.Hinweis:
log-folderist nicht für die normalen Anwendungslogs. SLF4J-/ Anwendungs-Logging wird überlogback.xmlkonfiguriert (interner Default im JAR, überschreibbar mitconfig/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 (DimseAuditStage → dimse_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 zumanagement.*).capture-patient-namewird 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-namegatet ausschliesslich die DIMSE-Erfassung, nichtrouter_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 samtphiFreeCause-Muster indocs/plugins.mdim 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
reloadwird 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…\Ebehandelt 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.AETitleRewriteStagech.immeditech.imconnect.dicomrouter.plugin.CMoveDestinationRewriteStagech.immeditech.imconnect.dicomrouter.plugin.WorklistAetRewriteStagech.immeditech.imconnect.dicomrouter.plugin.QrFieldRewriteStagech.immeditech.imconnect.dicomrouter.plugin.DicomTapStagech.immeditech.imconnect.dicomrouter.plugin.CMoveTrackingStagech.immeditech.imconnect.dicomrouter.plugin.charset.CharsetTranscodePluginch.immeditech.imconnect.dicomrouter.plugin.charset.CharsetDeclarePluginch.immeditech.imconnect.plugins.PduLoggingStagePluginch.immeditech.imconnect.plugins.ExamplePluginStage
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,timeoutsnur anqr-bridge;host,port,aetnur anforward(einereject- oderqr-bridge-Destination baut nie eine Verbindung auf);result,source,reason,messagenur anreject;return-port/return-vipnur anforward(siehehaproxy-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-recognizedsource = 2(ACSE):1= no-reason,2= protocol-version-not-supportedsource = 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-RJgibt es nur, wenn kein Kontext akzeptabel ist. Sendet ein PACS trotzdem auf einem abgelehnten Kontext, wird die Message mitC000beantwortet 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.1selbst inpresentation-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:
MoveOriginatorMessageIDundMoveOriginatorAETtragen 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
A700beantwortet. 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:
clientLocalIpauf 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;callingAETauf den AE-Titel des PACS festnageln — kein*und kein weitesregex:;calledAETauf den AE-Titel, unter dem der Router im PACS als DICOM-Knoten eingetragen ist (im Beispiel untenROUTER), 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-portausserhalb 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: 0ohne konfigurierteport-range→ eigener Fehler (Marker nicht auflösbar).port-rangenicht im Format"from-to"mit 1 ≤ from ≤ to ≤ 65535 → Fehler.return-portgesetzt, aber wederreturn-vipnochhaproxy-return.vip→ Fehler.return-vipohnereturn-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 wedervipnochport-rangegesetzt → Fehler (vip: ""explizit leer bleibt auch mitport-rangeein Fehler).return-port/return-vipan einertype: reject-Destination → Fehler.vip/return-vip— und ebensohostjeder forward-Destination — mit Zeichen ausserhalb des Hostname/IP-ZeichensatzesA-Za-z0-9._:-plus[](IPv6-Klammern) und%(IPv6-Zone-IDs) → Fehler: die Werte landen wörtlich in denbind-/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:
- Association 1 — Gerät → Router → PACS: das C-MOVE-RQ.
- 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 überallstore: 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 — siehetype: 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_us2sind 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.
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.