Zum Inhalt

Management, Drain & Config-Reload

Diese Doku beschreibt den Management-Layer des DICOM-Routers: die beiden zusätzlichen TCP-Listener für Zero-Downtime-Deployment hinter HAProxy, die Operator-Kommandos, den Config-Hot-Reload und das CLI-Tool. Alle Aussagen sind gegen den aktuellen Code verifiziert.

Verwandte Doku: User-Guide · Configuration · Deployment


1. Überblick: Zero-Downtime hinter HAProxy

Für rollende Deployments betreibt der Router neben dem DICOM-Listener zwei weitere TCP-Listener. Beide werden nur gestartet, wenn management.enabled: true gesetzt ist (Wiring in App.java).

Listener Default-Port Default-Bind Zweck
Agent-Check-Port 8404 0.0.0.0 HAProxy pollt den Server-Zustand (netzwerk-erreichbar)
Management-Port 8405 127.0.0.1 Operator-Kommandos (localhost-only aus Sicherheitsgründen)

Defaults stammen aus default-dicom-router.yaml:

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

Agent-Check-Port (HAProxy-Integration)

AgentCheckServer.java antwortet auf jede Verbindung mit einer einzeiligen ASCII-Antwort und schliesst danach. Die Antwort leitet sich direkt aus der RouterState-State-Machine ab:

Router-Zustand Agent-Check-Antwort Bedeutung für HAProxy
READY ready up nimmt neue Verbindungen an
DRAIN drain keine neuen Verbindungen, bestehende laufen weiter
STOPPING down Instanz fährt herunter

HAProxy-Konfiguration (Beispiel):

server dicom1 10.0.0.1:11112 agent-check agent-port 8404 agent-inter 2s

Zustandsautomat (RouterState.java)

Thread-sicher über AtomicReference<State>. Erlaubte Übergänge:

READY  --drain-->  DRAIN  --shutdown-->  STOPPING
  ^                  |
  +----resume--------+
  • drain() wechselt nur von READY nach DRAIN (CAS).
  • resume() wechselt nur von DRAIN zurück nach READY (CAS).
  • stop() wechselt aus jedem Zustand ausser STOPPING nach STOPPING.

Der DICOM-Listener wertet RouterState beim Accept aus: ist der Zustand nicht READY, wird die frisch angenommene Verbindung sofort geschlossen (SocketHandler.java, accept-Schleife). Bestehende Verbindungen laufen unverändert weiter.


2. Management-Kommandos

Der ManagementServer.java liest pro Verbindung eine Zeile im Format <kommando> [token], verarbeitet sie über ManagementCommandHandler.java und schliesst nach Antwort. Kommandos werden case-insensitiv ausgewertet; das optionale Token (siehe Sicherheits-Abschnitt unten) wird verbatim nach dem ersten Whitespace übernommen. Ohne konfigurierte Token-Authentifizierung bleibt das Protokoll strikt: jeder Trailing-Text ist ERROR unknown command (wie vor Stufe 2) — ein Client, der Tokens an einen Router ohne Auth schickt, hat Konfigurations-Drift, und die soll sichtbar sein.

Die Kommandozeile ist auf 4096 Zeichen begrenzt. Wer mehr schickt, bekommt ERROR command line too long und die Verbindung wird geschlossen, ohne dass das Kommando verarbeitet wird. Das längste legitime Kommando — Kommandowort plus Token — liegt weit darunter, die Grenze begrenzt also den Lesepuffer und nicht das Protokoll. Sie ist nötig, weil der Socket-Timeout pro read() gilt und nicht für die ganze Zeile: ein Client, der dauerhaft Bytes ohne Zeilenende schickt, löst ihn nie aus. Der Token-Check läuft erst nach dem Lesen, die Grenze greift also vor jeder Authentifizierung.

Kommando Wirkung (verifiziert gegen ManagementCommandHandler)
status Liefert state, connections (Anzahl aktiver Verbindungen), uptime, die Kennung der geladenen Config (config-hash = SHA-256 der Datei-Bytes beim letzten erfolgreichen Load/Reload, config-loaded = Zeitpunkt), die Q/R-Zähler und den Audit-Pipeline-Zustand (siehe unten).
connections Tabelle der aktiven DICOM-Assoziationen: UUID, Client, Calling-AET, Called-AET, Dauer.
drain RouterState.drain() → keine neuen Verbindungen mehr; meldet verbleibende aktive Verbindungen.
resume RouterState.resume() → nimmt wieder Verbindungen an (nur aus DRAIN).
reload Triggert Config-Re-Read (siehe Abschnitt 3); meldet OK config reloaded oder die Validierungsfehler.
shutdown RouterState.stop(), dann Graceful Shutdown in eigenem Virtual-Thread; meldet zu schliessende Verbindungen.

Beispielantworten:

$ status
state: ready
connections: 3
uptime: 2d 4h 11m 8s
config-hash: 3f9a1c0e7b2d… (SHA-256 der zuletzt erfolgreich geladenen YAML)
config-loaded: 2026-07-12T16:41:03Z
qr-untracked-moves: 0
qr-lookup-errors: 0
audit: enabled
audit-backlog: 0 events / 118 bytes
audit-shipped: 4211
audit-skipped: 0
audit-dropped: 0
audit-undecodable: 0
events-dropped: 0
events-written: 137
audit-last-ship: 2026-07-05T14:02:11.482Z

$ drain
OK drain mode activated, 3 active connections remaining

$ reload
OK config reloaded

Unbekannte oder leere Kommandos liefern ERROR unknown command: ... (es wird nur das Kommando-Wort zurück-echot, nie der Rest der Zeile — ein vertipptes Kommando darf das Token nicht reflektieren). Abgelehnte state-changing Kommandos liefern ERROR unauthorized.

Ein Kommando endet nie ohne Antwort: schlägt intern die Ermittlung der Verbindungsliste fehl, antwortet connections mit einer ERROR-Zeile, und drain/shutdown führen ihre Zustandsänderung trotzdem aus — die Verbindungszahl in der OK-Zeile degradiert dann auf unknown. Der Shutdown-Thread startet bei shutdown in jedem Fall; eine leere Antwort (Verbindungsabbruch ohne Zeile) ist damit kein regulärer Ausgang mehr.

Sicherheit: Loopback-Zwang + Token für state-changing Kommandos

Zwei unabhängige Schutzstufen; der Agent-Check-Port ist von beiden ausgenommen — er ist by design netzwerk-erreichbar (HAProxy-Polling) und liefert nur einen Zustands-String zurück.

Stufe 1 — Loopback-Zwang (Remote-Bedrohung): ConfigValidator (validateManagementBindNotRemote) erzwingt, dass management-bind-address eine Loopback-Adresse ist (127.0.0.1, ::1); eine andere Adresse führt beim Start zu einem Validierungsfehler. Zum bewussten Opt-out (z.B. Management aus einem separaten, abgesicherten Netz-Segment) gibt es management.allow-remote-management: true (Default false). Der Loopback-Zwang gilt unabhängig davon, ob ein Token konfiguriert ist.

Das Opt-out hebt die Loopback-Pflicht auf, nicht die Authentisierung: seit 1.17.0 verlangt die Kombination aus Nicht-Loopback-Bind-Adresse und allow-remote-management: true zusätzlich eine gesetzte management.token-file, sonst bricht der Start (und ein Reload) mit einem Validierungsfehler ab. Ein netzwerk-erreichbarer Port, der shutdown/drain/ reload von jedem annimmt, der ihn erreicht, ist die eine Kombination ohne legitimen Anwendungsfall — sie scheitert deshalb, statt sich auf ein Start-Banner zu verlassen. Ist die Bind-Adresse nicht loopback, loggt App.java beim Start weiterhin eine WARN, damit die Risikoentscheidung im Log sichtbar bleibt; sie unterscheidet die beiden Fälle: ohne Token den mehrzeiligen Alarm-Block, mit Token eine Zeile, die den netzgebundenen Port und die Token-Pflicht benennt.

Stufe 2 — Shared-Secret-Token (lokale Bedrohung): Auch loopback-only kann sonst jeder unprivilegierte lokale Prozess den Router stoppen (printf 'shutdown\n' | nc 127.0.0.1 8405). Mit management.token-file verlangen die state-changing Kommandos drain/resume/reload/shutdown ein Token auf derselben Zeile (<kommando> <token>); die read-only Kommandos status/connections antworten weiterhin ohne Token — status reduziert dann aber die audit-last-error-Ausgabe auf ihre Kategorie (siehe unten). Verhalten im Detail (TokenVerifier/FileTokenVerifier, ManagementCommandHandler):

  • Ohne token-file (Default): heutiges Verhalten ohne Auth, aber einmaliger WARN beim Start („management port accepts state-changing commands … from any local process"). Trailing-Text (auch ein Token) wird auf state-changing und unbekannten Kommandos nicht stillschweigend geschluckt, sondern als ERROR unknown command abgelehnt — sichtbare Konfigurations-Drift statt überraschend ausgeführter Kommandos. Ausnahme sind die read-only Kommandos status/connections: dort wird ein mitgesendeter Token ignoriert (Antwort in der reduzierten Sicht) — dicom-router-ctl status schickt den Token, sobald lokal eine Token-Datei liegt, und ein Fehler hier bräche Monitoring und dicom-router-sync gegen Router ohne Token-Auth.
  • Token-Semantik: Der Token ist die erste non-blank Zeile der Datei, getrimmt (FileTokenVerifier.readTokenFromFile — dieselbe Normalisierung nutzt auch der ConfigValidator, eine Datei, die die Validierung besteht, authentifiziert also garantiert). Weitere Zeilen (z.B. Kommentare) werden ignoriert; Dateien über 64 KB werden als Fehlkonfiguration abgelehnt.
  • Mit token-file: Beim Start validiert ConfigValidator, dass die Datei existiert, lesbar, ≤ 64 KB und nicht leer (erste non-blank Zeile vorhanden) ist — sonst Startfehler. Der Token-Wert wird bei jedem Kommando frisch aus der Datei gelesen: Rotation = Datei neu schreiben, kein Restart/Reload nötig (der Datei-Pfad dagegen ist restart-pflichtig, siehe Abschnitt 3). Lesefehler zur Laufzeit (Datei gelöscht/unlesbar/leer) lehnen das Kommando ab und loggen ERROR (fail-closed). Fehlendes/falsches Token → ERROR unauthorized + WARN mit Peer-Adresse. Der Vergleich läuft konstantzeit über MessageDigest.isEqual (kein Timing-Leak). Auf status ist das Token optional, wird aber geprüft, wenn es mitgeschickt wird: mit gültigem Token rendert status die volle audit-last-error-Zeile, ohne Token nur deren Kategorie, ein falscher Token ergibt ERROR unauthorized wie bei den state-changing Kommandos. Das zugehörige WARN ist auf status auf eines pro Minute gedrosselt (Rest DEBUG): status wird gepollt, eine veraltete Token-Datei im Monitoring würde sonst pro Poll eine WARN-Zeile samt router_events-Eintrag erzeugen; auf den state-changing Kommandos bleibt jedes WARN erhalten. Ein Trailing-Token auf connections wird weiterhin ignoriert.
  • Datei-Hygiene: Ist die Token-Datei group/other-lesbar, loggt FileTokenVerifier beim Start einen WARN (kein Fehler — analog ssh-Key-Hygiene).

Setup:

# Token erzeugen, nur für den Router-User lesbar (install -m 600-Äquivalent):
umask 077; openssl rand -hex 32 > /opt/imconnect/config/management.token
chown imconnect:imconnect /opt/imconnect/config/management.token

# dicom-router.yaml:
#   management:
#     token-file: /opt/imconnect/config/management.token

# Rotation ohne Restart — einfach die Datei neu schreiben:
umask 077; openssl rand -hex 32 > /opt/imconnect/config/management.token.new
mv /opt/imconnect/config/management.token.new /opt/imconnect/config/management.token

bin/dicom-router-ctl findet die Datei unter dem Default-Pfad /opt/imconnect/config/management.token automatisch (sonst DICOM_ROUTER_TOKEN_FILE setzen, siehe Abschnitt 4) und hängt das Token an state-changing Kommandos an — damit funktioniert auch systemctl stop (ExecStop = dicom-router-ctl drain-and-shutdown) unverändert. Auch status schickt das Token mit, wenn die Datei lesbar ist (volle audit-last-error-Zeile); ist sie nicht lesbar, läuft status tokenfrei weiter und zeigt die Kategorie. Lehnt der Router einen mitgeschickten Token ab (veraltete oder fremde Token-Datei), fasst status einmalig tokenfrei nach und zeigt die reduzierte Sicht — mit einer Hinweiszeile auf stderr, damit stdout-Parser unberührt bleiben. Die Warteschleife von drain-and-shutdown fragt den Status bewusst tokenfrei ab — sie liest nur die connections:-Zeile, und eine mitten im Drain rotierte Token-Datei darf das Warten nicht abbrechen.

Q/R-Zähler

Die beiden qr--Zeilen stehen immer da, auch auf einem Knoten ohne Q/R-Konfiguration (dann dauerhaft 0). Sie zählen knotenweit seit Prozessstart und werden von einem Reload nicht zurückgesetzt.

Zeile Bedeutung
qr-untracked-moves C-MOVE-RQs, die diesen Knoten ohne Tracking-Eintrag verlassen haben: keine MoveDestination, kein passendes Rewrite konfiguriert, kein Store, oder die Zuteilung einer Router-MessageID ist gescheitert. Der Retrieve läuft dann an der Bridge vorbei — die Bilder gehen dorthin, wohin das Gerät sie adressiert hatte. Ein wachsender Wert heisst: Konfiguration prüfen, oder der Tracking-Raum ist voll (siehe Kapazität des Tracking-Raums).
qr-lookup-errors Tracking-Abfragen auf dem Rückweg, die der Store nicht beantworten konnte — Datenbank weg, Pool erschöpft, oder gar kein Store auf der Verbindung. Gezählt wird pro fehlgeschlagener Abfrage, nicht pro verworfenem Objekt: nach dem ersten Fehler überspringt die Bridge den Store für den Rest der Association und beantwortet jede weitere Message mit C000. Jeder Zähler > 0 bedeutet also, dass Bilder eines Retrieves verworfen wurden. Sollte 0 sein.

Audit-Zeilen im status

Bei aktiviertem Audit-Trail (siehe Configuration — audit) zeigt status den Zustand des Spool-/Shipper-Pfads; die beiden events--Zeilen kommen nur bei zusätzlich aktiviertem audit.events dazu und gehören zum davon unabhängigen Events-Writer:

Zeile Bedeutung
audit Vier Zustände: enabled (Writer und Shipper laufen), disabled (audit.enabled: false oder DB_ENABLED=false), failed (eingeschaltet, aber der Spool liess sich nicht öffnen — es wird nichts aufgezeichnet, die Ursache steht in audit-last-error) und ended (lief in diesem Prozess und ist gestoppt, die Zähler sind Endstände). failed und ended haben früher wie disabled ausgesehen; ein voller Datenträger war damit von einem bewusst abgeschalteten Audit-Trail nicht zu unterscheiden. Bei failed erscheinen keine Audit-Zählerzeilen — sie wären alle 0 und würden „nichts verloren" behaupten; die events--Zeilen stehen weiterhin da, denn dieser Writer hängt nicht am Spool. ended ist naturgemäss nur zu sehen, solange der Prozess noch antwortet — praktisch im Drain-/Stop-Fenster; danach antwortet der Management-Port nicht mehr.
audit-backlog Noch nicht in die DB versendete Events (Anzahl / Spool-Bytes). Wächst während eines DB-Ausfalls.
audit-shipped Events, die der Shipper aus dem Spool genommen und an die Datenbank übergeben hat, ohne dass diese einen Fehler gemeldet hat. Nicht gleichbedeutend mit „gespeichert" — dafür siehe audit-skipped.
audit-skipped Von den versendeten Events diejenigen, die die Datenbank nicht ablegen konnte, weil die Zeile fehlt, an der sie hängen (die Association zu einer DIMSE-Operation, der Listener zu einer Association). Das ist die Folgewirkung eines früheren Verlusts oder einer Retention-Lücke: das Event selbst hat den Spool unversehrt verlassen, es fehlt der Anker in der Datenbank. Bewusst nicht in audit-dropped eingerechnet — die beiden Ursachen liegen an verschiedenen Stellen. Sollte 0 sein.
audit-dropped Endgültig verlorene Events. Fünf Wege führen hierher: das spool-max-mb-Cap; ein Schreibfehler des Spools auf der Platte; eine volle Übergabe-Queue (Writer hängt); ein Event, das sich nicht in den Spool schreiben liess, weil schon das Kodieren scheiterte; und ein Event, das der Shipper nach wiederholten Einzelversuchen als „poison" verwirft. Sollte 0 sein.
audit-undecodable Spool-Zeilen, die der Shipper nicht dekodieren konnte und übersprungen hat. Kein Verlustpfad im Sinne von audit-dropped (die Zeilen zählen auch nicht mehr zum Backlog), aber der direkte Hinweis auf einen Binary-Rollback: eine ältere Version trifft auf Events, die eine neuere geschrieben hat. Sollte 0 sein.
events-dropped Nur bei audit.events.enabled: Events, die der zentrale Events-Writer verloren hat — volle Übergabe-Queue, verworfener Batch, Emit nach Writer-Ende oder Fehler beim Erfassen im Appender (best-effort, es wird nie erneut versucht). Bewusst nicht mitgezählt: Zeilen, die der Re-Entrancy-Guard unterdrückt — das sind Meldungen des Persistenz-Stacks aus dem eigenen Schreibpfad (Schutzverhalten, kein Verlust von Router-Events). Dauerhaft wachsend heisst: DB-Sink hängt oder queue-size zu klein. Sollte 0 sein.
events-written Nur bei audit.events.enabled: erfolgreich nach router_events geschriebene Events seit Prozessstart.
audit-last-ship Zeitpunkt des letzten erfolgreichen DB-Batches (never vor dem ersten).
audit-last-error Während eines Ausfallfensters der letzte Sink-Fehler (z.B. connection refused); bei audit: failed der Grund, aus dem sich der Spool nicht öffnen liess. Einzeilig und normalisiert: Exception-Klasse, SQLState und die erste Zeile der Meldung (gekürzt), gefolgt von der eigentlichen Ursache (| caused by …, gleiche Form). Die Folgezeilen eines PostgreSQL-Fehlers (Detail:/Where:/Hint:) bleiben weg — dort stehen die Spaltenwerte der betroffenen Zeile. Die volle Zeile ist token-pflichtig (status <token>): schon die erste Meldungszeile kann den DB-Benutzer und Host:Port tragen (Verbindungsfehler) oder den absoluten Spool-Pfad (Spool-Öffnungsfehler), und der Management-Port kann remote gebunden sein. Ohne Token (oder ohne konfiguriertes token-file) erscheint nur die Kategorie — Exception-Klasse(n) und SQLState, keine Meldungsteile; der volle normalisierte Text steht unverändert im Anwendungslog. dicom-router-ctl status schickt das Token automatisch mit, wenn die Token-Datei konfiguriert und lesbar ist.

Ein wachsender Backlog mit audit-last-error bedeutet DB-Ausfall: Routing läuft unverändert weiter, der Spool puffert, nach Wiederherstellung wird vollständig und ohne Duplikate nachgesendet — auch über einen Router-Neustart hinweg.


3. Config-Reload im Detail

Der Reload ist als atomarer Config-Swap implementiert (siehe SocketHandler.java und ConfigDiff.java). Ein Neustart ist nicht nötig, solange nur hot-reloadbare Felder geändert wurden.

Ablauf (App.reloadConfig())

Serialisiert über einen reloadLock:

  1. Reload ist in READY und DRAIN erlaubt, im Zustand STOPPING abgelehnt.
  2. YAML wird neu von Disk geladen (configLoader.load()). Strukturell ungültige Configs oder I/O-Fehler werden als Validierungsfehler zurückgegeben — die alte Config bleibt aktiv. Fehlt die Config-Datei, wird der Reload ebenso abgelehnt: aus dem eingebauten Template wird nichts angelegt (das Bootstrap passiert nur beim ersten regulären Start), die Betreiber-Datei wird nie erzeugt oder überschrieben.
  3. ConfigDiff.validateReloadable(oldConfig, newConfig) prüft auf restart-pflichtige Änderungen; zusätzlich werden Plugin-Configs via PluginFactory.validatePluginConfigsForReload(...) validiert.
  4. Nur wenn keine Fehler vorliegen: handler.setConfig(newConfig).

Jeder Fehler lässt die zuvor aktive Config unverändert. Dazu zählt seit 1.17.0 auch jeder Schlüssel, der auf kein Feld passt — in einem Abschnitt oder im config:-Block eines Plugins. Ein Reload mit einem Tippfehler wird also abgelehnt, statt die betroffene Einstellung stillschweigend auf den Default zurückzusetzen; die alte Config läuft weiter.

Atomarer Swap (SocketHandler.java)

Die aktive Config liegt in einer AtomicReference<ApplicationConfig> configRef. setConfig(newConfig) ersetzt sie atomar. In handleClient(...) snapshottet jede Verbindung beim Accept einmal configRef.get() in eine lokale Variable und arbeitet durchgehend mit diesem Snapshot.

Folge: Neue Verbindungen nach dem Swap sehen die neue Config; in-flight-Verbindungen behalten ihren Snapshot, auch wenn die Config mitten in der Assoziation getauscht wird.

Hot-reloadbar vs. restart-required

Die massgebliche Feldliste steht in ConfigDiff.java. Nur die folgenden Felder werden als restart-pflichtig markiert (sie werden beim Boot gebunden: Server-Socket-Binding, Management-Server-Ports, AE-Title in der Listener-DB-Tabelle):

Restart-required (Reload wird mit requires restart abgelehnt):

  • router.listener.host
  • router.listener.port
  • router.listener.aet
  • management.enabled
  • management.agent-check-port
  • management.agent-check-bind-address
  • management.management-port
  • management.management-bind-address
  • management.allow-remote-management
  • management.token-file (der Pfad; der Token-Wert in der Datei wird pro Kommando frisch gelesen und rotiert ohne Restart)
  • audit.enabled
  • audit.spool-path
  • audit.spool-max-mb
  • audit.batch-size
  • audit.flush-interval
  • audit.events.enabled
  • audit.events.level
  • audit.events.queue-size
  • store: einer CMoveTrackingStage-Action (memorydatabase) — die beiden Tracking-Stores sind zwei getrennte Zustände; ein Wechsel im laufenden Betrieb liesse jedes bereits verfolgte C-MOVE ins Leere laufen. Gemeldet als actions.<name>.store: requires restart; die Meldung nennt den betroffenen Eintrag über sein returnDestination, weil eine Action mehrere CMoveTrackingStage-Einträge tragen kann. Geschlüsselt wird bewusst über diese identifizierende Eigenschaft statt über die Position in der Liste: ein Store-Wechsel zusammen mit einer Umsortierung entkommt so der Restart-Pflicht nicht, und umgekehrt löst weder ein davor eingefügter noch ein eingeschalteter Eintrag einen Fehlalarm für die übrigen aus. Ebenfalls restart-pflichtig, sobald ein Eintrag verschwindet (umbenannt oder entfernt): dann muss ein neu erschienener Eintrag mit demselben Store 1:1 seine Stelle einnehmen. Bleibt ein verschwundener Eintrag ungedeckt — oder kommt im selben Reload ein neuer Eintrag mit einem anderen Store dazu —, ist es restart-pflichtig, weil ein Umbenennen von einem Entfernen nicht zu unterscheiden ist und ein Geschwister-Eintrag mit demselben Store sonst für den Wechsel eines umbenannten aufkäme. Reine Ergänzungen (nichts verschwindet) bleiben hot: beide Stores existieren ab dem Start.
  • store: einer type: qr-bridge-Destination — dieselbe Begründung von der Leseseite her: ein Wechsel liesse jeden Lookup für bereits verfolgte Retrieves ins Leere laufen. Gemeldet als destinations.<name>.store: requires restart. Ebenfalls restart-pflichtig, sobald eine Bridge verschwindet, ohne dass eine neu erschienene mit demselben Store 1:1 an ihre Stelle tritt — dieselbe Regel wie oben, samt der konservativen Auflösung, wenn im selben Reload eine Bridge verschwindet und eine mit anderem Store dazukommt. Alle übrigen Bridge-Felder (calling-aet, allowed-calling-aet, presentation-contexts, max-drain-bytes, timeouts.connect/.response/.release) sind hot-reloadbar — sie werden pro eingehender Association aus dem Snapshot gelesen.

Eine reine Ergänzung mit store: database ist dabei nur auf Knoten hot, die auch einen datenbank-gestützten Tracking-Store haben: hat der Knoten keinen (Datenbank deaktiviert oder das JDBC-Wiring beim Start fehlgeschlagen), wird der Reload abgelehnt statt still auf den In-Memory-Store zu degradieren — die Store-Instanzen sind boot-gebunden, also Eintrag auf store: memory stellen oder die Datenbank in Ordnung bringen und den Knoten neu starten. Dieselbe Regel verhindert seit 1.16.0 bereits den Boot mit store: database ohne Datenbank.

Zusätzlich: wird der gesamte management-Block hinzugefügt oder entfernt, meldet ConfigDiff management.enabled: requires restart (section added or removed); analog für den audit- und den audit.events-Block.

Hot-reloadbar (alles andere, nicht in ConfigDiff geprüft):

  • router.listener.socketTimeout — wird pro Verbindung aus dem Snapshot gelesen (clientSocket.setSoTimeout(...)) und greift damit automatisch für neue Verbindungen. Derselbe Wert begrenzt seit 1.17.0 auch einen einzelnen blockierenden Write in Richtung Client oder Ziel (SO_TIMEOUT deckt nur Reads ab).
  • router.listener.connectTimeout — das Zeitbudget für Namensauflösung plus TCP-Handshake beim Verbindungsaufbau zu einer Forward-Destination; wird pro Verbindung aus dem Snapshot gelesen (ConnectionHandler.connect).
  • router.listener.tcplog
  • router.memory-threshold / router.materialization-threshold / router.max-elements — die Streaming-Objektmodell-Schwellen; werden pro Verbindung aus dem Snapshot gelesen (DicomObjectPipeline / DicomStage) und greifen damit für neue Verbindungen.
  • router.max-pending-assemblies / router.assembly-idle-timeout / router.max-object-bytes — die Limits für halb-offene P-DATA-Assemblies und der Deckel je assembliertem Data Set; werden ebenfalls pro Verbindung aus dem Snapshot gelesen (DicomStage).
  • actions (Plugin-Instanzen und deren config:-Parameter) — mit einer Ausnahme: der store:-Schalter der CMoveTrackingStage ist restart-pflichtig (siehe oben)
  • filter (Routing-/Filterregeln)
  • destinations (forward-/reject-/qr-bridge-Ziele, inkl. return-port/return-vip) — mit einer Ausnahme: das store: einer qr-bridge-Destination ist restart-pflichtig (siehe oben)
  • haproxy-return (globale Default-VIP und port-range für HAProxy-Rückwege, siehe configuration.md)
  • proxyprotocol (Mappings)
  • audit.capture-patient-name — wird pro Erfassung aus dem Verbindungs-Snapshot gelesen (die übrigen audit-Felder binden Spool-Writer/Shipper beim Start und sind restart-pflichtig, siehe oben; audit.events.* bindet zusätzlich den Writer-Thread und den beim Start eingehängten Logback-Appender)

Hinweis: socketTimeout, connectTimeout und tcplog liegen unter router.listener, gehören aber bewusst nicht zur restart-pflichtigen Liste — ConfigDiff vergleicht unter router.listener ausschliesslich host, port und aet.


4. CLI-Tool bin/dicom-router-ctl

Bash-Wrapper, der ein einzeiliges Kommando an den Management-Port schickt.

Für die Verbindung nutzt das Skript bevorzugt Bashs eingebautes /dev/tcp — das braucht kein zusätzliches Paket auf dem Host. Ist die Netz-Umleitung im Bash-Build abgeschaltet, fällt es auf nc zurück und ruft es nur mit -w auf, dem einzigen Schalter, den alle Varianten kennen (netcat-openbsd, nmap-ncat, busybox). Ein installiertes netcat ist damit nicht mehr Voraussetzung, und die Variante auf dem Host spielt keine Rolle mehr.

Umgebungsvariablen:

Variable Default Zweck
DICOM_ROUTER_HOST localhost Management-Host
DICOM_ROUTER_MGMT_PORT 8405 Management-Port
DICOM_ROUTER_TIMEOUT 5 Sekunden, die auf Verbindung und Antwort gewartet wird
DICOM_ROUTER_POLL_INTERVAL 2 Poll-Intervall (nur drain-and-shutdown)
DICOM_ROUTER_TOKEN_FILE /opt/imconnect/config/management.token (falls vorhanden) Token-Datei für state-changing Kommandos und die volle audit-last-error-Zeile von status (siehe Abschnitt 2)

Befehle:

dicom-router-ctl status              # Zustand, Verbindungszahl, Uptime
dicom-router-ctl connections         # aktive DICOM-Verbindungen auflisten
dicom-router-ctl drain               # keine neuen Verbindungen annehmen
dicom-router-ctl resume              # Annahme wieder aufnehmen
dicom-router-ctl reload              # Config neu einlesen (listener/management/audit → Restart nötig)
dicom-router-ctl shutdown            # SOFORT stoppen — laufende Übertragungen werden getrennt
dicom-router-ctl drain-and-shutdown  # verlustfrei stoppen: drain, auf 0 Verbindungen warten, dann shutdown

Exit-Codes der zustandsändernden Kommandos (drain, resume, reload, shutdown): 0 = ausgeführt, 1 = Router nicht erreichbar oder Antwort unbrauchbar, 3 = der Router hat geantwortet und abgelehnt — ein zurückgewiesener Reload, ein fehlendes oder vom Router abgelehntes Token, ein shutdown während bereits gestoppt wird. Eine konfigurierte, aber nicht lesbare Token-Datei endet dagegen mit 1 — der Router wurde gar nicht erst gefragt. (Fehlt schlicht jede Token-Datei, geht das Kommando ohne Token hinaus; ein token-geschützter Router lehnt dann mit ERROR unauthorized ab → 3.) Bis 1.17.1 endeten die 3er-Fälle mit 0, eine Automatisierung las also Erfolg, während die alte Konfiguration weiterlief (#343). Zwei Ausnahmen bleiben bei 0, weil der gewünschte Zustand bereits erreicht ist: ein drain auf einen bereits drainenden Router und ein resume auf einen bereits bereiten. Damit bricht ein wiederholter Aufruf in einer Retry-Schleife nicht.

3 und nicht 1, damit „abgelehnt" von „nicht erreichbar" unterscheidbar bleibt: dicom-router-sync braucht genau diese Unterscheidung, um eine rein restart-pflichtige Antwort weiterhin als legitimen Ausgang zu behandeln.

shutdown vs. drain-and-shutdown: shutdown beendet den Router sofort — bestehende DICOM-Associations (auch ein gerade laufender C-STORE-Transfer) werden dabei geschlossen. drain-and-shutdown stoppt verlustfrei: erst keine neuen Verbindungen mehr (der HAProxy-Agent-Check meldet drain, der Load Balancer nimmt die Instanz aus der Rotation), dann warten bis alle laufenden Übertragungen fertig sind, erst dann der Stopp. Für Wartung und Deployments ist drain-and-shutdown der richtige Weg.

Zeitbudget des Teardowns. Was nach dem shutdown-Kommando passiert, hat ein festes Budget: der Router schliesst alle noch offenen Verbindungen parallel und rechnet die Wartezeiten gegen eine gemeinsame Deadline von 30 Sekunden — nicht je Verbindung. Danach folgen die Tap-Shipper, der Drain der Audit-Queue in den Spool und der Event-Writer mit je wenigen Sekunden. Der Teardown kostet also im schlechtesten Fall rund eine Minute, unabhängig davon, wie viele Verbindungen hängen. Das TimeoutStopSec der systemd-Unit (300 s) begrenzt demgegenüber den gesamten ExecStop — und der besteht überwiegend aus dem Drain, der auf laufende Associations wartet. Wer Associations über fünf Minuten fährt, erhöht dort; ein kleinerer Wert beschleunigt den Teardown nicht, er kürzt nur den Drain.

Restart: Es gibt bewusst kein restart-Kommando — das CLI spricht über den Management-Port mit dem laufenden Prozess und kann keinen neuen starten (das ist Sache des Prozess-Managers, und DICOM_ROUTER_HOST kann auf einen Remote-Host zeigen). Unter systemd: systemctl restart dicom-router — die Unit drained dabei automatisch zuerst (ExecStop=dicom-router-ctl drain-and-shutdown) und startet dann den neuen Prozess. Ein bewusstes dicom-router-ctl shutdown bleibt dagegen unten (Restart=on-failure greift nur bei Fehlern).

connections wird 1:1 an den Server durchgereicht und berührt die Token-Datei nie; status hängt das Token an, wenn die Token-Datei konfiguriert bzw. am Default-Pfad vorhanden und lesbar ist (volle audit-last-error-Zeile, siehe Abschnitt 2) — ist sie nicht lesbar, läuft status tokenfrei weiter und zeigt die Kategorie, ein Fehler ist das nicht (beide Kommandos funktionieren also weiterhin für User ohne Leserecht auf die 0600-Datei); drain/resume/reload/shutdown bekommen das Token angehängt, wenn eine Token-Datei konfiguriert bzw. am Default-Pfad vorhanden ist (ohne Token-Datei wie bisher ohne Token — für Setups ohne Auth). Das Token wird dabei pro Kommando frisch aus der Datei gelesen (erste non-blank Zeile, getrimmt — wie der Server), sodass eine Rotation während eines langen Drain-Waits den finalen shutdown nicht bricht. Eine gesetzte, aber unlesbare DICOM_ROUTER_TOKEN_FILE bricht bei state-changing Kommandos mit Fehler ab.

Ist der Router nicht erreichbar, meldet jedes Kommando das auf stderr („router not reachable at <host>:<port>") und endet mit Exit 1 — statt kommentarlos nichts auszugeben. drain-and-shutdown ist clientseitig orchestriert:

  1. sendet drain (toleriert „already draining"); antwortet der Router hier nicht, ist nichts gedrained worden → Fehlermeldung + Exit 1,
  2. pollt in POLL_INTERVAL-Sekunden-Schritten status, bis connections: 0. Ab hier gilt ein nicht mehr erreichbarer Router als erfolgreicher Shutdown; eine Antwort ohne verwertbare connections:-Zeile dagegen als Fehler (Exit 1), damit ein abgeschnittener Status nicht in eine Endlosschleife bis zum systemd-Stop-Timeout läuft,
  3. sendet shutdown und prüft die Antwort: ERROR ... (z.B. unauthorized) → Fehlermeldung + Exit 1; nicht mehr erreichbar → Shutdown gilt als erfolgt.

Cluster-Verteilung: bin/dicom-router-sync

Für Multi-Host-Betrieb verteilt dicom-router-sync die auf dem Bearbeitungs-Host gepflegte config/dicom-router.yaml an alle Hosts aus config/cluster-hosts und reloaded sie — mit automatischer Return-Port-Vergabe (--allocate-return-ports, ersetzt return-port: 0-Marker vor Snapshot und Verifikation; ohne Marker No-Op, ohne greifbares Java/JAR bei vorhandenen Markern harter Abbruch), lokalem Verlaufs-Snapshot (config/config-history/), Preflight-Validierung ohne Router (--validate-config, best-effort wenn Java/JAR greifbar), Canary-Reload auf dem lokalen Router (Validierungsfehler brechen ab, bevor ein anderer Host angefasst wird), atomarer Kopie via ssh, Reload über den ctl des jeweiligen Ziel-Hosts (Management-Port bleibt loopback-only) und zweistufiger Verifikation: SHA-256 der Datei plus — wo der Router es meldet — Abgleich gegen den config-hash aus status (beweist den geladenen Stand, Tabelle zeigt dann MATCH (loaded)). Reihenfolge: allocate → Snapshot → --validate-config → Canary → Verteilung — Verlauf und SHA-Verifikation erfassen so immer den allozierten Stand. Modi: --check (nur Drift-Prüfung), --no-reload (nur verteilen, Allokation läuft auch hier). Anwendersicht: user-guide.md, Abschnitt 4.

Der Canary kennt vier Ausgänge: OK → verteilen und überall reloaden; ein ERROR unauthorized → Abbruch mit eigenem Hinweis auf die Token-Datei (ein Credentials-Problem, kein Config-Problem — nichts wird verteilt); eine Antwort, deren Fehlerzeilen alle : requires restart lauten → verteilen, aber keinen Reload auslösen (die Rolling-Restart-Anleitung wird ausgegeben); alles Übrige → Abbruch, bevor ein anderer Host angefasst wird. Die Prüfung sieht jede Fehlerzeile einzeln an, weil „restart-pflichtig" die Datei an alle Hosts verteilt: eine Ablehnung wegen eines vertippten Plugin-Klassennamens nennt ebenfalls requires restart (im Fliesstext ihrer Meldung), und eine gemischte Antwort — echte restart-pflichtige Änderung neben einem kaputten Plugin-Eintrag — ist eine Ablehnung und keine restart-pflichtige Config.

Ein Grenzfall auf heterogenen Clustern (Knoten mit und ohne Datenbank): der Canary-Reload prüft nur den lokalen Router. Eine Config mit store: database kann dort gültig sein, während ein Zielknoten ohne Datenbank den Reload ablehnt — die bereits verteilte Datei bleibt auf diesem Knoten liegen, und weil der Start store: database ohne Datenbank genauso verweigert wie der Reload, würde sie beim nächsten Restart den Boot verhindern. Configs mit store: database deshalb nur auf Cluster verteilen, deren Knoten alle eine Datenbank haben.

Validate-only: --validate-config

Die Konfiguration lässt sich ohne laufenden Router prüfen — mit exakt derselben Validator-Kette wie Start und Reload (YAML-Parse, Core-Validierung, Plugin-Config-Validierung), ohne Seiteneffekte (keine Listener, keine DB; eine fehlende Datei wird nie aus der Vorlage angelegt):

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

Aus dem Install-Root ausführen (bzw. in Automatisierung wie Ansible chdir auf das Install-Verzeichnis setzen): externe Plugin-Klassen werden aus einem plugins/-Verzeichnis geladen, das relativ zum Arbeitsverzeichnis aufgelöst wird — derselbe Grund, aus dem start.sh ein cd macht und die systemd-Unit WorkingDirectory= setzt. Aus einem anderen Verzeichnis liefert die Prüfung bei Configs mit externen Plugins falsch-negative Ergebnisse (ClassNotFoundException).

Die Prüfung beurteilt die Datei, nicht den Rechner, auf dem sie läuft: Sie braucht weder eine Datenbank noch die JDBC-Umgebung und ist damit auf jedem Arbeitsplatz aussagekräftig — auch für eine Config, die auf einen anderen Knoten verteilt wird. store: database ist deshalb hier immer gültig; ob dieser Knoten tatsächlich eine Datenbank hat, entscheiden Start und Reload (beide verweigern sonst, siehe Konfiguration).

Exit-Codes: 0 = gültig, 1 = ungültig (Fehlerliste auf stderr), 2 = Datei nicht gefunden. dicom-router-sync nutzt das automatisch als Preflight (und startet die JVM dafür selbst im Install-Root, egal von wo es aufgerufen wird).

HAProxy-Rückwege generieren: --render-haproxy

Der Router generiert die HAProxy-Konfiguration für die konfigurierten Rückwege (haproxy-return + return-port an den Destinations) direkt aus seiner eigenen YAML. Das Subkommando erzeugt nur die Datei — die Verteilung auf die HAProxy-Hosts samt Reload übernimmt bin/dicom-router-haproxy-sync (nächster Abschnitt) oder weiterhin der Betreiber selbst:

cd /opt/imconnect
java -jar dicom-router-core-*.jar --render-haproxy config/dicom-router.yaml [ausgabe-datei]

Ohne Ausgabe-Datei geht die Ausgabe nach stdout, und dort steht dann die gerenderte Config. Zwei Quellen, die sich vorher davorschoben, sind abgeräumt: für die Dauer dieses Laufs wird alles abgehängt, was das Logging nach stdout schreiben würde (#346) — vorher landete die INFO-Zeile des Config-Ladens als erste Zeile in der umgeleiteten Datei —, und Logbacks eigene Statusmeldungen gehen nach stderr statt nach stdout (#363). Die Statusmeldungen erscheinen, sobald an der Logging-Konfiguration selbst etwas schiefgeht: ruft ein Konto ohne Schreibrecht auf LOG_DIR das Kommando direkt per java -jar auf, scheitert der Datei-Appender — die Statusausgabe samt Stacktrace stand dann vor der ersten Config-Zeile. HAProxy lehnte beides mit „unknown keyword" ab. Diagnose bleibt auf stderr sichtbar — die Skip-Hinweise ohnehin, und ein Console-Appender, der bereits auf stderr konfiguriert ist, wird nicht angefasst.

Für Automatisierung bleibt die Form mit Ausgabe-Datei die verlässlichere: sie schreibt die Config direkt und hängt gar nicht an stdout — auch dann nicht, wenn ein eigenes -Dlogback.statusListenerClass die Statusmeldungen bewusst wieder auf stdout legt. dicom-router-haproxy-sync nutzt deshalb diese Form.

Die Config wird zuerst mit derselben Validator-Kette wie bei --validate-config geladen und geprüft; der Install-Root-Hinweis von oben gilt genauso (externe Plugins werden relativ zum Arbeitsverzeichnis aufgelöst). Exit-Codes: 0 = generiert, 1 = Config ungültig oder der Name einer gerenderten Destination ist kein gültiger HAProxy-Proxy-Name (erlaubt: A-Za-z0-9-_.: — es wird nie stillschweigend umbenannt), 2 = Datei nicht gefunden. Übersprungene Destinations (siehe unten) sind kein Fehler.

Die Ausgabe ist deterministisch (nach Destination-Name sortiert, kein Zeitstempel — identische Eingabe ergibt eine byte-identische Datei, diff-freundlich für Config-Management), ein listen-Block pro gerenderter Destination, jeweils mit explizitem mode tcp (die Datei ist für ein zusätzliches -f gedacht, die Defaults des Ziel-HAProxy sind unbekannt) und ohne Health-Check (TCP-Probes erzeugen auf DICOM-Geräten Log-Spam und Fehlalarme):

# generated by dicom-router --render-haproxy — do not edit
# source: dicom-router.yaml

listen dicom_return_ct_a
    mode tcp
    bind 10.0.0.10:5013
    server ct_a 192.168.10.5:104

Was gerendert wird: die port-range als Grenze

Gerendert wird eine forward-Destination nur, wenn ihr return-port innerhalb von haproxy-return.port-range liegt. Die Range trennt damit die vom Router verwalteten Rückwege von denen, die der Betreiber selbst in seiner HAProxy-Config führt: die generierte Datei wird bei jedem Sync-Lauf komplett überschrieben, ein handgepflegter listen-Block mit demselben bind würde kollidieren (cannot bind socket beim HAProxy-Start).

Ein return-port ausserhalb der Range bleibt gültige Konfiguration und wird zur Laufzeit unverändert über vip:return-port angewählt — nur der listen-Block dazu gehört in die HAProxy-Config des Betreibers. Übersprungene Destinations werden an zwei Stellen sichtbar, mit unterschiedlichem Wortlaut:

In der generierten Datei stehen sie als Kommentarblock (Teil der Datei, wird also mitverteilt und geht in die Prüfsumme ein):

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

Auf stderr kommt zusätzlich eine einzeilige Zusammenfassung. Sie ist nicht Teil der Datei — bei Ausgabe nach stdout bleibt sie dadurch sichtbar, und die Datei selbst bleibt byte-identisch für den Drift-Check. dicom-router-haproxy-sync fängt stderr des Render-Aufrufs ein und gibt die Zeile eingerückt aus:

NOTE: 1 destination(s) skipped (return-port outside port-range 5000-5999) — these return paths are manual HAProxy entries: ct_b

Die Prüfung ist rein portbasiert und VIP-unabhängig (die port-range ist global definiert). Ist keine port-range konfiguriert, gibt es keine Grenze und jede forward-Destination mit return-port wird gerendert. Für übersprungene Destinations greift die Proxy-Namensprüfung nicht — sie werden nie zu einem HAProxy-Proxy-Namen; ihr Name erscheint im Kommentar stattdessen konservativ bereinigt (alles ausserhalb A-Za-z0-9-_.: wird zu ?). VIP und Host in derselben Zeile werden ebenso bereinigt, dort sind zusätzlich [, ] und % erlaubt (IPv6-Klammern und Zone-IDs — derselbe Zeichensatz, den die Validierung für Adressen ohnehin verlangt).

Ohne eine einzige gerenderte Destination entsteht nur der Header plus ein Kommentar, der mit # no destinations with return-port beginnt (Exit 0) — mit dem Zusatz inside port-range 5000-5999, wenn Destinations mit return-port existieren, aber alle ausserhalb der Range liegen.

HAProxy-Rückwege verteilen: bin/dicom-router-haproxy-sync

Verteilt die generierte Rückweg-Config auf die HAProxy-Hosts und reloaded sie — nach dem Muster von dicom-router-sync: verteilen, prüfen, reloaden, verifizieren. Ablauf: lokal rendern (--render-haproxy, JVM im Install-Root; ohne greifbares Java/JAR harter Abbruch — ohne Render gibt es nichts zu verteilen; die deterministische Ausgabe macht den SHA-Drift-Check byte-genau) → Drift-Check pro Host (SHA-256; unverändert → Kopie und Preflight entfallen, der Reload läuft trotzdem: „Datei stimmt" heisst nicht „Config ist geladen" — ein früherer Lauf kann am Reload gescheitert oder mit --no-reload gelaufen sein, und der Seamless-Reload ist idempotent und billig; nur --no-reload überspringt ihn auch hier) → Kopie via ssh in eine Punkt-Temp-Datei neben dem Ziel (bei Directory-Loading eines conf.d/ ignoriert HAProxy Dotfiles — eine halb geschriebene oder ungültige Temp-Datei kann nie mitgeladen werden, das atomare Rename bleibt möglich) → Preflight auf dem Ziel-Host gegen die Temp-Datei (haproxy -c -f /etc/haproxy/haproxy.cfg -f <temp>); nur bei Erfolg wird sie atomar über das Ziel umbenannt — bei einem Fehler wird sie entfernt und das Ziel bleibt unangetastet auf dem alten Stand, auch ein späterer Reboot oder manueller Reload kann nie eine ungeprüfte Datei laden. Voraussetzung des Preflights: die HAProxy-Unit lädt genau haproxy.cfg plus die Zieldatei/conf.d — weitere -f-Dateien der Unit sieht der Preflight nicht. → Reload (Default sudo -n systemctl reload haproxy, Seamless-Reload: bestehende TCP-Verbindungen und laufende Associations überleben) → SHA-Verifikation mit Ergebnis-Tabelle (Exit 0 nur, wenn jeder Host OK ist). Modi: --check (nur Drift-Prüfung, read-only), --no-reload (verteilen + Preflight, kein Reload).

Die Ziel-Hosts stehen in config/haproxy-hosts (Vorlage: share/haproxy-hosts.example) — bewusst nicht in der Router-YAML: die YAML ist load-strikt validierte Laufzeit-Config, wird byte-identisch auf alle Router-Knoten gesynct und ist config-hash-überwacht; die HAProxy-Deployment-Topologie ist Betriebs-Infrastruktur, die der Router zur Laufzeit nie braucht. Format: eine Zeile pro Ziel host [zielpfad] (user@host erlaubt, Kommentare/Leerzeilen ignoriert), Default-Zielpfad /etc/haproxy/conf.d/dicom-return.cfg. Env-Overrides: DICOM_ROUTER_HAPROXY_HOSTS (Hosts-Datei), DICOM_ROUTER_HAPROXY_TARGET (Default-Zielpfad), DICOM_ROUTER_HAPROXY_RELOAD_CMD (Reload-Kommando — Setups unterscheiden sich, conf.d vs. EXTRAOPTS--f, das Skript erzwingt kein Layout).

Das Skript verwaltet ausschliesslich die eine generierte Datei — nie die Haupt-haproxy.cfg. Die Rollout-Reihenfolge gibt es als Hinweis aus (nie erzwungen): fehlen auf dem Ziel listen-Blöcke, die die YAML neu hat → vor dicom-router-sync ausführen (ein neuer Block ist harmlos, solange ihn niemand anwählt; umgekehrt wählte der Router einen Port an, den HAProxy noch nicht kennt); hat das Ziel Blöcke, die die YAML nicht mehr kennt → nach dicom-router-sync.

Einen dritten Fall unterscheidet der Hinweis davon: wandert ein return-port aus der port-range heraus, verschwindet sein Block aus der generierten Datei, während der Router die Destination weiter über vip:return-port anwählt. Auf dem Ziel-Host sieht das identisch aus wie eine entfernte Destination — das Skript trennt beides über den # skipped (…)-Block der frisch gerenderten Datei: steht der Blockname dort, ist die Destination noch konfiguriert. Für diese Blöcke gibt es einen eigenen Hinweis: der listen-Block gehört in die HAProxy-Config des Betreibers, und bis er dort steht, ist der Rückweg nicht erreichbar. Verwaist ist ein solcher Block ausdrücklich nicht — wird er entfernt, bleibt der Rückweg dauerhaft nicht erreichbar. Die Hinweise schliessen einander nicht aus: sind in einem Lauf sowohl eine entfernte als auch eine herausgewanderte Destination betroffen, erscheinen beide nebeneinander, jeder bezogen auf seine eigenen Blöcke.

Der Zeitbezug hängt vom Modus ab. --check ändert nichts, der Hinweis nennt den nächsten regulären Lauf als Zeitpunkt. Mit --no-reload ist die Zieldatei bereits überschrieben, geladen ist sie noch nicht — hier ist der nächste Reload oder Neustart der Zeitpunkt. Im regulären Lauf sind Überschreiben und Reload bereits erfolgt, wenn die Zusammenfassung erscheint; sie sagt das entsprechend und nennt den Weg zurück (Block in die eigene Config, reloaden).

Einen Restfall deckt der Abgleich nicht ab: Er läuft über den Blocknamen. Wird eine Destination mit return-port ausserhalb der Range umbenannt, steht im Skip-Block der neue Name, auf dem Ziel der alte — der alte Block wird als verwaist gemeldet, obwohl sein Bind weiter angewählt wird. Der Hinweistext für verwaiste Blöcke verweist deshalb ausdrücklich auf den Skip-Block, bevor von Hand etwas entfernt wird.

Eine Render-Ausgabe ganz ohne listen-Blöcke wird erkannt und mit Hinweis trotzdem verteilt (legitim, um Rückwege zu entfernen) — das gilt seit der port-range-Grenze auch dann, wenn zwar Destinations mit return-port existieren, aber alle ausserhalb der Range liegen; der Hinweistext benennt beide Fälle. Voraussetzungen auf den Ziel-Hosts (ssh, Schreibrechte, sudo-Regel): deployment.md; Anwendersicht: user-guide.md, Abschnitt 4.

Return-Ports automatisch vergeben: --allocate-return-ports

Ersetzt jeden return-port: 0-Marker in der YAML durch den kleinsten freien Port der haproxy-return.port-range — bezogen auf die effektive VIP der Destination (return-vip-Override oder globale vip; Eindeutigkeit pro (vip, port)-Paar). Bereits vergebene Ports werden nie neu berechnet (sticky):

cd /opt/imconnect
java -jar dicom-router-core-*.jar --allocate-return-ports config/dicom-router.yaml

Die Config wird zuerst mit derselben Validator-Kette wie bei --validate-config geladen und geprüft — toleriert werden dabei nur die Marker selbst, jeder andere Fehler bricht ab (auf einer kaputten Config wird nichts alloziert); der Install-Root-Hinweis von oben gilt genauso. Die YAML wird textuell und kommentarerhaltend zurückgeschrieben: es ändern sich ausschliesslich die betroffenen return-port: 0-Zeilen (atomar via Temp-Datei + Rename im selben Verzeichnis). Die Zuordnung wird pro Destination ausgegeben:

ct_b: return-port 5014
OK allocated 1 return-port(s) in: config/dicom-router.yaml

Idempotent: ohne Marker ein No-Op mit Exit 0. Exit-Codes: 0 = alloziert/nichts zu tun, 1 = Config ungültig, Range erschöpft oder Marker ohne port-range, 2 = Datei nicht gefunden. Dies ist das einzige Subkommando mit Seiteneffekt auf die Config-Datei — --validate-config und --render-haproxy bleiben strikt lesend. dicom-router-sync führt die Allokation automatisch vor Snapshot und Preflight aus (siehe oben).


5. Dependency-Hinweis

Das Paket management hat keine Imports aus dem core-Paket. ManagementCommandHandler arbeitet ausschliesslich mit Callbacks (Supplier/Runnable) und den entkoppelten Records (ConnectionInfo, ConfigValidationError). Das Wiring zwischen Management und Core findet ausschliesslich in App.java statt:

  • toConnectionInfoList(handler) ist die einzige Stelle, an der Core-Typen (ConnectionHandler) auf Management-Typen (ConnectionInfo) gemappt werden.
  • Shutdown- und Reload-Callbacks (shutdown(...), App::reloadConfig) werden dort als Lambdas in den ManagementCommandHandler injiziert.

Relevante Klassen:

  • dicom-router-core/.../management/RouterState.java — Zustandsautomat (READY/DRAIN/STOPPING)
  • dicom-router-core/.../management/AgentCheckServer.java — HAProxy-Agent-Check-Listener
  • dicom-router-core/.../management/ManagementServer.java — Kommando-Listener
  • dicom-router-core/.../management/ManagementCommandHandler.java — Kommando-Verarbeitung (ohne Core-Deps)
  • dicom-router-core/.../management/TokenVerifier.java / FileTokenVerifier.java — Token-Auth für state-changing Kommandos; konstruiert aus der Config ausschliesslich in App.java
  • dicom-router-core/.../management/ConnectionInfo.java — immutabler Verbindungs-Snapshot (Record)
  • dicom-router-core/.../config/ConfigDiff.java — Reload-Diff (restart-pflichtige Felder)
  • dicom-router-core/.../App.java — Wiring & reloadConfig()
  • dicom-router-core/.../core/SocketHandler.java — atomarer Config-Swap, Drain-Enforcement

6. Logging (Kurzfassung)

Zwei unabhängige, leicht zu verwechselnde Mechanismen:

Mechanismus Konfiguriert über Was geschrieben wird
Application-Logs logback.xml reguläre SLF4J-Ausgaben (INFO/WARN/ERROR/…)
DICOM-PDU-Dumps YAML router.log-folder + filter logLevel 5/6/7 binäre PDU-Captures via PduLoggingStage

Die beiden Mechanismen sind unabhängig — router.log-folder steuert nicht die Application-Logs.

PDU-Logging nur temporär aktivieren. PDU-Dumps (logLevel 5/6/7) sind ein Diagnose-Werkzeug: sie enthalten die vollständigen Patientendaten (PHI) und werden nicht rotiert oder automatisch gelöscht — Aufbewahrung und Löschung sind Betreiber-Verantwortung. Nach der Analyse den logLevel zurücksetzen und die Dumps in router.log-folder entfernen. Für dauerhafte Nachvollziehbarkeit ist der Datenbank-Audit-Trail (Konfigurationsabschnitt audit) da, nicht PDU-Dumps.

Logback-Discovery-Reihenfolge (erster Treffer gewinnt)

  1. -Dlogback.configurationFile=<pfad> — von bin/start.sh auf config/logback.xml gesetzt (operator-überschreibbar).
  2. logback-test.xml im Classpath — wird in Testläufen automatisch genutzt.
  3. logback.xml im Classpath — interner Default in dicom-router-core/src/main/resources/logback.xml.

Interner logback.xml (verifiziert)

Liefert sinnvolle Defaults, damit auch ein direkter java -jar-Start File-Rotation erhält:

  • CONSOLE-Appender (ConsoleAppender) + FILE-Appender (RollingFileAppender).
  • Rolling-Policy: maxFileSize 100 MB, maxHistory 30 Tage, totalSizeCap 5 GB.
  • Datei: ${LOG_DIR}/dicom-router.log, rotiert nach dicom-router-%d{yyyy-MM-dd}.%i.log.gz.
  • Logger-Levels: ch.immeditech INFO, org.hibernate WARN, org.jboss.weld WARN, Root INFO.
  • org.hibernate.engine.jdbc.spi.SqlExceptionHelper steht auf OFF: dieser Logger gibt bei SQL-Fehlern die rohe Treibermeldung als ERROR aus, und PostgreSQL rendert Constraint-Verletzungen mit der kompletten betroffenen Zeile (Detail: Failing row contains (…) — inklusive Patientenkennungen) wörtlich in diese Meldung. Audit-Trail, Q/R-Tracking und der zentrale Events-Writer schreiben für ihre Datenbankfehler eine eigene normalisierte, SQLState-tragende Zeile ins Log — dort geht keine Diagnose-Information verloren; die übrigen Datenbankpfade melden ihre Fehler weiterhin über die eigenen Fehlerbehandlungen.

Logback-Statusmeldungen

Logback protokolliert seine eigene Konfiguration in einer internen Statusliste und gibt sie aus, sobald dabei eine Warnung oder ein Fehler auftrat — etwa ein Datei-Appender, der sein Verzeichnis nicht beschreiben kann. Der Router lenkt diese Ausgabe nach stderr (StderrStatusListener, gesetzt vor dem ersten Logger-Aufruf); ohne diese Umlenkung schreibt Logback sie nach stdout und verschmutzt damit die Ausgabe der Offline-Modi. Ausgegeben werden nur Meldungen ab WARN. Ein explizit gesetztes -Dlogback.statusListenerClass=… hat Vorrang und wird nicht überschrieben.

LOG_DIR

Env-Variable, Default /var/log/imconnect (dasselbe Verzeichnis wie die übrigen Deployment-Defaults — systemd ReadWritePaths, router.log-folder), überschreibt das Log-Verzeichnis in interner und externer Config. Die mitgelieferte systemd-Unit setzt sie explizit über das Env-File (EnvironmentFile=, siehe dicom-router.env.example).