Establish clean homelab infrastructure baseline
Reorganize the brownfield repository, remove retired and generated artifacts, harden ignore rules, and record the GitOps/IaC redesign.
This commit is contained in:
@@ -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
|
||||
@@ -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"]
|
||||
}
|
||||
+5
@@ -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
|
||||
+6
@@ -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"
|
||||
}
|
||||
+11
@@ -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"
|
||||
}
|
||||
+9
@@ -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
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
# {{ ansible_managed }}
|
||||
[Unit]
|
||||
Description=OpenBao Raft snapshot timer
|
||||
|
||||
[Timer]
|
||||
OnCalendar={{ openbao_snapshot_oncalendar }}
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
+5
@@ -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"]
|
||||
}
|
||||
+10
@@ -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
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user