16 KiB
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
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-multipleare merged into one endpoint. The handler checks for commas inaccession_numberto decide single vs multi mode. This matches the PHP behavior wheresend_rimage_multiple.phphandles both viaexplode(",", ...).
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:
auth:
enabled: true # Stage 1: false, Stage 2: true
api_key: "changeme" # Shared key — keep secret
Middleware (~10 lines, stdlib only):
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:
# 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):
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:
- Start
storescpONCE for all accessions - Run
movescuin a loop for each accession number - Call patient API to get
Namafor filename - ISO filename:
{PATIENT_NAME}-{ACC_LIST}.iso - 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:
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)
// 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:
# 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;
}