Add KeyCape login overlay gateway for OpenBao browser UI

Streamline bao.coulomb.social login as "Sign in with KeyCape" via a versioned
nginx gateway that injects overlay assets and proxies to OpenBao. Disable chart
ingress in favor of the overlay ingress, wire make openbao-deploy, and add
openbao-verify-login-overlay with upstream drift detection.
This commit is contained in:
2026-06-19 20:28:16 +02:00
parent 665d43386f
commit 6ddf4e56b4
14 changed files with 728 additions and 22 deletions

View File

@@ -0,0 +1,121 @@
# OpenBao browser UI gateway — injects the KeyCape login overlay and proxies
# to the OpenBao service. Public ingress for bao.coulomb.social targets this
# gateway instead of the chart-managed OpenBao ingress.
#
# ConfigMap data is applied by scripts/openbao-ui-overlay-apply.sh from
# helm/openbao-ui-overlay/*.
apiVersion: apps/v1
kind: Deployment
metadata:
name: openbao-ui-gateway
namespace: openbao
labels:
app.kubernetes.io/name: openbao-ui-gateway
app.kubernetes.io/part-of: railiance-platform
railiance-platform/component: secrets
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: openbao-ui-gateway
template:
metadata:
labels:
app.kubernetes.io/name: openbao-ui-gateway
app.kubernetes.io/part-of: railiance-platform
railiance-platform/component: secrets
spec:
containers:
- name: nginx
image: nginx:1.27-alpine
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 8080
protocol: TCP
readinessProbe:
httpGet:
path: /ui/platform-overlay/presets.json
port: http
initialDelaySeconds: 3
periodSeconds: 10
livenessProbe:
httpGet:
path: /ui/platform-overlay/presets.json
port: http
initialDelaySeconds: 10
periodSeconds: 20
resources:
requests:
cpu: 25m
memory: 32Mi
limits:
cpu: 200m
memory: 128Mi
volumeMounts:
- name: nginx-config
mountPath: /etc/nginx/nginx.conf
subPath: nginx.conf
readOnly: true
- name: overlay-assets
mountPath: /etc/nginx/overlay
readOnly: true
volumes:
- name: nginx-config
configMap:
name: openbao-ui-gateway-nginx
- name: overlay-assets
configMap:
name: openbao-ui-overlay
---
apiVersion: v1
kind: Service
metadata:
name: openbao-ui-gateway
namespace: openbao
labels:
app.kubernetes.io/name: openbao-ui-gateway
app.kubernetes.io/part-of: railiance-platform
railiance-platform/component: secrets
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: openbao-ui-gateway
ports:
- name: http
port: 8080
targetPort: http
protocol: TCP
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: openbao-ui-gateway
namespace: openbao
labels:
app.kubernetes.io/name: openbao-ui-gateway
app.kubernetes.io/part-of: railiance-platform
railiance-platform/component: secrets
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
traefik.ingress.kubernetes.io/router.middlewares: >-
openbao-openbao-rate-limit@kubernetescrd,
openbao-openbao-hsts@kubernetescrd
spec:
ingressClassName: traefik
tls:
- secretName: bao-tls
hosts:
- bao.coulomb.social
rules:
- host: bao.coulomb.social
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: openbao-ui-gateway
port:
number: 8080

View File

@@ -0,0 +1,67 @@
# OpenBao KeyCape login overlay
Streamlines the browser login mask at `https://bao.coulomb.social` to a single
**Sign in with KeyCape** action. Namespace, auth method, mount path, and role
are preset in `presets.json` and hidden by `overlay.css` / `overlay.js`.
## Mechanism (T01 decision)
OpenBao ships UI assets inside the container image. There is no supported API
to customize the login form ([`/sys/config/ui`](https://openbao.org/api-docs/system/config-ui/)
only configures response headers).
We use an **nginx UI gateway** (`openbao-ui-gateway`) that:
1. Proxies all traffic to `openbao.openbao.svc.cluster.local:8200`.
2. Serves overlay assets from a ConfigMap at `/ui/platform-overlay/`.
3. Injects `overlay.css` and `overlay.js` into HTML responses via `sub_filter`.
Overlay assets live entirely in this directory. Upgrading OpenBao does not
require hand-editing files inside the OpenBao pod.
Track upstream [openbao/openbao#2936](https://github.com/openbao/openbao/issues/2936)
for native custom CSS. When available, keep `presets.json` and branding assets
and retire nginx `sub_filter` injection if the upstream API covers the same
behaviour.
## Layout
| File | Purpose |
| --- | --- |
| `VERSION` | OpenBao image tag this overlay targets (`openbao-values.yaml`) |
| `presets.json` | Hidden login defaults (`netkingdom`, `platform-admin`, …) |
| `overlay.css` | Hide raw OpenBao login fields |
| `overlay.js` | Apply presets, branding, mount deep-link |
| `nginx.conf` | Gateway proxy + HTML injection |
| `patches/<version>/manifest.sha256` | Upstream UI fingerprints for drift detection |
## Deploy
From `railiance-platform`:
```bash
make openbao-overlay-apply # overlay only
make openbao-deploy # middleware + overlay + Helm upgrade
make openbao-verify-login-overlay
```
## Reapply after an OpenBao upgrade
1. Bump `server.image.tag` in `helm/openbao-values.yaml`.
2. Deploy: `make openbao-deploy`.
3. Fetch live UI assets and compare hashes:
```bash
curl -sS https://bao.coulomb.social/ui/ -o /tmp/index.html
# locate vault-*.js path in /tmp/index.html, then:
curl -sS "https://bao.coulomb.social/ui/assets/vault-....js" -o /tmp/vault.js
sha256sum /tmp/index.html /tmp/vault.js
```
4. If hashes differ from `patches/<old-version>/manifest.sha256`, update
`overlay.css` / `overlay.js` selectors against the new Ember templates.
5. Write `patches/<new-version>/manifest.sha256`, update `VERSION`.
6. Run `make openbao-verify-login-overlay CHECK_UPSTREAM_DRIFT=1`.
7. Attended browser login through KeyCape MFA.
Workplan: `helix-forge/workplans/HF-WP-0003-openbao-keycape-login-overlay.md`

View File

@@ -0,0 +1 @@
2.5.4

View File

@@ -0,0 +1,45 @@
worker_processes auto;
error_log /dev/stderr notice;
pid /tmp/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log /dev/stdout;
sendfile on;
keepalive_timeout 65;
server_tokens off;
upstream openbao_upstream {
server openbao.openbao.svc.cluster.local:8200;
}
server {
listen 8080;
location /ui/platform-overlay/ {
alias /etc/nginx/overlay/;
add_header Cache-Control "public, max-age=300";
}
location / {
proxy_pass http://openbao_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Disable upstream compression so sub_filter can rewrite HTML.
proxy_set_header Accept-Encoding "";
proxy_buffering on;
sub_filter_types text/html;
sub_filter_once on;
sub_filter '</head>' '<link rel="stylesheet" href="/ui/platform-overlay/overlay.css"><script src="/ui/platform-overlay/overlay.js" defer></script></head>';
}
}
}

View File

@@ -0,0 +1,37 @@
/* KeyCape login overlay for OpenBao UI — see presets.json and overlay.js */
html.keycape-overlay-active .toolbar-namespace-picker,
html.keycape-overlay-active nav.tabs,
html.keycape-overlay-active label[for="namespace"],
html.keycape-overlay-active label[for="role"],
html.keycape-overlay-active label[for="custom-path"],
html.keycape-overlay-active #namespace,
html.keycape-overlay-active #role,
html.keycape-overlay-active #custom-path,
html.keycape-overlay-active select[name="auth-method"],
html.keycape-overlay-active .auth-form .box.has-slim-padding.is-shadowless,
html.keycape-overlay-active .auth-form .has-bottom-margin-s {
display: none !important;
}
html.keycape-overlay-active .splash-page-header .brand-icon-large {
display: none !important;
}
html.keycape-overlay-active h1.title.is-3 {
font-size: 1.45rem;
font-weight: 600;
}
.keycape-overlay-banner {
padding: 0.75rem 1rem;
background: #f4f6f8;
border-bottom: 1px solid #d9dee3;
font-size: 0.875rem;
color: #3d4f5f;
line-height: 1.4;
}
html.keycape-overlay-active .login-form .auth-form {
padding-top: 0.25rem;
}

View File

@@ -0,0 +1,161 @@
(function () {
"use strict";
const PRESETS_URL = "/ui/platform-overlay/presets.json";
const DEFAULT_PRESETS = {
namespace: "",
method: "oidc",
mount: "netkingdom",
role: "platform-admin",
title: "Sign in with KeyCape",
signInLabel: "Sign in with KeyCape",
banner:
"Platform operators authenticate through KeyCape at kc.coulomb.social.",
};
let presets = { ...DEFAULT_PRESETS };
function isAuthPage() {
const path = window.location.pathname;
return (
/\/ui\/vault\/auth(?:\/|$)/.test(path) ||
/\/ui\/?$/.test(path)
);
}
function hideNode(node) {
if (!node) return;
const field =
node.closest(".field.is-horizontal") ||
node.closest(".field") ||
node.closest(".box") ||
node;
field.style.display = "none";
field.setAttribute("aria-hidden", "true");
}
function setInputValue(input, value) {
if (!input || input.value === value) return;
input.value = value;
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
}
function ensureAuthMountSelected() {
const mount = presets.mount || "netkingdom";
const withValue = mount.endsWith("/") ? mount : `${mount}/`;
const params = new URLSearchParams(window.location.search);
const current = params.get("with") || "";
if (current.replace(/\/$/, "") === withValue.replace(/\/$/, "")) {
return;
}
if (!isAuthPage() || window.location.pathname.includes("/oidc/")) {
return;
}
params.set("with", withValue);
const next = `${window.location.pathname}?${params.toString()}`;
if (next !== `${window.location.pathname}${window.location.search}`) {
window.location.replace(next);
}
}
function applyDom() {
if (!isAuthPage()) return;
hideNode(document.querySelector(".toolbar-namespace-picker"));
document
.querySelectorAll(
'#namespace, input[name="namespace"], label[for="namespace"]'
)
.forEach(hideNode);
document
.querySelectorAll('select[name="auth-method"], #auth-method')
.forEach((el) => hideNode(el.closest(".field") || el));
document
.querySelectorAll('#custom-path, input[name="custom-path"]')
.forEach(hideNode);
document
.querySelectorAll('#role, input[name="role"], label[for="role"]')
.forEach(hideNode);
document.querySelectorAll("nav.tabs").forEach((el) => {
el.style.display = "none";
el.setAttribute("aria-hidden", "true");
});
document
.querySelectorAll(".auth-form .has-bottom-margin-s")
.forEach(hideNode);
document.querySelectorAll("h1.title.is-3").forEach((heading) => {
if (/Sign in to OpenBao|Authenticate/.test(heading.textContent)) {
heading.textContent = presets.title;
}
});
document
.querySelectorAll('#auth-submit, button[data-test="auth-submit"]')
.forEach((button) => {
button.textContent = presets.signInLabel;
});
document
.querySelectorAll('#namespace, input[name="namespace"]')
.forEach((input) => setInputValue(input, presets.namespace || ""));
document
.querySelectorAll('#role, input[name="role"]')
.forEach((input) => setInputValue(input, presets.role || "platform-admin"));
if (!document.getElementById("keycape-overlay-banner")) {
const banner = document.createElement("div");
banner.id = "keycape-overlay-banner";
banner.className = "keycape-overlay-banner";
banner.textContent = presets.banner;
const loginForm = document.querySelector(".login-form");
if (loginForm) {
loginForm.prepend(banner);
}
}
document.documentElement.classList.add("keycape-overlay-active");
}
async function loadPresets() {
try {
const response = await fetch(PRESETS_URL, { cache: "no-store" });
if (!response.ok) return;
const data = await response.json();
presets = { ...DEFAULT_PRESETS, ...data };
} catch (_error) {
presets = { ...DEFAULT_PRESETS };
}
}
function observe() {
const observer = new MutationObserver(() => applyDom());
observer.observe(document.body, { childList: true, subtree: true });
applyDom();
}
async function init() {
await loadPresets();
if (!isAuthPage()) return;
ensureAuthMountSelected();
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", observe);
} else {
observe();
}
}
init();
})();

View File

@@ -0,0 +1,8 @@
# OpenBao UI asset fingerprints for image tag 2.5.4.
# Regenerate after an OpenBao image bump when login markup drifts.
# Compare vault.js only — index.html is intentionally modified by the gateway.
# curl -sS https://bao.coulomb.social/ui/ -o /tmp/index.html
# vault_path=$(rg -o '/ui/assets/vault-[a-f0-9]+\\.js' /tmp/index.html | head -1)
# curl -sS "https://bao.coulomb.social${vault_path}" -o /tmp/vault.js
# sha256sum /tmp/vault.js
f0214b5be89377395f8d6521c34139877529bd95ba703901c78b527ab0f1c231 ui/assets/vault-bae6b876038fbf475728f993b5a62002.js

View File

@@ -0,0 +1,9 @@
{
"namespace": "",
"method": "oidc",
"mount": "netkingdom",
"role": "platform-admin",
"title": "Sign in with KeyCape",
"signInLabel": "Sign in with KeyCape",
"banner": "Platform operators authenticate through KeyCape at kc.coulomb.social."
}

View File

@@ -30,24 +30,10 @@ server:
cpu: 500m
memory: 512Mi
# Public browser ingress is owned by helm/openbao-ui-overlay-k8s.yaml so the
# KeyCape login overlay gateway can inject overlay assets.
ingress:
enabled: true
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
traefik.ingress.kubernetes.io/router.middlewares: >-
openbao-openbao-rate-limit@kubernetescrd,
openbao-openbao-hsts@kubernetescrd
ingressClassName: traefik
pathType: Prefix
activeService: true
hosts:
- host: bao.coulomb.social
paths:
- /
tls:
- secretName: bao-tls
hosts:
- bao.coulomb.social
enabled: false
authDelegator:
enabled: true