191 lines
7.8 KiB
Markdown
191 lines
7.8 KiB
Markdown
# Go mkiso Server — Implementation Checklist
|
|
|
|
## Stage 1 — Plain MVP (no auth)
|
|
|
|
Goal: Functional replacement for all three PHP scripts. No security — plain GET endpoints.
|
|
|
|
- [ ] 1. **Project scaffolding**
|
|
- Create `go.mod` (`module mkiso-server`, go 1.22+)
|
|
- Create directory structure (`internal/{config,route,handler,service,repo}`, `pkg/dicom`)
|
|
- Create `config.example.yaml` with all documented keys (no secrets)
|
|
- Create `.gitignore` (ignore `config.yaml`, `*.iso`, temp dirs)
|
|
|
|
- [ ] 2. **Config loading** — `internal/config/config.go`
|
|
- Define `Config` struct matching `docs/go-mkiso-design.md` § config.yaml
|
|
- Load from YAML (`gopkg.in/yaml.v3`) or JSON (`encoding/json` for zero deps)
|
|
- Validate required fields on load (pacs host, dcmtk paths)
|
|
- ⚠️ Skip `auth:` section for Stage 1
|
|
|
|
- [ ] 3. **DICOM command runner** — `pkg/dicom/command.go`
|
|
- `StartStoresCP`, `StopStoresCP`, `RunMoveSCU`, `RunStoreSCU`, `RunGenISOImage`
|
|
- Port allocation: `allocatePort(basePort, portRange)` — mutex-guarded map
|
|
- All functions return `(exitCode int, stdout, stderr string, error)`
|
|
- Logging with slog
|
|
|
|
- [ ] 4. **Patient repo** — `internal/repo/patient.go`
|
|
- HTTP client to patient-data API (see `docs/patient-api-spec.md`)
|
|
- `ByAccessionNumber(ctx, accessionNumber) → (*PatientData, error)`
|
|
- Configurable auth header from config (for when patient API itself needs auth)
|
|
- Timeout + retry with backoff
|
|
|
|
- [ ] 5. **DICOM service** — `internal/service/dicom.go`
|
|
- `FetchDICOM(ctx, accessionNumber, destDir) → (filesCount int, error)`
|
|
- `FetchDICOMMultiple(ctx, accessionNumbers []string, destDir) → (totalFiles int, error)`
|
|
- storescp lifecycle: start → wait ready → movescu → stop
|
|
- `defer` for guaranteed storescp cleanup
|
|
- ⚠️ After FetchDICOM, check `filesCount > 0`. If 0, return error `"no DICOM data"` before attempting ISO/genisoimage.
|
|
|
|
- [ ] 6. **ISO service** — `internal/service/iso.go`
|
|
- `GenerateISO(ctx, accessionNumber) → (isoPath string, cleanup func(), error)`
|
|
- `GenerateISOMultiple(ctx, accessionNumbers) → (isoPath string, cleanup func(), error)`
|
|
- Temp dir + microdicom copy + FetchDICOM + genisoimage
|
|
- Patient API for multi-accession filename
|
|
|
|
- [ ] 7. **Relay service** — `internal/service/relay.go` (see `todo/go-print-relay.detail.md`)
|
|
- `RelayToCDPublisher(ctx, accessionNumbers) → (*RelayResult, error)`
|
|
- Patient API validation + FetchDICOM + storescu to CD Publisher
|
|
- Response includes `patient_name`, `destination`, `files_sent`
|
|
|
|
- [ ] 8. **Handlers** — `internal/handler/{health,iso,print}.go`
|
|
- `GET /api/health` — health check + dependency status
|
|
- `GET /api/iso/download?accession_number=X` — single ISO download
|
|
- `GET /api/iso/download-multiple?accession_numbers=X,Y,Z` — multi ISO download
|
|
- `GET /api/iso/print?accession_number=X` — DICOM relay (auto-detects comma → multi)
|
|
- ⚠️ Print uses ONE endpoint for both single and multi: comma in `accession_number` triggers multi mode, no `print-multiple` endpoint needed (see pre-flight gap #3)
|
|
|
|
- [ ] 9. **Route wiring** — `internal/route/route.go`
|
|
- Go 1.22+ `http.NewServeMux` with method+path patterns
|
|
- Request ID middleware (UUID, `X-Request-ID` header)
|
|
- Recovery middleware (catch panics, log stack, return 500)
|
|
- **Stage 1**: 4 endpoints — health, download, download-multiple, print (single endpoint, auto-detects commas)
|
|
- **No auth middleware in Stage 1**
|
|
|
|
- [ ] 10. **Main entrypoint** — `main.go`
|
|
- Load config, wire dependencies, start server
|
|
- Graceful shutdown (SIGINT/SIGTERM)
|
|
- `slog.Info("server started", "port", cfg.Server.Port)`
|
|
|
|
- [ ] 11. **Stage 1 testing**
|
|
- Test all four endpoints with curl (no auth header needed)
|
|
- Test concurrent requests
|
|
- Test error cases
|
|
- Test through nginx reverse proxy
|
|
|
|
---
|
|
|
|
## Stage 2 — Add Authentication
|
|
|
|
Goal: Lock down endpoints. **Suggestion below**, implementor picks approach.
|
|
|
|
### Recommended: API Key via Reverse 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 ────────────
|
|
```
|
|
|
|
**nginx config:**
|
|
```nginx
|
|
location ~ ^/(mkiso|mkiso_multiple|send_rimage_multiple)\.php$ {
|
|
proxy_set_header X-API-Key "${MKISO_API_KEY}";
|
|
proxy_pass http://127.0.0.1:8080/api/iso/$1$is_args$args;
|
|
}
|
|
|
|
# Direct API access (for testing/other clients)
|
|
location /api/ {
|
|
proxy_pass http://127.0.0.1:8080;
|
|
}
|
|
```
|
|
|
|
**Go middleware (simple):**
|
|
```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 {
|
|
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|
|
```
|
|
|
|
**Pros:**
|
|
- Zero HIS code changes — nginx handles it
|
|
- Simple to implement (one string comparison in middleware)
|
|
- No token expiry, no login endpoint needed
|
|
- Key rotation = update config.yaml + reload nginx
|
|
|
|
**Cons:**
|
|
- Single shared key (no user-level audit)
|
|
- Key visible in nginx config
|
|
- No token expiry (must rotate manually)
|
|
|
|
### Alternative Suggestions
|
|
|
|
| Approach | Complexity | HIS Changes | Best For |
|
|
|----------|-----------|-------------|----------|
|
|
| **API Key (recommended)** | Low | None | Internal hospital network, nginx proxy in place |
|
|
| **Hardcoded JWT** | Low | None (nginx injects) | When you want expiry + claims but no login flow |
|
|
| **JWT + Login endpoint** | Medium | Yes (or client script) | Multi-client, need user-level audit |
|
|
| **IP whitelist** | Low | None | Fixed HIS server IP, simplest possible |
|
|
| **mTLS** | High | Yes (client cert) | Strictest security, external-facing |
|
|
|
|
### Stage 2 Todo Items
|
|
|
|
- [ ] 12. **Config additions** — `internal/config/config.go`
|
|
- Add `auth.api_key` field (the expected key)
|
|
- Add `auth.enabled` boolean (false in Stage 1 config, true in Stage 2)
|
|
|
|
- [ ] 13. **API Key middleware** — `internal/middleware/auth.go`
|
|
- Extract `X-API-Key` from request header
|
|
- Compare against config value
|
|
- Return 401 on mismatch
|
|
- Skip health endpoint (`/api/health` stays public)
|
|
|
|
- [ ] 14. **Route wiring update** — `internal/route/route.go`
|
|
- Apply API key middleware to all `/api/iso/*` routes (download, download-multiple, print)
|
|
- Keep `/api/health` public
|
|
|
|
- [ ] 15. **nginx config** — deploy reverse proxy with header injection
|
|
- Map old PHP paths to new Go API paths
|
|
- Inject `X-API-Key` header
|
|
- Verify HIS `window.open()` calls work end-to-end
|
|
|
|
- [ ] 16. **Stage 2 testing**
|
|
- Test with valid API key → 200
|
|
- Test with invalid API key → 401
|
|
- Test with missing API key → 401
|
|
- Test health endpoint still public
|
|
- Test through nginx proxy (key injected) → 200
|
|
- Test direct access without nginx → 401
|
|
|
|
---
|
|
|
|
## Stage 3 (Future)
|
|
|
|
- [ ] JWT login endpoint (if multi-user audit needed)
|
|
- [ ] Rate limiting
|
|
- [ ] Metrics / Prometheus endpoint
|
|
- [ ] Docker image
|
|
- [ ] Remove Java/PHP after Go verified in production
|
|
|
|
---
|
|
|
|
## Dependencies (go.mod)
|
|
|
|
| Package | Purpose |
|
|
|---------|---------|
|
|
| `gopkg.in/yaml.v3` | YAML config parsing *(only external dep)* |
|
|
|
|
Everything else: **Go stdlib only**.
|