Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,21 @@ activation screen there.
Send HL7v2 over MLLP to `localhost:2575` and watch messages flow through to
the FHIR server.

## Deploy on Kubernetes

For a cluster deployment with the dashboard properly secured — internal ingress,
TLS, and an SSO login gate (the dashboard has no built-in auth) — see
[`k8s/`](k8s/):

- [`k8s/k3d/`](k8s/k3d/) — a **runnable, one-command** reference on
[k3d](https://k3d.io) (local). Stands up the full secure stack (ingress-nginx +
cert-manager + oauth2-proxy + Dex) so you can see it work before a real cluster.
- [`k8s/aks/`](k8s/aks/) — **production templates** for AKS (internal LB,
cert-manager DNS-01 / internal CA, Entra ID SSO, managed Postgres).

The Interbox Helm chart itself lives in
[HealthSamurai/helm-charts](https://github.com/HealthSamurai/helm-charts/tree/main/interbox).

## Configure the dashboard assistant

The dashboard's assistant is a Claude Code agent running **inside the container**,
Expand Down
86 changes: 86 additions & 0 deletions k8s/aks/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Secure AKS reference (TLS + SSO)

Production-shaped templates for deploying Interbox on **AKS** with the dashboard
behind an **internal ingress-nginx**, **cert-manager** TLS, and **Entra ID** SSO.

> **These are templates, not a verified one-command deploy.** They depend on your
> Azure resources (DNS zone, Entra tenant, managed Postgres, workload identity), so
> they can't be exercised end-to-end here. Fill every `<placeholder>` and validate
> in a staging cluster. The **[k3d reference](../k3d/)** is the runnable, verified
> version of the same architecture — start there to see it work.

Same architecture as k3d; the swaps are: internal LB, cert-manager real issuer,
Entra ID (not Dex), managed Postgres.

## Prerequisites

- An **AKS** cluster with **workload identity** enabled.
- **ingress-nginx** installed *internal* — `helm install ingress-nginx
ingress-nginx/ingress-nginx -n ingress-nginx --create-namespace -f
ingress-nginx.values.yaml` (gives its LB a private IP).
- **cert-manager** installed (`--set crds.enabled=true`).
- **Azure Database for PostgreSQL Flexible Server** reachable privately. Allowlist
the extensions Interbox needs — set the server parameter
**`azure.extensions = PG_TRGM,BTREE_GIST`** — *before* first boot, or the schema
migration fails.
- An **Entra ID app registration** for the dashboard (below).
- Interbox needs **outbound** access to your workspace repo and Aidbox; for the
**FHIR-poll** path, pin the cluster's **NAT-gateway egress IP** so upstreams
(e.g. Epic) can allowlist it.

## 1. TLS issuer — pick one

- **Public DNS zone (recommended)** → `kubectl apply -f clusterissuer-dns01.yaml`.
cert-manager gets auto-renewing, publicly-trusted certs via Azure DNS DNS-01 —
works even when the host's A record is a private IP. Federate the cert-manager
service account with a managed identity that has *DNS Zone Contributor*.
- **Private-DNS-only host** → `kubectl apply -f clusterissuer-internal-ca.yaml` and
set `ingress.annotations.cert-manager.io/cluster-issuer: internal-ca` in
`values.yaml`. Distribute the CA to clients.

## 2. Entra ID app + secret

1. Entra ID → App registrations → New. Add a **Web** redirect URI:
`https://<host>/oauth2/callback`.
2. Certificates & secrets → new **client secret**.
3. Create the secret oauth2-proxy reads:

```sh
kubectl create secret generic oauth2-proxy-secret \
--from-literal=client-id=<entra-app-client-id> \
--from-literal=client-secret=<entra-client-secret> \
--from-literal=cookie-secret=$(openssl rand -hex 16)
```

## 3. Deploy the gate + Interbox

```sh
# fill <tenant-id> and <host> in oauth2-proxy.yaml first
kubectl apply -f oauth2-proxy.yaml

helm repo add healthsamurai https://healthsamurai.github.io/helm-charts
helm upgrade --install interbox healthsamurai/interbox -f values.yaml # fill placeholders first
```

## 4. Verify

```sh
# unauthenticated -> 302 to the Entra login
curl -sk -o /dev/null -D - https://<host>/api/messages | grep -iE '^HTTP|^location'
# cert issued + trusted (DNS-01) or CA-signed (internal CA)
echo | openssl s_client -connect <host>:443 -servername <host> 2>/dev/null | openssl x509 -noout -issuer
```

Then browse `https://<host>/`, authenticate with Entra ID, land on the dashboard.

## Files

| File | Purpose |
| ---- | ------- |
| `values.yaml` | Interbox chart values: internal ingress + TLS + SSO annotations + internal MLLP LB |
| `ingress-nginx.values.yaml` | install ingress-nginx as an *internal* controller |
| `clusterissuer-dns01.yaml` | **primary** TLS issuer (Azure DNS DNS-01) |
| `clusterissuer-internal-ca.yaml` | alternative TLS issuer (private-DNS-only) |
| `oauth2-proxy.yaml` | SSO gate wired to Entra ID |

Concepts: the docs' **Operations → Securing Dashboard Access**.
28 changes: 28 additions & 0 deletions k8s/aks/clusterissuer-dns01.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# PRIMARY cert issuer for AKS: cert-manager ACME with the Azure DNS DNS-01 solver.
# Works for a PRIVATE host as long as its name lives in a PUBLIC Azure DNS zone you
# control (the A record may point to a private IP) — DNS-01 only writes TXT records,
# no inbound needed. Auto-issues + auto-renews publicly-trusted certs.
#
# Auth: AKS workload identity (recommended) — federate the cert-manager service
# account with a managed identity that has "DNS Zone Contributor" on the zone.
#
# TEMPLATE: fill every <placeholder>.
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: azuredns-dns01
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: <ops-email>
privateKeySecretRef:
name: azuredns-dns01-account-key
solvers:
- dns01:
azureDNS:
subscriptionID: <subscription-id>
resourceGroupName: <dns-zone-resource-group>
hostedZoneName: internal.example # the PUBLIC zone
environment: AzurePublicCloud
managedIdentity:
clientID: <cert-manager-workload-identity-client-id>
31 changes: 31 additions & 0 deletions k8s/aks/clusterissuer-internal-ca.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# ALTERNATIVE cert issuer: internal CA. Use this instead of DNS-01 when the host is
# PRIVATE-DNS-ONLY (Let's Encrypt can't validate it). Clients must trust the CA
# (usually already true on managed corporate devices).
#
# Option A — import your org's existing CA: create a secret with the CA cert+key,
# then a `ca` ClusterIssuer that signs leaf certs from it:
# kubectl -n cert-manager create secret tls org-ca --cert=ca.crt --key=ca.key
# Option B — a cert-manager-generated self-signed root (shown here; fine for a
# closed environment where you distribute the root).
#
# To use: set ingress.annotations.cert-manager.io/cluster-issuer: internal-ca in values.yaml.
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata: { name: selfsigned-root }
spec: { selfSigned: {} }
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata: { name: internal-ca, namespace: cert-manager }
spec:
isCA: true
commonName: interbox-internal-ca
secretName: internal-ca
privateKey: { algorithm: ECDSA, size: 256 }
issuerRef: { name: selfsigned-root, kind: ClusterIssuer, group: cert-manager.io }
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata: { name: internal-ca }
spec:
ca: { secretName: internal-ca }
15 changes: 15 additions & 0 deletions k8s/aks/ingress-nginx.values.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Helm values to install ingress-nginx as an INTERNAL controller on AKS — its Azure
# Load Balancer gets a PRIVATE IP (no public exposure). The dashboard is reached over
# the VPN/VNet only.
#
# helm install ingress-nginx ingress-nginx/ingress-nginx \
# -n ingress-nginx --create-namespace -f ingress-nginx.values.yaml
controller:
service:
annotations:
service.beta.kubernetes.io/azure-load-balancer-internal: "true"
externalTrafficPolicy: Local
ingressClassResource:
name: nginx
default: false
# replicaCount: 2 # for HA of the ingress tier
65 changes: 65 additions & 0 deletions k8s/aks/oauth2-proxy.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# oauth2-proxy — SSO login gate in front of the dashboard, wired to Entra ID
# (Azure AD). Unlike the k3d/Dex reference this uses normal OIDC discovery (Entra
# is internet-reachable) and no insecure flags.
#
# Prereqs (see README):
# 1. Register an app in Entra ID; redirect URI = https://<host>/oauth2/callback
# 2. Create a client secret on it.
# 3. kubectl create secret generic oauth2-proxy-secret \
# --from-literal=client-id=<entra-app-client-id> \
# --from-literal=client-secret=<entra-client-secret> \
# --from-literal=cookie-secret=$(openssl rand -hex 16)
#
# TEMPLATE: fill <tenant-id> and <host>.
apiVersion: apps/v1
kind: Deployment
metadata: { name: oauth2-proxy, labels: { app: oauth2-proxy } }
spec:
replicas: 2
selector: { matchLabels: { app: oauth2-proxy } }
template:
metadata: { labels: { app: oauth2-proxy } }
spec:
containers:
- name: oauth2-proxy
image: quay.io/oauth2-proxy/oauth2-proxy:v7.7.1
args:
- --provider=oidc
- --oidc-issuer-url=https://login.microsoftonline.com/<tenant-id>/v2.0
- --redirect-url=https://<host>/oauth2/callback
- --scope=openid email profile
- --email-domain=* # tighten to your tenant's domain(s)
- --http-address=0.0.0.0:4180
- --reverse-proxy=true
- --upstream=static://200
- --cookie-secure=true
- --cookie-domain=<host>
- --whitelist-domain=<host>
- --skip-provider-button=true
- --set-xauthrequest=true
env:
- { name: OAUTH2_PROXY_CLIENT_ID, valueFrom: { secretKeyRef: { name: oauth2-proxy-secret, key: client-id } } }
- { name: OAUTH2_PROXY_CLIENT_SECRET, valueFrom: { secretKeyRef: { name: oauth2-proxy-secret, key: client-secret } } }
- { name: OAUTH2_PROXY_COOKIE_SECRET, valueFrom: { secretKeyRef: { name: oauth2-proxy-secret, key: cookie-secret } } }
ports: [{ containerPort: 4180 }]
---
apiVersion: v1
kind: Service
metadata: { name: oauth2-proxy }
spec:
selector: { app: oauth2-proxy }
ports: [{ port: 4180, targetPort: 4180 }]
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata: { name: interbox-oauth2 }
spec:
ingressClassName: nginx
tls: [{ hosts: [interbox.internal.example], secretName: interbox-tls }]
rules:
- host: interbox.internal.example
http:
paths:
- path: /oauth2
pathType: Prefix
backend: { service: { name: oauth2-proxy, port: { number: 4180 } } }
64 changes: 64 additions & 0 deletions k8s/aks/values.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Interbox Helm values — AKS, secure (internal ingress + TLS + SSO).
# TEMPLATE: fill every <placeholder>. Not verified end-to-end (needs a real AKS +
# Azure DNS + Entra ID + managed Postgres). The ingress annotations/tls block is
# identical to the k3d reference — only the class target, issuer, host, IdP, and
# DATABASE_URL differ.
image:
repository: healthsamurai/interbox
tag: "1.4.1" # pin a released version
pullPolicy: IfNotPresent

config:
MLLP_HOST: "0.0.0.0"
MLLP_PORT: "2575"
AIDBOX_URL: "https://<aidbox-host>" # sibling chart or existing Aidbox
INTERBOX_WORKSPACE_GIT_URL: "https://github.com/<org>/<your-workspace>"

# Prefer a pre-provisioned Secret (External Secrets Operator / Key Vault CSI) in prod:
# secrets: { existingSecretName: interbox-secrets }
secrets:
data:
# Azure Database for PostgreSQL Flexible Server, private endpoint, TLS required.
# The admin user has CREATEDB; allowlist extensions first (see README):
# server param azure.extensions = PG_TRGM,BTREE_GIST
DATABASE_URL: "postgres://<user>:<pass>@<pg-host>.postgres.database.azure.com:5432/interbox?sslmode=require"
AIDBOX_CLIENT_SECRET: "<aidbox-client-secret>"
INTERBOX_LICENSE: "<license-jwt-or-activate-via-dashboard>"

database:
createDatabase: true # false if the PG user lacks CREATEDB (pre-create it)

# Dashboard/API: internal ingress-nginx + cert-manager TLS + oauth2-proxy SSO gate.
ingress:
enabled: true
className: nginx # the INTERNAL ingress-nginx (see ingress-nginx.values.yaml)
host: interbox.internal.example
path: /
pathType: Prefix
tls:
- hosts: [interbox.internal.example]
secretName: interbox-tls
annotations:
# PRIMARY: cert-manager DNS-01 (public zone). Private-DNS-only? use an internal
# CA issuer instead (see README) and change this to its name.
cert-manager.io/cluster-issuer: azuredns-dns01
nginx.ingress.kubernetes.io/auth-url: "http://oauth2-proxy.default.svc.cluster.local:4180/oauth2/auth"
nginx.ingress.kubernetes.io/auth-signin: "https://interbox.internal.example/oauth2/start?rd=$escaped_request_uri"

# MLLP over a PRIVATE Azure LB (hospital reaches it over VPN/ExpressRoute).
mllp:
enabled: true
port: 2575
service:
type: LoadBalancer
internal: true
externalTrafficPolicy: Local
annotations:
service.beta.kubernetes.io/azure-load-balancer-internal: "true"
service.beta.kubernetes.io/azure-load-balancer-tcp-idle-timeout: "30"
loadBalancerIP: "<free-subnet-ip>" # pin it — stable VPN/firewall target
loadBalancerSourceRanges: # restrict to sender subnets
- "<hospital-cidr>"

resources:
requests: { cpu: 250m, memory: 1Gi }
68 changes: 68 additions & 0 deletions k8s/k3d/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Secure k3d reference (TLS + SSO)

A **worked, runnable reference** for deploying Interbox on Kubernetes with the
dashboard properly locked down: an internal-style **ingress**, **TLS**, and an
**SSO login gate** in front (the dashboard has no built-in auth). It runs on
[k3d](https://k3d.io) (k3s in Docker) for local evaluation — the same chart and
wiring you'd use on a real cluster (see [Production swaps](#production-swaps)).

`./deploy.sh` stands the whole thing up on [k3d](https://k3d.io) in a few minutes
so you can see it work and copy it to a real cluster.

> **Local / evaluation only.** This uses a self-signed CA, a static test login,
> and a throwaway in-cluster Postgres. Do **not** use it as-is in production —
> see [Production swaps](#production-swaps).

## What it deploys

| Component | Role |
| --------- | ---- |
| `ingress-nginx` | front door + TLS termination |
| `cert-manager` + `ca-issuer.yaml` | issues the dashboard's TLS cert from an internal CA |
| `dex.yaml` | OIDC identity provider with a test user (stand-in for Entra ID / Okta) |
| `oauth2-proxy.yaml` | the SSO gate — no session ⇒ redirect to login |
| `postgres.yaml` | throwaway Postgres |
| `values.yaml` | Interbox Helm values wiring the ingress class + TLS + SSO annotations |

`deploy.sh` generates the password hash and client/cookie secrets at run time —
nothing sensitive is committed.

## Run it

Prereqs: `k3d`, `kubectl`, `helm`, `docker`, `openssl`.

```sh
./deploy.sh
```

Then open **https://interbox.localhost:8443/** (accept the self-signed warning) and
log in as **`user@interbox.local`** / **`password`**. `*.localhost` resolves to
loopback, so no `/etc/hosts` edit is needed.

Tear down with `./teardown.sh`.

## How a request flows

```text
Browser → ingress-nginx (terminates TLS)
→ nginx asks oauth2-proxy "logged in?" (no) → 302 to Dex login
→ user logs in → oauth2-proxy sets a cookie
→ nginx allows → Interbox dashboard
```

## Production swaps

The chart values (ingress class + `tls` + cert-manager + auth annotations) are
**identical** in production. Only these change:

| This reference (local) | Production |
| ---------------------- | ---------- |
| `ingress-nginx` on mapped host ports | `ingress-nginx` behind an **internal** LB (e.g. Azure `service.beta.kubernetes.io/azure-load-balancer-internal: true`), or AGIC |
| self-signed CA (`ca-issuer.yaml`) | cert-manager **DNS-01** (public DNS zone) or your **internal CA** / Key Vault cert |
| **Dex** + static test user (`dex.yaml`) | your real **OIDC IdP** (Entra ID / Okta): register an app, set redirect `https://<host>/oauth2/callback`, give oauth2-proxy the issuer/client id/secret |
| throwaway Postgres (`postgres.yaml`) | **managed Postgres** (private endpoint, `sslmode=require`); allowlist `pg_trgm` + `btree_gist` (Azure: `azure.extensions`) |
| host `interbox.localhost:8443` | your real private hostname on port 443 |

Everything else — the Interbox chart, the ingress annotations, cert-manager, and
oauth2-proxy — stays the same. See the docs' **Operations → Securing Dashboard
Access** for the concepts.
Loading