# Go mkiso Server — Architecture & Design ## Overview A Go HTTP server (stdlib only, no frameworks) that replaces the PHP mkiso scripts. It handles DICOM retrieval from PACS (via dcmtk CLI), ISO creation (via genisoimage), and streaming download — all behind JWT authentication. --- ## Project Structure ``` mkiso-server/ ├── config.yaml # Configuration (PACS, AE, binaries, JWT) ├── config.example.yaml # Template config without secrets ├── go.mod ├── main.go # Entrypoint: load config, wire deps, start server ├── internal/ │ ├── config/ │ │ └── config.go # Config struct, YAML loading │ ├── route/ │ │ └── route.go # Route registration, middleware chain │ ├── middleware/ │ │ └── auth.go # JWT middleware (slog, context injection) │ ├── handler/ │ │ ├── auth.go # POST /api/auth/login │ │ ├── iso.go # ISO download handlers │ │ ├── print.go # Print ISO (stub) │ │ └── health.go # GET /api/health │ ├── service/ │ │ ├── dicom.go # DICOM operations (storescp + movescu orchestration) │ │ ├── iso.go # ISO creation + cleanup │ │ └── auth.go # JWT generation + validation │ └── repo/ │ └── patient.go # HTTP client to patient-data API ├── pkg/ │ └── dicom/ │ └── command.go # Low-level dcmtk binary execution wrapper └── testdata/ └── ... # Test fixtures ``` --- ## External Dependencies | Binary | Package | Config Key | |--------|---------|------------| | `storescp` | dcmtk | `dcmtk.storescp` | | `movescu` | dcmtk | `dcmtk.movescu` | | `genisoimage` | `genisoimage` (apt) | `tools.genisoimage` | **All binary paths are in `config.yaml`.** No hardcoded paths. --- ## config.yaml Design ```yaml server: port: 8080 read_timeout: 300s # ISO generation can take minutes write_timeout: 600s auth: enabled: false # Stage 1: false (no auth). Stage 2: true api_key: "" # Stage 2: shared key, checked via X-API-Key header dcmtk: storescp: "/usr/local/bin/storescp" movescu: "/usr/local/bin/movescu" storescu: "/usr/local/bin/storescu" tools: genisoimage: "/usr/bin/genisoimage" pacs: ae_title: "ABPACS" host: "localhost" port: 11112 our_ae: ae_title: "CDRECORD" base_port: 10104 # storescp listen port range start port_range: 100 # 10104-10203 (100 unique ports for concurrent requests) patient_api: base_url: "http://his-server/api" # External patient data API timeout: 10s cd_publisher: host: "172.16.0.120" # CD Publisher server (for print/relay) port: 104 # DICOM port on CD Publisher iso: microdicom_path: "/var/www/html/microdicom" # DICOM viewer bootstrap files temp_dir: "/tmp" # Working directory for dicomdir_* ``` --- ## API Endpoints ### Stage 1 — All public (auth.enabled = false) | Method | Path | Description | Replaces | |--------|------|-------------|----------| | `GET` | `/api/health` | Health check + dependency status | — | | `GET` | `/api/iso/download?accession_number=X` | Single accession → ISO download | `mkiso.php` | | `GET` | `/api/iso/download-multiple?accession_numbers=X,Y,Z` | Multi-accession → ISO download with patient-name filename | `mkiso_multiple.php` | | `GET` | `/api/iso/print?accession_number=X` | DICOM relay → CD Publisher (auto-detects comma → multi) | `send_rimage_multiple.php` | | `GET` | `/api/iso/print?accession_number=X,Y,Z` | (same endpoint — comma triggers multi) | `send_rimage_multiple.php` | > **Note:** `print` and `print-multiple` are merged into one endpoint. The handler checks for commas in `accession_number` to decide single vs multi mode. This matches the PHP behavior where `send_rimage_multiple.php` handles both via `explode(",", ...)`. ### Stage 2 — Authenticated (auth.enabled = true) Same endpoints, but `/api/iso/*` requires `X-API-Key` header. `/api/health` stays public. ``` GET /api/iso/download?accession_number=X X-API-Key: GET /api/iso/print?accession_number=X,Y,Z X-API-Key: ``` --- ## Authentication (Stage 2) ### Recommended: API Key via nginx proxy injection **Why:** Zero changes to the HIS `pacs_downloadiso` module. The existing `window.open()` calls continue to work because nginx injects the auth header before proxying to Go. ``` Browser (HIS) nginx (PACS server) Go Server ──────────── ─────────────────── ───────── window.open("/mkiso.php?..") ────── GET ──────→ add X-API-Key header ───── GET + key ──────→ validate key → 200 OK ←─── ISO stream ─────── ←── ISO download ── ``` **Config:** ```yaml auth: enabled: true # Stage 1: false, Stage 2: true api_key: "changeme" # Shared key — keep secret ``` **Middleware (~10 lines, stdlib only):** ```go func APIKey(expectedKey string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("X-API-Key") != expectedKey { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusUnauthorized) w.Write([]byte(`{"error":"unauthorized"}`)) return } next.ServeHTTP(w, r) }) } } ``` **nginx reverse proxy config:** ```nginx # Map old PHP paths to Go API + inject auth location = /mkiso.php { proxy_set_header X-API-Key "${MKISO_API_KEY}"; proxy_pass http://127.0.0.1:8080/api/iso/download$is_args$args; } location = /mkiso_multiple.php { proxy_set_header X-API-Key "${MKISO_API_KEY}"; proxy_pass http://127.0.0.1:8080/api/iso/download-multiple$is_args$args; } location = /send_rimage_multiple.php { proxy_set_header X-API-Key "${MKISO_API_KEY}"; proxy_pass http://127.0.0.1:8080/api/iso/print$is_args$args; } ``` **Route wiring (Stage 2):** ```go mux.HandleFunc("GET /api/health", handler.Health(cfg)) // always public apiKey := middleware.APIKey(cfg.Auth.APIKey) mux.Handle("GET /api/iso/download", apiKey(handler.DownloadSingle(isoSvc))) mux.Handle("GET /api/iso/download-multiple", apiKey(handler.DownloadMultiple(isoSvc))) // ... same for print endpoints ``` --- ## DICOM Flow (Go — replaces Java dcmqr) ### Single Accession (`/api/iso/download`) ``` 1. handler.iso.go: DownloadSingle() ├─ Parse accession_number from query ├─ Create temp dir: /tmp/dicomdir_/ ├─ service.iso.go: GenerateISO() │ ├─ service.dicom.go: FetchDICOM() │ │ ├─ pkg/dicom: StartStoresCP() → storescp as child process │ │ │ storescp -aet CDRECORD -od +xa │ │ ├─ Wait for storescp ready (poll / retry) │ │ ├─ pkg/dicom: RunMoveSCU() → movescu as child process │ │ │ movescu -aet CDRECORD -aec ABPACS -aem CDRECORD │ │ │ localhost 11112 -S -k 0008,0050= │ │ │ -k 0010,0020= --no-port │ │ ├─ Wait for movescu (with timeout) │ │ └─ pkg/dicom: StopStoresCP() → SIGTERM │ ├─ Copy microdicom viewer files into temp dir │ ├─ Run genisoimage │ │ genisoimage -iso-level 4 -r -V DICOM -o .iso │ └─ Return ISO path ├─ Stream ISO to response (Content-Type: application/octet-stream) ├─ Cleanup temp dir └─ Return ``` ### Multiple Accessions (`/api/iso/download-multiple`) Same as single but: 1. Start `storescp` ONCE for all accessions 2. Run `movescu` in a loop for each accession number 3. Call **patient API** to get `Nama` for filename 4. ISO filename: `{PATIENT_NAME}-{ACC_LIST}.iso` 5. Cleanup after everything ### Print / DICOM Relay (`/api/iso/print`, `/api/iso/print-multiple`) Replaces `send_rimage_multiple.php`. Same DICOM fetch as download, then C-STORE relay instead of ISO. Uses patient API for validation (same as download-multiple). ``` 1. handler.print.go: PrintISO() ├─ Parse accession_number(s) from query ├─ Call patient API → validate accession, get patient_name ├─ Create temp dir: /tmp/dicomdir_/ ├─ service.relay.go: RelayToCDPublisher() │ ├─ Copy microdicom viewer files into temp dir │ ├─ service.dicom.go: FetchDICOM() → storescp + movescu loop │ ├─ pkg/dicom: RunStoreSCU() → storescu to CD Publisher │ │ storescu -aet +sd +r │ ├─ Count files sent │ └─ Return relay result with patient_name ├─ Respond JSON: { status, accessions_sent, patient_name, destination, files_sent } ├─ Cleanup temp dir └─ Return ``` **Key difference from download:** No genisoimage, no streaming. Instead `storescu +sd +r` (scan directory + recurse) sends all DICOM files to the CD Publisher server. Response is JSON (success/failure), not an ISO file. Patient API is used for accession validation, not just filename. ### Concurrent Safety ``` Port allocation: base_port + hash(request_id) % port_range Temp dir: /tmp/dicomdir_/ ``` Multiple incoming requests each get their own `storescp` port and temp directory. No collisions. --- ## Patient Data API Specification The Go server does NOT access the database directly. It calls an external API for patient data. This is defined in `docs/patient-api-spec.md`. ### Endpoint: `GET /patient/by-accession` ``` Request: GET {patient_api.base_url}/patient/by-accession?accession_number=MR.2024.001 Authorization: Bearer (or API key header) Response 200: { "accession_number": "MR.2024.001", "medrec_id": "00012345", "reg_id": "REG-2024-00123", "patient_name": "JOHN DOE", "modality": "MR", "study_description": "MRI Brain" } Response 404: { "error": "accession_number not found" } ``` This API must be provided by the HIS team (or deploy a thin proxy). The Go server calls it with HTTP GET. --- ## Error Handling | Scenario | HTTP Status | Response | |----------|-------------|----------| | Invalid/missing accession_number | `400` | `{"error": "invalid accession_number"}` | | PACS unreachable | `502` | `{"error": "PACS unreachable", "detail": "..."}` | | movescu failed (accession not found) | `404` | `{"error": "no DICOM data for accession_number"}` | | Patient API unavailable | `502` | `{"error": "patient API unavailable"}` | | genisoimage failed | `500` | `{"error": "ISO creation failed"}` | | Print ISO — CD Publisher unreachable | `502` | `{"error": "CD Publisher unreachable", "destination": "..."}` | | Concurrent port exhaustion | `503` | `{"error": "too many concurrent requests"}` | | Timeout (C-MOVE) | `504` | `{"error": "PACS move timed out"}` | **Stage 2 additions:** | Scenario | HTTP Status | Response | |----------|-------------|----------| | Missing/invalid API key | `401` | `{"error": "unauthorized"}` | --- ## Logging (slog) All components use `log/slog`. Key log points: ```go slog.Info("starting storescp", "port", port, "dir", dir, "pid", pid) slog.Info("running movescu", "accession", acc, "pacs", pacs) slog.Info("iso created", "path", isoPath, "size_bytes", size, "duration_ms", d) slog.Error("movescu failed", "accession", acc, "exit_code", code, "stderr", err) slog.Warn("patient API slow", "latency_ms", lat) ``` --- ## Route Wiring (stdlib net/http) ```go // internal/route/route.go // Stage 1 — no auth func Setup(cfg *config.Config, isoSvc service.ISOService, relaySvc service.RelayService) http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /api/health", handler.Health(cfg)) mux.HandleFunc("GET /api/iso/download", handler.DownloadSingle(isoSvc)) mux.HandleFunc("GET /api/iso/download-multiple", handler.DownloadMultiple(isoSvc)) mux.HandleFunc("GET /api/iso/print", handler.PrintISO(relaySvc)) // auto-detects single vs multi return middleware.Recovery(middleware.RequestID(mux)) } // Stage 2 — API key auth on /api/iso/* func SetupSecure(cfg *config.Config, isoSvc service.ISOService, relaySvc service.RelayService) http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /api/health", handler.Health(cfg)) // always public apiKey := middleware.APIKey(cfg.Auth.APIKey) mux.Handle("GET /api/iso/download", apiKey(http.HandlerFunc(handler.DownloadSingle(isoSvc)))) mux.Handle("GET /api/iso/download-multiple", apiKey(http.HandlerFunc(handler.DownloadMultiple(isoSvc)))) mux.Handle("GET /api/iso/print", apiKey(http.HandlerFunc(handler.PrintISO(relaySvc)))) return middleware.Recovery(middleware.RequestID(mux)) } ``` --- ## Repository Pattern Boundaries ``` ┌─────────────────────────────────────────────────────────┐ │ handler/ HTTP layer │ │ Parse request, call service, write response │ │ No business logic │ ├─────────────────────────────────────────────────────────┤ │ service/ Business logic │ │ Orchestrate DICOM fetch + ISO creation + cleanup │ │ Calls repo for external data, pkg/dicom for commands │ │ Uses slog for logging │ ├─────────────────────────────────────────────────────────┤ │ repo/ External data access │ │ patient.go: HTTP client to patient-data API │ │ Uses net/http with timeout + retry │ ├─────────────────────────────────────────────────────────┤ │ pkg/dicom/ DICOM command execution │ │ Low-level: spawn storescp/movescu, monitor, kill │ │ Returns (filesRetrieved int, error) │ │ Pure os/exec, no business logic │ └─────────────────────────────────────────────────────────┘ ``` --- ## Replacement Mapping | Old (PHP on PACS server) | New (Go on PACS server) | |--------------------------|-------------------------| | `mkiso.php?accession_number=X` | `GET /api/iso/download?accession_number=X` | | `mkiso_multiple.php?accession_number=X,Y,Z` | `GET /api/iso/download-multiple?accession_numbers=X,Y,Z` | | `send_rimage_multiple.php?accession_number=X` | `GET /api/iso/print?accession_number=X` | | `send_rimage_multiple.php?accession_number=X,Y,Z` | `GET /api/iso/print?accession_number=X,Y,Z` (same endpoint, comma auto-detect) | | Direct DB: `pacsdb_his` → `pacs_result_series`, `medrec` | HTTP: patient API | ### HIS Module Impact The `pacs_downloadiso` module JS opens `window.open("http://{PACS_HOST}/mkiso.php?accession_number=X")`. **Recommended: nginx reverse proxy** — zero HIS code changes, works in both stages: ```nginx # Map old PHP paths to new Go API paths # mkiso.php → /api/iso/download location = /mkiso.php { proxy_set_header X-API-Key "${MKISO_API_KEY}"; # Stage 2 only proxy_pass http://127.0.0.1:8080/api/iso/download$is_args$args; } # mkiso_multiple.php → /api/iso/download-multiple location = /mkiso_multiple.php { proxy_set_header X-API-Key "${MKISO_API_KEY}"; proxy_pass http://127.0.0.1:8080/api/iso/download-multiple$is_args$args; } # send_rimage_multiple.php → /api/iso/print # (single handler auto-detects commas for multi mode) location = /send_rimage_multiple.php { proxy_set_header X-API-Key "${MKISO_API_KEY}"; proxy_pass http://127.0.0.1:8080/api/iso/print$is_args$args; } ```