feat(security): OpenBao-backed secret injection via entrypoint
Adds an optional secret-fetch step at container boot. When OPENBAO_ADDR, OPENBAO_ROLE_ID, and OPENBAO_SECRET_ID are set, the entrypoint authenticates to OpenBao via AppRole, pulls kv/ped-ai/prod, and exports each key as a process env var before exec'ing node. When OPENBAO_ADDR is unset the entrypoint is a no-op — the legacy .env flow continues to work unchanged (e2e container, local dev, rollback). Changes: - docker-entrypoint.sh: new — AppRole login + KV fetch + env inject + exec. Fails fast on missing/invalid creds; unsets bootstrap vars before launching node so they don't linger in the process env. - Dockerfile: multi-stage copy of /bin/bao from openbao/openbao:2.5.3 (multi-arch handled automatically by buildx manifest-list resolution). Adds jq for JSON parsing. Wires ENTRYPOINT to the script; CMD remains ["node", "server.js"]. - .env.example: documents the three vault-bootstrap variables at the top and notes that everything below is vault-sourced when OPENBAO_ADDR is set. Rollout is two-phase for safety: rebuild image with unchanged .env (proves no regression in legacy mode), then add the three OpenBao vars and restart to cut over to vault-sourced secrets. Rollback at any point is blanking OPENBAO_ADDR in .env + restart.
This commit is contained in:
parent
1a3c6d312f
commit
1f648da08d
3 changed files with 91 additions and 2 deletions
17
.env.example
17
.env.example
|
|
@ -1,3 +1,20 @@
|
|||
# ============================================================
|
||||
# OPENBAO (optional — recommended for production)
|
||||
# ============================================================
|
||||
# When these three are set, the container fetches everything else below
|
||||
# from OpenBao at kv/ped-ai/prod and ignores the equivalent .env values.
|
||||
# Leave them unset (or blank) to fall back to .env-only (local dev, e2e).
|
||||
#
|
||||
# OPENBAO_ADDR=https://app.danvics.com
|
||||
# OPENBAO_ROLE_ID=<from: bao read auth/approle/role/ped-ai/role-id>
|
||||
# OPENBAO_SECRET_ID=<from: bao write -f auth/approle/role/ped-ai/secret-id>
|
||||
# OPENBAO_KV_PATH=kv/ped-ai/prod # override path if needed
|
||||
|
||||
# ============================================================
|
||||
# Everything below is sourced from OpenBao when OPENBAO_ADDR is set.
|
||||
# Only fill these in for local dev / e2e / when running without vault.
|
||||
# ============================================================
|
||||
|
||||
# ============================================================
|
||||
# AI PROVIDER (choose one)
|
||||
# ============================================================
|
||||
|
|
|
|||
22
Dockerfile
22
Dockerfile
|
|
@ -1,10 +1,21 @@
|
|||
# ─── OpenBao CLI, copied from upstream image (multi-arch automatic) ───
|
||||
# Update the tag here to adopt a newer OpenBao. Binary is statically linked,
|
||||
# safe to drop into the Node alpine image as-is.
|
||||
FROM openbao/openbao:2.5.3 AS bao-src
|
||||
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# ffmpeg: audio conversion for AWS Transcribe (WebM → PCM)
|
||||
# curl: download Whisper models for browser-based transcription
|
||||
RUN apk add --no-cache ffmpeg curl
|
||||
# curl: download Whisper models for browser-based transcription
|
||||
# jq: JSON parsing for the entrypoint's OpenBao secret-fetch step
|
||||
RUN apk add --no-cache ffmpeg curl jq
|
||||
|
||||
# Pull the bao CLI out of the upstream image — matches host arch because
|
||||
# buildx pulls the right manifest-list variant per build.
|
||||
COPY --from=bao-src /bin/bao /usr/local/bin/bao
|
||||
RUN /usr/local/bin/bao version
|
||||
|
||||
COPY package.json ./
|
||||
# argon2 compiles native code via node-gyp — needs python3/make/g++ at build time
|
||||
|
|
@ -14,6 +25,9 @@ RUN apk add --no-cache --virtual .build-deps python3 make g++ \
|
|||
|
||||
COPY . .
|
||||
|
||||
# Ensure the entrypoint is executable regardless of host file permissions
|
||||
RUN chmod +x /app/docker-entrypoint.sh
|
||||
|
||||
RUN mkdir -p /app/data/logs
|
||||
|
||||
# Download Browser Whisper (COMPLETE self-hosting - zero CDN dependencies)
|
||||
|
|
@ -37,5 +51,9 @@ EXPOSE 3000
|
|||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
|
||||
|
||||
# Entrypoint wrapper handles optional OpenBao secret fetch before exec'ing CMD.
|
||||
# See docker-entrypoint.sh for the logic — it is a no-op if OPENBAO_ADDR is
|
||||
# unset, so legacy .env-only deployments continue to work unchanged.
|
||||
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
|
|
|
|||
54
docker-entrypoint.sh
Executable file
54
docker-entrypoint.sh
Executable file
|
|
@ -0,0 +1,54 @@
|
|||
#!/bin/sh
|
||||
# Container entrypoint. Optionally fetches secrets from OpenBao before
|
||||
# starting the app. Backwards compatible: if OPENBAO_ADDR is unset (e.g. e2e
|
||||
# container, local dev with a populated .env), the vault step is skipped
|
||||
# and the process starts with whatever's already in the environment.
|
||||
#
|
||||
# When OPENBAO_ADDR is set, OPENBAO_ROLE_ID + OPENBAO_SECRET_ID are required.
|
||||
# The entrypoint logs in via AppRole, fetches kv/ped-ai/prod, exports each
|
||||
# key as an env var, and then unsets the auth material before execing the
|
||||
# real command so the Node process doesn't carry them.
|
||||
|
||||
set -eu
|
||||
|
||||
if [ -n "${OPENBAO_ADDR:-}" ]; then
|
||||
if [ -z "${OPENBAO_ROLE_ID:-}" ] || [ -z "${OPENBAO_SECRET_ID:-}" ]; then
|
||||
echo "[entrypoint] FATAL: OPENBAO_ADDR is set but OPENBAO_ROLE_ID or OPENBAO_SECRET_ID is missing." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export BAO_ADDR="${OPENBAO_ADDR}"
|
||||
echo "[entrypoint] authenticating to OpenBao at ${OPENBAO_ADDR} via AppRole..."
|
||||
|
||||
BAO_TOKEN="$(bao write -field=token auth/approle/login \
|
||||
role_id="${OPENBAO_ROLE_ID}" \
|
||||
secret_id="${OPENBAO_SECRET_ID}" 2>&1)"
|
||||
if [ -z "${BAO_TOKEN}" ] || printf '%s' "${BAO_TOKEN}" | grep -qi error; then
|
||||
echo "[entrypoint] FATAL: AppRole authentication failed:" >&2
|
||||
echo "${BAO_TOKEN}" >&2
|
||||
exit 1
|
||||
fi
|
||||
export BAO_TOKEN
|
||||
|
||||
SECRET_PATH="${OPENBAO_KV_PATH:-kv/ped-ai/prod}"
|
||||
echo "[entrypoint] fetching secrets from ${SECRET_PATH}..."
|
||||
SECRET_JSON="$(bao kv get -format=json "${SECRET_PATH}" 2>/dev/null | jq -c '.data.data' 2>/dev/null || true)"
|
||||
if [ -z "${SECRET_JSON}" ] || [ "${SECRET_JSON}" = "null" ]; then
|
||||
echo "[entrypoint] FATAL: no secrets returned from ${SECRET_PATH}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Export each key/value as a shell-safe env var.
|
||||
# jq's @sh filter single-quote-escapes values so eval is safe.
|
||||
eval "$(printf '%s' "${SECRET_JSON}" | jq -r 'to_entries[] | "export \(.key)=\(.value | @sh)"')"
|
||||
|
||||
# Bootstrap credentials are no longer needed in the Node process env.
|
||||
unset OPENBAO_ROLE_ID OPENBAO_SECRET_ID BAO_TOKEN
|
||||
|
||||
SECRET_COUNT="$(printf '%s' "${SECRET_JSON}" | jq -r 'keys | length')"
|
||||
echo "[entrypoint] ✅ loaded ${SECRET_COUNT} secrets from OpenBao"
|
||||
else
|
||||
echo "[entrypoint] OPENBAO_ADDR not set — using existing environment (legacy .env path)"
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
Loading…
Reference in a new issue