Skip to content

Commit e794d12

Browse files
ipasechnikovclaude
andauthored
feat: add k8s secure deployment references (k3d + aks) (#12)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 678307a commit e794d12

15 files changed

Lines changed: 682 additions & 0 deletions

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,21 @@ activation screen there.
2525
Send HL7v2 over MLLP to `localhost:2575` and watch messages flow through to
2626
the FHIR server.
2727

28+
## Deploy on Kubernetes
29+
30+
For a cluster deployment with the dashboard properly secured — internal ingress,
31+
TLS, and an SSO login gate (the dashboard has no built-in auth) — see
32+
[`k8s/`](k8s/):
33+
34+
- [`k8s/k3d/`](k8s/k3d/) — a **runnable, one-command** reference on
35+
[k3d](https://k3d.io) (local). Stands up the full secure stack (ingress-nginx +
36+
cert-manager + oauth2-proxy + Dex) so you can see it work before a real cluster.
37+
- [`k8s/aks/`](k8s/aks/)**production templates** for AKS (internal LB,
38+
cert-manager DNS-01 / internal CA, Entra ID SSO, managed Postgres).
39+
40+
The Interbox Helm chart itself lives in
41+
[HealthSamurai/helm-charts](https://github.com/HealthSamurai/helm-charts/tree/main/interbox).
42+
2843
## Configure the dashboard assistant
2944

3045
The dashboard's assistant is a Claude Code agent running **inside the container**,

k8s/aks/README.md

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# Secure AKS reference (TLS + SSO)
2+
3+
Production-shaped templates for deploying Interbox on **AKS** with the dashboard
4+
behind an **internal ingress-nginx**, **cert-manager** TLS, and **Entra ID** SSO.
5+
6+
> **These are templates, not a verified one-command deploy.** They depend on your
7+
> Azure resources (DNS zone, Entra tenant, managed Postgres, workload identity), so
8+
> they can't be exercised end-to-end here. Fill every `<placeholder>` and validate
9+
> in a staging cluster. The **[k3d reference](../k3d/)** is the runnable, verified
10+
> version of the same architecture — start there to see it work.
11+
12+
Same architecture as k3d; the swaps are: internal LB, cert-manager real issuer,
13+
Entra ID (not Dex), managed Postgres.
14+
15+
## Prerequisites
16+
17+
- An **AKS** cluster with **workload identity** enabled.
18+
- **ingress-nginx** installed *internal* — `helm install ingress-nginx
19+
ingress-nginx/ingress-nginx -n ingress-nginx --create-namespace -f
20+
ingress-nginx.values.yaml` (gives its LB a private IP).
21+
- **cert-manager** installed (`--set crds.enabled=true`).
22+
- **Azure Database for PostgreSQL Flexible Server** reachable privately. Allowlist
23+
the extensions Interbox needs — set the server parameter
24+
**`azure.extensions = PG_TRGM,BTREE_GIST`***before* first boot, or the schema
25+
migration fails.
26+
- An **Entra ID app registration** for the dashboard (below).
27+
- Interbox needs **outbound** access to your workspace repo and Aidbox; for the
28+
**FHIR-poll** path, pin the cluster's **NAT-gateway egress IP** so upstreams
29+
(e.g. Epic) can allowlist it.
30+
31+
## 1. TLS issuer — pick one
32+
33+
- **Public DNS zone (recommended)**`kubectl apply -f clusterissuer-dns01.yaml`.
34+
cert-manager gets auto-renewing, publicly-trusted certs via Azure DNS DNS-01 —
35+
works even when the host's A record is a private IP. Federate the cert-manager
36+
service account with a managed identity that has *DNS Zone Contributor*.
37+
- **Private-DNS-only host**`kubectl apply -f clusterissuer-internal-ca.yaml` and
38+
set `ingress.annotations.cert-manager.io/cluster-issuer: internal-ca` in
39+
`values.yaml`. Distribute the CA to clients.
40+
41+
## 2. Entra ID app + secret
42+
43+
1. Entra ID → App registrations → New. Add a **Web** redirect URI:
44+
`https://<host>/oauth2/callback`.
45+
2. Certificates & secrets → new **client secret**.
46+
3. Create the secret oauth2-proxy reads:
47+
48+
```sh
49+
kubectl create secret generic oauth2-proxy-secret \
50+
--from-literal=client-id=<entra-app-client-id> \
51+
--from-literal=client-secret=<entra-client-secret> \
52+
--from-literal=cookie-secret=$(openssl rand -hex 16)
53+
```
54+
55+
## 3. Deploy the gate + Interbox
56+
57+
```sh
58+
# fill <tenant-id> and <host> in oauth2-proxy.yaml first
59+
kubectl apply -f oauth2-proxy.yaml
60+
61+
helm repo add healthsamurai https://healthsamurai.github.io/helm-charts
62+
helm upgrade --install interbox healthsamurai/interbox -f values.yaml # fill placeholders first
63+
```
64+
65+
## 4. Verify
66+
67+
```sh
68+
# unauthenticated -> 302 to the Entra login
69+
curl -sk -o /dev/null -D - https://<host>/api/messages | grep -iE '^HTTP|^location'
70+
# cert issued + trusted (DNS-01) or CA-signed (internal CA)
71+
echo | openssl s_client -connect <host>:443 -servername <host> 2>/dev/null | openssl x509 -noout -issuer
72+
```
73+
74+
Then browse `https://<host>/`, authenticate with Entra ID, land on the dashboard.
75+
76+
## Files
77+
78+
| File | Purpose |
79+
| ---- | ------- |
80+
| `values.yaml` | Interbox chart values: internal ingress + TLS + SSO annotations + internal MLLP LB |
81+
| `ingress-nginx.values.yaml` | install ingress-nginx as an *internal* controller |
82+
| `clusterissuer-dns01.yaml` | **primary** TLS issuer (Azure DNS DNS-01) |
83+
| `clusterissuer-internal-ca.yaml` | alternative TLS issuer (private-DNS-only) |
84+
| `oauth2-proxy.yaml` | SSO gate wired to Entra ID |
85+
86+
Concepts: the docs' **Operations → Securing Dashboard Access**.

k8s/aks/clusterissuer-dns01.yaml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# PRIMARY cert issuer for AKS: cert-manager ACME with the Azure DNS DNS-01 solver.
2+
# Works for a PRIVATE host as long as its name lives in a PUBLIC Azure DNS zone you
3+
# control (the A record may point to a private IP) — DNS-01 only writes TXT records,
4+
# no inbound needed. Auto-issues + auto-renews publicly-trusted certs.
5+
#
6+
# Auth: AKS workload identity (recommended) — federate the cert-manager service
7+
# account with a managed identity that has "DNS Zone Contributor" on the zone.
8+
#
9+
# TEMPLATE: fill every <placeholder>.
10+
apiVersion: cert-manager.io/v1
11+
kind: ClusterIssuer
12+
metadata:
13+
name: azuredns-dns01
14+
spec:
15+
acme:
16+
server: https://acme-v02.api.letsencrypt.org/directory
17+
email: <ops-email>
18+
privateKeySecretRef:
19+
name: azuredns-dns01-account-key
20+
solvers:
21+
- dns01:
22+
azureDNS:
23+
subscriptionID: <subscription-id>
24+
resourceGroupName: <dns-zone-resource-group>
25+
hostedZoneName: internal.example # the PUBLIC zone
26+
environment: AzurePublicCloud
27+
managedIdentity:
28+
clientID: <cert-manager-workload-identity-client-id>
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# ALTERNATIVE cert issuer: internal CA. Use this instead of DNS-01 when the host is
2+
# PRIVATE-DNS-ONLY (Let's Encrypt can't validate it). Clients must trust the CA
3+
# (usually already true on managed corporate devices).
4+
#
5+
# Option A — import your org's existing CA: create a secret with the CA cert+key,
6+
# then a `ca` ClusterIssuer that signs leaf certs from it:
7+
# kubectl -n cert-manager create secret tls org-ca --cert=ca.crt --key=ca.key
8+
# Option B — a cert-manager-generated self-signed root (shown here; fine for a
9+
# closed environment where you distribute the root).
10+
#
11+
# To use: set ingress.annotations.cert-manager.io/cluster-issuer: internal-ca in values.yaml.
12+
apiVersion: cert-manager.io/v1
13+
kind: ClusterIssuer
14+
metadata: { name: selfsigned-root }
15+
spec: { selfSigned: {} }
16+
---
17+
apiVersion: cert-manager.io/v1
18+
kind: Certificate
19+
metadata: { name: internal-ca, namespace: cert-manager }
20+
spec:
21+
isCA: true
22+
commonName: interbox-internal-ca
23+
secretName: internal-ca
24+
privateKey: { algorithm: ECDSA, size: 256 }
25+
issuerRef: { name: selfsigned-root, kind: ClusterIssuer, group: cert-manager.io }
26+
---
27+
apiVersion: cert-manager.io/v1
28+
kind: ClusterIssuer
29+
metadata: { name: internal-ca }
30+
spec:
31+
ca: { secretName: internal-ca }

k8s/aks/ingress-nginx.values.yaml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Helm values to install ingress-nginx as an INTERNAL controller on AKS — its Azure
2+
# Load Balancer gets a PRIVATE IP (no public exposure). The dashboard is reached over
3+
# the VPN/VNet only.
4+
#
5+
# helm install ingress-nginx ingress-nginx/ingress-nginx \
6+
# -n ingress-nginx --create-namespace -f ingress-nginx.values.yaml
7+
controller:
8+
service:
9+
annotations:
10+
service.beta.kubernetes.io/azure-load-balancer-internal: "true"
11+
externalTrafficPolicy: Local
12+
ingressClassResource:
13+
name: nginx
14+
default: false
15+
# replicaCount: 2 # for HA of the ingress tier

k8s/aks/oauth2-proxy.yaml

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# oauth2-proxy — SSO login gate in front of the dashboard, wired to Entra ID
2+
# (Azure AD). Unlike the k3d/Dex reference this uses normal OIDC discovery (Entra
3+
# is internet-reachable) and no insecure flags.
4+
#
5+
# Prereqs (see README):
6+
# 1. Register an app in Entra ID; redirect URI = https://<host>/oauth2/callback
7+
# 2. Create a client secret on it.
8+
# 3. kubectl create secret generic oauth2-proxy-secret \
9+
# --from-literal=client-id=<entra-app-client-id> \
10+
# --from-literal=client-secret=<entra-client-secret> \
11+
# --from-literal=cookie-secret=$(openssl rand -hex 16)
12+
#
13+
# TEMPLATE: fill <tenant-id> and <host>.
14+
apiVersion: apps/v1
15+
kind: Deployment
16+
metadata: { name: oauth2-proxy, labels: { app: oauth2-proxy } }
17+
spec:
18+
replicas: 2
19+
selector: { matchLabels: { app: oauth2-proxy } }
20+
template:
21+
metadata: { labels: { app: oauth2-proxy } }
22+
spec:
23+
containers:
24+
- name: oauth2-proxy
25+
image: quay.io/oauth2-proxy/oauth2-proxy:v7.7.1
26+
args:
27+
- --provider=oidc
28+
- --oidc-issuer-url=https://login.microsoftonline.com/<tenant-id>/v2.0
29+
- --redirect-url=https://<host>/oauth2/callback
30+
- --scope=openid email profile
31+
- --email-domain=* # tighten to your tenant's domain(s)
32+
- --http-address=0.0.0.0:4180
33+
- --reverse-proxy=true
34+
- --upstream=static://200
35+
- --cookie-secure=true
36+
- --cookie-domain=<host>
37+
- --whitelist-domain=<host>
38+
- --skip-provider-button=true
39+
- --set-xauthrequest=true
40+
env:
41+
- { name: OAUTH2_PROXY_CLIENT_ID, valueFrom: { secretKeyRef: { name: oauth2-proxy-secret, key: client-id } } }
42+
- { name: OAUTH2_PROXY_CLIENT_SECRET, valueFrom: { secretKeyRef: { name: oauth2-proxy-secret, key: client-secret } } }
43+
- { name: OAUTH2_PROXY_COOKIE_SECRET, valueFrom: { secretKeyRef: { name: oauth2-proxy-secret, key: cookie-secret } } }
44+
ports: [{ containerPort: 4180 }]
45+
---
46+
apiVersion: v1
47+
kind: Service
48+
metadata: { name: oauth2-proxy }
49+
spec:
50+
selector: { app: oauth2-proxy }
51+
ports: [{ port: 4180, targetPort: 4180 }]
52+
---
53+
apiVersion: networking.k8s.io/v1
54+
kind: Ingress
55+
metadata: { name: interbox-oauth2 }
56+
spec:
57+
ingressClassName: nginx
58+
tls: [{ hosts: [interbox.internal.example], secretName: interbox-tls }]
59+
rules:
60+
- host: interbox.internal.example
61+
http:
62+
paths:
63+
- path: /oauth2
64+
pathType: Prefix
65+
backend: { service: { name: oauth2-proxy, port: { number: 4180 } } }

k8s/aks/values.yaml

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Interbox Helm values — AKS, secure (internal ingress + TLS + SSO).
2+
# TEMPLATE: fill every <placeholder>. Not verified end-to-end (needs a real AKS +
3+
# Azure DNS + Entra ID + managed Postgres). The ingress annotations/tls block is
4+
# identical to the k3d reference — only the class target, issuer, host, IdP, and
5+
# DATABASE_URL differ.
6+
image:
7+
repository: healthsamurai/interbox
8+
tag: "1.4.1" # pin a released version
9+
pullPolicy: IfNotPresent
10+
11+
config:
12+
MLLP_HOST: "0.0.0.0"
13+
MLLP_PORT: "2575"
14+
AIDBOX_URL: "https://<aidbox-host>" # sibling chart or existing Aidbox
15+
INTERBOX_WORKSPACE_GIT_URL: "https://github.com/<org>/<your-workspace>"
16+
17+
# Prefer a pre-provisioned Secret (External Secrets Operator / Key Vault CSI) in prod:
18+
# secrets: { existingSecretName: interbox-secrets }
19+
secrets:
20+
data:
21+
# Azure Database for PostgreSQL Flexible Server, private endpoint, TLS required.
22+
# The admin user has CREATEDB; allowlist extensions first (see README):
23+
# server param azure.extensions = PG_TRGM,BTREE_GIST
24+
DATABASE_URL: "postgres://<user>:<pass>@<pg-host>.postgres.database.azure.com:5432/interbox?sslmode=require"
25+
AIDBOX_CLIENT_SECRET: "<aidbox-client-secret>"
26+
INTERBOX_LICENSE: "<license-jwt-or-activate-via-dashboard>"
27+
28+
database:
29+
createDatabase: true # false if the PG user lacks CREATEDB (pre-create it)
30+
31+
# Dashboard/API: internal ingress-nginx + cert-manager TLS + oauth2-proxy SSO gate.
32+
ingress:
33+
enabled: true
34+
className: nginx # the INTERNAL ingress-nginx (see ingress-nginx.values.yaml)
35+
host: interbox.internal.example
36+
path: /
37+
pathType: Prefix
38+
tls:
39+
- hosts: [interbox.internal.example]
40+
secretName: interbox-tls
41+
annotations:
42+
# PRIMARY: cert-manager DNS-01 (public zone). Private-DNS-only? use an internal
43+
# CA issuer instead (see README) and change this to its name.
44+
cert-manager.io/cluster-issuer: azuredns-dns01
45+
nginx.ingress.kubernetes.io/auth-url: "http://oauth2-proxy.default.svc.cluster.local:4180/oauth2/auth"
46+
nginx.ingress.kubernetes.io/auth-signin: "https://interbox.internal.example/oauth2/start?rd=$escaped_request_uri"
47+
48+
# MLLP over a PRIVATE Azure LB (hospital reaches it over VPN/ExpressRoute).
49+
mllp:
50+
enabled: true
51+
port: 2575
52+
service:
53+
type: LoadBalancer
54+
internal: true
55+
externalTrafficPolicy: Local
56+
annotations:
57+
service.beta.kubernetes.io/azure-load-balancer-internal: "true"
58+
service.beta.kubernetes.io/azure-load-balancer-tcp-idle-timeout: "30"
59+
loadBalancerIP: "<free-subnet-ip>" # pin it — stable VPN/firewall target
60+
loadBalancerSourceRanges: # restrict to sender subnets
61+
- "<hospital-cidr>"
62+
63+
resources:
64+
requests: { cpu: 250m, memory: 1Gi }

k8s/k3d/README.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# Secure k3d reference (TLS + SSO)
2+
3+
A **worked, runnable reference** for deploying Interbox on Kubernetes with the
4+
dashboard properly locked down: an internal-style **ingress**, **TLS**, and an
5+
**SSO login gate** in front (the dashboard has no built-in auth). It runs on
6+
[k3d](https://k3d.io) (k3s in Docker) for local evaluation — the same chart and
7+
wiring you'd use on a real cluster (see [Production swaps](#production-swaps)).
8+
9+
`./deploy.sh` stands the whole thing up on [k3d](https://k3d.io) in a few minutes
10+
so you can see it work and copy it to a real cluster.
11+
12+
> **Local / evaluation only.** This uses a self-signed CA, a static test login,
13+
> and a throwaway in-cluster Postgres. Do **not** use it as-is in production —
14+
> see [Production swaps](#production-swaps).
15+
16+
## What it deploys
17+
18+
| Component | Role |
19+
| --------- | ---- |
20+
| `ingress-nginx` | front door + TLS termination |
21+
| `cert-manager` + `ca-issuer.yaml` | issues the dashboard's TLS cert from an internal CA |
22+
| `dex.yaml` | OIDC identity provider with a test user (stand-in for Entra ID / Okta) |
23+
| `oauth2-proxy.yaml` | the SSO gate — no session ⇒ redirect to login |
24+
| `postgres.yaml` | throwaway Postgres |
25+
| `values.yaml` | Interbox Helm values wiring the ingress class + TLS + SSO annotations |
26+
27+
`deploy.sh` generates the password hash and client/cookie secrets at run time —
28+
nothing sensitive is committed.
29+
30+
## Run it
31+
32+
Prereqs: `k3d`, `kubectl`, `helm`, `docker`, `openssl`.
33+
34+
```sh
35+
./deploy.sh
36+
```
37+
38+
Then open **https://interbox.localhost:8443/** (accept the self-signed warning) and
39+
log in as **`user@interbox.local`** / **`password`**. `*.localhost` resolves to
40+
loopback, so no `/etc/hosts` edit is needed.
41+
42+
Tear down with `./teardown.sh`.
43+
44+
## How a request flows
45+
46+
```text
47+
Browser → ingress-nginx (terminates TLS)
48+
→ nginx asks oauth2-proxy "logged in?" (no) → 302 to Dex login
49+
→ user logs in → oauth2-proxy sets a cookie
50+
→ nginx allows → Interbox dashboard
51+
```
52+
53+
## Production swaps
54+
55+
The chart values (ingress class + `tls` + cert-manager + auth annotations) are
56+
**identical** in production. Only these change:
57+
58+
| This reference (local) | Production |
59+
| ---------------------- | ---------- |
60+
| `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 |
61+
| self-signed CA (`ca-issuer.yaml`) | cert-manager **DNS-01** (public DNS zone) or your **internal CA** / Key Vault cert |
62+
| **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 |
63+
| throwaway Postgres (`postgres.yaml`) | **managed Postgres** (private endpoint, `sslmode=require`); allowlist `pg_trgm` + `btree_gist` (Azure: `azure.extensions`) |
64+
| host `interbox.localhost:8443` | your real private hostname on port 443 |
65+
66+
Everything else — the Interbox chart, the ingress annotations, cert-manager, and
67+
oauth2-proxy — stays the same. See the docs' **Operations → Securing Dashboard
68+
Access** for the concepts.

0 commit comments

Comments
 (0)