431 lines
16 KiB
Markdown
431 lines
16 KiB
Markdown
# 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: <shared-key-from-config>
|
|
|
|
GET /api/iso/print?accession_number=X,Y,Z
|
|
X-API-Key: <shared-key-from-config>
|
|
```
|
|
|
|
---
|
|
|
|
## 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_<uuid>/
|
|
├─ service.iso.go: GenerateISO()
|
|
│ ├─ service.dicom.go: FetchDICOM()
|
|
│ │ ├─ pkg/dicom: StartStoresCP() → storescp as child process
|
|
│ │ │ storescp <port> -aet CDRECORD -od <dir> +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=<acc>
|
|
│ │ │ -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 <acc>.iso <dir>
|
|
│ └─ 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_<uuid>/
|
|
├─ 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 <our_ae> +sd +r <cd_host> <cd_port> <dir>
|
|
│ ├─ 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_<uuid>/
|
|
```
|
|
|
|
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 <service-token> (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;
|
|
}
|
|
```
|