From 7c6f9f96aa8f10410722311472abfed7b979bed7 Mon Sep 17 00:00:00 2001 From: Ilia Pasechnikov Date: Thu, 9 Jul 2026 00:31:23 +0900 Subject: [PATCH] feat: add k8s secure deployment references (k3d + aks) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a k8s/ directory with worked, secure deployment references for the Interbox Helm chart — dashboard behind an internal ingress + TLS + SSO (the dashboard has no built-in auth): - k8s/k3d/ — a runnable, one-command reference on k3d: ingress-nginx + cert-manager (internal CA) + oauth2-proxy + Dex + Interbox + Postgres. Secrets are generated at deploy time (nothing sensitive committed). Verified end-to-end via deploy.sh — the SSO gate (302 to login) and cert-manager TLS both confirmed. - k8s/aks/ — production templates for AKS: internal LB, cert-manager DNS-01 / internal CA, Entra ID SSO, managed Postgres. Schema-validated against cert-manager + k8s; fill the . Also add a "Deploy on Kubernetes" pointer to the README. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 15 ++++ k8s/aks/README.md | 86 +++++++++++++++++++++++ k8s/aks/clusterissuer-dns01.yaml | 28 ++++++++ k8s/aks/clusterissuer-internal-ca.yaml | 31 +++++++++ k8s/aks/ingress-nginx.values.yaml | 15 ++++ k8s/aks/oauth2-proxy.yaml | 65 ++++++++++++++++++ k8s/aks/values.yaml | 64 +++++++++++++++++ k8s/k3d/README.md | 68 ++++++++++++++++++ k8s/k3d/ca-issuer.yaml | 23 +++++++ k8s/k3d/deploy.sh | 95 ++++++++++++++++++++++++++ k8s/k3d/dex.yaml | 44 ++++++++++++ k8s/k3d/oauth2-proxy.yaml | 65 ++++++++++++++++++ k8s/k3d/postgres.yaml | 31 +++++++++ k8s/k3d/teardown.sh | 3 + k8s/k3d/values.yaml | 49 +++++++++++++ 15 files changed, 682 insertions(+) create mode 100644 k8s/aks/README.md create mode 100644 k8s/aks/clusterissuer-dns01.yaml create mode 100644 k8s/aks/clusterissuer-internal-ca.yaml create mode 100644 k8s/aks/ingress-nginx.values.yaml create mode 100644 k8s/aks/oauth2-proxy.yaml create mode 100644 k8s/aks/values.yaml create mode 100644 k8s/k3d/README.md create mode 100644 k8s/k3d/ca-issuer.yaml create mode 100644 k8s/k3d/deploy.sh create mode 100644 k8s/k3d/dex.yaml create mode 100644 k8s/k3d/oauth2-proxy.yaml create mode 100644 k8s/k3d/postgres.yaml create mode 100644 k8s/k3d/teardown.sh create mode 100644 k8s/k3d/values.yaml diff --git a/README.md b/README.md index 310b770..123242c 100644 --- a/README.md +++ b/README.md @@ -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**, diff --git a/k8s/aks/README.md b/k8s/aks/README.md new file mode 100644 index 0000000..0d7c683 --- /dev/null +++ b/k8s/aks/README.md @@ -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 `` 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:///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= \ + --from-literal=client-secret= \ + --from-literal=cookie-secret=$(openssl rand -hex 16) + ``` + +## 3. Deploy the gate + Interbox + +```sh +# fill and 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:///api/messages | grep -iE '^HTTP|^location' +# cert issued + trusted (DNS-01) or CA-signed (internal CA) +echo | openssl s_client -connect :443 -servername 2>/dev/null | openssl x509 -noout -issuer +``` + +Then browse `https:///`, 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**. diff --git a/k8s/aks/clusterissuer-dns01.yaml b/k8s/aks/clusterissuer-dns01.yaml new file mode 100644 index 0000000..9fcac5f --- /dev/null +++ b/k8s/aks/clusterissuer-dns01.yaml @@ -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 . +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: azuredns-dns01 +spec: + acme: + server: https://acme-v02.api.letsencrypt.org/directory + email: + privateKeySecretRef: + name: azuredns-dns01-account-key + solvers: + - dns01: + azureDNS: + subscriptionID: + resourceGroupName: + hostedZoneName: internal.example # the PUBLIC zone + environment: AzurePublicCloud + managedIdentity: + clientID: diff --git a/k8s/aks/clusterissuer-internal-ca.yaml b/k8s/aks/clusterissuer-internal-ca.yaml new file mode 100644 index 0000000..bcbf048 --- /dev/null +++ b/k8s/aks/clusterissuer-internal-ca.yaml @@ -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 } diff --git a/k8s/aks/ingress-nginx.values.yaml b/k8s/aks/ingress-nginx.values.yaml new file mode 100644 index 0000000..31e39b8 --- /dev/null +++ b/k8s/aks/ingress-nginx.values.yaml @@ -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 diff --git a/k8s/aks/oauth2-proxy.yaml b/k8s/aks/oauth2-proxy.yaml new file mode 100644 index 0000000..d350b36 --- /dev/null +++ b/k8s/aks/oauth2-proxy.yaml @@ -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:///oauth2/callback +# 2. Create a client secret on it. +# 3. kubectl create secret generic oauth2-proxy-secret \ +# --from-literal=client-id= \ +# --from-literal=client-secret= \ +# --from-literal=cookie-secret=$(openssl rand -hex 16) +# +# TEMPLATE: fill and . +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//v2.0 + - --redirect-url=https:///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= + - --whitelist-domain= + - --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 } } } diff --git a/k8s/aks/values.yaml b/k8s/aks/values.yaml new file mode 100644 index 0000000..49e743b --- /dev/null +++ b/k8s/aks/values.yaml @@ -0,0 +1,64 @@ +# Interbox Helm values — AKS, secure (internal ingress + TLS + SSO). +# TEMPLATE: fill every . 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://" # sibling chart or existing Aidbox + INTERBOX_WORKSPACE_GIT_URL: "https://github.com//" + +# 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://:@.postgres.database.azure.com:5432/interbox?sslmode=require" + AIDBOX_CLIENT_SECRET: "" + INTERBOX_LICENSE: "" + +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: "" # pin it — stable VPN/firewall target + loadBalancerSourceRanges: # restrict to sender subnets + - "" + +resources: + requests: { cpu: 250m, memory: 1Gi } diff --git a/k8s/k3d/README.md b/k8s/k3d/README.md new file mode 100644 index 0000000..b5391e8 --- /dev/null +++ b/k8s/k3d/README.md @@ -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:///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. diff --git a/k8s/k3d/ca-issuer.yaml b/k8s/k3d/ca-issuer.yaml new file mode 100644 index 0000000..3ddf4d8 --- /dev/null +++ b/k8s/k3d/ca-issuer.yaml @@ -0,0 +1,23 @@ +# Local internal CA (cert-manager). Stand-in for your org PKI / internal CA — the +# realistic issuer for a PRIVATE dashboard host (Let's Encrypt can't validate a +# private-only name). In production, swap this for a cert-manager DNS-01 issuer +# (public DNS zone) or your internal CA / Key Vault cert. +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: { name: selfsigned } +spec: { selfSigned: {} } +--- +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: { name: local-ca, namespace: cert-manager } +spec: + isCA: true + commonName: interbox-local-ca + secretName: local-ca + privateKey: { algorithm: ECDSA, size: 256 } + issuerRef: { name: selfsigned, kind: ClusterIssuer, group: cert-manager.io } +--- +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: { name: local-ca } +spec: { ca: { secretName: local-ca } } diff --git a/k8s/k3d/deploy.sh b/k8s/k3d/deploy.sh new file mode 100644 index 0000000..53342d2 --- /dev/null +++ b/k8s/k3d/deploy.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# One-command SECURE reference deploy on k3d: +# nginx ingress + cert-manager (internal CA) TLS + oauth2-proxy SSO + Dex IdP +# + Interbox (Helm) + throwaway Postgres. +# +# LOCAL / EVALUATION ONLY: self-signed CA, a test login, and a throwaway Postgres. +# See README.md for the production swaps. Re-runnable (recreates the cluster). +set -euo pipefail +cd "$(dirname "$0")" +CLUSTER=interbox +HTTPS=8443 + +echo "==> (re)create k3d cluster '$CLUSTER' (ingress-nginx, no traefik)" +k3d cluster delete "$CLUSTER" >/dev/null 2>&1 || true +k3d cluster create "$CLUSTER" \ + --k3s-arg "--disable=traefik@server:0" \ + -p "8081:80@loadbalancer" -p "${HTTPS}:443@loadbalancer" --wait + +echo "==> helm repos" +helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx >/dev/null 2>&1 || true +helm repo add jetstack https://charts.jetstack.io >/dev/null 2>&1 || true +helm repo add healthsamurai https://healthsamurai.github.io/helm-charts >/dev/null 2>&1 || true +helm repo update >/dev/null + +echo "==> ingress-nginx" +helm upgrade --install ingress-nginx ingress-nginx/ingress-nginx \ + -n ingress-nginx --create-namespace \ + --set controller.service.type=LoadBalancer --wait + +echo "==> cert-manager + internal CA" +helm upgrade --install cert-manager jetstack/cert-manager \ + -n cert-manager --create-namespace --set crds.enabled=true --wait +kubectl -n cert-manager rollout status deploy/cert-manager-webhook --timeout=120s +kubectl apply -f ca-issuer.yaml +kubectl -n cert-manager wait --for=condition=Ready certificate/local-ca --timeout=90s + +echo "==> postgres" +kubectl apply -f postgres.yaml +kubectl rollout status deploy/interbox-pg --timeout=120s + +echo "==> generate local secrets (bcrypt password hash + client/cookie secrets)" +BHASH=$(docker run --rm httpd:2-alpine htpasswd -bnBC 10 "" 'password' | tr -d ':\n' | sed 's/\$2y/\$2a/') +CLIENT_SECRET=$(openssl rand -hex 24) +COOKIE=$(openssl rand -hex 16) + +echo "==> Dex (OIDC identity provider) + test user" +kubectl create namespace dex --dry-run=client -o yaml | kubectl apply -f - +cat > /tmp/dex-config.yaml < oauth2-proxy (SSO gate)" +kubectl create secret generic oauth2-proxy-secret \ + --from-literal=client-secret="${CLIENT_SECRET}" \ + --from-literal=cookie-secret="${COOKIE}" \ + --dry-run=client -o yaml | kubectl apply -f - +kubectl apply -f oauth2-proxy.yaml +kubectl rollout status deploy/oauth2-proxy --timeout=120s + +echo "==> interbox (Helm) — boots by cloning + building the workspace, give it a minute" +helm upgrade --install interbox healthsamurai/interbox -f values.yaml +for i in $(seq 1 40); do + [ "$(kubectl get pods -l app=interbox -o jsonpath='{.items[0].status.containerStatuses[0].ready}' 2>/dev/null)" = "true" ] && break + sleep 6 +done + +echo +echo "==> verify" +sleep 3 +echo "-- SSO gate (no session -> should 302 to the login):" +curl -sk -o /dev/null -D - "https://interbox.localhost:${HTTPS}/api/messages" | grep -iE '^HTTP|^location' || true +echo "-- TLS issuer (should be the internal CA):" +echo | openssl s_client -connect "interbox.localhost:${HTTPS}" -servername interbox.localhost 2>/dev/null | openssl x509 -noout -issuer || true +echo +echo "Done. Open: https://interbox.localhost:${HTTPS}/" +echo " Login: user@interbox.local / password (accept the self-signed cert warning)" +echo " Down: ./teardown.sh" diff --git a/k8s/k3d/dex.yaml b/k8s/k3d/dex.yaml new file mode 100644 index 0000000..8bdf0e9 --- /dev/null +++ b/k8s/k3d/dex.yaml @@ -0,0 +1,44 @@ +# Dex — a small OIDC identity provider, the LOCAL stand-in for Entra ID / Okta. +# Its config (issuer, client, test user) is generated by deploy.sh into the +# `dex-config` ConfigMap so no secrets are committed. In production, delete this +# and point oauth2-proxy at your real IdP. +apiVersion: apps/v1 +kind: Deployment +metadata: { name: dex, namespace: dex, labels: { app: dex } } +spec: + replicas: 1 + selector: { matchLabels: { app: dex } } + template: + metadata: { labels: { app: dex } } + spec: + containers: + - name: dex + image: ghcr.io/dexidp/dex:v2.41.1 + command: ["dex", "serve", "/etc/dex/config.yaml"] + ports: [{ containerPort: 5556 }] + volumeMounts: [{ name: config, mountPath: /etc/dex }] + volumes: [{ name: config, configMap: { name: dex-config } }] +--- +apiVersion: v1 +kind: Service +metadata: { name: dex, namespace: dex } +spec: + selector: { app: dex } + ports: [{ port: 5556, targetPort: 5556 }] +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: dex + namespace: dex + annotations: { cert-manager.io/cluster-issuer: local-ca } +spec: + ingressClassName: nginx + tls: [{ hosts: [dex.localhost], secretName: dex-tls }] + rules: + - host: dex.localhost + http: + paths: + - path: /dex + pathType: Prefix + backend: { service: { name: dex, port: { number: 5556 } } } diff --git a/k8s/k3d/oauth2-proxy.yaml b/k8s/k3d/oauth2-proxy.yaml new file mode 100644 index 0000000..dcf9004 --- /dev/null +++ b/k8s/k3d/oauth2-proxy.yaml @@ -0,0 +1,65 @@ +# oauth2-proxy — the login gate in front of the dashboard. nginx asks it on every +# request (auth_url); no session -> redirect to the IdP. The client + cookie +# secrets come from the `oauth2-proxy-secret` created by deploy.sh (not committed). +# +# Split endpoints: the browser-facing login URL uses the public host; the +# server-side token/keys calls use the in-cluster Dex service (so it works without +# resolving dex.localhost inside the cluster). In production, replace the Dex URLs +# with your IdP's (e.g. Entra ID) and drop --ssl-insecure-skip-verify. +apiVersion: apps/v1 +kind: Deployment +metadata: { name: oauth2-proxy, labels: { app: oauth2-proxy } } +spec: + replicas: 1 + 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://dex.localhost:8443/dex + - --skip-oidc-discovery=true + - --login-url=https://dex.localhost:8443/dex/auth + - --redeem-url=http://dex.dex.svc.cluster.local:5556/dex/token + - --oidc-jwks-url=http://dex.dex.svc.cluster.local:5556/dex/keys + - --client-id=interbox-dashboard + - --redirect-url=https://interbox.localhost:8443/oauth2/callback + - --email-domain=* + - --http-address=0.0.0.0:4180 + - --reverse-proxy=true + - --upstream=static://200 + - --cookie-secure=true + - --cookie-domain=interbox.localhost + - --whitelist-domain=interbox.localhost:8443 + - --skip-provider-button=true + - --set-xauthrequest=true + - --ssl-insecure-skip-verify=true + env: + - { 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 }] +--- +# /oauth2/* on the dashboard host -> oauth2-proxy (start, callback, auth) +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: { name: interbox-oauth2 } +spec: + ingressClassName: nginx + tls: [{ hosts: [interbox.localhost], secretName: interbox-tls }] + rules: + - host: interbox.localhost + http: + paths: + - path: /oauth2 + pathType: Prefix + backend: { service: { name: oauth2-proxy, port: { number: 4180 } } } diff --git a/k8s/k3d/postgres.yaml b/k8s/k3d/postgres.yaml new file mode 100644 index 0000000..3333812 --- /dev/null +++ b/k8s/k3d/postgres.yaml @@ -0,0 +1,31 @@ +# Throwaway in-cluster Postgres for LOCAL testing only. In production use a managed +# Postgres (private endpoint, sslmode=require) and delete this file — just point +# DATABASE_URL in values.yaml at it. +apiVersion: apps/v1 +kind: Deployment +metadata: { name: interbox-pg, labels: { app: interbox-pg } } +spec: + replicas: 1 + selector: { matchLabels: { app: interbox-pg } } + template: + metadata: { labels: { app: interbox-pg } } + spec: + containers: + - name: postgres + image: postgres:18-alpine + env: + - { name: POSTGRES_USER, value: postgres } + - { name: POSTGRES_PASSWORD, value: postgres } + - { name: POSTGRES_DB, value: postgres } + ports: [{ containerPort: 5432 }] + readinessProbe: + exec: { command: ["pg_isready", "-U", "postgres"] } + initialDelaySeconds: 5 + periodSeconds: 5 +--- +apiVersion: v1 +kind: Service +metadata: { name: interbox-pg } +spec: + selector: { app: interbox-pg } + ports: [{ port: 5432, targetPort: 5432 }] diff --git a/k8s/k3d/teardown.sh b/k8s/k3d/teardown.sh new file mode 100644 index 0000000..4d53653 --- /dev/null +++ b/k8s/k3d/teardown.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +k3d cluster delete interbox diff --git a/k8s/k3d/values.yaml b/k8s/k3d/values.yaml new file mode 100644 index 0000000..a151f20 --- /dev/null +++ b/k8s/k3d/values.yaml @@ -0,0 +1,49 @@ +# Interbox Helm values for the secure reference (dashboard behind nginx + TLS + SSO). +# Local defaults; the annotations/tls block is identical in production — only the +# class, issuer, host, IdP, and DATABASE_URL change (see README). +image: + repository: healthsamurai/interbox + tag: "1.4.1" + pullPolicy: IfNotPresent + +config: + MLLP_HOST: "0.0.0.0" + MLLP_PORT: "2575" + AIDBOX_URL: "http://aidbox.placeholder.svc:8080" # no Aidbox in this reference + INTERBOX_WORKSPACE_GIT_URL: "https://github.com/HealthSamurai/interbox-workspace" + +secrets: + data: + DATABASE_URL: "postgres://postgres:postgres@interbox-pg:5432/interbox?sslmode=disable" + AIDBOX_CLIENT_SECRET: "placeholder" + # INTERBOX_LICENSE left blank -> dashboard shows activation; pipeline parks (fine for a demo) + +database: + createDatabase: true + +# Dashboard/API behind the nginx ingress: cert-manager TLS + oauth2-proxy SSO gate. +ingress: + enabled: true + className: nginx + host: interbox.localhost + path: / + pathType: Prefix + tls: + - hosts: [interbox.localhost] + secretName: interbox-tls + annotations: + cert-manager.io/cluster-issuer: local-ca + nginx.ingress.kubernetes.io/auth-url: "http://oauth2-proxy.default.svc.cluster.local:4180/oauth2/auth" + nginx.ingress.kubernetes.io/auth-signin: "https://interbox.localhost:8443/oauth2/start?rd=$escaped_request_uri" + +mllp: + enabled: true + port: 2575 + service: + type: LoadBalancer + internal: false + +resources: + requests: + cpu: 100m + memory: 512Mi