Establish clean homelab infrastructure baseline
lint / yaml (push) Has been cancelled
lint / ansible (push) Has been cancelled
lint / terraform (push) Has been cancelled

Reorganize the brownfield repository, remove retired and generated artifacts, harden ignore rules, and record the GitOps/IaC redesign.
This commit is contained in:
2026-09-09 16:47:20 +00:00
commit 88a02ababa
418 changed files with 50579 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
# ansible/group_vars/all/vault.yml is now ANSIBLE-VAULT ENCRYPTED and IS committed.
# Password: ../.vault_pass (gitignored) / OpenBao kv/infra/ansible-vault.
# ⚠ If you ever `ansible-vault decrypt` it, DO NOT commit until re-encrypted.
# Local inventory overrides.
ansible/inventory/hosts.local.yml
# Exported PGP public keys used for `bao operator init`.
*.pub.b64
+229
View File
@@ -0,0 +1,229 @@
# OpenBao
Self-hosted secrets management + internal CA (SSH cert authority, dynamic secrets,
KV, PKI, transit) — the root-of-trust for AI agents and internal TLS.
## Design decisions
- **OpenBao, not Vault** — covers every engine we need (SSH CA, KV, dynamic Postgres
creds, transit, PKI) under an OSI license. Vault only if you later need an engine
OpenBao dropped in its fork.
- **On a dedicated host, NOT in k8s** — a secrets/CA root-of-trust must outlive the
cluster it serves (no circular dependency) and sit outside its blast radius.
`k8s-auth` still reaches back into the cluster for in-cluster workloads.
- **Tailscale + LAN only, never the Cloudflare tunnel** — machine clients authenticate
with tokens/JWTs (not browser SSO), and the recovery service must not depend on an
external SaaS to be reachable. Pods use the LAN IP; humans + the laptop agent use
Tailscale.
- **Integrated Raft storage** — no external DB.
## Layout
```
ansible/
create-bao-vm.yml # create the host as a tiny libvirt VM (cloud-init)
provision-openbao.yml # deploy play (install + Raft + TLS + systemd)
inventory/hosts.yml # the bao host (LAN IP)
group_vars/all/vars.yml # version/checksum, addresses, firewall, auto-unseal
group_vars/all/vault.example.yml
roles/bao_vm/ # 1 vCPU / 1 GB / 10 GB Ubuntu VM on br0 (ZFS zvol)
roles/openbao/ # install, TLS bootstrap cert, config, systemd unit
```
`provision-openbao.yml` **only deploys the daemon**. It does not initialize, unseal, or
configure any secrets engines/auth methods — that is a separate bootstrap play (later).
```
terraform/ # OpenBao's API-level CONFIGURATION (see below)
mounts.tf pki.tf ssh.tf auth.tf policies.tf
imports.tf # adopts the already-running instance into state
policies/*.hcl # policy bodies, kept diffable
```
## Ownership: Terraform vs Ansible
**Terraform manages OpenBao itself. Ansible manages the machine under it, plus the
few things Terraform must not own.**
| Terraform (`terraform/`) | Ansible (`ansible/`) |
|---|---|
| secrets engine mounts (`kv`, `pki`, `ssh-client-signer`) | the daemon, Raft, TLS files, systemd |
| PKI role, issuing/CRL URLs, cluster paths, **ACME** | `bao operator init` / unseal (manual, PGP-wrapped) |
| SSH signing role (`ai-agent`) | **PKI root CA + SSH CA signing key** |
| OIDC auth *mount* and *role* | OIDC **client secret** (`auth/oidc/config`) |
| all policies | snapshot token + script + systemd timer |
| | host-level CA trust distribution (`openbao_ssh_ca_trust`) |
Why the exceptions stay in Ansible:
- **Root-of-trust key material** (PKI root CA, SSH CA signing key). A Terraform
resource treats drift as *regenerate*, which would silently invalidate every
issued certificate and every `TrustedUserCAKeys` line on every host. Generated
once, never reconciled.
- **The OIDC client secret.** Terraform cannot read it back from the API, so
managing it would put the plaintext into `terraform.tfstate` **and** produce a
permanent diff. It lives in `group_vars/all/vault.yml` (ansible-vault).
- **Snapshot token + timer.** A systemd unit on a host, and a token that would
otherwise land in state.
The switch is `openbao_config_managed_by_terraform` (default `true`) in
`roles/openbao_bootstrap/defaults/main.yml`. It gates every task Terraform now
owns. Set it `false` only to bootstrap without Terraform — leaving it `false`
against a Terraform-managed instance makes the two overwrite each other on
alternate runs.
**Fresh-install order** (the one ordering constraint this split creates — CA
material needs its mount to exist first):
1. `ansible-playbook provision-openbao.yml` — daemon, TLS, systemd
2. `bao operator init` + unseal — manual, PGP-wrapped to the YubiKey
3. `terraform apply` — mounts, roles, policies, ACME
4. `ansible-playbook bootstrap-openbao.yml` — root CA, SSH CA key, OIDC secret, snapshots
For the **existing** instance, `terraform/imports.tf` adopts what is already
running; the plan must read *"N to import, 0 to destroy"*. A proposed destroy or
replace of `vault_mount.pki` would take the root CA with it — fix the HCL, never
apply through it.
Terraform authenticates from the ambient CLI session (`bao login -method=oidc`,
then `VAULT_ADDR`/`VAULT_TOKEN`), mirroring how `smtp-relay/terraform` uses
`az login`. It targets OpenBao via the **`hashicorp/vault`** provider, because the
native `openbao/openbao` provider is published only to the OpenTofu registry and
cannot be resolved by the HashiCorp `terraform` CLI.
## DNS
`bao.ad.ddupan.top` is an **internal-only** name — not a public Cloudflare record and
not behind the tunnel. Add the A record on the Samba DC (authoritative for
`ad.ddupan.top`), same pattern as the KMS record:
```bash
# on the DC, or with -U administrator%<pass>:
samba-tool dns add 192.168.10.5 ad.ddupan.top bao A 192.168.10.8 -U administrator
```
For remote/off-LAN clients, resolution rides your existing setup: the DC (`192.168.10.5`)
is reachable over the tailnet via the `192.168.10.0/24` subnet route, so point
`ad.ddupan.top` at it in Tailscale **split-DNS** (as you already do for other internal
names). LAN clients that use the DC as resolver get it directly.
## Create the host
A dedicated minimal VM — NOT on the DC or the k8s host — keeps the root-of-trust out of
the blast radius of what it protects. bao is tiny, so this costs almost nothing.
```bash
cd ansible
ansible-galaxy collection install -r requirements.yml
# set openbao_lan_ip in group_vars/all/vars.yml (also the VM's static IP)
ansible-playbook create-bao-vm.yml # runs on the libvirt host (localhost)
```
## Deploy
```bash
# inventory/hosts.yml bao1 IP must match openbao_lan_ip
ansible-playbook provision-openbao.yml --ask-vault-pass
ansible-playbook provision-openbao.yml --tags verify # smoke tests
```
After the first run the node is **uninitialized + sealed** — expected.
## Initialize (once, by hand — PGP-wrapped to your YubiKey)
Encrypt the unseal keys + root token to your YubiKey's GPG public key so they are
never printed in plaintext. Add a **backup offline GPG key** as a second share so a
lost/dead YubiKey doesn't make the data unrecoverable.
```bash
gpg --export <YUBIKEY_KEYID> | base64 > yubikey.pub.b64
gpg --export <BACKUP_KEYID> | base64 > backup.pub.b64
BAO_ADDR=https://127.0.0.1:8200 BAO_SKIP_VERIFY=true \
bao operator init -key-shares=2 -key-threshold=1 \
-pgp-keys="yubikey.pub.b64,backup.pub.b64" \
-root-token-pgp-key="yubikey.pub.b64"
# unseal (decrypt a share — touch the YubiKey):
echo "<encrypted-key-b64>" | base64 -d | gpg -dq | xargs bao operator unseal
```
With transit auto-unseal (`openbao_auto_unseal: true`) there are no unseal keys — use
`-recovery-pgp-keys` instead, and unsealing becomes automatic on restart.
## Bootstrap (engines, auth, policies)
Once initialized + unsealed, configure the bao side. Authenticate with the root token
(decrypt it, then export), and run the bootstrap play:
```bash
echo "<encrypted-root-token-b64>" | base64 -d | gpg -dq # touch YubiKey
export BAO_TOKEN=<plaintext-root-token>
ansible-playbook bootstrap-openbao.yml --ask-vault-pass
ansible-playbook bootstrap-openbao.yml --tags verify
```
It enables **KV v2**, the **SSH CA** (`ssh-client-signer` + `ai-agent` role), **PKI**,
**OIDC auth** (the Authelia `openbao` client → `admin` policy for the
`vault-admins` AD group), the **ai-agent-ssh** policy, and a **Raft snapshot** timer.
Idempotent — safe to re-run. Selective runs via tags: `kv,ssh_ca,oidc,pki,k8s,policies,snapshots`.
Debug a step with `-e openbao_no_log=false`.
Off by default (need extra inputs, enable when ready):
- **Kubernetes auth** — `openbao_enable_k8s_auth: true` + reviewer JWT/CA (for in-cluster agents like hermes).
- **PKI listener cert** — `openbao_pki_replace_listener_cert: true` swaps the self-signed
cert for a PKI-issued one (clients must then trust the PKI root CA; a Shamir node
re-seals on the restart).
Afterwards, create a scoped admin path (OIDC login) and **revoke the root token**:
`bao token revoke -self`.
## Publicly-trusted TLS (ACME DNS-01)
Replace the self-signed listener cert with a **Let's Encrypt** cert so clients drop
`BAO_SKIP_VERIFY`. `ad.ddupan.top` is split-horizon — the DC serves it internally, but
it is **not delegated** in public DNS, so Cloudflare answers `*.ad.ddupan.top`
authoritatively. lego writes a **transient** `_acme-challenge.bao.ad.ddupan.top` TXT into
the Cloudflare `ddupan.top` zone, LE validates, and issues for the **internal** name —
no permanent record, no IP leak. Renewal reloads bao via **SIGHUP** (no restart/reseal).
By default it **reuses the Cloudflare token already managed for the tunnel**
(`cloudflared/terraform/terraform.tfvars` — it has `Zone:DNS:Edit` on ddupan.top), so
there's nothing new to store. To use a dedicated least-privilege token instead, set
`vault_openbao_cf_dns_token` in `vault.yml`.
```bash
ansible-playbook acme-openbao.yml --ask-vault-pass
```
Test against LE **staging** first to avoid rate limits: set `openbao_acme_server:
https://acme-staging-v02.api.letsencrypt.org/directory`, run, confirm, then clear it and
re-run for a real cert (`rm -rf /etc/openbao/acme` on the host between the two to reset).
Note: the LE cert covers the **hostname only** (`bao.ad.ddupan.top`), not the IP — so
after this, use `BAO_ADDR=https://bao.ad.ddupan.top:8200` (no skip-verify), not the IP.
## Using it
```bash
# human: log in via Authelia (2FA)
bao login -method=oidc # browser → auth.ddupan.top
# agent: mint a 5-min SSH cert for a target, then connect
ssh-keygen -t ed25519 -f /run/agent/id -N ''
bao write -field=signed_key ssh-client-signer/sign/ai-agent \
public_key=@/run/agent/id.pub valid_principals=<node> > /run/agent/id-cert.pub
ssh -i /run/agent/id <user>@<node>
```
Each no-root target trusts the CA via one line in `~/.ssh/authorized_keys` (the play
prints it): `cert-authority,principals="<node>",restrict,pty <ca-pubkey>`.
## Operational notes
- **Restart re-seals** a Shamir node (needs manual unseal). Harmless before first init;
automatic re-unseal with transit auto-unseal.
- **Back up** Raft snapshots off-box once initialized: `bao operator raft snapshot save`
(belongs in the bootstrap play — it needs a token).
- The listener uses a **self-signed bootstrap cert**; replace it with a cert issued by
OpenBao's own PKI engine in the bootstrap play.
@@ -0,0 +1,19 @@
---
# Obtain a publicly-trusted Let's Encrypt cert for bao's listener via ACME DNS-01
# (Cloudflare), replacing the self-signed bootstrap cert, and set up auto-renewal.
#
# ad.ddupan.top is split-horizon: the DC serves it internally, but it is NOT delegated
# in public DNS, so Cloudflare answers *.ad.ddupan.top authoritatively. lego drops a
# transient _acme-challenge.bao.ad.ddupan.top TXT into the Cloudflare ddupan.top zone,
# LE validates it, and the cert is issued for the internal name — no permanent record,
# no IP leak. Renewal reloads bao via SIGHUP (no restart, no reseal).
#
# ansible-playbook acme-openbao.yml --ask-vault-pass
#
# Needs vault_openbao_cf_dns_token — a Cloudflare API token with Zone:DNS:Edit on ddupan.top.
- name: OpenBao ACME certificate (Let's Encrypt via Cloudflare DNS-01)
hosts: openbao
become: true
gather_facts: true
roles:
- role: openbao_acme
@@ -0,0 +1,12 @@
[defaults]
inventory = inventory/hosts.yml
roles_path = roles
host_key_checking = False
callback_result_format = yaml
nocows = True
# All hosts here are Linux (sudo). Plays declare `become: true` themselves.
# Encrypted group_vars/all/vault.yml are COMMITTED; the password is not.
# Relative to this file, so it resolves for any checkout location.
vault_password_file = ../../../.vault_pass
@@ -0,0 +1,26 @@
---
# Bootstrap a running, INITIALIZED + UNSEALED OpenBao: enable engines/auth/policies.
# Idempotent — safe to re-run. Authenticates with a token you export after decrypting
# the PGP-wrapped root token:
#
# echo "<encrypted-root-token-b64>" | base64 -d | gpg -dq # touch YubiKey
# export BAO_TOKEN=<that-plaintext-root-token>
# ansible-playbook bootstrap-openbao.yml --ask-vault-pass
#
# Run selectively with tags: --tags kv,ssh_ca,oidc,pki,k8s,policies,snapshots,verify
# Debug a step by disabling no_log: -e openbao_no_log=false
#
# After bootstrap, create a scoped admin token/OIDC login and REVOKE the root token:
# bao token revoke -self
- name: Bootstrap OpenBao (engines, auth, policies)
hosts: openbao
become: true
gather_facts: true
roles:
- role: openbao_bootstrap
post_tasks:
- name: Smoke tests
ansible.builtin.import_role:
name: openbao_bootstrap
tasks_from: verify.yml
tags: [verify, never]
@@ -0,0 +1,16 @@
---
# Create the OpenBao host as a small Ubuntu VM locally via libvirt + cloud-init.
# Runs on the libvirt host itself (localhost / qemu:///system).
# ansible-playbook create-bao-vm.yml
# Then deploy + initialize OpenBao:
# ansible-playbook provision-openbao.yml --ask-vault-pass
#
# Deliberately its own minimal VM — NOT co-located on the DC or the k8s host — so the
# secrets/CA root-of-trust stays out of the blast radius of what it protects.
- name: Create the OpenBao VM
hosts: localhost
connection: local
become: true
gather_facts: false
roles:
- role: bao_vm
@@ -0,0 +1,31 @@
---
# Non-secret variables for the OpenBao deploy. EDIT to your environment.
# Secrets (transit unseal token) live in group_vars/all/vault.yml — see vault.example.yml.
# --- Release (pinned + checksum-verified) ---
# Bump both together. Get the checksum from the release's checksums.txt:
# curl -sL https://github.com/openbao/openbao/releases/download/v<VER>/checksums.txt \
# | grep openbao_<VER>_linux_amd64.tar.gz
openbao_version: "2.6.1"
openbao_download_checksum: "sha256:ca8d836eb3a5c80407e45e762300b64e7138c419e78826955f2e4ba4ce6d8a6b"
# --- Identity / addresses ---
openbao_fqdn: "bao.ad.ddupan.top" # A record on the Samba DC (ad.ddupan.top zone)
openbao_lan_ip: "192.168.10.8" # bao's LAN IP — pods use this; also the VM's static IP
# --- Optional host firewall ---
# Restrict the API/cluster ports to tailnet + cluster once you're ready (needs ufw).
openbao_manage_firewall: false
openbao_allowed_cidrs:
- "100.64.0.0/10" # Tailscale CGNAT range (remote humans + laptop agent)
- "192.168.10.0/24" # LAN / k8s cluster
# --- Auto-unseal (transit) ---
# Leave OFF for the first init so you do a normal Shamir init (keys handed to you).
# Turn ON once a second bao/transit source exists, then supply the token from vault
# and re-run the play (a restart re-reads config; auto-unseal makes restarts hands-off).
openbao_auto_unseal: false
openbao_transit_address: "https://bao-seal.ddupan.top:8200"
openbao_transit_key_name: "autounseal"
openbao_transit_mount_path: "transit/"
openbao_transit_token: "{{ vault_openbao_transit_token | default('') }}"
@@ -0,0 +1,19 @@
---
# Copy to vault.yml and encrypt: ansible-vault encrypt group_vars/all/vault.yml
# NEVER commit the decrypted vault.yml.
#
# Only needed once you switch to transit auto-unseal (openbao_auto_unseal: true).
# This is the token the seal source hands out for the autounseal transit key —
# scope it to just encrypt/decrypt on that key.
vault_openbao_transit_token: "CHANGE-ME-transit-unseal-token"
# Plaintext of the Authelia 'openbao' OIDC client secret (Authelia stores the pbkdf2
# hash; OpenBao holds this plaintext). Used by the bootstrap play's auth/oidc config.
vault_openbao_oidc_client_secret: "CHANGE-ME-authelia-openbao-client-secret"
# Reviewer SA JWT for Kubernetes auth (only when openbao_enable_k8s_auth: true).
# kubectl -n agents create token bao-reviewer --duration=87600h
vault_openbao_k8s_reviewer_jwt: "CHANGE-ME-k8s-reviewer-jwt"
# Cloudflare API token for ACME DNS-01 (acme-openbao.yml). Scope: Zone:DNS:Edit on ddupan.top.
vault_openbao_cf_dns_token: "CHANGE-ME-cloudflare-dns-edit-token"
@@ -0,0 +1,88 @@
$ANSIBLE_VAULT;1.1;AES256
32653035643430653731303165663162316566633364633933376235326265653032313939363632
3730656332633132643935616238313739643264383866390a306637656430646262646339346661
31623536346535336336306639333161373134313536396166623334323633613465363834363864
3434633836356138370a336430316164326538383866646134353565393739303263636532346661
66623830393366626631666363313639393463626264356530623435636432643961653832386162
63323864393763396633353365653437646564646534383638323439663664316639633766316330
63373134613065656561363030616466636262356439656530383661393033386534333661323037
64333139356330373636316438353733376235623531346437333666383936333937373730333333
39333733333964383535666235633331373663313438353366333439383765633962386665376337
35303739343865386562326163306438646238613663373561353132323139373063303337656238
36383162643762313362336232663266383034386238346639646336643463623763393361356565
34616464326330373262383736353061366538333739653163636530383566313039623539313134
38646530663137666238313036333038303964663036643035333062616438323239656565643432
36656535383563393364626465393635343433646661643037643536373666353562333261333162
34383635336565366431393663343464656438613138396563386531663862623861353061396166
31383937653633373734383536323366313364363864626239646465656361346265316364636664
37666465313336636638613635393261343065333533366638646161333964323634343237646430
37353030393336376662383961303662386632666262346130646631346166383262393639363433
33636463323737396466626131323834623064373035396566356563393862373562633564366165
32643433333431626237326633336564356437653033376362666435363938643033643235333561
33653636383631663435623064353133393561656539366238396331313336363138353066613530
38656633303262393336346233313761343036623965633836376336386537636535633837393133
36366331356437316236326432666362643736373038616163663265383762323837316430323266
36353432313930636431316638326630633534346635333130393031666361356463383432616664
63383732306435653134303566366633393437316663353136366662653462666439646339643430
33653738323764386131623166313030313734313933316561363439386663636261343436653232
66623465326631663462633466616335623265343034623161326161616138643131646436396535
34663330666363626631336264656566306237343539353932343535313035346438323838366434
62663934363737633232353066363231353635626339643862353266656562643235366164636163
31613432326634633161373038636537626266353365306331383661343538626133343965623764
38373532646265633964326435663430356536336234376135636332333165613131323566633562
31636433393466646130346238366462306236383966333739316133376638656462653761316131
30356265326263633834353362643238353963636665303063316637373230353732363333386636
63323333303861323830356336346536376561643136363961653535396562666266383162353061
61396364616331626466356661636433363239383266633935353865336266383636363431386331
64316634353665653966386536333966663633313865303235366164383434636334366531303337
34373634306465613435303930336265303731373931316263323164343166336334623465626466
30643736623435343530666137383132386364623533346132633563616239393433386361623136
32383531366365633564323835376433376462313664616433303361366238303264633061353262
61613530393832306265623837356232666131333235663866363830666235383366623330646338
65656266393237366636383037643131393761666164633163646161616562623965653735653766
65633731366338346337366332383135323364616666653963383532353362366437656434626433
32386230653564613035333032666364306537353964346437313561356635336330336465303533
33366533653265363366633039326434366537396661663235613938386262313637383064393235
38666265383839633165626334336365666365343639356662303966613236383538373762633131
32653237633238633565303532653331343131303530313537366138626434336264643532626337
66326335333937643036383063303561666237386434303935383334326364336463306331663864
33376263633430383763346631306331353638653063383132313132303332613036353663316234
64383663323131383831653533376666323666313031663862376266636262373635633932343064
64623761356537656631316134656136663962343431373136633465333362316263316530313664
36303062303938623764326131396635633239323961323064333436313564633635383038663838
37363735393639323464663531376537386431393431303232613335643565303463656235643663
63323035396164623839623663366430363361313563643462346335623061316361333530393766
35353864313937633065333533323330626337643733623332303462343435366664623862313363
30623631386139616664323562333433663862646430333131626134666233653762363936653165
63623061316330383537396536313263376536316563303766383237636164376465623863356334
39373863343232323361333939393264303030383430313864616261333238616637343834313264
36333130376339646461346530343461383265613538376135363565666563633031613332633136
32666161613766626636653533363131353137653139336265363337373265626365306333613136
64363663393039363734316139633231613562623466616333653063643531313430643433356231
32646639393637316530623339316239306265353333363737643232656330623634333533653232
39373564316433353263653061376334376263343935333831653937393434666430326137613339
36386634343166646164353066343665333066613538373639393733316438356235323961653132
33373264363335346633636336316631323064636238333031336333366537366139323636393766
39376632363935633835356163646235326366623035653730346331333237363063323861633639
61613566343561376466626133306634643437386364643839343535313661613462316263383031
33373534396566383039643930393337613233306164356636383333626663643037363232623539
38353863636535383830343734663337633633626133623230626235646332343166366232633962
39646333336434643063633765386466653632386431393430356135343165326461373934366133
33663137306566316361326633616436393962313564346566393563646263616335383830366139
35666437636238323135306231346436626563386237383134343465666231373331363965316265
31313838383236396433306430376533383139326466383438383366396537643935653230383963
62616365306564326661386461303662396565613561653938636161326437333162363935643337
36633064643035363532363161383264343634383463653563333236666665616566666363623838
31393036333934303534393965653662343064656339343039383730653461343932363335306231
36626534363966323431333532353639643533663339616262336134363261393163346236316633
39313161303166643337366265663162626637323666366363386335343931313837623231303164
34663633396532346266663032376363373262376238653435646261366633386531613538356632
30636639653966383637323635653539613039623039623463333535613561616539386334323866
39373133316637633330653636313436313635396539356436343631356461303639376265623862
32303831353338323265373636613834356366666137666234633536343837326534386162343730
35383938643662363266316365383930663939666134346562363363363661633335363938626262
63623565366439323230633663333861313638656632396164313032643138363665663961656539
31346135373066363038303038643431636139373331616663623638366331656336626664646166
61346461626336363432636633613836326236666664663663356461373331613739663163326366
31626230383132626437386361616237666130366536656136663931666239363430336433626535
623564643364303638626233343331336361
@@ -0,0 +1,22 @@
---
# Inventory for the OpenBao root-of-trust host.
# A single dedicated VM/host on the LAN (like the Samba DC) — NOT in the k8s cluster.
all:
children:
openbao:
hosts:
bao1:
ansible_host: 192.168.10.8 # bao's static LAN IP — match openbao_lan_ip
ansible_user: ansible # cloud-init / provisioning user with sudo
# Root-managed hosts that should trust bao's SSH user CA (TrustedUserCAKeys).
# Add any node you want agents to reach with signed certs.
ssh_ca_trust:
hosts:
bao1: {} # bao itself (connection vars from the openbao group)
dc1:
ansible_host: 192.168.10.5 # Samba AD DC
ansible_user: ansible
laptop:
ansible_connection: local # this machine (192.168.10.127), passwordless sudo
ansible_host: 127.0.0.1
@@ -0,0 +1,31 @@
---
# Deploy the OpenBao server: install binary + integrated Raft storage + TLS listener
# + systemd unit. This play does NOT initialize, unseal, or configure any secrets
# engines / auth methods — that is the separate bootstrap play (added later).
#
# ansible-playbook provision-openbao.yml --ask-vault-pass
# ansible-playbook provision-openbao.yml --tags verify # smoke tests only
#
# After the first run the node is UNINITIALIZED + SEALED. Initialize it once, by hand,
# encrypting the unseal keys + root token to your YubiKey's GPG public key so they are
# never printed in plaintext (add a backup offline GPG key as a second share):
# gpg --export <YUBIKEY_KEYID> | base64 > yubikey.pub.b64
# BAO_ADDR=https://127.0.0.1:8200 BAO_SKIP_VERIFY=true \
# bao operator init -key-shares=2 -key-threshold=1 \
# -pgp-keys="yubikey.pub.b64,backup.pub.b64" \
# -root-token-pgp-key="yubikey.pub.b64"
# # decrypt a key to unseal (touch YubiKey):
# echo "<encrypted-key-b64>" | base64 -d | gpg -dq | xargs bao operator unseal
# With transit auto-unseal, use -recovery-pgp-keys instead (unseal is then automatic).
- name: OpenBao server
hosts: openbao
become: true
gather_facts: true
roles:
- role: openbao
post_tasks:
- name: Smoke tests
ansible.builtin.import_role:
name: openbao
tasks_from: verify.yml
tags: [verify, never]
@@ -0,0 +1,5 @@
---
# Install with: ansible-galaxy collection install -r requirements.yml
collections:
- name: community.general # ufw (optional host firewall)
- name: community.crypto # openssl_* for the bootstrap TLS listener cert
@@ -0,0 +1,37 @@
---
# bao_vm role — create the OpenBao host as a small Ubuntu VM via libvirt + cloud-init
# (NoCloud). Runs on the libvirt host (localhost). Configures nothing inside the OS
# beyond the cloud-init seed; the openbao role installs the daemon afterward.
bao_vm_name: "bao1"
bao_vm_domain: "ddupan.top"
# Tiny footprint — OpenBao idle is ~50-150 MB RAM, one Go process.
bao_vm_vcpus: 1
bao_vm_memory_mb: 1024
bao_vm_disk_gb: 10
# Latest Ubuntu LTS cloud image (24.04 Noble). "current" always points at the newest build.
bao_vm_image_url: "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img"
bao_vm_osinfo: "ubuntu24.04"
# libvirt placement (matches the dc_vm convention).
bao_vm_images_dir: "/var/lib/libvirt/images"
bao_vm_bridge: "br0" # LAN bridge → puts bao on 192.168.10.0/24
# ZFS zvol for the root disk. Thin-provisioned (sparse) — bao stores very little.
bao_vm_zvol_parent: "data/vm"
bao_vm_zvol: "{{ bao_vm_zvol_parent }}/{{ bao_vm_name }}"
bao_vm_zvol_dev: "/dev/zvol/{{ bao_vm_zvol }}"
bao_vm_zvol_volblocksize: "16K"
bao_vm_zvol_sparse: true
# Guest networking (static). IP comes from group_vars/all/vars.yml (openbao_lan_ip).
bao_vm_ip: "{{ openbao_lan_ip }}"
bao_vm_prefix: 24
bao_vm_gateway: "192.168.10.1"
bao_vm_boot_dns: "192.168.10.5" # internal resolver (the Samba DC); or the gateway
# Cloud-init login user + the public key Ansible will connect with.
bao_vm_user: "ansible"
bao_vm_ssh_pubkey_file: "~/.ssh/id_ed25519.pub"
@@ -0,0 +1,113 @@
---
# Create the OpenBao VM on the local libvirt host. Idempotent: if the domain already
# exists it does nothing. Run on localhost with qemu:///system (become: true).
- name: Resolve the SSH public key to inject
ansible.builtin.set_fact:
bao_vm_ssh_pubkey: "{{ lookup('file', bao_vm_ssh_pubkey_file | expanduser) }}"
- name: Fail early if no usable public key
ansible.builtin.assert:
that:
- bao_vm_ssh_pubkey is search('^ssh-')
fail_msg: >-
No SSH public key at {{ bao_vm_ssh_pubkey_file }}. Generate one
(ssh-keygen -t ed25519) or set bao_vm_ssh_pubkey_file.
- name: Check whether the libvirt domain already exists
ansible.builtin.command: "virsh dominfo {{ bao_vm_name }}"
register: bao_vm_dominfo
changed_when: false
failed_when: false
- name: Create the VM
when: bao_vm_dominfo.rc != 0
block:
- name: Ensure image directories exist
ansible.builtin.file:
path: "{{ item }}"
state: directory
mode: "0711"
loop:
- "{{ bao_vm_images_dir }}"
- "{{ bao_vm_images_dir }}/base"
- name: Download the Ubuntu cloud image (once)
ansible.builtin.get_url:
url: "{{ bao_vm_image_url }}"
dest: "{{ bao_vm_images_dir }}/base/{{ bao_vm_image_url | basename }}"
mode: "0644"
- name: Check whether the root-disk zvol already exists
ansible.builtin.command: "zfs list -H -o name {{ bao_vm_zvol }}"
register: bao_vm_zvol_check
changed_when: false
failed_when: false
- name: Create the root-disk zvol
ansible.builtin.command:
cmd: >-
zfs create {{ '-s ' if bao_vm_zvol_sparse else '' }}-V {{ bao_vm_disk_gb }}G
-o volblocksize={{ bao_vm_zvol_volblocksize }}
{{ bao_vm_zvol }}
when: bao_vm_zvol_check.rc != 0
- name: Wait for the zvol device node to appear
ansible.builtin.wait_for:
path: "{{ bao_vm_zvol_dev }}"
timeout: 30
when: bao_vm_zvol_check.rc != 0
- name: Write the cloud image into the zvol (raw)
ansible.builtin.command:
cmd: >-
qemu-img convert -O raw
{{ bao_vm_images_dir }}/base/{{ bao_vm_image_url | basename }}
{{ bao_vm_zvol_dev }}
when: bao_vm_zvol_check.rc != 0
# cloud-init growpart expands the rootfs to fill the zvol on first boot.
- name: Render the cloud-init seed files
ansible.builtin.template:
src: "{{ item }}.j2"
dest: "{{ bao_vm_images_dir }}/{{ bao_vm_name }}-seed-{{ item }}"
mode: "0644"
loop:
- user-data
- meta-data
- network-config
- name: Build the NoCloud seed ISO
ansible.builtin.command:
cmd: >-
genisoimage -output {{ bao_vm_images_dir }}/{{ bao_vm_name }}-seed.iso
-volid cidata -joliet -rock
-graft-points
user-data={{ bao_vm_images_dir }}/{{ bao_vm_name }}-seed-user-data
meta-data={{ bao_vm_images_dir }}/{{ bao_vm_name }}-seed-meta-data
network-config={{ bao_vm_images_dir }}/{{ bao_vm_name }}-seed-network-config
args:
creates: "{{ bao_vm_images_dir }}/{{ bao_vm_name }}-seed.iso"
- name: Define and start the domain (cloud-init imports the disk)
ansible.builtin.command:
cmd: >-
virt-install
--name {{ bao_vm_name }}
--memory {{ bao_vm_memory_mb }}
--vcpus {{ bao_vm_vcpus }}
--osinfo require=off,name={{ bao_vm_osinfo }}
--disk path={{ bao_vm_zvol_dev }},format=raw,bus=virtio
--disk path={{ bao_vm_images_dir }}/{{ bao_vm_name }}-seed.iso,device=cdrom
--network bridge={{ bao_vm_bridge }},model=virtio
--graphics none --noautoconsole --import
register: virt_install
changed_when: true
- name: Wait for SSH on the new bao host
ansible.builtin.wait_for:
host: "{{ bao_vm_ip }}"
port: 22
delay: 10
timeout: 300
when: bao_vm_dominfo.rc != 0
@@ -0,0 +1,2 @@
instance-id: {{ bao_vm_name }}-001
local-hostname: {{ bao_vm_name }}
@@ -0,0 +1,20 @@
version: 2
ethernets:
primary:
# Match the (single) ethernet NIC by kernel name and configure it in place.
# NOTE: do NOT add set-name here — netplan only supports set-name when matching
# on mac/driver, not on name, and a name-match + rename leaves the NIC unconfigured.
match:
name: "en*"
dhcp4: false
dhcp6: false
addresses:
- {{ bao_vm_ip }}/{{ bao_vm_prefix }}
routes:
- to: default
via: {{ bao_vm_gateway }}
nameservers:
addresses:
- {{ bao_vm_boot_dns }}
search:
- {{ bao_vm_domain }}
@@ -0,0 +1,18 @@
#cloud-config
# NoCloud user-data for the OpenBao host VM.
hostname: {{ bao_vm_name }}
fqdn: {{ bao_vm_name }}.{{ bao_vm_domain }}
preserve_hostname: false
users:
- name: {{ bao_vm_user }}
groups: [sudo]
shell: /bin/bash
sudo: "ALL=(ALL) NOPASSWD:ALL"
lock_passwd: true
ssh_authorized_keys:
- {{ bao_vm_ssh_pubkey }}
ssh_pwauth: false
package_update: true
package_upgrade: false
@@ -0,0 +1,49 @@
---
# openbao role defaults — override in group_vars/host_vars.
# Secrets (openbao_transit_token) MUST come from an Ansible Vault file, not here.
# --- Release ---
openbao_version: "2.6.1"
openbao_download_checksum: "" # "sha256:..." REQUIRED — set in group_vars
openbao_download_url: "https://github.com/openbao/openbao/releases/download/v{{ openbao_version }}/openbao_{{ openbao_version }}_linux_amd64.tar.gz"
# --- Service account ---
openbao_user: "openbao"
openbao_group: "openbao"
# --- Paths ---
openbao_bin: "/usr/local/bin/bao"
openbao_config_dir: "/etc/openbao"
openbao_tls_dir: "/etc/openbao/tls"
openbao_data_dir: "/opt/openbao/data" # integrated Raft storage
# --- Identity / addresses ---
openbao_fqdn: "bao.example.com"
openbao_lan_ip: "10.10.10.10"
openbao_api_port: 8200
openbao_cluster_port: 8201
openbao_ui: true
openbao_node_id: "{{ ansible_facts['hostname'] }}"
# Listener binds all interfaces; lock it down with the firewall vars below.
openbao_listen_address: "0.0.0.0:{{ openbao_api_port }}"
openbao_api_addr: "https://{{ openbao_lan_ip }}:{{ openbao_api_port }}"
openbao_cluster_addr: "https://{{ openbao_lan_ip }}:{{ openbao_cluster_port }}"
# SANs for the bootstrap self-signed listener cert (replace with PKI-issued later).
openbao_tls_sans:
- "DNS:{{ openbao_fqdn }}"
- "IP:{{ openbao_lan_ip }}"
- "IP:127.0.0.1"
# --- Auto-unseal (transit) — off by default (Shamir manual unseal on first init) ---
openbao_auto_unseal: false
openbao_transit_address: ""
openbao_transit_token: ""
openbao_transit_key_name: "autounseal"
openbao_transit_mount_path: "transit/"
# --- Optional host firewall ---
openbao_manage_firewall: false
openbao_allowed_cidrs:
- "100.64.0.0/10"
@@ -0,0 +1,11 @@
---
- name: reload systemd
ansible.builtin.systemd:
daemon_reload: true
# NOTE: restarting a running, Shamir-sealed node re-seals it (manual unseal needed).
# Harmless before first init; automatic re-unseal when transit auto-unseal is on.
- name: restart openbao
ansible.builtin.systemd:
name: openbao
state: restarted
@@ -0,0 +1,11 @@
---
galaxy_info:
role_name: openbao
description: Deploy OpenBao (secrets management) with integrated Raft storage + TLS listener, under systemd.
min_ansible_version: "2.15"
platforms:
- name: Debian
versions: [bookworm]
- name: Ubuntu
versions: [jammy, noble]
dependencies: []
@@ -0,0 +1,138 @@
---
# Deploy OpenBao as a systemd service with integrated Raft storage + a TLS listener.
# This role ONLY installs and runs the daemon. It does NOT initialize/unseal or enable
# any secrets engines/auth methods — that is the separate bootstrap play.
#
# NOTE: restarting a running, Shamir-sealed node re-seals it (needs manual unseal).
# On first deploy the node is uninitialized, so the restart handler is harmless; with
# transit auto-unseal a restart re-unseals automatically.
- name: Assert required variables are set
ansible.builtin.assert:
that:
- openbao_version | length > 0
- openbao_download_checksum | length > 0
- openbao_fqdn | length > 0
- openbao_lan_ip | length > 0
- (not openbao_auto_unseal) or (openbao_transit_address | length > 0 and openbao_transit_token | length > 0)
fail_msg: >-
Set openbao_version + openbao_download_checksum (sha256:...), openbao_fqdn and
openbao_lan_ip. If openbao_auto_unseal is true you must also provide
openbao_transit_address and openbao_transit_token (from vault).
- name: Install runtime dependencies
ansible.builtin.apt:
name:
- tar
- python3-cryptography # community.crypto (bootstrap TLS cert)
state: present
update_cache: true
# --- Service account + directories --------------------------------------------
- name: Create openbao group
ansible.builtin.group:
name: "{{ openbao_group }}"
system: true
- name: Create openbao system user
ansible.builtin.user:
name: "{{ openbao_user }}"
group: "{{ openbao_group }}"
system: true
shell: /usr/sbin/nologin
home: "{{ openbao_data_dir }}"
create_home: false
- name: Create config, tls and data directories
ansible.builtin.file:
path: "{{ item.path }}"
state: directory
owner: "{{ item.owner }}"
group: "{{ openbao_group }}"
mode: "{{ item.mode }}"
loop:
- { path: "{{ openbao_config_dir }}", owner: "root", mode: "0755" }
- { path: "{{ openbao_tls_dir }}", owner: "root", mode: "0750" }
- { path: "{{ openbao_data_dir }}", owner: "{{ openbao_user }}", mode: "0700" }
# --- Install binary (checksum-verified release tarball) ------------------------
- name: Check installed bao version
ansible.builtin.command: "{{ openbao_bin }} version"
register: bao_installed
changed_when: false
failed_when: false
- name: Install OpenBao when missing or version mismatch
when: openbao_version not in (bao_installed.stdout | default(''))
block:
- name: Download OpenBao release tarball (checksum-verified)
ansible.builtin.get_url:
url: "{{ openbao_download_url }}"
dest: "/tmp/openbao_{{ openbao_version }}.tar.gz"
checksum: "{{ openbao_download_checksum }}"
mode: "0644"
- name: Create staging directory for extraction
ansible.builtin.file:
path: "/tmp/openbao_{{ openbao_version }}"
state: directory
mode: "0755"
- name: Extract tarball
ansible.builtin.unarchive:
src: "/tmp/openbao_{{ openbao_version }}.tar.gz"
dest: "/tmp/openbao_{{ openbao_version }}"
remote_src: true
- name: Install bao binary
ansible.builtin.copy:
src: "/tmp/openbao_{{ openbao_version }}/bao"
dest: "{{ openbao_bin }}"
remote_src: true
owner: root
group: root
mode: "0755"
notify: restart openbao
# --- Bootstrap TLS listener cert (replace with PKI-issued later) ---------------
- name: Generate bootstrap TLS certificate
ansible.builtin.import_tasks: tls.yml
# --- Config + service ----------------------------------------------------------
- name: Write OpenBao config
ansible.builtin.template:
src: config.hcl.j2
dest: "{{ openbao_config_dir }}/config.hcl"
owner: root
group: "{{ openbao_group }}"
mode: "0640"
no_log: "{{ openbao_auto_unseal }}" # config carries the transit token when auto-unseal is on
notify: restart openbao
- name: Install systemd unit
ansible.builtin.template:
src: openbao.service.j2
dest: /etc/systemd/system/openbao.service
owner: root
group: root
mode: "0644"
notify:
- reload systemd
- restart openbao
- name: Enable and start OpenBao
ansible.builtin.systemd:
name: openbao
state: started
enabled: true
daemon_reload: true
# --- Optional host firewall ----------------------------------------------------
- name: Restrict API/cluster ports to tailnet + cluster CIDRs
community.general.ufw:
rule: allow
port: "{{ item.0 }}"
proto: tcp
from_ip: "{{ item.1 }}"
loop: "{{ [openbao_api_port, openbao_cluster_port] | product(openbao_allowed_cidrs) | list }}"
when: openbao_manage_firewall | bool
@@ -0,0 +1,40 @@
---
# Bootstrap TLS for the OpenBao listener: a self-signed server certificate with SANs
# (FQDN + LAN IP + loopback), generated ONCE to get the listener up. It is never
# regenerated once a cert exists — so the ACME (or PKI) cert that replaces it later is
# never clobbered on a re-run. Delete cert.pem to force a fresh self-signed bootstrap.
- name: Check for an existing listener certificate
ansible.builtin.stat:
path: "{{ openbao_tls_dir }}/cert.pem"
register: openbao_listener_cert
- name: OpenBao server private key
community.crypto.openssl_privatekey:
path: "{{ openbao_tls_dir }}/key.pem"
size: 4096
owner: "{{ openbao_user }}"
group: "{{ openbao_group }}"
mode: "0640"
when: not openbao_listener_cert.stat.exists
- name: OpenBao server CSR (FQDN + SANs)
community.crypto.openssl_csr:
path: "{{ openbao_tls_dir }}/server.csr"
privatekey_path: "{{ openbao_tls_dir }}/key.pem"
common_name: "{{ openbao_fqdn }}"
subject_alt_name: "{{ openbao_tls_sans }}"
when: not openbao_listener_cert.stat.exists
- name: OpenBao server certificate (self-signed, positive serial)
community.crypto.x509_certificate:
path: "{{ openbao_tls_dir }}/cert.pem"
csr_path: "{{ openbao_tls_dir }}/server.csr"
privatekey_path: "{{ openbao_tls_dir }}/key.pem"
provider: selfsigned
selfsigned_not_after: "+825d"
owner: "{{ openbao_user }}"
group: "{{ openbao_group }}"
mode: "0644"
when: not openbao_listener_cert.stat.exists
notify: restart openbao
@@ -0,0 +1,26 @@
---
# Smoke tests — run via the `verify` tag:
# ansible-playbook provision-openbao.yml --tags verify
# Confirms the daemon is up and the API answers. A sealed/uninitialized node is the
# EXPECTED state before you run `bao operator init`, so that is treated as success.
- name: OpenBao service is active
ansible.builtin.systemd:
name: openbao
register: bao_svc
changed_when: false
failed_when: bao_svc.status.ActiveState != "active"
- name: API listener answers (sealed/uninitialized is OK)
ansible.builtin.command: "{{ openbao_bin }} status -address=https://127.0.0.1:{{ openbao_api_port }}"
environment:
BAO_SKIP_VERIFY: "true"
register: bao_status
changed_when: false
failed_when: bao_status.rc not in [0, 2] # 0 = unsealed, 2 = sealed/uninitialized
- name: Report
ansible.builtin.debug:
msg: >-
OpenBao {{ openbao_version }} is running and answering on :{{ openbao_api_port }}
(status rc={{ bao_status.rc }}; 2 = sealed/uninitialized, expected before init).
@@ -0,0 +1,27 @@
# {{ ansible_managed }} — openbao role. Do not edit by hand.
ui = {{ openbao_ui | lower }}
storage "raft" {
path = "{{ openbao_data_dir }}"
node_id = "{{ openbao_node_id }}"
}
listener "tcp" {
address = "{{ openbao_listen_address }}"
tls_cert_file = "{{ openbao_tls_dir }}/cert.pem"
tls_key_file = "{{ openbao_tls_dir }}/key.pem"
tls_min_version = "tls12"
}
api_addr = "{{ openbao_api_addr }}"
cluster_addr = "{{ openbao_cluster_addr }}"
{% if openbao_auto_unseal %}
# Auto-unseal via a Transit engine on a separate OpenBao/Vault instance.
seal "transit" {
address = "{{ openbao_transit_address }}"
token = "{{ openbao_transit_token }}"
key_name = "{{ openbao_transit_key_name }}"
mount_path = "{{ openbao_transit_mount_path }}"
}
{% endif %}
@@ -0,0 +1,31 @@
# {{ ansible_managed }} — openbao role. Do not edit by hand.
[Unit]
Description=OpenBao secrets management
Documentation=https://openbao.org/docs/
Requires=network-online.target
After=network-online.target
ConditionFileNotEmpty={{ openbao_config_dir }}/config.hcl
[Service]
User={{ openbao_user }}
Group={{ openbao_group }}
ProtectSystem=full
ProtectHome=read-only
PrivateTmp=yes
PrivateDevices=yes
SecureBits=keep-caps
AmbientCapabilities=CAP_IPC_LOCK
CapabilityBoundingSet=CAP_SYSLOG CAP_IPC_LOCK
NoNewPrivileges=yes
ExecStart={{ openbao_bin }} server -config={{ openbao_config_dir }}/config.hcl
ExecReload=/bin/kill --signal HUP $MAINPID
KillMode=process
KillSignal=SIGINT
Restart=on-failure
RestartSec=5
TimeoutStopSec=30
LimitNOFILE=65536
LimitMEMLOCK=infinity
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,41 @@
---
# openbao_acme role defaults. Cloudflare token comes from group_vars/all/vault.yml.
# --- lego (ACME client) release ---
openbao_acme_version: "5.3.1"
openbao_acme_checksum: "sha256:b3c71b122ee1947eacfe0b809b955647f6377239fe4bfc49f73b1a091ae1252a"
openbao_acme_url: "https://github.com/go-acme/lego/releases/download/v{{ openbao_acme_version }}/lego_v{{ openbao_acme_version }}_linux_amd64.tar.gz"
openbao_acme_bin: "/usr/local/bin/lego"
# --- Paths / identity ---
openbao_acme_dir: "/etc/openbao/acme" # lego state (account, certs, cloudflare.env)
openbao_acme_tls_dir: "/etc/openbao/tls" # where bao's listener reads cert.pem/key.pem
openbao_user: "openbao"
openbao_group: "openbao"
# --- Certificate ---
openbao_acme_domain: "{{ openbao_fqdn }}" # bao.ad.ddupan.top (from group_vars/all)
openbao_acme_email: "[email protected]" # ACME account / expiry-notice email
# Cloudflare API token with Zone:DNS:Edit on ddupan.top. Set vault_openbao_cf_dns_token
# in group_vars/all/vault.yml (you can copy the value from the cloudflared tunnel's
# terraform.tfvars, which is scoped the same).
openbao_acme_cf_token: "{{ vault_openbao_cf_dns_token | default('') }}"
# Empty = Let's Encrypt production. To dry-run without burning rate limits, set:
# https://acme-staging-v02.api.letsencrypt.org/directory
openbao_acme_server: ""
# Resolvers for lego's zone/apex detection. Must give the PUBLIC view: both the DC AND
# the LAN gateway forward ad.ddupan.top to the DC (split-horizon) → they'd resolve the
# zone to the non-existent CF zone "ad.ddupan.top". Only real public resolvers see that
# ad.ddupan.top isn't delegated and return the ddupan.top apex. List several so a flaky
# WAN query to one falls through to another.
openbao_acme_dns_resolvers: "1.1.1.1:53,1.0.0.1:53,8.8.8.8:53,9.9.9.9:53"
openbao_acme_dns_timeout: 30 # per-query DNS timeout (s); default 10 is tight over a flaky WAN
# Skip the 2-min propagation polling (many WAN DNS queries); just wait, then ask LE to
# validate (LE queries public DNS itself, independent of this host's WAN).
openbao_acme_propagation_wait: "120s"
# Renewal timer (lego only renews within --days of expiry).
openbao_acme_renew_oncalendar: "*-*-* 03:17:00"
@@ -0,0 +1,11 @@
---
galaxy_info:
role_name: openbao_acme
description: Publicly-trusted Let's Encrypt cert for bao's listener via lego + Cloudflare DNS-01, with SIGHUP reload and auto-renewal.
min_ansible_version: "2.15"
platforms:
- name: Debian
versions: [bookworm]
- name: Ubuntu
versions: [jammy, noble]
dependencies: []
@@ -0,0 +1,105 @@
---
# Install lego, obtain the initial cert (DNS-01 via Cloudflare), deploy it to bao's
# listener + reload, and enable a renewal timer. Idempotent.
- name: Assert a Cloudflare DNS token is available
ansible.builtin.assert:
that:
- openbao_acme_cf_token | length > 0
fail_msg: >-
Set vault_openbao_cf_dns_token in group_vars/all/vault.yml — a Cloudflare API
token with Zone:DNS:Edit on ddupan.top.
# --- Install lego (checksum-verified) -----------------------------------------
- name: Check installed lego version
ansible.builtin.command: "{{ openbao_acme_bin }} --version"
register: lego_installed
changed_when: false
failed_when: false
- name: Install lego when missing or version mismatch
when: openbao_acme_version not in (lego_installed.stdout | default(''))
block:
- name: Download lego release tarball (checksum-verified)
ansible.builtin.get_url:
url: "{{ openbao_acme_url }}"
dest: "/tmp/lego_{{ openbao_acme_version }}.tar.gz"
checksum: "{{ openbao_acme_checksum }}"
mode: "0644"
- name: Create lego staging dir
ansible.builtin.file:
path: "/tmp/lego_{{ openbao_acme_version }}"
state: directory
mode: "0755"
- name: Extract lego
ansible.builtin.unarchive:
src: "/tmp/lego_{{ openbao_acme_version }}.tar.gz"
dest: "/tmp/lego_{{ openbao_acme_version }}"
remote_src: true
- name: Install lego binary
ansible.builtin.copy:
src: "/tmp/lego_{{ openbao_acme_version }}/lego"
dest: "{{ openbao_acme_bin }}"
remote_src: true
owner: root
group: root
mode: "0755"
# --- State dir + credentials + scripts ----------------------------------------
- name: Create ACME state directory
ansible.builtin.file:
path: "{{ openbao_acme_dir }}"
state: directory
owner: root
group: root
mode: "0700"
- name: Write Cloudflare credentials env file
ansible.builtin.copy:
content: "CLOUDFLARE_DNS_API_TOKEN={{ openbao_acme_cf_token }}\n"
dest: "{{ openbao_acme_dir }}/cloudflare.env"
owner: root
group: root
mode: "0600"
no_log: true
- name: Install the obtain/renew wrapper and deploy hook
ansible.builtin.template:
src: "{{ item }}.j2"
dest: "/usr/local/bin/{{ item }}"
owner: root
group: root
mode: "0755"
loop:
- openbao-acme.sh
- openbao-acme-deploy.sh
- name: Install the ACME systemd service + timer
ansible.builtin.template:
src: "{{ item }}.j2"
dest: "/etc/systemd/system/{{ item }}"
owner: root
group: root
mode: "0644"
loop:
- openbao-acme.service
- openbao-acme.timer
# --- Obtain the first cert (deploys to bao + reloads via SIGHUP) ---------------
- name: Obtain the initial certificate and deploy it
ansible.builtin.command: /usr/local/bin/openbao-acme.sh
environment:
CLOUDFLARE_DNS_API_TOKEN: "{{ openbao_acme_cf_token }}"
args:
creates: "{{ openbao_acme_dir }}/certificates/{{ openbao_acme_domain }}.crt"
no_log: true
- name: Enable and start the renewal timer
ansible.builtin.systemd:
name: openbao-acme.timer
state: started
enabled: true
daemon_reload: true
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# {{ ansible_managed }}
# Install the freshly issued/renewed cert into bao's listener and reload (SIGHUP —
# no restart, no reseal). Invoked by lego's --deploy-hook on create/renew; lego passes
# the cert paths in LEGO_CERT_PATH / LEGO_CERT_KEY_PATH.
set -euo pipefail
CRT="${LEGO_CERT_PATH:-{{ openbao_acme_dir }}/certificates/{{ openbao_acme_domain }}.crt}"
KEY="${LEGO_CERT_KEY_PATH:-{{ openbao_acme_dir }}/certificates/{{ openbao_acme_domain }}.key}"
install -o {{ openbao_user }} -g {{ openbao_group }} -m 0644 "${CRT}" "{{ openbao_acme_tls_dir }}/cert.pem"
install -o {{ openbao_user }} -g {{ openbao_group }} -m 0640 "${KEY}" "{{ openbao_acme_tls_dir }}/key.pem"
systemctl reload openbao
@@ -0,0 +1,10 @@
# {{ ansible_managed }}
[Unit]
Description=OpenBao ACME certificate (lego, Cloudflare DNS-01)
After=network-online.target openbao.service
Wants=network-online.target
[Service]
Type=oneshot
EnvironmentFile={{ openbao_acme_dir }}/cloudflare.env
ExecStart=/usr/local/bin/openbao-acme.sh
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# {{ ansible_managed }}
# Obtain or renew bao's Let's Encrypt cert via Cloudflare DNS-01. lego's `run` does both:
# it renews only when due (ARI + --renew-days) and fires --deploy-hook on any actual
# create/renew to install the cert and reload bao. CLOUDFLARE_DNS_API_TOKEN comes from
# the environment (systemd EnvironmentFile, or Ansible on the first run).
set -euo pipefail
exec {{ openbao_acme_bin }} run \
--accept-tos \
--email "{{ openbao_acme_email }}" \
--dns cloudflare \
--dns.resolvers "{{ openbao_acme_dns_resolvers }}" \
--dns.timeout {{ openbao_acme_dns_timeout }} \
--dns.propagation.wait "{{ openbao_acme_propagation_wait }}" \
--domains "{{ openbao_acme_domain }}" \
--path "{{ openbao_acme_dir }}" \
--renew-days 30 \
--deploy-hook /usr/local/bin/openbao-acme-deploy.sh{% if openbao_acme_server %} \
--server "{{ openbao_acme_server }}"{% endif %}
@@ -0,0 +1,11 @@
# {{ ansible_managed }}
[Unit]
Description=OpenBao ACME renewal timer
[Timer]
OnCalendar={{ openbao_acme_renew_oncalendar }}
RandomizedDelaySec=3600
Persistent=true
[Install]
WantedBy=timers.target
@@ -0,0 +1,98 @@
---
# openbao_bootstrap role defaults. Secrets come from group_vars/all/vault.yml.
# --- Connection to the running, unsealed bao (local to the host) ---
openbao_api_port: 8200
openbao_tls_dir: "/etc/openbao/tls"
# ⚠ MUST be the hostname, not 127.0.0.1. Since openbao_acme gave bao a real Let's
# Encrypt cert, that cert carries a DNS SAN only — so a loopback connection dies
# with "cannot validate certificate for 127.0.0.1 because it doesn't contain any
# IP SANs". Same trap as dc1's LDAPS cert and as reaching bao on 192.168.10.8.
# bao1 resolves its own name via the DC (verified: HTTP 200, tls ok).
openbao_addr: "https://bao.ad.ddupan.top:{{ openbao_api_port }}"
openbao_token: "{{ lookup('env', 'BAO_TOKEN') }}" # export BAO_TOKEN before running
openbao_no_log: true # -e openbao_no_log=false to debug
# Reusable CLI environment (token-bearing → tasks using it set no_log).
openbao_cli_env:
BAO_ADDR: "{{ openbao_addr }}"
BAO_CACERT: "{{ openbao_tls_dir }}/cert.pem"
BAO_TOKEN: "{{ openbao_token }}"
# --- KV v2 (static agent secrets) ---
openbao_kv_path: "kv"
# --- SSH certificate authority (short-lived agent certs) ---
openbao_ssh_mount: "ssh-client-signer"
# Principals allowed at sign time. "*" is permissive — real per-node scoping is the
# `principals="..."` option on each target's `cert-authority` authorized_keys line.
# Tighten to an explicit list (e.g. "nodeA,nodeB,ansible") for a second gate.
openbao_ssh_allowed_users: "*"
openbao_ssh_default_user: "ansible"
openbao_ssh_cert_ttl: "5m"
openbao_ssh_cert_max_ttl: "15m"
openbao_ssh_ca_pub_path: "/etc/openbao/ssh-ca.pub"
# --- OIDC human login via Authelia ---
openbao_oidc_discovery_url: "https://auth.ddupan.top"
openbao_oidc_client_id: "openbao"
openbao_oidc_client_secret: "{{ vault_openbao_oidc_client_secret | default('') }}"
openbao_oidc_default_role: "admin"
openbao_oidc_admin_group: "vault-admins" # AD group whose members get the admin policy
openbao_oidc_redirect_uris:
- "https://bao.ad.ddupan.top:8200/ui/vault/auth/oidc/oidc/callback"
- "http://localhost:8250/oidc/callback"
# --- Kubernetes auth (in-cluster agents, e.g. hermes) — OFF until inputs are ready ---
# Was opt-in (false) and had therefore never run — the backend did not exist at all
# until 2026-07-28. Now ON by default: the External Secrets Operator authenticates
# through it, so every cluster Secret depends on it. Prerequisites, both satisfied:
# the cluster CA at openbao_k8s_ca_cert_file, and vault_openbao_k8s_reviewer_jwt.
openbao_enable_k8s_auth: true
# The k3s API server, as bao must reach it. NOT the address in your kubeconfig —
# k3s writes https://127.0.0.1:6443 there, which is meaningless from another host.
# Was 192.168.10.10 (wrong, nothing listens there); corrected 2026-07-28 after
# verifying from bao1: curl --cacert /etc/openbao/k8s-ca.crt -> HTTP 401, tls ok.
openbao_k8s_host: "https://192.168.10.127:6443"
openbao_k8s_ca_cert_file: "/etc/openbao/k8s-ca.crt" # cluster CA, placed on the bao host
openbao_k8s_reviewer_jwt: "{{ vault_openbao_k8s_reviewer_jwt | default('') }}"
openbao_k8s_agent_sa: "ai-agent"
openbao_k8s_agent_ns: "agents"
# --- PKI (internal TLS; can replace the self-signed listener cert) ---
openbao_pki_mount: "pki"
openbao_pki_ca_cn: "ddupan.top Internal CA"
openbao_pki_max_lease_ttl: "87600h" # 10y
openbao_pki_server_role: "bao-server"
openbao_pki_allowed_domains: "ad.ddupan.top"
# GATED: issue bao's own listener cert from this PKI and restart. Off by default so
# bootstrap never risks the listener. Clients must then trust the PKI CA (printed out).
openbao_pki_replace_listener_cert: false
# --- Raft snapshots (local timer; ship the dir off-box yourself) ---
openbao_snapshot_dir: "/var/backups/openbao"
openbao_snapshot_keep: 14
openbao_snapshot_oncalendar: "*-*-* 02:00:00"
# ── Terraform / Ansible ownership boundary ───────────────────────────────
# TRUE (default) = OpenBao's API-level CONFIGURATION lives in ../terraform:
# mounts, roles, policies, auth mounts+roles, PKI URLs/ACME. This role then
# does ONLY what Terraform cannot sensibly own:
# * the daemon, TLS files, systemd, init/unseal (openbao_deploy role)
# * ROOT-OF-TRUST KEY MATERIAL — the PKI root CA and the SSH CA signing key.
# Terraform would treat drift on those as "regenerate", silently
# invalidating every issued cert and every TrustedUserCAKeys line.
# * the OIDC client SECRET (Terraform cannot read it back; managing it would
# put plaintext in tfstate and cause a perpetual diff)
# * the snapshot token + script + systemd timer (host-level, and a secret)
#
# Set FALSE only to bootstrap a brand-new instance entirely from Ansible, or to
# fall back if Terraform is unavailable. Leaving it FALSE against an instance
# Terraform manages makes the two overwrite each other on alternate runs.
#
# FRESH-INSTALL ORDER when true:
# 1. ansible-playbook deploy-openbao.yml # daemon, TLS, systemd
# 2. bao operator init / unseal # manual, PGP-wrapped
# 3. terraform apply # mounts, roles, policies
# 4. ansible-playbook bootstrap-openbao.yml # CA material, OIDC secret, snapshots
openbao_config_managed_by_terraform: true
@@ -0,0 +1,8 @@
---
# Only fired by the gated PKI listener-cert swap.
# NOTE: a Shamir-sealed node re-seals on restart — unseal it afterwards.
# With transit auto-unseal it re-unseals automatically.
- name: restart openbao
ansible.builtin.systemd:
name: openbao
state: restarted
@@ -0,0 +1,11 @@
---
galaxy_info:
role_name: openbao_bootstrap
description: Configure a running OpenBao — engines (kv, ssh CA, pki), auth (oidc, kubernetes), policies, snapshots.
min_ansible_version: "2.15"
platforms:
- name: Debian
versions: [bookworm]
- name: Ubuntu
versions: [jammy, noble]
dependencies: []
@@ -0,0 +1,52 @@
---
# Kubernetes auth — in-cluster agents (e.g. hermes) authenticate with their SA JWT.
# External bao, so we must supply the cluster host, CA cert and a reviewer JWT.
# Get them from the cluster:
# kubectl -n agents create sa bao-reviewer
# kubectl create clusterrolebinding bao-reviewer --clusterrole=system:auth-delegator \
# --serviceaccount=agents:bao-reviewer
# kubectl -n agents create token bao-reviewer --duration=87600h → openbao_k8s_reviewer_jwt
# kubectl get cm kube-root-ca.crt -o jsonpath='{.data.ca\.crt}' > k8s-ca.crt → on the bao host
- name: Assert Kubernetes auth inputs are provided
ansible.builtin.assert:
that:
- openbao_k8s_reviewer_jwt | length > 0
- openbao_k8s_host | length > 0
fail_msg: "Set openbao_k8s_host and vault_openbao_k8s_reviewer_jwt, and place the cluster CA at openbao_k8s_ca_cert_file."
- name: Enable the Kubernetes auth method
ansible.builtin.command: "bao auth enable kubernetes"
environment: "{{ openbao_cli_env }}"
register: k8s_enable
changed_when: k8s_enable.rc == 0
failed_when:
- k8s_enable.rc != 0
- "'already in use' not in (k8s_enable.stderr | default('')) + (k8s_enable.stdout | default(''))"
no_log: "{{ openbao_no_log }}"
# RECONCILE ACTION — always reports "changed". `bao write` returns 0 whether or
# not anything differed, and detecting a real diff would mean reading the config
# back, which never returns token_reviewer_jwt. So a second run showing changed=2
# for this file means "re-applied", NOT "drift was found". See CLAUDE.md on
# idempotency being the acceptance test, and the exception for reconcile actions.
- name: Configure the Kubernetes auth method
ansible.builtin.command: >-
bao write auth/kubernetes/config
kubernetes_host={{ openbao_k8s_host }}
kubernetes_ca_cert=@{{ openbao_k8s_ca_cert_file }}
token_reviewer_jwt={{ openbao_k8s_reviewer_jwt }}
disable_local_ca_jwt=true
environment: "{{ openbao_cli_env }}"
register: k8s_config
changed_when: k8s_config.rc == 0
no_log: true # carries the reviewer JWT
- name: Create/update the ai-agent Kubernetes role
ansible.builtin.command: "bao write auth/kubernetes/role/ai-agent -"
args:
stdin: "{{ lookup('template', 'k8s-ai-agent-role.json.j2') }}"
environment: "{{ openbao_cli_env }}"
register: k8s_role
changed_when: k8s_role.rc == 0
no_log: "{{ openbao_no_log }}"
@@ -0,0 +1,44 @@
---
# OIDC auth via Authelia — human login (bao login -method=oidc / UI).
# The Authelia 'openbao' client must already exist (authelia/values.yaml).
- name: Assert the OIDC client secret is provided
ansible.builtin.assert:
that:
- openbao_oidc_client_secret | length > 0
fail_msg: "Set vault_openbao_oidc_client_secret (plaintext of the Authelia openbao client)."
- name: Enable the OIDC auth method
ansible.builtin.command: "bao auth enable -path=oidc oidc"
environment: "{{ openbao_cli_env }}"
register: oidc_enable
changed_when: oidc_enable.rc == 0
failed_when:
- oidc_enable.rc != 0
- "'already in use' not in (oidc_enable.stderr | default('')) + (oidc_enable.stdout | default(''))"
no_log: "{{ openbao_no_log }}"
# Terraform owns this (../terraform). See openbao_config_managed_by_terraform.
when: not openbao_config_managed_by_terraform | bool
- name: Configure the OIDC provider (Authelia)
ansible.builtin.command: >-
bao write auth/oidc/config
oidc_discovery_url={{ openbao_oidc_discovery_url }}
oidc_client_id={{ openbao_oidc_client_id }}
oidc_client_secret={{ openbao_oidc_client_secret }}
default_role={{ openbao_oidc_default_role }}
environment: "{{ openbao_cli_env }}"
register: oidc_config
changed_when: oidc_config.rc == 0
no_log: true # carries the client secret — always hidden
- name: Create/update the admin OIDC role (restricted to the admin AD group)
ansible.builtin.command: "bao write auth/oidc/role/{{ openbao_oidc_default_role }} -"
args:
stdin: "{{ lookup('template', 'oidc-admin-role.json.j2') }}"
environment: "{{ openbao_cli_env }}"
register: oidc_role
changed_when: oidc_role.rc == 0
no_log: "{{ openbao_no_log }}"
# Terraform owns this (../terraform). See openbao_config_managed_by_terraform.
when: not openbao_config_managed_by_terraform | bool
@@ -0,0 +1,14 @@
---
# KV v2 engine for static agent secrets.
- name: Enable KV v2 at {{ openbao_kv_path }}/
ansible.builtin.command: "bao secrets enable -path={{ openbao_kv_path }} -version=2 kv"
environment: "{{ openbao_cli_env }}"
register: kv_enable
changed_when: kv_enable.rc == 0
failed_when:
- kv_enable.rc != 0
- "'already in use' not in (kv_enable.stderr | default('')) + (kv_enable.stdout | default(''))"
no_log: "{{ openbao_no_log }}"
# Terraform owns this (../terraform). See openbao_config_managed_by_terraform.
when: not openbao_config_managed_by_terraform | bool
@@ -0,0 +1,59 @@
---
# Orchestrator. Preflight, then each concern as its own tagged task file.
- name: Assert a bao token is provided
ansible.builtin.assert:
that:
- openbao_token | length > 0
fail_msg: >-
No token. Decrypt the PGP-wrapped root token and export it:
echo "<b64>" | base64 -d | gpg -dq → export BAO_TOKEN=<plaintext>
- name: Preflight — bao is reachable, initialized and UNSEALED
ansible.builtin.command: "bao status -format=json"
environment:
BAO_ADDR: "{{ openbao_addr }}"
BAO_CACERT: "{{ openbao_tls_dir }}/cert.pem"
register: bao_status
changed_when: false
failed_when: bao_status.rc != 0 # 0 = unsealed; 2 = sealed → unseal first
- name: Preflight — the provided token is valid
ansible.builtin.command: "bao token lookup"
environment: "{{ openbao_cli_env }}"
register: bao_tok
changed_when: false
failed_when: bao_tok.rc != 0
no_log: "{{ openbao_no_log }}"
- name: Policies
# Terraform owns ALL policies (../terraform/policies.tf + policies/*.hcl).
# Kept for a Terraform-less bootstrap; see openbao_config_managed_by_terraform.
ansible.builtin.import_tasks: policies.yml
tags: [policies]
when: not openbao_config_managed_by_terraform | bool
- name: KV v2 engine
ansible.builtin.import_tasks: kv.yml
tags: [kv]
- name: SSH certificate authority
ansible.builtin.import_tasks: ssh_ca.yml
tags: [ssh_ca]
- name: OIDC auth (Authelia)
ansible.builtin.import_tasks: auth_oidc.yml
tags: [oidc]
- name: Kubernetes auth
ansible.builtin.import_tasks: auth_kubernetes.yml
tags: [k8s]
when: openbao_enable_k8s_auth | bool
- name: PKI engine
ansible.builtin.import_tasks: pki.yml
tags: [pki]
- name: Raft snapshot timer
ansible.builtin.import_tasks: snapshots.yml
tags: [snapshots]
@@ -0,0 +1,106 @@
---
# PKI engine — internal TLS CA. Optionally re-issues bao's own listener cert.
- name: Enable the PKI secrets engine at {{ openbao_pki_mount }}/
ansible.builtin.command: "bao secrets enable -path={{ openbao_pki_mount }} pki"
environment: "{{ openbao_cli_env }}"
register: pki_enable
changed_when: pki_enable.rc == 0
failed_when:
- pki_enable.rc != 0
- "'already in use' not in (pki_enable.stderr | default('')) + (pki_enable.stdout | default(''))"
no_log: "{{ openbao_no_log }}"
# Terraform owns this (../terraform). See openbao_config_managed_by_terraform.
when: not openbao_config_managed_by_terraform | bool
- name: Tune PKI max lease TTL
ansible.builtin.command: "bao secrets tune -max-lease-ttl={{ openbao_pki_max_lease_ttl }} {{ openbao_pki_mount }}"
environment: "{{ openbao_cli_env }}"
register: pki_tune
changed_when: pki_tune.rc == 0
no_log: "{{ openbao_no_log }}"
# Terraform owns this (../terraform). See openbao_config_managed_by_terraform.
when: not openbao_config_managed_by_terraform | bool
- name: Check whether the root CA already exists
ansible.builtin.command: "bao read -field=certificate {{ openbao_pki_mount }}/cert/ca"
environment: "{{ openbao_cli_env }}"
register: pki_ca_check
changed_when: false
failed_when: false
no_log: "{{ openbao_no_log }}"
- name: Generate the internal root CA (once)
ansible.builtin.command: >-
bao write {{ openbao_pki_mount }}/root/generate/internal
common_name="{{ openbao_pki_ca_cn }}" ttl={{ openbao_pki_max_lease_ttl }}
environment: "{{ openbao_cli_env }}"
when: "'BEGIN CERTIFICATE' not in (pki_ca_check.stdout | default(''))"
register: pki_root
changed_when: pki_root.rc == 0
no_log: "{{ openbao_no_log }}"
- name: Configure issuing/CRL URLs
ansible.builtin.command: >-
bao write {{ openbao_pki_mount }}/config/urls
issuing_certificates={{ openbao_addr }}/v1/{{ openbao_pki_mount }}/ca
crl_distribution_points={{ openbao_addr }}/v1/{{ openbao_pki_mount }}/crl
environment: "{{ openbao_cli_env }}"
register: pki_urls
changed_when: pki_urls.rc == 0
no_log: "{{ openbao_no_log }}"
# Terraform owns this (../terraform). See openbao_config_managed_by_terraform.
when: not openbao_config_managed_by_terraform | bool
- name: Create/update the server-cert role
ansible.builtin.command: >-
bao write {{ openbao_pki_mount }}/roles/{{ openbao_pki_server_role }}
allowed_domains={{ openbao_pki_allowed_domains }}
allow_subdomains=true allow_ip_sans=true max_ttl=8760h
environment: "{{ openbao_cli_env }}"
register: pki_role
changed_when: pki_role.rc == 0
no_log: "{{ openbao_no_log }}"
# Terraform owns this (../terraform). See openbao_config_managed_by_terraform.
when: not openbao_config_managed_by_terraform | bool
# --- GATED: replace the self-signed listener cert with a PKI-issued one ----------
- name: Replace bao's listener cert from PKI
when: openbao_pki_replace_listener_cert | bool
block:
- name: Issue a listener certificate
ansible.builtin.command: >-
bao write -format=json {{ openbao_pki_mount }}/issue/{{ openbao_pki_server_role }}
common_name={{ openbao_fqdn }}
ip_sans={{ openbao_lan_ip }},127.0.0.1
ttl=8760h
environment: "{{ openbao_cli_env }}"
register: pki_issue
changed_when: true
no_log: true
- name: Install listener private key
ansible.builtin.copy:
content: "{{ (pki_issue.stdout | from_json).data.private_key }}\n"
dest: "{{ openbao_tls_dir }}/key.pem"
owner: openbao
group: openbao
mode: "0640"
no_log: true
notify: restart openbao
- name: Install listener certificate (leaf + issuing CA chain)
ansible.builtin.copy:
content: "{{ (pki_issue.stdout | from_json).data.certificate }}\n{{ (pki_issue.stdout | from_json).data.issuing_ca }}\n"
dest: "{{ openbao_tls_dir }}/cert.pem"
owner: openbao
group: openbao
mode: "0644"
notify: restart openbao
- name: Print the PKI root CA (clients must trust this)
ansible.builtin.debug:
msg: >-
Listener now uses a PKI-issued cert. Distribute the root CA to clients:
bao read -field=certificate {{ openbao_pki_mount }}/cert/ca
(NOTE: a Shamir-sealed node re-seals on restart — unseal it afterwards.)
@@ -0,0 +1,20 @@
---
# Policies. Declaratively applied each run (bao policy write is an idempotent overwrite).
- name: Write the ai-agent-ssh policy (sign SSH certs, nothing else)
ansible.builtin.command: "bao policy write ai-agent-ssh -"
args:
stdin: "{{ lookup('template', 'ai-agent-ssh-policy.hcl.j2') }}"
environment: "{{ openbao_cli_env }}"
register: pol_agent
changed_when: pol_agent.rc == 0
no_log: "{{ openbao_no_log }}"
- name: Write the admin policy (human OIDC logins)
ansible.builtin.command: "bao policy write admin -"
args:
stdin: "{{ lookup('template', 'admin-policy.hcl.j2') }}"
environment: "{{ openbao_cli_env }}"
register: pol_admin
changed_when: pol_admin.rc == 0
no_log: "{{ openbao_no_log }}"
@@ -0,0 +1,70 @@
---
# Automated Raft snapshots via a systemd timer + a scoped periodic token.
- name: Write the snapshot policy (read raft snapshots only)
ansible.builtin.command: "bao policy write snapshot -"
args:
stdin: "{{ lookup('template', 'snapshot-policy.hcl.j2') }}"
environment: "{{ openbao_cli_env }}"
register: snap_pol
changed_when: snap_pol.rc == 0
no_log: "{{ openbao_no_log }}"
# Terraform owns this (../terraform). See openbao_config_managed_by_terraform.
when: not openbao_config_managed_by_terraform | bool
- name: Check whether the snapshot token already exists
ansible.builtin.stat:
path: /etc/openbao/snapshot.token
register: snap_tok_stat
- name: Create a periodic snapshot token (once)
ansible.builtin.command: "bao token create -policy=snapshot -period=768h -orphan -field=token"
environment: "{{ openbao_cli_env }}"
register: snap_tok_new
when: not snap_tok_stat.stat.exists
changed_when: snap_tok_new.rc == 0
no_log: true
- name: Store the snapshot token (root-only)
ansible.builtin.copy:
content: "{{ snap_tok_new.stdout }}"
dest: /etc/openbao/snapshot.token
owner: root
group: root
mode: "0600"
when: not snap_tok_stat.stat.exists
no_log: true
- name: Ensure the snapshot output directory exists
ansible.builtin.file:
path: "{{ openbao_snapshot_dir }}"
state: directory
owner: root
group: root
mode: "0700"
- name: Install the snapshot script
ansible.builtin.template:
src: bao-snapshot.sh.j2
dest: /usr/local/bin/bao-snapshot.sh
owner: root
group: root
mode: "0755"
- name: Install the snapshot systemd service + timer
ansible.builtin.template:
src: "{{ item }}.j2"
dest: "/etc/systemd/system/{{ item }}"
owner: root
group: root
mode: "0644"
loop:
- openbao-snapshot.service
- openbao-snapshot.timer
- name: Enable and start the snapshot timer
ansible.builtin.systemd:
name: openbao-snapshot.timer
state: started
enabled: true
daemon_reload: true
@@ -0,0 +1,62 @@
---
# SSH certificate authority: sign short-lived client certs for the ai-agent role.
- name: Enable the SSH secrets engine at {{ openbao_ssh_mount }}/
ansible.builtin.command: "bao secrets enable -path={{ openbao_ssh_mount }} ssh"
environment: "{{ openbao_cli_env }}"
register: ssh_enable
changed_when: ssh_enable.rc == 0
failed_when:
- ssh_enable.rc != 0
- "'already in use' not in (ssh_enable.stderr | default('')) + (ssh_enable.stdout | default(''))"
no_log: "{{ openbao_no_log }}"
# Terraform owns this (../terraform). See openbao_config_managed_by_terraform.
when: not openbao_config_managed_by_terraform | bool
- name: Check whether the SSH CA signing key already exists
ansible.builtin.command: "bao read -field=public_key {{ openbao_ssh_mount }}/config/ca"
environment: "{{ openbao_cli_env }}"
register: ssh_ca_check
changed_when: false
failed_when: false
no_log: "{{ openbao_no_log }}"
- name: Generate the SSH CA signing key (once)
ansible.builtin.command: "bao write {{ openbao_ssh_mount }}/config/ca generate_signing_key=true"
environment: "{{ openbao_cli_env }}"
when: ssh_ca_check.rc != 0
register: ssh_ca_gen
changed_when: ssh_ca_gen.rc == 0
no_log: "{{ openbao_no_log }}"
- name: Create/update the ai-agent signing role
ansible.builtin.command: "bao write {{ openbao_ssh_mount }}/roles/ai-agent -"
args:
stdin: "{{ lookup('template', 'ssh-ai-agent-role.json.j2') }}"
environment: "{{ openbao_cli_env }}"
register: ssh_role
changed_when: ssh_role.rc == 0
no_log: "{{ openbao_no_log }}"
# Terraform owns this (../terraform). See openbao_config_managed_by_terraform.
when: not openbao_config_managed_by_terraform | bool
- name: Fetch the SSH CA public key
ansible.builtin.command: "bao read -field=public_key {{ openbao_ssh_mount }}/config/ca"
environment: "{{ openbao_cli_env }}"
register: ssh_ca_public
changed_when: false
no_log: "{{ openbao_no_log }}"
- name: Save the SSH CA public key on the bao host (for cert-authority lines)
ansible.builtin.copy:
content: "{{ ssh_ca_public.stdout }}\n"
dest: "{{ openbao_ssh_ca_pub_path }}"
owner: root
group: root
mode: "0644"
- name: Show the cert-authority line for no-root target hosts
ansible.builtin.debug:
msg: >-
Add to ~/.ssh/authorized_keys on each target (scope per node):
cert-authority,principals="<node>",restrict,pty {{ ssh_ca_public.stdout }}
@@ -0,0 +1,45 @@
---
# Smoke tests — ansible-playbook bootstrap-openbao.yml --tags verify
# Confirms the engines/auth are mounted and the SSH CA actually signs.
- name: Secrets engines are mounted
ansible.builtin.command: "bao secrets list -format=json"
environment: "{{ openbao_cli_env }}"
register: v_secrets
changed_when: false
no_log: "{{ openbao_no_log }}"
- name: Auth methods are enabled
ansible.builtin.command: "bao auth list -format=json"
environment: "{{ openbao_cli_env }}"
register: v_auth
changed_when: false
no_log: "{{ openbao_no_log }}"
- name: Assert expected mounts exist
ansible.builtin.assert:
that:
- "'{{ openbao_kv_path }}/' in (v_secrets.stdout | from_json)"
- "'{{ openbao_ssh_mount }}/' in (v_secrets.stdout | from_json)"
- "'{{ openbao_pki_mount }}/' in (v_secrets.stdout | from_json)"
- "'oidc/' in (v_auth.stdout | from_json)"
fail_msg: "Expected mounts missing — check the bootstrap run."
- name: SSH CA signs a throwaway key (end-to-end)
ansible.builtin.shell: >-
set -o pipefail;
ssh-keygen -t ed25519 -f /tmp/bao-verify -N '' -q -C verify <<<y >/dev/null 2>&1;
bao write -field=signed_key {{ openbao_ssh_mount }}/sign/ai-agent
public_key=@/tmp/bao-verify.pub valid_principals={{ openbao_ssh_default_user }};
rm -f /tmp/bao-verify /tmp/bao-verify.pub
args:
executable: /bin/bash
environment: "{{ openbao_cli_env }}"
register: v_sign
changed_when: false
failed_when: "'ssh-ed25519-cert' not in (v_sign.stdout | default('')) and 'ssh-rsa-cert' not in (v_sign.stdout | default(''))"
no_log: "{{ openbao_no_log }}"
- name: Report
ansible.builtin.debug:
msg: "Bootstrap verified: kv/ ssh-client-signer/ pki/ + oidc auth mounted; SSH CA signed a test cert."
@@ -0,0 +1,6 @@
# {{ ansible_managed }}
# Broad admin for human OIDC logins (mapped from the {{ openbao_oidc_admin_group }} AD group).
# Homelab-broad on purpose; scope down to specific mounts if you want least privilege.
path "*" {
capabilities = ["create", "read", "update", "delete", "list", "sudo"]
}
@@ -0,0 +1,5 @@
# {{ ansible_managed }}
# The AI agent may sign short-lived SSH client certs for the ai-agent role — nothing else.
path "{{ openbao_ssh_mount }}/sign/ai-agent" {
capabilities = ["create", "update"]
}
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# {{ ansible_managed }}
# Take a Raft snapshot and prune old ones. Ship {{ openbao_snapshot_dir }} off-box
# separately (rsync/restic/scp) — a snapshot on the same host is not a backup.
set -euo pipefail
export BAO_ADDR="{{ openbao_addr }}"
export BAO_CACERT="{{ openbao_tls_dir }}/cert.pem"
BAO_TOKEN="$(cat /etc/openbao/snapshot.token)"
export BAO_TOKEN
dir="{{ openbao_snapshot_dir }}"
stamp="$(date +%Y%m%d-%H%M%S)"
out="${dir}/openbao-${stamp}.snap"
bao operator raft snapshot save "${out}"
chmod 600 "${out}"
# Retention: keep the newest {{ openbao_snapshot_keep }}.
ls -1t "${dir}"/openbao-*.snap 2>/dev/null | tail -n +{{ openbao_snapshot_keep + 1 }} | xargs -r rm -f
@@ -0,0 +1,6 @@
{
"bound_service_account_names": "{{ openbao_k8s_agent_sa }}",
"bound_service_account_namespaces": "{{ openbao_k8s_agent_ns }}",
"token_policies": ["ai-agent-ssh"],
"token_ttl": "10m"
}
@@ -0,0 +1,11 @@
{
"role_type": "oidc",
"user_claim": "preferred_username",
"groups_claim": "groups",
"bound_audiences": "{{ openbao_oidc_client_id }}",
"bound_claims": { "groups": "{{ openbao_oidc_admin_group }}" },
"oidc_scopes": ["profile", "email", "groups"],
"allowed_redirect_uris": {{ openbao_oidc_redirect_uris | to_json }},
"token_policies": ["admin"],
"token_ttl": "1h"
}
@@ -0,0 +1,9 @@
# {{ ansible_managed }}
[Unit]
Description=OpenBao Raft snapshot
After=openbao.service
Wants=openbao.service
[Service]
Type=oneshot
ExecStart=/usr/local/bin/bao-snapshot.sh
@@ -0,0 +1,10 @@
# {{ ansible_managed }}
[Unit]
Description=OpenBao Raft snapshot timer
[Timer]
OnCalendar={{ openbao_snapshot_oncalendar }}
Persistent=true
[Install]
WantedBy=timers.target
@@ -0,0 +1,5 @@
# {{ ansible_managed }}
# Read-only access to take Raft snapshots — used by the snapshot timer's token.
path "sys/storage/raft/snapshot" {
capabilities = ["read"]
}
@@ -0,0 +1,10 @@
{
"key_type": "ca",
"allow_user_certificates": true,
"allowed_users": "{{ openbao_ssh_allowed_users }}",
"default_user": "{{ openbao_ssh_default_user }}",
"allowed_extensions": "",
"default_extensions": { "permit-pty": "" },
"ttl": "{{ openbao_ssh_cert_ttl }}",
"max_ttl": "{{ openbao_ssh_cert_max_ttl }}"
}
@@ -0,0 +1,17 @@
---
# openbao_ssh_ca_trust — make a (root-managed) host trust bao's SSH USER CA, so sshd
# accepts the short-lived certs bao signs. Additive: normal key auth is unaffected.
# For no-root hosts, use a `cert-authority` line in ~/.ssh/authorized_keys instead.
openbao_ssh_mount: "ssh-client-signer"
openbao_addr: "https://bao.ad.ddupan.top:8200"
# The CA public key. Leave empty to fetch it (once, from the control node) from bao's
# UNAUTHENTICATED public_key endpoint — so it stays current even after a CA rotation.
# Set it explicitly to pin a key or work offline.
openbao_ssh_ca_pubkey: ""
openbao_ssh_ca_url: "{{ openbao_addr }}/v1/{{ openbao_ssh_mount }}/public_key"
openbao_ssh_ca_file: "/etc/ssh/openbao_user_ca.pub"
openbao_ssh_ca_dropin: "/etc/ssh/sshd_config.d/50-openbao-ca.conf"
openbao_ssh_service: "ssh" # Debian/Ubuntu; RHEL-family = "sshd"
@@ -0,0 +1,14 @@
---
# Validate the config BEFORE reloading — a broken sshd config must never be applied
# (lock-out risk). If `sshd -t` fails the play errors here and the reload never runs,
# so the running sshd keeps its current (good) config.
- name: reload sshd
ansible.builtin.command: sshd -t
changed_when: false
listen: reload sshd
- name: reload sshd service
ansible.builtin.service:
name: "{{ openbao_ssh_service }}"
state: reloaded
listen: reload sshd
@@ -0,0 +1,11 @@
---
galaxy_info:
role_name: openbao_ssh_ca_trust
description: Trust bao's SSH user CA on a host (TrustedUserCAKeys), so sshd accepts short-lived certs bao signs.
min_ansible_version: "2.15"
platforms:
- name: Debian
versions: [bookworm]
- name: Ubuntu
versions: [jammy, noble]
dependencies: []
@@ -0,0 +1,61 @@
---
# Trust bao's SSH user CA on this host. Requires root (writes sshd config).
- name: Fetch bao's SSH CA public key (once, on the control node)
ansible.builtin.uri:
url: "{{ openbao_ssh_ca_url }}"
return_content: true
delegate_to: localhost
run_once: true
become: false
register: _ssh_ca_fetch
when: openbao_ssh_ca_pubkey | length == 0
- name: Resolve the CA public key (an explicit var wins over the fetched one)
ansible.builtin.set_fact:
_openbao_ssh_ca: >-
{{ openbao_ssh_ca_pubkey if (openbao_ssh_ca_pubkey | length > 0)
else (_ssh_ca_fetch.content | default('') | trim) }}
- name: Assert we have a real SSH CA public key
ansible.builtin.assert:
that:
- _openbao_ssh_ca is search('^ssh-(rsa|ed25519|ecdsa)')
fail_msg: >-
No valid SSH CA public key. Set openbao_ssh_ca_pubkey, or make
{{ openbao_ssh_ca_url }} reachable from the control node.
- name: Install the SSH CA public key
ansible.builtin.copy:
content: "{{ _openbao_ssh_ca }}\n"
dest: "{{ openbao_ssh_ca_file }}"
owner: root
group: root
mode: "0644"
notify: reload sshd
- name: Detect sshd drop-in support
ansible.builtin.command: grep -qiE '^[[:space:]]*Include[[:space:]]+/etc/ssh/sshd_config.d/' /etc/ssh/sshd_config
register: _sshd_dropins
changed_when: false
failed_when: false
- name: Trust the CA via an sshd drop-in
ansible.builtin.copy:
content: |
# OpenBao SSH CA ({{ openbao_addr }}) — accept short-lived user certs it signs.
TrustedUserCAKeys {{ openbao_ssh_ca_file }}
dest: "{{ openbao_ssh_ca_dropin }}"
owner: root
group: root
mode: "0644"
when: _sshd_dropins.rc == 0
notify: reload sshd
- name: Trust the CA in the main sshd_config (no drop-in support)
ansible.builtin.blockinfile:
path: /etc/ssh/sshd_config
marker: "# {mark} OPENBAO SSH CA"
block: "TrustedUserCAKeys {{ openbao_ssh_ca_file }}"
when: _sshd_dropins.rc != 0
notify: reload sshd
@@ -0,0 +1,10 @@
---
# Make each host in the ssh_ca_trust group trust bao's SSH user CA (TrustedUserCAKeys),
# so it accepts the short-lived certs bao signs. Additive + validates before reload.
# ansible-playbook trust-ssh-ca.yml
- name: Trust the OpenBao SSH CA
hosts: ssh_ca_trust
become: true
gather_facts: false
roles:
- role: openbao_ssh_ca_trust
@@ -0,0 +1,5 @@
*.tfstate
*.tfstate.backup
*.tfplan
.terraform/
.terraform.lock.hcl
+61
View File
@@ -0,0 +1,61 @@
# OIDC auth (human logins via Authelia).
#
# SCOPE NOTE: only the auth MOUNT and the ROLE are managed here. The backend
# CONFIG (auth/oidc/config) is intentionally left to ../ansible because it
# carries `oidc_client_secret`. Terraform cannot read that value back from the
# API, so managing it here would (a) force the plaintext secret into
# terraform.tfstate and (b) produce a perpetual diff. Ansible already holds it
# in an ansible-vault file.
resource "vault_auth_backend" "oidc" {
type = "oidc"
path = "oidc"
}
resource "vault_jwt_auth_backend_role" "admin" {
backend = vault_auth_backend.oidc.path
role_name = "admin"
role_type = "oidc"
user_claim = "preferred_username"
bound_audiences = ["openbao"]
# Only members of the AD group vault-admins get the admin policy.
bound_claims = {
groups = "vault-admins"
}
groups_claim = "groups"
oidc_scopes = ["profile", "email", "groups"]
allowed_redirect_uris = [
"https://bao.ad.ddupan.top:8200/ui/vault/auth/oidc/oidc/callback", # web UI
"http://localhost:8250/oidc/callback", # CLI login
]
token_policies = ["admin"]
token_ttl = 3600
token_max_ttl = 0
}
# ── Kubernetes auth: the External Secrets Operator ─────────────────────────
# The BACKEND itself (auth/kubernetes/config) is NOT managed here — it needs the
# cluster CA and a long-lived reviewer JWT, which is key material Terraform must
# not hold. That stays in ../ansible (openbao_bootstrap/tasks/auth_kubernetes.yml),
# per the ownership split in CLAUDE.md. A ROLE is pure API config, so it lives here.
#
# This is what makes the operator credential-less: it presents its own
# ServiceAccount JWT, bao verifies it via the cluster's TokenReview API, and
# returns a short-lived token carrying only the external-secrets policy.
resource "vault_kubernetes_auth_backend_role" "external_secrets" {
backend = "kubernetes"
role_name = "external-secrets"
# Must match serviceAccount.name / namespace in
# ../../../platform/external-secrets/values.yaml and the serviceAccountRef in
# ../../../platform/external-secrets/clustersecretstore.yaml.
bound_service_account_names = ["external-secrets"]
bound_service_account_namespaces = ["external-secrets"]
token_policies = [vault_policy.external_secrets.name]
# Short-lived on purpose: ESO re-authenticates as needed, so there is no value
# in a long TTL and every extra hour is a longer-lived credential in memory.
token_ttl = 3600
}
@@ -0,0 +1,40 @@
# Remote state in SeaweedFS S3, on the LAN.
#
# WHY remote at all: local state means the only copy lives on this laptop, which is
# also the k3s node, the NFS server and the libvirt host — i.e. the single point of
# failure. It also cannot be locked, so two concurrent applies silently corrupt it.
#
# WHY s3.ad.ddupan.top and NOT obj.ddupan.top: the public name resolves to
# Cloudflare and hairpins through the WAN. On 2026-07-28 that path was blackholed
# for hours by a dead VPN tunnel. State must be reachable when the WAN is not —
# it is what you need DURING an incident. See ../../../apps/seaweedfs/httproute-s3.yaml.
#
# CREDENTIALS are not in this file. Export them before running terraform:
# export AWS_ACCESS_KEY_ID=$(bao kv get -field=... kv/k8s/seaweedfs-s3) # see README
# export AWS_SECRET_ACCESS_KEY=...
# The `terraform` S3 identity is scoped to this bucket only — it deliberately
# cannot create buckets or read anything else in the store.
terraform {
backend "s3" {
bucket = "tfstate"
key = "openbao/terraform.tfstate"
endpoints = {
s3 = "https://s3.ad.ddupan.top"
}
# SeaweedFS is not AWS: it has no regions, no IAM, no metadata service and no
# account IDs, so every AWS-specific validation has to be skipped or the
# provider fails before it ever talks to the endpoint.
region = "us-east-1"
use_path_style = true
skip_credentials_validation = true
skip_metadata_api_check = true
skip_region_validation = true
skip_requesting_account_id = true
# Native S3 locking (Terraform >= 1.10; this repo runs 1.15). Writes a
# .tflock object alongside the state — no DynamoDB table needed.
use_lockfile = true
}
}
@@ -0,0 +1,70 @@
# Adopt the already-running OpenBao configuration into Terraform state.
#
# These use TF 1.5 `import` blocks rather than `terraform import` CLI calls so
# the adoption is reviewable: `terraform plan` shows exactly what Terraform
# thinks differs from reality BEFORE anything is written.
#
# The plan should be "N to import, 0 to destroy". Anything proposing a DESTROY
# or a replace means the HCL does not match the live object — fix the HCL, never
# apply through it. Destroying the pki mount would take the root CA with it.
#
# Once applied, these blocks are inert and can be deleted.
import {
to = vault_mount.kv
id = "kv"
}
import {
to = vault_mount.pki
id = "pki"
}
import {
to = vault_mount.ssh_client_signer
id = "ssh-client-signer"
}
import {
to = vault_pki_secret_backend_role.bao_server
id = "pki/roles/bao-server"
}
import {
to = vault_ssh_secret_backend_role.ai_agent
id = "ssh-client-signer/roles/ai-agent"
}
import {
# NOTE: vault_auth_backend imports by the BARE path ("oidc"), not the
# API-prefixed "auth/oidc" — the latter gives "Cannot import non-existent
# remote object". The ROLE below does use the full path, which is the
# inconsistency that makes this easy to get wrong.
to = vault_auth_backend.oidc
id = "oidc"
}
import {
to = vault_jwt_auth_backend_role.admin
id = "auth/oidc/role/admin"
}
import {
to = vault_policy.admin
id = "admin"
}
import {
to = vault_policy.ai_agent_ssh
id = "ai-agent-ssh"
}
import {
to = vault_policy.snapshot
id = "snapshot"
}
import {
to = vault_pki_secret_backend_config_urls.this
id = "pki/config/urls"
}
@@ -0,0 +1,31 @@
# Secrets engines. IMPORTED from the running instance (see imports.tf) — these
# were originally created by ../ansible (role openbao_bootstrap).
#
# NOT MANAGED HERE, DELIBERATELY:
# * the PKI ROOT CA (pki/root/generate)
# * the SSH CA keypair (ssh-client-signer/config/ca)
# Both are root-of-trust material. A Terraform resource for them would treat any
# drift as "regenerate", which would silently invalidate every issued cert and
# every trusted SSH CA line on every host. They stay one-time Ansible bootstrap.
resource "vault_mount" "kv" {
path = "kv"
type = "kv"
options = { version = "2" }
}
resource "vault_mount" "pki" {
path = "pki"
type = "pki"
max_lease_ttl_seconds = 315360000 # 10y — must exceed the root CA's own lifetime
# Required by ACME: OpenBao strips response headers not listed here, and
# clients fail at the "new nonce" step without them. This is the ONE
# intentional change in the import plan.
allowed_response_headers = ["Replay-Nonce", "Link", "Location"]
}
resource "vault_mount" "ssh_client_signer" {
path = "ssh-client-signer"
type = "ssh"
}
@@ -0,0 +1,13 @@
output "acme_directory_url" {
value = "${var.bao_address}/v1/${var.pki_mount}/roles/${var.acme_role}/acme/directory"
description = <<-EOT
Point ACME clients here. Example, on dc1 (binds :80 only during renewal):
lego --server <this> --domains dc1.ad.ddupan.top --email [email protected] --http run
Clients must already trust the internal CA, or pass its PEM to the client.
EOT
}
output "acme_enabled" {
value = vault_pki_secret_backend_config_acme.this.enabled
description = "Whether the ACME directory is currently serving."
}
+74
View File
@@ -0,0 +1,74 @@
# ── issuing role ──────────────────────────────────────────────────────────
# Caps what ACME (and direct issuance) may mint. dc1's LDAPS cert comes from here.
resource "vault_pki_secret_backend_role" "bao_server" {
backend = vault_mount.pki.path
name = "bao-server"
allowed_domains = ["ad.ddupan.top"]
allow_subdomains = true
allow_bare_domains = false
allow_glob_domains = false
allow_any_name = false
allow_ip_sans = true # dc1's cert carries IP:192.168.10.5
server_flag = true
client_flag = true
key_type = "rsa"
key_bits = 2048
max_ttl = 31536000 # 1y
# 60d. NOT 0: ttl=0 falls back to the system default of 768h (32 days), which
# is what dc1's cert was getting. That is fine for lego (samba_ad_acme renews at
# 10 days left) but breaks Proxmox: PVE's renewal threshold is hardcoded at "30
# days to expiry" (PVE/API2/ACME.pm), so a 32-day cert renews every ~2 days and
# restarts pveproxy each time. 60d leaves PVE a full 30-day retry window -- which
# matters given the flaky WAN -- and stays well under OpenBao's 90d ACME cap.
ttl = 5184000 # 60d
use_csr_common_name = true
}
# ── cluster paths ─────────────────────────────────────────────────────────
# ACME directory/order URLs are built from these and embedded in issued certs as
# AIA URLs, so they must be reachable by clients exactly as written. Uses the
# public hostname (real Let's Encrypt cert via the openbao_acme Ansible role),
# not the bare IP.
resource "vault_pki_secret_backend_config_cluster" "this" {
backend = vault_mount.pki.path
path = "${var.bao_address}/v1/${vault_mount.pki.path}"
aia_path = "${var.bao_address}/v1/${vault_mount.pki.path}"
}
# ── ACME ──────────────────────────────────────────────────────────────────
# WHY: dc1's LDAPS cert was hand-issued 2026-07-25 and expires 2027-07-25 with
# nothing to renew it. If it lapses, Authelia loses its LDAPS backend and every
# SSO consumer fails at once. ACME takes the human out of that loop.
resource "vault_pki_secret_backend_config_acme" "this" {
backend = vault_mount.pki.path
enabled = var.acme_enabled
# SECURITY: OpenBao's default is "sign-verbatim" — it would issue ANY name a
# client asks for, meaning anything able to reach bao could mint a cert for
# dc1.ad.ddupan.top from the ROOT CA. Pinning to the role caps issuance at
# that role's allowed_domains.
default_directory_policy = "role:${vault_pki_secret_backend_role.bao_server.name}"
allowed_roles = [vault_pki_secret_backend_role.bao_server.name]
allowed_issuers = ["*"]
# See variables.tf for the not-required vs EAB trade-off.
eab_policy = var.acme_eab_policy
# Empty = server's own resolver. bao resolves ad.ddupan.top correctly
# (verified), so http-01 validation against internal hosts works.
dns_resolver = ""
depends_on = [vault_pki_secret_backend_config_cluster.this]
}
# ── issuing / CRL URLs ────────────────────────────────────────────────────
# Embedded in every issued cert so clients can fetch the CA and check the CRL.
# Previously set by ../ansible (pki.yml, "Configure issuing/CRL URLs") — moved
# here as part of the Terraform-owns-configuration split.
resource "vault_pki_secret_backend_config_urls" "this" {
backend = vault_mount.pki.path
issuing_certificates = ["${var.bao_address}/v1/${vault_mount.pki.path}/ca"]
crl_distribution_points = ["${var.bao_address}/v1/${vault_mount.pki.path}/crl"]
}
@@ -0,0 +1,27 @@
# Policy bodies live in policies/*.hcl so they stay readable and diffable.
# Exported verbatim from the running instance, so importing produces no diff.
#
# OVERLAP WARNING: ../ansible (role openbao_bootstrap) also writes these — hence
# the "# Ansible managed" header still inside each file. Gate those Ansible tasks
# off before applying, or the two will overwrite each other on alternate runs.
resource "vault_policy" "admin" {
name = "admin"
policy = file("${path.module}/policies/admin.hcl")
}
resource "vault_policy" "ai_agent_ssh" {
name = "ai-agent-ssh"
policy = file("${path.module}/policies/ai-agent-ssh.hcl")
}
resource "vault_policy" "snapshot" {
name = "snapshot"
policy = file("${path.module}/policies/snapshot.hcl")
}
# Read-only kv/k8s/* for the External Secrets Operator. Unlike the three above
# this one is NOT also written by Ansible, so there is no overlap to gate off.
resource "vault_policy" "external_secrets" {
name = "external-secrets"
policy = file("${path.module}/policies/external-secrets.hcl")
}
@@ -0,0 +1,6 @@
# Ansible managed
# Broad admin for human OIDC logins (mapped from the vault-admins AD group).
# Homelab-broad on purpose; scope down to specific mounts if you want least privilege.
path "*" {
capabilities = ["create", "read", "update", "delete", "list", "sudo"]
}
@@ -0,0 +1,5 @@
# Ansible managed
# The AI agent may sign short-lived SSH client certs for the ai-agent role — nothing else.
path "ssh-client-signer/sign/ai-agent" {
capabilities = ["create", "update"]
}
@@ -0,0 +1,17 @@
# Read-only access for the External Secrets Operator in k3s.
#
# Scoped to kv/k8s/* deliberately — ESO syncs Kubernetes Secrets and has no reason
# to see the SSH CA, the PKI, or any other KV path. This is narrower than the human
# `admin` policy, which is the point: the machine identity is less privileged than
# the person.
#
# KV v2 splits data from metadata: reads go to <mount>/data/<path>, and listing or
# checking existence goes to <mount>/metadata/<path>. ESO needs both — without
# metadata it cannot resolve `dataFrom.extract`.
path "kv/data/k8s/*" {
capabilities = ["read"]
}
path "kv/metadata/k8s/*" {
capabilities = ["read", "list"]
}
@@ -0,0 +1,5 @@
# Ansible managed
# Read-only access to take Raft snapshots — used by the snapshot timer's token.
path "sys/storage/raft/snapshot" {
capabilities = ["read"]
}
+21
View File
@@ -0,0 +1,21 @@
# SSH certificate authority for machine/agent access.
# The CA KEYPAIR itself is NOT managed here (see mounts.tf) — only this role,
# which is what actually constrains what a signed cert may do.
resource "vault_ssh_secret_backend_role" "ai_agent" {
backend = vault_mount.ssh_client_signer.path
name = "ai-agent"
key_type = "ca"
allow_user_certificates = true
default_user = "ansible"
allowed_users = "*"
# Deliberately short: access is scoped by TTL + principals rather than by
# source IP, so a leaked cert expires in minutes.
ttl = 300 # 5m
max_ttl = 900 # 15m
default_extensions = {
"permit-pty" = ""
}
}
@@ -0,0 +1,45 @@
variable "bao_address" {
type = string
default = "https://bao.ad.ddupan.top:8200"
description = <<-EOT
OpenBao API address. Must be the name clients can actually reach and verify:
it is baked into ACME directory URLs and issued certs' AIA extension.
EOT
}
variable "pki_mount" {
type = string
default = "pki"
description = "Path of the PKI secrets engine. Mount itself is Ansible-owned (openbao_bootstrap)."
}
variable "acme_enabled" {
type = bool
default = true
description = "Enable the ACME directory on the PKI mount."
}
variable "acme_role" {
type = string
default = "bao-server"
description = <<-EOT
Role that constrains ACME issuance. Its allowed_domains cap what any ACME
client can obtain — currently ad.ddupan.top with subdomains, IP SANs allowed.
NEVER leave the policy as sign-verbatim; that would let ACME issue any name.
EOT
}
variable "acme_eab_policy" {
type = string
default = "not-required"
description = <<-EOT
"not-required": any host reaching bao may enroll (names still capped by acme_role).
"new-account-required": each client must present an External Account Binding
credential from `bao write -f pki/acme/new-eab`. Tighter, but needs per-host
provisioning and rotation.
EOT
validation {
condition = contains(["not-required", "new-account-required", "always-required"], var.acme_eab_policy)
error_message = "Must be not-required, new-account-required, or always-required."
}
}
@@ -0,0 +1,25 @@
terraform {
required_version = ">= 1.5"
required_providers {
# NOTE: this targets OpenBao, but uses the HASHICORP VAULT provider.
# The native `openbao/openbao` provider is published to the OpenTofu
# registry, NOT registry.terraform.io, so it cannot be resolved by the
# HashiCorp `terraform` CLI in use here ("provider registry
# registry.terraform.io does not have a provider named openbao/openbao").
# OpenBao is API-compatible with Vault, so this provider drives it fine.
# If this repo ever switches to `tofu`, swap to openbao/openbao and rename
# the vault_* resources to openbao_*.
vault = {
source = "hashicorp/vault"
version = "~> 4.0"
}
}
}
# Authenticates from the ambient CLI session, same pattern as smtp-relay/terraform
# uses `az login`: run `bao login -method=oidc` first, which writes ~/.vault-token.
# VAULT_ADDR/VAULT_TOKEN (or BAO_ADDR/BAO_TOKEN exported into them) override.
# No credentials are stored in this config.
provider "vault" {
address = var.bao_address
}