Add unsealer, internal CA/TLS, auto-unseal, and automated cert+backup
Build out the home-lab OpenBAO deployment beyond the basic node: - docker-compose: add openbao-unsealer sidecar; main node now transit auto-unseals against it (seal config kept in gitignored config/seal.hcl) - policies/admin.hcl: non-root admin policy; per-engine rules for ssh/pki/pki_int/totp/transit - Internal two-tier CA (pki/ root + pki_int/ intermediate) issues the openbao.famfi.home leaf Traefik serves; root CA published under ca/ - scripts/ + systemd/: daily cert renewal and Raft snapshot backups (both instances), with scoped tokens stored outside the repo - README: full runbook (auto-unseal, PKI, renewal, backups, DR/restore) Secrets (init/unsealer keys, tokens, seal stanza) stay gitignored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,7 +1,9 @@
|
|||||||
# NEVER commit unseal keys, root tokens, or storage data
|
# NEVER commit unseal keys, root tokens, or storage data
|
||||||
unseal-keys.json
|
unseal-keys.json
|
||||||
init-output.json
|
init-output.json
|
||||||
|
unsealer-init.json
|
||||||
admin-credentials.txt
|
admin-credentials.txt
|
||||||
|
config/seal.hcl
|
||||||
*.token
|
*.token
|
||||||
config/tls/
|
config/tls/
|
||||||
data/
|
data/
|
||||||
|
|||||||
155
README.md
155
README.md
@@ -7,9 +7,13 @@ storage. Suitable for a self-hosted home lab.
|
|||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `docker-compose.yml` | Container definition (port 8200, Raft data volume, IPC_LOCK) |
|
| `docker-compose.yml` | Main node + `openbao-unsealer` sidecar, Raft volumes |
|
||||||
| `config/openbao.hcl` | Server config: Raft storage, TCP listener, UI |
|
| `config/openbao.hcl` | Main server config: Raft storage, TCP listener, UI |
|
||||||
| `.gitignore` | Keeps unseal keys / tokens / data out of git |
|
| `config/seal.hcl` | Transit auto-unseal stanza (git-ignored — holds a token) |
|
||||||
|
| `config-unsealer/` | Config for the transit unsealer instance |
|
||||||
|
| `policies/admin.hcl` | Non-root admin policy |
|
||||||
|
| `scripts/`, `systemd/` | Automated cert renewal (timer + script) |
|
||||||
|
| `ca/` | Internal root CA cert (public — for trusting on devices) |
|
||||||
|
|
||||||
## 1. Start the server
|
## 1. Start the server
|
||||||
|
|
||||||
@@ -33,13 +37,38 @@ docker compose exec openbao bao operator init \
|
|||||||
in a password manager and delete the file afterward. It is git-ignored, but
|
in a password manager and delete the file afterward. It is git-ignored, but
|
||||||
treat it like the master key to everything — because it is.
|
treat it like the master key to everything — because it is.
|
||||||
|
|
||||||
## 3. Unseal
|
## 3. Unseal — automatic (transit auto-unseal)
|
||||||
|
|
||||||
OpenBAO starts sealed after every restart. Provide 3 of the 5 keys:
|
The main node **auto-unseals** via the `openbao-unsealer` sidecar (transit
|
||||||
|
seal, see "Auto-unseal" below). You normally never unseal it by hand. The
|
||||||
|
original 5 Shamir keys are now **recovery keys** (for root-token regen /
|
||||||
|
recovery operations), not unseal keys.
|
||||||
|
|
||||||
|
> First-time bring-up only: a brand-new install is Shamir-sealed until you run
|
||||||
|
> `bao operator init` + `bao operator unseal` (3 keys) once, before migrating to
|
||||||
|
> transit auto-unseal.
|
||||||
|
|
||||||
|
## Auto-unseal (transit via the unsealer sidecar)
|
||||||
|
|
||||||
|
`openbao-unsealer` is a tiny second OpenBAO instance that holds one transit key
|
||||||
|
(`autounseal`). The main node's `config/seal.hcl` points at it and unwraps its
|
||||||
|
root key on every start — so main-node restarts/upgrades need no manual unseal.
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose exec openbao bao operator unseal # run 3x, paste a key each time
|
|
||||||
```
|
```
|
||||||
|
main openbao ──seal:transit──▶ openbao-unsealer (transit/autounseal)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **The unsealer itself is Shamir-sealed (1 key).** Its key + root token are in
|
||||||
|
`unsealer-init.json` (git-ignored). On a **full host reboot** the unsealer
|
||||||
|
comes up sealed, so unseal it once and the main node follows automatically:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec openbao-unsealer bao operator unseal <unsealer-key>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Caveat: the unsealer currently runs on the same host (SPOF). For real
|
||||||
|
separation, relocate it to another host and point `seal.hcl` at it. To make
|
||||||
|
host reboots fully hands-off, give the unsealer its own boot-unseal.
|
||||||
|
|
||||||
## 4. Log in & use
|
## 4. Log in & use
|
||||||
|
|
||||||
@@ -60,7 +89,19 @@ The `admin` policy ([policies/admin.hcl](policies/admin.hcl)) grants full
|
|||||||
day-to-day administration but **not** root-only operations (sys/raw, root-token
|
day-to-day administration but **not** root-only operations (sys/raw, root-token
|
||||||
generation, rekey). Keep the root token offline.
|
generation, rekey). Keep the root token offline.
|
||||||
|
|
||||||
A KV v2 secrets engine is mounted at `secret/`:
|
### Enabled secrets engines
|
||||||
|
|
||||||
|
| Engine | Path | Notes |
|
||||||
|
|---------|-----------|-------|
|
||||||
|
| KV v2 | `secret/` | static key/value secrets |
|
||||||
|
| SSH | `ssh/` | SSH CA — sign short-lived host/client certs (needs CA + roles configured) |
|
||||||
|
| PKI | `pki/` | internal CA, max lease 10y (needs root/intermediate CA generated) |
|
||||||
|
| TOTP | `totp/` | 2FA code generation/validation |
|
||||||
|
| Transit | `transit/`| encryption-as-a-service (needs a key created) |
|
||||||
|
|
||||||
|
These are mounted but **not yet configured** (no CAs/keys/roles). Each engine
|
||||||
|
mounted at a new path has a matching rule in [policies/admin.hcl](policies/admin.hcl);
|
||||||
|
add one per future engine.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose exec openbao bao kv put secret/myapp/db password=s3cr3t
|
docker compose exec openbao bao kv put secret/myapp/db password=s3cr3t
|
||||||
@@ -79,22 +120,98 @@ by Traefik (default self-signed cert).
|
|||||||
- **DNS action required:** add an A record `openbao.famfi.home → 192.168.0.142`
|
- **DNS action required:** add an A record `openbao.famfi.home → 192.168.0.142`
|
||||||
(the `websecure` entrypoint IP) on your LAN DNS, or a hosts entry on clients.
|
(the `websecure` entrypoint IP) on your LAN DNS, or a hosts entry on clients.
|
||||||
- The built-in **web UI** is served at https://openbao.famfi.home once DNS is set.
|
- The built-in **web UI** is served at https://openbao.famfi.home once DNS is set.
|
||||||
- Because the cert is self-signed, CLI clients pointed at the HTTPS name need the
|
- TLS uses a **trusted cert from OpenBAO's own internal CA** (see PKI section
|
||||||
CA trusted or `BAO_SKIP_VERIFY=true` (local CLI can just use the loopback
|
below) — once you install the root CA on a device, no browser warnings and
|
||||||
http://127.0.0.1:8200 instead).
|
no `BAO_SKIP_VERIFY` needed.
|
||||||
|
|
||||||
## Backups (Raft snapshots)
|
## Internal CA (PKI) & the openbao.famfi.home cert
|
||||||
|
|
||||||
|
OpenBAO runs a two-tier internal CA and issues the cert Traefik serves:
|
||||||
|
|
||||||
|
| Mount | Role | TTL |
|
||||||
|
|-------|------|-----|
|
||||||
|
| `pki/` | Root CA (`famfi.home Internal Root CA`) | 10y |
|
||||||
|
| `pki_int/` | Intermediate CA (`famfi.home Intermediate CA`) | 5y |
|
||||||
|
| `pki_int/roles/famfi-home` | issuing role for `*.famfi.home` | 90d max |
|
||||||
|
|
||||||
|
The leaf for `openbao.famfi.home` lives in Traefik at
|
||||||
|
`/srv/TRAEFIK/etc/traefik/tls/openbao/` and is loaded via
|
||||||
|
`/srv/TRAEFIK/etc/traefik/traefik.d/tls-openbao.yml`.
|
||||||
|
|
||||||
|
**Trust the CA on your devices** (one time) using [ca/famfi-home-root-ca.pem](ca/famfi-home-root-ca.pem):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose exec openbao bao operator raft snapshot save /openbao/file/snap.bak
|
# Linux (Debian/Ubuntu family):
|
||||||
docker compose cp openbao:/openbao/file/snap.bak ./snap-$(date +%F).bak
|
sudo cp ca/famfi-home-root-ca.pem /usr/local/share/ca-certificates/famfi-home-root-ca.crt
|
||||||
|
sudo update-ca-certificates
|
||||||
|
# macOS: add to Keychain and mark trusted. Windows: import to "Trusted Root CAs".
|
||||||
|
# Browsers (Firefox) use their own store — import there too.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Issue a cert for another `.home` service:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec openbao bao write pki_int/issue/famfi-home \
|
||||||
|
common_name="gitea.famfi.home" ttl=2160h
|
||||||
|
```
|
||||||
|
|
||||||
|
**Renewal is automated.** `scripts/renew-openbao-cert.sh` re-issues the leaf and
|
||||||
|
reinstalls it for Traefik (rewriting `traefik.d/tls-openbao.yml` to force a
|
||||||
|
reload — changing the cert file alone does *not* trigger one). A systemd timer
|
||||||
|
(`systemd/openbao-cert-renew.timer`, installed to `/etc/systemd/system`) runs it
|
||||||
|
daily; the script no-ops until the cert is within 21 days of expiry. The scoped
|
||||||
|
renewal token lives at `/etc/openbao-cert-renew.token` (root-only).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl list-timers openbao-cert-renew.timer # next run
|
||||||
|
sudo /home/lutz/Projects/OpenBAO/scripts/renew-openbao-cert.sh --force # renew now
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backups (Raft snapshots) — automated
|
||||||
|
|
||||||
|
`scripts/backup-raft-snapshots.sh` snapshots **both** instances and prunes to
|
||||||
|
the newest `KEEP` (default 14). A systemd timer (`openbao-backup.timer`) runs it
|
||||||
|
daily at ~02:30. Snapshots land in `/var/backups/openbao/{main,unsealer}/`
|
||||||
|
(root-only, 0600). Scoped backup tokens: `/etc/openbao-backup.token`,
|
||||||
|
`/etc/openbao-unsealer-backup.token`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo /home/lutz/Projects/OpenBAO/scripts/backup-raft-snapshots.sh # run now
|
||||||
|
sudo systemctl list-timers openbao-backup.timer # next run
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ **Backups are local to this Pi** — if the disk dies you lose data *and*
|
||||||
|
> backups. Add an offsite copy (e.g. rsync the snapshot dirs to a Synology) for
|
||||||
|
> real DR. This is the most valuable next hardening step.
|
||||||
|
|
||||||
|
### Disaster-recovery set (keep these together, offsite)
|
||||||
|
|
||||||
|
To rebuild from nothing you need **all** of:
|
||||||
|
1. A `main` snapshot **and** an `unsealer` snapshot (same run).
|
||||||
|
2. `unsealer-init.json` — the unsealer's unseal key (without it the unsealer
|
||||||
|
can't be unsealed, so the main node can't be transit-unsealed).
|
||||||
|
3. `init-output.json` — the main node's recovery keys + root token.
|
||||||
|
|
||||||
|
### Restore outline
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Restore the unsealer, unseal it (so transit auto-unseal works again):
|
||||||
|
docker compose cp <unsealer.snap> openbao-unsealer:/tmp/u.snap
|
||||||
|
docker compose exec openbao-unsealer bao operator raft snapshot restore /tmp/u.snap
|
||||||
|
docker compose exec openbao-unsealer bao operator unseal <unsealer-key>
|
||||||
|
# 2. Restore the main node (it auto-unseals via the unsealer):
|
||||||
|
docker compose cp <main.snap> openbao:/tmp/m.snap
|
||||||
|
docker compose exec openbao bao operator raft snapshot restore /tmp/m.snap
|
||||||
```
|
```
|
||||||
|
|
||||||
## Hardening checklist (before storing real secrets)
|
## Hardening checklist (before storing real secrets)
|
||||||
|
|
||||||
- [ ] Put TLS in front (reverse proxy) or enable native TLS in `openbao.hcl`
|
- [x] Put TLS in front (Traefik, LAN-only, trusted internal-CA cert)
|
||||||
- [ ] Create a non-root admin policy + token; stop using the root token day-to-day
|
- [x] Create a non-root admin policy + user; stop using the root token day-to-day
|
||||||
- [ ] Revoke or store the root token offline
|
- [x] Enable auto-unseal (transit via the `openbao-unsealer` sidecar)
|
||||||
- [ ] Enable auto-unseal (e.g. transit/KMS) if you don't want manual unseal on reboot
|
- [x] Automate cert renewal (systemd timer)
|
||||||
- [ ] Schedule the snapshot backup above
|
- [ ] Move the root token + recovery keys offline (out of `init-output.json`)
|
||||||
|
- [x] Schedule the snapshot backup (systemd timer, both instances)
|
||||||
|
- [ ] Copy snapshots offsite (e.g. rsync to a Synology) — backups are local-only
|
||||||
- [ ] Disable or encrypt swap on the host (OpenBAO 2.x dropped mlock support)
|
- [ ] Disable or encrypt swap on the host (OpenBAO 2.x dropped mlock support)
|
||||||
|
- [ ] (Optional) Relocate the unsealer to a second host; consider 3-node HA
|
||||||
|
|||||||
31
ca/famfi-home-root-ca.pem
Normal file
31
ca/famfi-home-root-ca.pem
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
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
|
||||||
|
3g==
|
||||||
|
-----END CERTIFICATE-----
|
||||||
19
config-unsealer/openbao.hcl
Normal file
19
config-unsealer/openbao.hcl
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
ui = false
|
||||||
|
|
||||||
|
# Tiny dedicated instance whose ONLY job is to hold a transit key that
|
||||||
|
# auto-unseals the main OpenBAO node. It is itself Shamir-sealed (unsealed
|
||||||
|
# manually or by a boot script). Keep its footprint minimal.
|
||||||
|
storage "raft" {
|
||||||
|
path = "/openbao/file"
|
||||||
|
node_id = "unsealer-node-1"
|
||||||
|
}
|
||||||
|
|
||||||
|
listener "tcp" {
|
||||||
|
address = "0.0.0.0:8200"
|
||||||
|
tls_disable = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
api_addr = "http://openbao-unsealer:8200"
|
||||||
|
cluster_addr = "https://openbao-unsealer:8201"
|
||||||
|
|
||||||
|
# OpenBAO 2.x: no mlock; disable/encrypt host swap instead.
|
||||||
@@ -26,6 +26,30 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
depends_on:
|
||||||
|
- openbao-unsealer
|
||||||
|
|
||||||
|
# Minimal sidecar instance that provides transit auto-unseal for the main
|
||||||
|
# node above. Shamir-sealed itself; only reachable on the internal network.
|
||||||
|
openbao-unsealer:
|
||||||
|
image: openbao/openbao:2.5.5
|
||||||
|
container_name: openbao-unsealer
|
||||||
|
restart: unless-stopped
|
||||||
|
command: server
|
||||||
|
# No host port published — only the main node needs it, over the default net.
|
||||||
|
environment:
|
||||||
|
BAO_ADDR: "http://127.0.0.1:8200"
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
volumes:
|
||||||
|
- ./config-unsealer:/openbao/config:ro
|
||||||
|
- openbao-unsealer-data:/openbao/file
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "bao", "status", "-address=http://127.0.0.1:8200"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
# Project-internal network (default).
|
# Project-internal network (default).
|
||||||
@@ -37,3 +61,4 @@ networks:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
openbao-data:
|
openbao-data:
|
||||||
|
openbao-unsealer-data:
|
||||||
|
|||||||
@@ -16,12 +16,15 @@ path "sys/policies/acl" { capabilities = ["list"] }
|
|||||||
path "sys/mounts/*" { capabilities = ["create", "read", "update", "delete", "sudo"] }
|
path "sys/mounts/*" { capabilities = ["create", "read", "update", "delete", "sudo"] }
|
||||||
path "sys/mounts" { capabilities = ["read"] }
|
path "sys/mounts" { capabilities = ["read"] }
|
||||||
|
|
||||||
# Work with secrets data in mounted engines.
|
# Work with secrets data in mounted engines. One rule per engine mount path.
|
||||||
|
# NOTE: each secrets engine mounted at a NEW path needs its own rule here.
|
||||||
|
# The UI runs a preflight capability check on the mount path, so missing = 403.
|
||||||
path "secret/*" { capabilities = ["create", "read", "update", "patch", "delete", "list"] }
|
path "secret/*" { capabilities = ["create", "read", "update", "patch", "delete", "list"] }
|
||||||
path "ssh/*" { capabilities = ["create", "read", "update", "patch", "delete", "list", "sudo"] }
|
path "ssh/*" { capabilities = ["create", "read", "update", "patch", "delete", "list", "sudo"] }
|
||||||
# NOTE: each secrets engine mounted at a NEW path needs its own rule here
|
path "pki/*" { capabilities = ["create", "read", "update", "patch", "delete", "list", "sudo"] }
|
||||||
# (e.g. add `path "pki/*"` when you enable a PKI engine). The UI runs a
|
path "pki_int/*" { capabilities = ["create", "read", "update", "patch", "delete", "list", "sudo"] }
|
||||||
# preflight capability check on the mount path, so a missing rule = 403.
|
path "totp/*" { capabilities = ["create", "read", "update", "patch", "delete", "list"] }
|
||||||
|
path "transit/*" { capabilities = ["create", "read", "update", "patch", "delete", "list"] }
|
||||||
|
|
||||||
# Lease management
|
# Lease management
|
||||||
path "sys/leases/*" { capabilities = ["create", "read", "update", "delete", "list", "sudo"] }
|
path "sys/leases/*" { capabilities = ["create", "read", "update", "delete", "list", "sudo"] }
|
||||||
|
|||||||
55
scripts/backup-raft-snapshots.sh
Executable file
55
scripts/backup-raft-snapshots.sh
Executable file
@@ -0,0 +1,55 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Take Raft snapshots of BOTH OpenBAO instances (main + unsealer) and prune old
|
||||||
|
# ones. Run as root (reads the root-only backup tokens, writes /var/backups).
|
||||||
|
#
|
||||||
|
# DR note: a restored MAIN snapshot can only be unsealed with the unsealer's
|
||||||
|
# transit key — so the unsealer snapshot (+ its unseal key in unsealer-init.json
|
||||||
|
# + the recovery keys in init-output.json) are part of the same recovery set.
|
||||||
|
#
|
||||||
|
# Installed as a systemd timer (openbao-backup.timer). Manual run:
|
||||||
|
# sudo /home/lutz/Projects/OpenBAO/scripts/backup-raft-snapshots.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PROJECT_DIR="/home/lutz/Projects/OpenBAO"
|
||||||
|
BACKUP_ROOT="${BACKUP_ROOT:-/var/backups/openbao}"
|
||||||
|
KEEP="${KEEP:-14}" # how many snapshots to retain per instance
|
||||||
|
STAMP="$(date '+%Y%m%d-%H%M%S')"
|
||||||
|
|
||||||
|
log() { printf '%s [backup] %s\n' "$(date '+%F %T')" "$*"; }
|
||||||
|
die() { log "ERROR: $*"; exit 1; }
|
||||||
|
|
||||||
|
cd "$PROJECT_DIR" || die "cannot cd to $PROJECT_DIR"
|
||||||
|
|
||||||
|
# snapshot <service> <token-file> <label>
|
||||||
|
snapshot() {
|
||||||
|
local svc="$1" tokfile="$2" label="$3"
|
||||||
|
[ -r "$tokfile" ] || die "token file $tokfile not readable (run as root?)"
|
||||||
|
local tok dir out incontainer="/tmp/${label}-${STAMP}.snap"
|
||||||
|
tok="$(cat "$tokfile")"
|
||||||
|
dir="${BACKUP_ROOT}/${label}"
|
||||||
|
out="${dir}/openbao-${label}-${STAMP}.snap"
|
||||||
|
install -d -o root -g root -m 0700 "$dir"
|
||||||
|
|
||||||
|
docker compose exec -T -e BAO_TOKEN="$tok" "$svc" \
|
||||||
|
bao operator raft snapshot save "$incontainer" >/dev/null \
|
||||||
|
|| die "snapshot save failed for $svc"
|
||||||
|
docker compose cp "${svc}:${incontainer}" "$out" >/dev/null \
|
||||||
|
|| die "copy-out failed for $svc"
|
||||||
|
docker compose exec -T "$svc" rm -f "$incontainer" >/dev/null 2>&1 || true
|
||||||
|
chmod 0600 "$out"
|
||||||
|
|
||||||
|
# Validate: non-trivial size + gzip integrity (a raft snapshot is a gzip archive)
|
||||||
|
local size; size=$(stat -c %s "$out")
|
||||||
|
[ "$size" -ge 1024 ] || die "snapshot $out suspiciously small (${size}B)"
|
||||||
|
gzip -t "$out" 2>/dev/null && log "OK ${label}: ${out} (${size}B, gzip-valid)" \
|
||||||
|
|| die "snapshot $out failed gzip integrity check"
|
||||||
|
|
||||||
|
# Retention: keep newest $KEEP, delete the rest
|
||||||
|
ls -1t "${dir}"/openbao-${label}-*.snap 2>/dev/null | tail -n +$((KEEP + 1)) | while read -r old; do
|
||||||
|
rm -f -- "$old"; log "pruned old snapshot $(basename "$old")"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot "openbao" "/etc/openbao-backup.token" "main"
|
||||||
|
snapshot "openbao-unsealer" "/etc/openbao-unsealer-backup.token" "unsealer"
|
||||||
|
log "done; retained up to ${KEEP} snapshots per instance under ${BACKUP_ROOT}"
|
||||||
75
scripts/renew-openbao-cert.sh
Executable file
75
scripts/renew-openbao-cert.sh
Executable file
@@ -0,0 +1,75 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Renew the openbao.famfi.home leaf cert from OpenBAO's pki_int engine and
|
||||||
|
# install it where Traefik serves it. Idempotent: only renews when the current
|
||||||
|
# cert expires within $RENEW_WINDOW_DAYS. Run as root (writes Traefik's tls dir).
|
||||||
|
#
|
||||||
|
# Installed as a systemd timer (openbao-cert-renew.timer). Manual run:
|
||||||
|
# sudo /home/lutz/Projects/OpenBAO/scripts/renew-openbao-cert.sh [--force]
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ADDR="${BAO_ADDR:-http://127.0.0.1:8200}"
|
||||||
|
TOKEN_FILE="/etc/openbao-cert-renew.token"
|
||||||
|
ROLE="pki_int/issue/famfi-home"
|
||||||
|
CN="openbao.famfi.home"
|
||||||
|
TTL="2160h" # 90 days
|
||||||
|
DEST="/srv/TRAEFIK/etc/traefik/tls/openbao"
|
||||||
|
DYN="/srv/TRAEFIK/etc/traefik/traefik.d/tls-openbao.yml"
|
||||||
|
RENEW_WINDOW_DAYS="${RENEW_WINDOW_DAYS:-21}" # renew when <= this many days left
|
||||||
|
FORCE="${1:-}"
|
||||||
|
|
||||||
|
log() { printf '%s [renew-cert] %s\n' "$(date '+%F %T')" "$*"; }
|
||||||
|
die() { log "ERROR: $*"; exit 1; }
|
||||||
|
|
||||||
|
[ -r "$TOKEN_FILE" ] || die "token file $TOKEN_FILE not readable (run as root?)"
|
||||||
|
TOKEN="$(cat "$TOKEN_FILE")"
|
||||||
|
|
||||||
|
# Skip if the current cert is still good (unless --force)
|
||||||
|
if [ "$FORCE" != "--force" ] && [ -f "$DEST/fullchain.pem" ]; then
|
||||||
|
end="$(openssl x509 -in "$DEST/fullchain.pem" -noout -enddate | cut -d= -f2)"
|
||||||
|
days_left=$(( ( $(date -d "$end" +%s) - $(date +%s) ) / 86400 ))
|
||||||
|
if [ "$days_left" -gt "$RENEW_WINDOW_DAYS" ]; then
|
||||||
|
log "cert valid ${days_left}d (> ${RENEW_WINDOW_DAYS}d) — nothing to do"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
log "cert has ${days_left}d left (<= ${RENEW_WINDOW_DAYS}d) — renewing"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Issue a fresh cert
|
||||||
|
resp="$(curl -sS --fail-with-body --max-time 15 \
|
||||||
|
-H "X-Vault-Token: ${TOKEN}" \
|
||||||
|
--data "{\"common_name\":\"${CN}\",\"ttl\":\"${TTL}\"}" \
|
||||||
|
"${ADDR}/v1/${ROLE}")" || die "issue request failed"
|
||||||
|
|
||||||
|
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
|
||||||
|
export RESP="$resp"
|
||||||
|
python3 - "$tmp" <<PY
|
||||||
|
import json, sys, os
|
||||||
|
d = json.loads(os.environ["RESP"])["data"]
|
||||||
|
t = sys.argv[1]
|
||||||
|
open(f"{t}/fullchain.pem", "w").write(d["certificate"] + "\n" + "\n".join(d.get("ca_chain", [])) + "\n")
|
||||||
|
open(f"{t}/privkey.pem", "w").write(d["private_key"] + "\n")
|
||||||
|
PY
|
||||||
|
|
||||||
|
# Validate cert/key match before installing
|
||||||
|
cmod="$(openssl x509 -in "$tmp/fullchain.pem" -noout -modulus | openssl md5)"
|
||||||
|
kmod="$(openssl rsa -in "$tmp/privkey.pem" -noout -modulus 2>/dev/null | openssl md5)"
|
||||||
|
[ "$cmod" = "$kmod" ] || die "cert/key modulus mismatch — refusing to install"
|
||||||
|
|
||||||
|
install -d -o root -g root -m 0755 "$DEST"
|
||||||
|
install -o root -g root -m 0644 "$tmp/fullchain.pem" "$DEST/fullchain.pem"
|
||||||
|
install -o root -g root -m 0600 "$tmp/privkey.pem" "$DEST/privkey.pem"
|
||||||
|
|
||||||
|
# Rewrite the watched dynamic config (with a fresh timestamp) so Traefik's file
|
||||||
|
# provider reparses and reloads the cert from disk — changing the cert file
|
||||||
|
# alone does NOT trigger a reload.
|
||||||
|
cat > "$DYN" <<YML
|
||||||
|
# AUTO-MANAGED by renew-openbao-cert.sh — last renewed $(date -Iseconds).
|
||||||
|
# Loads OpenBAO's leaf cert for openbao.famfi.home; the openbao router uses
|
||||||
|
# \`tls: {}\` and serves this by SNI.
|
||||||
|
tls:
|
||||||
|
certificates:
|
||||||
|
- certFile: /etc/traefik/tls/openbao/fullchain.pem
|
||||||
|
keyFile: /etc/traefik/tls/openbao/privkey.pem
|
||||||
|
YML
|
||||||
|
chmod 0644 "$DYN"
|
||||||
|
log "installed new cert ($(openssl x509 -in "$DEST/fullchain.pem" -noout -enddate | cut -d= -f2)); Traefik reload triggered"
|
||||||
9
systemd/openbao-backup.service
Normal file
9
systemd/openbao-backup.service
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Raft snapshot backup of both OpenBAO instances (main + unsealer)
|
||||||
|
After=network-online.target docker.service
|
||||||
|
Wants=network-online.target
|
||||||
|
Requires=docker.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/home/lutz/Projects/OpenBAO/scripts/backup-raft-snapshots.sh
|
||||||
10
systemd/openbao-backup.timer
Normal file
10
systemd/openbao-backup.timer
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Daily Raft snapshot backup of OpenBAO
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 02:30:00
|
||||||
|
Persistent=true
|
||||||
|
RandomizedDelaySec=15m
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
10
systemd/openbao-cert-renew.service
Normal file
10
systemd/openbao-cert-renew.service
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Renew openbao.famfi.home cert from OpenBAO PKI and reload Traefik
|
||||||
|
After=network-online.target docker.service
|
||||||
|
Wants=network-online.target
|
||||||
|
Requires=docker.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/home/lutz/Projects/OpenBAO/scripts/renew-openbao-cert.sh
|
||||||
|
# Script no-ops unless the cert is within its renewal window.
|
||||||
10
systemd/openbao-cert-renew.timer
Normal file
10
systemd/openbao-cert-renew.timer
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Daily check/renew of the openbao.famfi.home leaf cert
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=daily
|
||||||
|
Persistent=true
|
||||||
|
RandomizedDelaySec=1h
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
Reference in New Issue
Block a user