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

Reorganize the brownfield repository, remove retired and generated artifacts, harden ignore rules, and record the GitOps/IaC redesign.
This commit is contained in:
2026-09-09 16:47:20 +00:00
commit 88a02ababa
418 changed files with 50579 additions and 0 deletions
@@ -0,0 +1,62 @@
# Cloudflare tunnel — the PUBLIC ingress for *.ddupan.top.
#
# The tunnel is TOKEN-MANAGED: `TUNNEL_TOKEN` is the whole credential, and the
# routing/ingress rules live server-side, managed by ../cloudflared/terraform
# (`cloudflare_zero_trust_tunnel_cloudflared_config`). There is deliberately no
# local config.yml and no credentials-file here.
#
# A ConfigMap + credentials Secret used to be embedded in this file. Both were
# dead and were removed 2026-07-28, with three independent proofs:
# 1. the config routed idm.ddupan.top -> keycloak, RETIRED 2026-07-10;
# 2. it pointed auth.ddupan.top at authelia:9091, which 502s — Terraform
# corrected that to :80 and auth works, so Terraform's config is the live one;
# 3. the credentials volume mounted subPath `<uuid>.json` while the Secret's
# key was `credentials-file`, so that mount never resolved at all.
# `origin_request.no_tls_verify` is likewise set API-side by Terraform.
#
# The token itself is NOT in this file — see secret.example.yaml.
---
apiVersion: v1
kind: Namespace
metadata:
name: cloudflared
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: cloudflared
namespace: cloudflared
spec:
replicas: 1
selector:
matchLabels:
app: cloudflared
template:
metadata:
labels:
app: cloudflared
spec:
containers:
- name: cloudflared
# NOTE: :latest on the public ingress. Pinning is desirable but a wrong
# pin is worse than an unpinned upgrade here; left as-is deliberately.
image: cloudflare/cloudflared:latest
command:
- cloudflared
- tunnel
- --no-autoupdate
- --loglevel
- info
- --metrics
- 0.0.0.0:2000
# No --config: with TUNNEL_TOKEN set, cloudflared pulls its ingress
# rules from the Cloudflare API. A --config pointing at a file that
# no longer exists would be a hard startup error.
- run
env:
- name: TUNNEL_TOKEN
valueFrom:
secretKeyRef:
name: cloudflared-tunnel
key: token
restartPolicy: Always
@@ -0,0 +1,23 @@
# Template. Copy to secret.yaml, fill in the real token, and apply.
# secret.yaml is gitignored — same convention as ../../platform/cert-manager, ../../apps/netbox
# and ../../apps/smtp-relay.
#
# The token IS the tunnel credential: it embeds the account tag, tunnel ID and
# tunnel secret, so anything holding it can serve traffic for the tunnel. It is
# also all cloudflared needs — the ingress rules come from the Cloudflare API,
# managed by ./terraform.
#
# Where to get it:
# Cloudflare dashboard -> Zero Trust -> Networks -> Tunnels -> <tunnel> ->
# Configure -> the `cloudflared service install <TOKEN>` command contains it.
# Rotating it there requires re-applying this Secret and restarting the
# Deployment; the tunnel UUID does not change.
---
apiVersion: v1
kind: Secret
metadata:
name: cloudflared-tunnel
namespace: cloudflared
type: Opaque
stringData:
token: REPLACE_WITH_CLOUDFLARE_TUNNEL_TOKEN
@@ -0,0 +1,7 @@
# Never commit secrets or state
terraform.tfvars
*.tfstate
*.tfstate.*
.terraform/
.terraform.lock.hcl.bak
crash.log
+19
View File
@@ -0,0 +1,19 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/cloudflare/cloudflare" {
version = "5.21.1"
constraints = "~> 5.0"
hashes = [
"h1:hU72otEs26Wx6tcJD9igX6I/BQtVgeRuaIe3s/hn6bQ=",
"zh:049719425b8be43d9d4f0c208217aca0baa22374f061d7ff92f02563490f649c",
"zh:0a8a3c1b26680b437fe9e7910ca81e532d36f8efacfb14f45690b6a779856993",
"zh:32b61f80892243f7ab8e453fa038c1f3e2aac733ccb98307c2cfe798b2793b32",
"zh:42c27f3cd62979e70716c51f682a3d131d51ad76d86dff83d8cdbfffcebac841",
"zh:4c8cd464f9b6ecde5cd4430bbba4be3b810826105e51ef6328b6a2b69f821443",
"zh:586ea42ef74d6c5bc4c9b89da6b1f8618a19f4e80272fe8d615e7d5b11c491af",
"zh:b09b86c7cac7085e01c9b7a828f09d13c44589d3e3cd42f0b694ca3e4cd3ed0a",
"zh:eac80665e60c701b37a6318f4e405d67f1720f8da5f93135c6256049282d3367",
"zh:f809ab383cca0a5f83072981c64208cbd7fa67e986a86ee02dd2c82333221e32",
]
}
@@ -0,0 +1,46 @@
# Cloudflare tunnel + DNS as code (Terraform)
The cloudflared tunnel runs **token-managed** (`TUNNEL_TOKEN` in the Deployment), so its
ingress config is authoritative **at Cloudflare's edge**, not the in-cluster `config.yml`
(which is ignored in token mode). This Terraform manages that edge config + DNS declaratively.
- `cloudflare_zero_trust_tunnel_cloudflared_config.main` — the tunnel's full ingress list
(all public hostnames → in-cluster services). First match wins; `http_status:404` stays last.
- `cloudflare_dns_record.auth` — proxied CNAME `auth.ddupan.top → <tunnel>.cfargotunnel.com`.
> The `../cloudflared/cloudflared.yaml` `config.yml` ingress is now cosmetic. Either keep it
> in sync for documentation, or drop `TUNNEL_TOKEN` to make it authoritative instead — but
> we chose Terrraform-over-edge-config so the tunnel stays dashboard/token managed.
## One-time setup
1. **Create a Cloudflare API token** (dash → My Profile → API Tokens) with:
- Account · **Cloudflare Tunnel : Edit**
- Zone · **DNS : Edit** (zone `ddupan.top`)
2. Provide it + the zone id:
```bash
export TF_VAR_cloudflare_api_token='...'
# zone id:
curl -s -H "Authorization: Bearer $TF_VAR_cloudflare_api_token" \
"https://api.cloudflare.com/client/v4/zones?name=ddupan.top" | jq -r '.result[0].id'
export TF_VAR_zone_id='<that id>'
```
3. **Adopt existing resources into state** (they already exist — don't recreate):
```bash
terraform init
# tunnel config singleton:
terraform import cloudflare_zero_trust_tunnel_cloudflared_config.main \
65bf9ede92caa915f992fdf3d1e7b2f1/ff392451-b0b1-45bb-964e-6d9372c3a9e3
# the auth DNS record (get its id from the API, then):
terraform import cloudflare_dns_record.auth $TF_VAR_zone_id/<record_id>
```
## Apply
```bash
terraform plan # should show only auth.ddupan.top being added to ingress
terraform apply
```
State is local (`terraform.tfstate`, gitignored). Move to a remote backend (r2/s3) if this
grows.
@@ -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 = "cloudflared/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,71 @@
# Manages the REMOTE (dashboard) config of the token-managed cloudflared tunnel.
# This is what's actually authoritative when the pod runs with TUNNEL_TOKEN — the
# in-cluster config.yml ingress is ignored in that mode. Terraform is the source of truth.
resource "cloudflare_zero_trust_tunnel_cloudflared_config" "main" {
account_id = var.account_id
tunnel_id = var.tunnel_id
config = {
# First match wins; the http_status:404 catch-all MUST stay last.
ingress = [
{
hostname = "auth.ddupan.top"
# Authelia's k8s Service listens on :80 (targetPort -> container 9091).
# Hitting :9091 on the service yields 502; the service port is 80.
service = "http://authelia.authelia.svc.cluster.local:80"
},
{
hostname = "git.ddupan.top"
service = "http://gitea-http.gitea.svc.cluster.local:3000"
},
{
hostname = "e5renew.ddupan.top"
service = "http://e5renew.e5renew.svc.cluster.local:80"
},
{
hostname = "obj.ddupan.top"
service = "http://seaweedfs-s3.seaweedfs.svc.cluster.local:8333"
},
{
service = "http_status:404"
},
]
origin_request = {
no_tls_verify = true
}
}
}
# Public DNS: proxied CNAME -> the tunnel. (auth was bootstrapped with
# `cloudflared tunnel route dns`; import it into state — see README.)
resource "cloudflare_dns_record" "auth" {
zone_id = var.zone_id
name = "auth.ddupan.top"
type = "CNAME"
content = "${var.tunnel_id}.cfargotunnel.com"
proxied = true
ttl = 1 # 1 = automatic (required when proxied)
}
# DKIM for Microsoft 365 mail sent as *@ddupan.top (via the smtp-relay). CNAMEs point
# at the tenant's DKIM keys; must be DNS-only (unproxied). Enable signing in Exchange
# after these resolve: smtp-relay/scripts/enable-dkim.ps1.
resource "cloudflare_dns_record" "dkim_selector1" {
zone_id = var.zone_id
name = "selector1._domainkey.ddupan.top"
type = "CNAME"
content = "selector1-ddupan-top._domainkey.ddupan1.d-v1.dkim.mail.microsoft"
proxied = false
ttl = 3600
}
resource "cloudflare_dns_record" "dkim_selector2" {
zone_id = var.zone_id
name = "selector2._domainkey.ddupan.top"
type = "CNAME"
content = "selector2-ddupan-top._domainkey.ddupan1.d-v1.dkim.mail.microsoft"
proxied = false
ttl = 3600
}
@@ -0,0 +1,4 @@
# Copy to terraform.tfvars (gitignored) and fill in. Or export TF_VAR_cloudflare_api_token.
cloudflare_api_token = "CHANGE-ME" # Account·Cloudflare Tunnel:Edit + Zone·DNS:Edit (ddupan.top)
zone_id = "CHANGE-ME" # GET /zones?name=ddupan.top -> result[0].id
# account_id / tunnel_id / zone_name have defaults in variables.tf
@@ -0,0 +1,27 @@
variable "cloudflare_api_token" {
type = string
sensitive = true
description = "Cloudflare API token — scopes: Account·Cloudflare Tunnel:Edit, Zone·DNS:Edit (ddupan.top). Provide via TF_VAR_cloudflare_api_token or terraform.tfvars (gitignored)."
}
variable "account_id" {
type = string
default = "65bf9ede92caa915f992fdf3d1e7b2f1"
description = "Cloudflare account tag (from the tunnel credentials)."
}
variable "zone_name" {
type = string
default = "ddupan.top"
}
variable "zone_id" {
type = string
description = "Zone ID for ddupan.top (fetch once: GET /zones?name=ddupan.top)."
}
variable "tunnel_id" {
type = string
default = "ff392451-b0b1-45bb-964e-6d9372c3a9e3"
description = "Existing cloudflared tunnel ID (remotely managed)."
}
@@ -0,0 +1,13 @@
terraform {
required_version = ">= 1.6"
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "~> 5.0"
}
}
}
provider "cloudflare" {
api_token = var.cloudflare_api_token
}
+13
View File
@@ -0,0 +1,13 @@
# OCI infrastructure recovery
The original Terraform source is currently unavailable. The likely authoritative
state remains in OCI Object Storage. Reconstruct configuration here only after
taking an encrypted independent state backup.
Safety requirements:
- preserve the existing state lineage and serial;
- reproduce the current VM and public-network design first;
- reach a zero-change plan before any apply;
- protect the instance and boot volume from destruction;
- treat migration to a private subnet as a separate reviewed change.
+7
View File
@@ -0,0 +1,7 @@
# ansible/group_vars/all/vault.yml is now ANSIBLE-VAULT ENCRYPTED and IS committed.
# Password: ../.vault_pass (gitignored) / OpenBao kv/infra/ansible-vault.
# ⚠ If you ever `ansible-vault decrypt` it, DO NOT commit until re-encrypted.
# Local inventory overrides.
ansible/inventory/hosts.local.yml
# Exported PGP public keys used for `bao operator init`.
*.pub.b64
+229
View File
@@ -0,0 +1,229 @@
# OpenBao
Self-hosted secrets management + internal CA (SSH cert authority, dynamic secrets,
KV, PKI, transit) — the root-of-trust for AI agents and internal TLS.
## Design decisions
- **OpenBao, not Vault** — covers every engine we need (SSH CA, KV, dynamic Postgres
creds, transit, PKI) under an OSI license. Vault only if you later need an engine
OpenBao dropped in its fork.
- **On a dedicated host, NOT in k8s** — a secrets/CA root-of-trust must outlive the
cluster it serves (no circular dependency) and sit outside its blast radius.
`k8s-auth` still reaches back into the cluster for in-cluster workloads.
- **Tailscale + LAN only, never the Cloudflare tunnel** — machine clients authenticate
with tokens/JWTs (not browser SSO), and the recovery service must not depend on an
external SaaS to be reachable. Pods use the LAN IP; humans + the laptop agent use
Tailscale.
- **Integrated Raft storage** — no external DB.
## Layout
```
ansible/
create-bao-vm.yml # create the host as a tiny libvirt VM (cloud-init)
provision-openbao.yml # deploy play (install + Raft + TLS + systemd)
inventory/hosts.yml # the bao host (LAN IP)
group_vars/all/vars.yml # version/checksum, addresses, firewall, auto-unseal
group_vars/all/vault.example.yml
roles/bao_vm/ # 1 vCPU / 1 GB / 10 GB Ubuntu VM on br0 (ZFS zvol)
roles/openbao/ # install, TLS bootstrap cert, config, systemd unit
```
`provision-openbao.yml` **only deploys the daemon**. It does not initialize, unseal, or
configure any secrets engines/auth methods — that is a separate bootstrap play (later).
```
terraform/ # OpenBao's API-level CONFIGURATION (see below)
mounts.tf pki.tf ssh.tf auth.tf policies.tf
imports.tf # adopts the already-running instance into state
policies/*.hcl # policy bodies, kept diffable
```
## Ownership: Terraform vs Ansible
**Terraform manages OpenBao itself. Ansible manages the machine under it, plus the
few things Terraform must not own.**
| Terraform (`terraform/`) | Ansible (`ansible/`) |
|---|---|
| secrets engine mounts (`kv`, `pki`, `ssh-client-signer`) | the daemon, Raft, TLS files, systemd |
| PKI role, issuing/CRL URLs, cluster paths, **ACME** | `bao operator init` / unseal (manual, PGP-wrapped) |
| SSH signing role (`ai-agent`) | **PKI root CA + SSH CA signing key** |
| OIDC auth *mount* and *role* | OIDC **client secret** (`auth/oidc/config`) |
| all policies | snapshot token + script + systemd timer |
| | host-level CA trust distribution (`openbao_ssh_ca_trust`) |
Why the exceptions stay in Ansible:
- **Root-of-trust key material** (PKI root CA, SSH CA signing key). A Terraform
resource treats drift as *regenerate*, which would silently invalidate every
issued certificate and every `TrustedUserCAKeys` line on every host. Generated
once, never reconciled.
- **The OIDC client secret.** Terraform cannot read it back from the API, so
managing it would put the plaintext into `terraform.tfstate` **and** produce a
permanent diff. It lives in `group_vars/all/vault.yml` (ansible-vault).
- **Snapshot token + timer.** A systemd unit on a host, and a token that would
otherwise land in state.
The switch is `openbao_config_managed_by_terraform` (default `true`) in
`roles/openbao_bootstrap/defaults/main.yml`. It gates every task Terraform now
owns. Set it `false` only to bootstrap without Terraform — leaving it `false`
against a Terraform-managed instance makes the two overwrite each other on
alternate runs.
**Fresh-install order** (the one ordering constraint this split creates — CA
material needs its mount to exist first):
1. `ansible-playbook provision-openbao.yml` — daemon, TLS, systemd
2. `bao operator init` + unseal — manual, PGP-wrapped to the YubiKey
3. `terraform apply` — mounts, roles, policies, ACME
4. `ansible-playbook bootstrap-openbao.yml` — root CA, SSH CA key, OIDC secret, snapshots
For the **existing** instance, `terraform/imports.tf` adopts what is already
running; the plan must read *"N to import, 0 to destroy"*. A proposed destroy or
replace of `vault_mount.pki` would take the root CA with it — fix the HCL, never
apply through it.
Terraform authenticates from the ambient CLI session (`bao login -method=oidc`,
then `VAULT_ADDR`/`VAULT_TOKEN`), mirroring how `smtp-relay/terraform` uses
`az login`. It targets OpenBao via the **`hashicorp/vault`** provider, because the
native `openbao/openbao` provider is published only to the OpenTofu registry and
cannot be resolved by the HashiCorp `terraform` CLI.
## DNS
`bao.ad.ddupan.top` is an **internal-only** name — not a public Cloudflare record and
not behind the tunnel. Add the A record on the Samba DC (authoritative for
`ad.ddupan.top`), same pattern as the KMS record:
```bash
# on the DC, or with -U administrator%<pass>:
samba-tool dns add 192.168.10.5 ad.ddupan.top bao A 192.168.10.8 -U administrator
```
For remote/off-LAN clients, resolution rides your existing setup: the DC (`192.168.10.5`)
is reachable over the tailnet via the `192.168.10.0/24` subnet route, so point
`ad.ddupan.top` at it in Tailscale **split-DNS** (as you already do for other internal
names). LAN clients that use the DC as resolver get it directly.
## Create the host
A dedicated minimal VM — NOT on the DC or the k8s host — keeps the root-of-trust out of
the blast radius of what it protects. bao is tiny, so this costs almost nothing.
```bash
cd ansible
ansible-galaxy collection install -r requirements.yml
# set openbao_lan_ip in group_vars/all/vars.yml (also the VM's static IP)
ansible-playbook create-bao-vm.yml # runs on the libvirt host (localhost)
```
## Deploy
```bash
# inventory/hosts.yml bao1 IP must match openbao_lan_ip
ansible-playbook provision-openbao.yml --ask-vault-pass
ansible-playbook provision-openbao.yml --tags verify # smoke tests
```
After the first run the node is **uninitialized + sealed** — expected.
## Initialize (once, by hand — PGP-wrapped to your YubiKey)
Encrypt the unseal keys + root token to your YubiKey's GPG public key so they are
never printed in plaintext. Add a **backup offline GPG key** as a second share so a
lost/dead YubiKey doesn't make the data unrecoverable.
```bash
gpg --export <YUBIKEY_KEYID> | base64 > yubikey.pub.b64
gpg --export <BACKUP_KEYID> | base64 > backup.pub.b64
BAO_ADDR=https://127.0.0.1:8200 BAO_SKIP_VERIFY=true \
bao operator init -key-shares=2 -key-threshold=1 \
-pgp-keys="yubikey.pub.b64,backup.pub.b64" \
-root-token-pgp-key="yubikey.pub.b64"
# unseal (decrypt a share — touch the YubiKey):
echo "<encrypted-key-b64>" | base64 -d | gpg -dq | xargs bao operator unseal
```
With transit auto-unseal (`openbao_auto_unseal: true`) there are no unseal keys — use
`-recovery-pgp-keys` instead, and unsealing becomes automatic on restart.
## Bootstrap (engines, auth, policies)
Once initialized + unsealed, configure the bao side. Authenticate with the root token
(decrypt it, then export), and run the bootstrap play:
```bash
echo "<encrypted-root-token-b64>" | base64 -d | gpg -dq # touch YubiKey
export BAO_TOKEN=<plaintext-root-token>
ansible-playbook bootstrap-openbao.yml --ask-vault-pass
ansible-playbook bootstrap-openbao.yml --tags verify
```
It enables **KV v2**, the **SSH CA** (`ssh-client-signer` + `ai-agent` role), **PKI**,
**OIDC auth** (the Authelia `openbao` client → `admin` policy for the
`vault-admins` AD group), the **ai-agent-ssh** policy, and a **Raft snapshot** timer.
Idempotent — safe to re-run. Selective runs via tags: `kv,ssh_ca,oidc,pki,k8s,policies,snapshots`.
Debug a step with `-e openbao_no_log=false`.
Off by default (need extra inputs, enable when ready):
- **Kubernetes auth** — `openbao_enable_k8s_auth: true` + reviewer JWT/CA (for in-cluster agents like hermes).
- **PKI listener cert** — `openbao_pki_replace_listener_cert: true` swaps the self-signed
cert for a PKI-issued one (clients must then trust the PKI root CA; a Shamir node
re-seals on the restart).
Afterwards, create a scoped admin path (OIDC login) and **revoke the root token**:
`bao token revoke -self`.
## Publicly-trusted TLS (ACME DNS-01)
Replace the self-signed listener cert with a **Let's Encrypt** cert so clients drop
`BAO_SKIP_VERIFY`. `ad.ddupan.top` is split-horizon — the DC serves it internally, but
it is **not delegated** in public DNS, so Cloudflare answers `*.ad.ddupan.top`
authoritatively. lego writes a **transient** `_acme-challenge.bao.ad.ddupan.top` TXT into
the Cloudflare `ddupan.top` zone, LE validates, and issues for the **internal** name —
no permanent record, no IP leak. Renewal reloads bao via **SIGHUP** (no restart/reseal).
By default it **reuses the Cloudflare token already managed for the tunnel**
(`cloudflared/terraform/terraform.tfvars` — it has `Zone:DNS:Edit` on ddupan.top), so
there's nothing new to store. To use a dedicated least-privilege token instead, set
`vault_openbao_cf_dns_token` in `vault.yml`.
```bash
ansible-playbook acme-openbao.yml --ask-vault-pass
```
Test against LE **staging** first to avoid rate limits: set `openbao_acme_server:
https://acme-staging-v02.api.letsencrypt.org/directory`, run, confirm, then clear it and
re-run for a real cert (`rm -rf /etc/openbao/acme` on the host between the two to reset).
Note: the LE cert covers the **hostname only** (`bao.ad.ddupan.top`), not the IP — so
after this, use `BAO_ADDR=https://bao.ad.ddupan.top:8200` (no skip-verify), not the IP.
## Using it
```bash
# human: log in via Authelia (2FA)
bao login -method=oidc # browser → auth.ddupan.top
# agent: mint a 5-min SSH cert for a target, then connect
ssh-keygen -t ed25519 -f /run/agent/id -N ''
bao write -field=signed_key ssh-client-signer/sign/ai-agent \
public_key=@/run/agent/id.pub valid_principals=<node> > /run/agent/id-cert.pub
ssh -i /run/agent/id <user>@<node>
```
Each no-root target trusts the CA via one line in `~/.ssh/authorized_keys` (the play
prints it): `cert-authority,principals="<node>",restrict,pty <ca-pubkey>`.
## Operational notes
- **Restart re-seals** a Shamir node (needs manual unseal). Harmless before first init;
automatic re-unseal with transit auto-unseal.
- **Back up** Raft snapshots off-box once initialized: `bao operator raft snapshot save`
(belongs in the bootstrap play — it needs a token).
- The listener uses a **self-signed bootstrap cert**; replace it with a cert issued by
OpenBao's own PKI engine in the bootstrap play.
@@ -0,0 +1,19 @@
---
# Obtain a publicly-trusted Let's Encrypt cert for bao's listener via ACME DNS-01
# (Cloudflare), replacing the self-signed bootstrap cert, and set up auto-renewal.
#
# ad.ddupan.top is split-horizon: the DC serves it internally, but it is NOT delegated
# in public DNS, so Cloudflare answers *.ad.ddupan.top authoritatively. lego drops a
# transient _acme-challenge.bao.ad.ddupan.top TXT into the Cloudflare ddupan.top zone,
# LE validates it, and the cert is issued for the internal name — no permanent record,
# no IP leak. Renewal reloads bao via SIGHUP (no restart, no reseal).
#
# ansible-playbook acme-openbao.yml --ask-vault-pass
#
# Needs vault_openbao_cf_dns_token — a Cloudflare API token with Zone:DNS:Edit on ddupan.top.
- name: OpenBao ACME certificate (Let's Encrypt via Cloudflare DNS-01)
hosts: openbao
become: true
gather_facts: true
roles:
- role: openbao_acme
@@ -0,0 +1,12 @@
[defaults]
inventory = inventory/hosts.yml
roles_path = roles
host_key_checking = False
callback_result_format = yaml
nocows = True
# All hosts here are Linux (sudo). Plays declare `become: true` themselves.
# Encrypted group_vars/all/vault.yml are COMMITTED; the password is not.
# Relative to this file, so it resolves for any checkout location.
vault_password_file = ../../../.vault_pass
@@ -0,0 +1,26 @@
---
# Bootstrap a running, INITIALIZED + UNSEALED OpenBao: enable engines/auth/policies.
# Idempotent — safe to re-run. Authenticates with a token you export after decrypting
# the PGP-wrapped root token:
#
# echo "<encrypted-root-token-b64>" | base64 -d | gpg -dq # touch YubiKey
# export BAO_TOKEN=<that-plaintext-root-token>
# ansible-playbook bootstrap-openbao.yml --ask-vault-pass
#
# Run selectively with tags: --tags kv,ssh_ca,oidc,pki,k8s,policies,snapshots,verify
# Debug a step by disabling no_log: -e openbao_no_log=false
#
# After bootstrap, create a scoped admin token/OIDC login and REVOKE the root token:
# bao token revoke -self
- name: Bootstrap OpenBao (engines, auth, policies)
hosts: openbao
become: true
gather_facts: true
roles:
- role: openbao_bootstrap
post_tasks:
- name: Smoke tests
ansible.builtin.import_role:
name: openbao_bootstrap
tasks_from: verify.yml
tags: [verify, never]
@@ -0,0 +1,16 @@
---
# Create the OpenBao host as a small Ubuntu VM locally via libvirt + cloud-init.
# Runs on the libvirt host itself (localhost / qemu:///system).
# ansible-playbook create-bao-vm.yml
# Then deploy + initialize OpenBao:
# ansible-playbook provision-openbao.yml --ask-vault-pass
#
# Deliberately its own minimal VM — NOT co-located on the DC or the k8s host — so the
# secrets/CA root-of-trust stays out of the blast radius of what it protects.
- name: Create the OpenBao VM
hosts: localhost
connection: local
become: true
gather_facts: false
roles:
- role: bao_vm
@@ -0,0 +1,31 @@
---
# Non-secret variables for the OpenBao deploy. EDIT to your environment.
# Secrets (transit unseal token) live in group_vars/all/vault.yml — see vault.example.yml.
# --- Release (pinned + checksum-verified) ---
# Bump both together. Get the checksum from the release's checksums.txt:
# curl -sL https://github.com/openbao/openbao/releases/download/v<VER>/checksums.txt \
# | grep openbao_<VER>_linux_amd64.tar.gz
openbao_version: "2.6.1"
openbao_download_checksum: "sha256:ca8d836eb3a5c80407e45e762300b64e7138c419e78826955f2e4ba4ce6d8a6b"
# --- Identity / addresses ---
openbao_fqdn: "bao.ad.ddupan.top" # A record on the Samba DC (ad.ddupan.top zone)
openbao_lan_ip: "192.168.10.8" # bao's LAN IP — pods use this; also the VM's static IP
# --- Optional host firewall ---
# Restrict the API/cluster ports to tailnet + cluster once you're ready (needs ufw).
openbao_manage_firewall: false
openbao_allowed_cidrs:
- "100.64.0.0/10" # Tailscale CGNAT range (remote humans + laptop agent)
- "192.168.10.0/24" # LAN / k8s cluster
# --- Auto-unseal (transit) ---
# Leave OFF for the first init so you do a normal Shamir init (keys handed to you).
# Turn ON once a second bao/transit source exists, then supply the token from vault
# and re-run the play (a restart re-reads config; auto-unseal makes restarts hands-off).
openbao_auto_unseal: false
openbao_transit_address: "https://bao-seal.ddupan.top:8200"
openbao_transit_key_name: "autounseal"
openbao_transit_mount_path: "transit/"
openbao_transit_token: "{{ vault_openbao_transit_token | default('') }}"
@@ -0,0 +1,19 @@
---
# Copy to vault.yml and encrypt: ansible-vault encrypt group_vars/all/vault.yml
# NEVER commit the decrypted vault.yml.
#
# Only needed once you switch to transit auto-unseal (openbao_auto_unseal: true).
# This is the token the seal source hands out for the autounseal transit key —
# scope it to just encrypt/decrypt on that key.
vault_openbao_transit_token: "CHANGE-ME-transit-unseal-token"
# Plaintext of the Authelia 'openbao' OIDC client secret (Authelia stores the pbkdf2
# hash; OpenBao holds this plaintext). Used by the bootstrap play's auth/oidc config.
vault_openbao_oidc_client_secret: "CHANGE-ME-authelia-openbao-client-secret"
# Reviewer SA JWT for Kubernetes auth (only when openbao_enable_k8s_auth: true).
# kubectl -n agents create token bao-reviewer --duration=87600h
vault_openbao_k8s_reviewer_jwt: "CHANGE-ME-k8s-reviewer-jwt"
# Cloudflare API token for ACME DNS-01 (acme-openbao.yml). Scope: Zone:DNS:Edit on ddupan.top.
vault_openbao_cf_dns_token: "CHANGE-ME-cloudflare-dns-edit-token"
@@ -0,0 +1,88 @@
$ANSIBLE_VAULT;1.1;AES256
32653035643430653731303165663162316566633364633933376235326265653032313939363632
3730656332633132643935616238313739643264383866390a306637656430646262646339346661
31623536346535336336306639333161373134313536396166623334323633613465363834363864
3434633836356138370a336430316164326538383866646134353565393739303263636532346661
66623830393366626631666363313639393463626264356530623435636432643961653832386162
63323864393763396633353365653437646564646534383638323439663664316639633766316330
63373134613065656561363030616466636262356439656530383661393033386534333661323037
64333139356330373636316438353733376235623531346437333666383936333937373730333333
39333733333964383535666235633331373663313438353366333439383765633962386665376337
35303739343865386562326163306438646238613663373561353132323139373063303337656238
36383162643762313362336232663266383034386238346639646336643463623763393361356565
34616464326330373262383736353061366538333739653163636530383566313039623539313134
38646530663137666238313036333038303964663036643035333062616438323239656565643432
36656535383563393364626465393635343433646661643037643536373666353562333261333162
34383635336565366431393663343464656438613138396563386531663862623861353061396166
31383937653633373734383536323366313364363864626239646465656361346265316364636664
37666465313336636638613635393261343065333533366638646161333964323634343237646430
37353030393336376662383961303662386632666262346130646631346166383262393639363433
33636463323737396466626131323834623064373035396566356563393862373562633564366165
32643433333431626237326633336564356437653033376362666435363938643033643235333561
33653636383631663435623064353133393561656539366238396331313336363138353066613530
38656633303262393336346233313761343036623965633836376336386537636535633837393133
36366331356437316236326432666362643736373038616163663265383762323837316430323266
36353432313930636431316638326630633534346635333130393031666361356463383432616664
63383732306435653134303566366633393437316663353136366662653462666439646339643430
33653738323764386131623166313030313734313933316561363439386663636261343436653232
66623465326631663462633466616335623265343034623161326161616138643131646436396535
34663330666363626631336264656566306237343539353932343535313035346438323838366434
62663934363737633232353066363231353635626339643862353266656562643235366164636163
31613432326634633161373038636537626266353365306331383661343538626133343965623764
38373532646265633964326435663430356536336234376135636332333165613131323566633562
31636433393466646130346238366462306236383966333739316133376638656462653761316131
30356265326263633834353362643238353963636665303063316637373230353732363333386636
63323333303861323830356336346536376561643136363961653535396562666266383162353061
61396364616331626466356661636433363239383266633935353865336266383636363431386331
64316634353665653966386536333966663633313865303235366164383434636334366531303337
34373634306465613435303930336265303731373931316263323164343166336334623465626466
30643736623435343530666137383132386364623533346132633563616239393433386361623136
32383531366365633564323835376433376462313664616433303361366238303264633061353262
61613530393832306265623837356232666131333235663866363830666235383366623330646338
65656266393237366636383037643131393761666164633163646161616562623965653735653766
65633731366338346337366332383135323364616666653963383532353362366437656434626433
32386230653564613035333032666364306537353964346437313561356635336330336465303533
33366533653265363366633039326434366537396661663235613938386262313637383064393235
38666265383839633165626334336365666365343639356662303966613236383538373762633131
32653237633238633565303532653331343131303530313537366138626434336264643532626337
66326335333937643036383063303561666237386434303935383334326364336463306331663864
33376263633430383763346631306331353638653063383132313132303332613036353663316234
64383663323131383831653533376666323666313031663862376266636262373635633932343064
64623761356537656631316134656136663962343431373136633465333362316263316530313664
36303062303938623764326131396635633239323961323064333436313564633635383038663838
37363735393639323464663531376537386431393431303232613335643565303463656235643663
63323035396164623839623663366430363361313563643462346335623061316361333530393766
35353864313937633065333533323330626337643733623332303462343435366664623862313363
30623631386139616664323562333433663862646430333131626134666233653762363936653165
63623061316330383537396536313263376536316563303766383237636164376465623863356334
39373863343232323361333939393264303030383430313864616261333238616637343834313264
36333130376339646461346530343461383265613538376135363565666563633031613332633136
32666161613766626636653533363131353137653139336265363337373265626365306333613136
64363663393039363734316139633231613562623466616333653063643531313430643433356231
32646639393637316530623339316239306265353333363737643232656330623634333533653232
39373564316433353263653061376334376263343935333831653937393434666430326137613339
36386634343166646164353066343665333066613538373639393733316438356235323961653132
33373264363335346633636336316631323064636238333031336333366537366139323636393766
39376632363935633835356163646235326366623035653730346331333237363063323861633639
61613566343561376466626133306634643437386364643839343535313661613462316263383031
33373534396566383039643930393337613233306164356636383333626663643037363232623539
38353863636535383830343734663337633633626133623230626235646332343166366232633962
39646333336434643063633765386466653632386431393430356135343165326461373934366133
33663137306566316361326633616436393962313564346566393563646263616335383830366139
35666437636238323135306231346436626563386237383134343465666231373331363965316265
31313838383236396433306430376533383139326466383438383366396537643935653230383963
62616365306564326661386461303662396565613561653938636161326437333162363935643337
36633064643035363532363161383264343634383463653563333236666665616566666363623838
31393036333934303534393965653662343064656339343039383730653461343932363335306231
36626534363966323431333532353639643533663339616262336134363261393163346236316633
39313161303166643337366265663162626637323666366363386335343931313837623231303164
34663633396532346266663032376363373262376238653435646261366633386531613538356632
30636639653966383637323635653539613039623039623463333535613561616539386334323866
39373133316637633330653636313436313635396539356436343631356461303639376265623862
32303831353338323265373636613834356366666137666234633536343837326534386162343730
35383938643662363266316365383930663939666134346562363363363661633335363938626262
63623565366439323230633663333861313638656632396164313032643138363665663961656539
31346135373066363038303038643431636139373331616663623638366331656336626664646166
61346461626336363432636633613836326236666664663663356461373331613739663163326366
31626230383132626437386361616237666130366536656136663931666239363430336433626535
623564643364303638626233343331336361
@@ -0,0 +1,22 @@
---
# Inventory for the OpenBao root-of-trust host.
# A single dedicated VM/host on the LAN (like the Samba DC) — NOT in the k8s cluster.
all:
children:
openbao:
hosts:
bao1:
ansible_host: 192.168.10.8 # bao's static LAN IP — match openbao_lan_ip
ansible_user: ansible # cloud-init / provisioning user with sudo
# Root-managed hosts that should trust bao's SSH user CA (TrustedUserCAKeys).
# Add any node you want agents to reach with signed certs.
ssh_ca_trust:
hosts:
bao1: {} # bao itself (connection vars from the openbao group)
dc1:
ansible_host: 192.168.10.5 # Samba AD DC
ansible_user: ansible
laptop:
ansible_connection: local # this machine (192.168.10.127), passwordless sudo
ansible_host: 127.0.0.1
@@ -0,0 +1,31 @@
---
# Deploy the OpenBao server: install binary + integrated Raft storage + TLS listener
# + systemd unit. This play does NOT initialize, unseal, or configure any secrets
# engines / auth methods — that is the separate bootstrap play (added later).
#
# ansible-playbook provision-openbao.yml --ask-vault-pass
# ansible-playbook provision-openbao.yml --tags verify # smoke tests only
#
# After the first run the node is UNINITIALIZED + SEALED. Initialize it once, by hand,
# encrypting the unseal keys + root token to your YubiKey's GPG public key so they are
# never printed in plaintext (add a backup offline GPG key as a second share):
# gpg --export <YUBIKEY_KEYID> | base64 > yubikey.pub.b64
# BAO_ADDR=https://127.0.0.1:8200 BAO_SKIP_VERIFY=true \
# bao operator init -key-shares=2 -key-threshold=1 \
# -pgp-keys="yubikey.pub.b64,backup.pub.b64" \
# -root-token-pgp-key="yubikey.pub.b64"
# # decrypt a key to unseal (touch YubiKey):
# echo "<encrypted-key-b64>" | base64 -d | gpg -dq | xargs bao operator unseal
# With transit auto-unseal, use -recovery-pgp-keys instead (unseal is then automatic).
- name: OpenBao server
hosts: openbao
become: true
gather_facts: true
roles:
- role: openbao
post_tasks:
- name: Smoke tests
ansible.builtin.import_role:
name: openbao
tasks_from: verify.yml
tags: [verify, never]
@@ -0,0 +1,5 @@
---
# Install with: ansible-galaxy collection install -r requirements.yml
collections:
- name: community.general # ufw (optional host firewall)
- name: community.crypto # openssl_* for the bootstrap TLS listener cert
@@ -0,0 +1,37 @@
---
# bao_vm role — create the OpenBao host as a small Ubuntu VM via libvirt + cloud-init
# (NoCloud). Runs on the libvirt host (localhost). Configures nothing inside the OS
# beyond the cloud-init seed; the openbao role installs the daemon afterward.
bao_vm_name: "bao1"
bao_vm_domain: "ddupan.top"
# Tiny footprint — OpenBao idle is ~50-150 MB RAM, one Go process.
bao_vm_vcpus: 1
bao_vm_memory_mb: 1024
bao_vm_disk_gb: 10
# Latest Ubuntu LTS cloud image (24.04 Noble). "current" always points at the newest build.
bao_vm_image_url: "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img"
bao_vm_osinfo: "ubuntu24.04"
# libvirt placement (matches the dc_vm convention).
bao_vm_images_dir: "/var/lib/libvirt/images"
bao_vm_bridge: "br0" # LAN bridge → puts bao on 192.168.10.0/24
# ZFS zvol for the root disk. Thin-provisioned (sparse) — bao stores very little.
bao_vm_zvol_parent: "data/vm"
bao_vm_zvol: "{{ bao_vm_zvol_parent }}/{{ bao_vm_name }}"
bao_vm_zvol_dev: "/dev/zvol/{{ bao_vm_zvol }}"
bao_vm_zvol_volblocksize: "16K"
bao_vm_zvol_sparse: true
# Guest networking (static). IP comes from group_vars/all/vars.yml (openbao_lan_ip).
bao_vm_ip: "{{ openbao_lan_ip }}"
bao_vm_prefix: 24
bao_vm_gateway: "192.168.10.1"
bao_vm_boot_dns: "192.168.10.5" # internal resolver (the Samba DC); or the gateway
# Cloud-init login user + the public key Ansible will connect with.
bao_vm_user: "ansible"
bao_vm_ssh_pubkey_file: "~/.ssh/id_ed25519.pub"
@@ -0,0 +1,113 @@
---
# Create the OpenBao VM on the local libvirt host. Idempotent: if the domain already
# exists it does nothing. Run on localhost with qemu:///system (become: true).
- name: Resolve the SSH public key to inject
ansible.builtin.set_fact:
bao_vm_ssh_pubkey: "{{ lookup('file', bao_vm_ssh_pubkey_file | expanduser) }}"
- name: Fail early if no usable public key
ansible.builtin.assert:
that:
- bao_vm_ssh_pubkey is search('^ssh-')
fail_msg: >-
No SSH public key at {{ bao_vm_ssh_pubkey_file }}. Generate one
(ssh-keygen -t ed25519) or set bao_vm_ssh_pubkey_file.
- name: Check whether the libvirt domain already exists
ansible.builtin.command: "virsh dominfo {{ bao_vm_name }}"
register: bao_vm_dominfo
changed_when: false
failed_when: false
- name: Create the VM
when: bao_vm_dominfo.rc != 0
block:
- name: Ensure image directories exist
ansible.builtin.file:
path: "{{ item }}"
state: directory
mode: "0711"
loop:
- "{{ bao_vm_images_dir }}"
- "{{ bao_vm_images_dir }}/base"
- name: Download the Ubuntu cloud image (once)
ansible.builtin.get_url:
url: "{{ bao_vm_image_url }}"
dest: "{{ bao_vm_images_dir }}/base/{{ bao_vm_image_url | basename }}"
mode: "0644"
- name: Check whether the root-disk zvol already exists
ansible.builtin.command: "zfs list -H -o name {{ bao_vm_zvol }}"
register: bao_vm_zvol_check
changed_when: false
failed_when: false
- name: Create the root-disk zvol
ansible.builtin.command:
cmd: >-
zfs create {{ '-s ' if bao_vm_zvol_sparse else '' }}-V {{ bao_vm_disk_gb }}G
-o volblocksize={{ bao_vm_zvol_volblocksize }}
{{ bao_vm_zvol }}
when: bao_vm_zvol_check.rc != 0
- name: Wait for the zvol device node to appear
ansible.builtin.wait_for:
path: "{{ bao_vm_zvol_dev }}"
timeout: 30
when: bao_vm_zvol_check.rc != 0
- name: Write the cloud image into the zvol (raw)
ansible.builtin.command:
cmd: >-
qemu-img convert -O raw
{{ bao_vm_images_dir }}/base/{{ bao_vm_image_url | basename }}
{{ bao_vm_zvol_dev }}
when: bao_vm_zvol_check.rc != 0
# cloud-init growpart expands the rootfs to fill the zvol on first boot.
- name: Render the cloud-init seed files
ansible.builtin.template:
src: "{{ item }}.j2"
dest: "{{ bao_vm_images_dir }}/{{ bao_vm_name }}-seed-{{ item }}"
mode: "0644"
loop:
- user-data
- meta-data
- network-config
- name: Build the NoCloud seed ISO
ansible.builtin.command:
cmd: >-
genisoimage -output {{ bao_vm_images_dir }}/{{ bao_vm_name }}-seed.iso
-volid cidata -joliet -rock
-graft-points
user-data={{ bao_vm_images_dir }}/{{ bao_vm_name }}-seed-user-data
meta-data={{ bao_vm_images_dir }}/{{ bao_vm_name }}-seed-meta-data
network-config={{ bao_vm_images_dir }}/{{ bao_vm_name }}-seed-network-config
args:
creates: "{{ bao_vm_images_dir }}/{{ bao_vm_name }}-seed.iso"
- name: Define and start the domain (cloud-init imports the disk)
ansible.builtin.command:
cmd: >-
virt-install
--name {{ bao_vm_name }}
--memory {{ bao_vm_memory_mb }}
--vcpus {{ bao_vm_vcpus }}
--osinfo require=off,name={{ bao_vm_osinfo }}
--disk path={{ bao_vm_zvol_dev }},format=raw,bus=virtio
--disk path={{ bao_vm_images_dir }}/{{ bao_vm_name }}-seed.iso,device=cdrom
--network bridge={{ bao_vm_bridge }},model=virtio
--graphics none --noautoconsole --import
register: virt_install
changed_when: true
- name: Wait for SSH on the new bao host
ansible.builtin.wait_for:
host: "{{ bao_vm_ip }}"
port: 22
delay: 10
timeout: 300
when: bao_vm_dominfo.rc != 0
@@ -0,0 +1,2 @@
instance-id: {{ bao_vm_name }}-001
local-hostname: {{ bao_vm_name }}
@@ -0,0 +1,20 @@
version: 2
ethernets:
primary:
# Match the (single) ethernet NIC by kernel name and configure it in place.
# NOTE: do NOT add set-name here — netplan only supports set-name when matching
# on mac/driver, not on name, and a name-match + rename leaves the NIC unconfigured.
match:
name: "en*"
dhcp4: false
dhcp6: false
addresses:
- {{ bao_vm_ip }}/{{ bao_vm_prefix }}
routes:
- to: default
via: {{ bao_vm_gateway }}
nameservers:
addresses:
- {{ bao_vm_boot_dns }}
search:
- {{ bao_vm_domain }}
@@ -0,0 +1,18 @@
#cloud-config
# NoCloud user-data for the OpenBao host VM.
hostname: {{ bao_vm_name }}
fqdn: {{ bao_vm_name }}.{{ bao_vm_domain }}
preserve_hostname: false
users:
- name: {{ bao_vm_user }}
groups: [sudo]
shell: /bin/bash
sudo: "ALL=(ALL) NOPASSWD:ALL"
lock_passwd: true
ssh_authorized_keys:
- {{ bao_vm_ssh_pubkey }}
ssh_pwauth: false
package_update: true
package_upgrade: false
@@ -0,0 +1,49 @@
---
# openbao role defaults — override in group_vars/host_vars.
# Secrets (openbao_transit_token) MUST come from an Ansible Vault file, not here.
# --- Release ---
openbao_version: "2.6.1"
openbao_download_checksum: "" # "sha256:..." REQUIRED — set in group_vars
openbao_download_url: "https://github.com/openbao/openbao/releases/download/v{{ openbao_version }}/openbao_{{ openbao_version }}_linux_amd64.tar.gz"
# --- Service account ---
openbao_user: "openbao"
openbao_group: "openbao"
# --- Paths ---
openbao_bin: "/usr/local/bin/bao"
openbao_config_dir: "/etc/openbao"
openbao_tls_dir: "/etc/openbao/tls"
openbao_data_dir: "/opt/openbao/data" # integrated Raft storage
# --- Identity / addresses ---
openbao_fqdn: "bao.example.com"
openbao_lan_ip: "10.10.10.10"
openbao_api_port: 8200
openbao_cluster_port: 8201
openbao_ui: true
openbao_node_id: "{{ ansible_facts['hostname'] }}"
# Listener binds all interfaces; lock it down with the firewall vars below.
openbao_listen_address: "0.0.0.0:{{ openbao_api_port }}"
openbao_api_addr: "https://{{ openbao_lan_ip }}:{{ openbao_api_port }}"
openbao_cluster_addr: "https://{{ openbao_lan_ip }}:{{ openbao_cluster_port }}"
# SANs for the bootstrap self-signed listener cert (replace with PKI-issued later).
openbao_tls_sans:
- "DNS:{{ openbao_fqdn }}"
- "IP:{{ openbao_lan_ip }}"
- "IP:127.0.0.1"
# --- Auto-unseal (transit) — off by default (Shamir manual unseal on first init) ---
openbao_auto_unseal: false
openbao_transit_address: ""
openbao_transit_token: ""
openbao_transit_key_name: "autounseal"
openbao_transit_mount_path: "transit/"
# --- Optional host firewall ---
openbao_manage_firewall: false
openbao_allowed_cidrs:
- "100.64.0.0/10"
@@ -0,0 +1,11 @@
---
- name: reload systemd
ansible.builtin.systemd:
daemon_reload: true
# NOTE: restarting a running, Shamir-sealed node re-seals it (manual unseal needed).
# Harmless before first init; automatic re-unseal when transit auto-unseal is on.
- name: restart openbao
ansible.builtin.systemd:
name: openbao
state: restarted
@@ -0,0 +1,11 @@
---
galaxy_info:
role_name: openbao
description: Deploy OpenBao (secrets management) with integrated Raft storage + TLS listener, under systemd.
min_ansible_version: "2.15"
platforms:
- name: Debian
versions: [bookworm]
- name: Ubuntu
versions: [jammy, noble]
dependencies: []
@@ -0,0 +1,138 @@
---
# Deploy OpenBao as a systemd service with integrated Raft storage + a TLS listener.
# This role ONLY installs and runs the daemon. It does NOT initialize/unseal or enable
# any secrets engines/auth methods — that is the separate bootstrap play.
#
# NOTE: restarting a running, Shamir-sealed node re-seals it (needs manual unseal).
# On first deploy the node is uninitialized, so the restart handler is harmless; with
# transit auto-unseal a restart re-unseals automatically.
- name: Assert required variables are set
ansible.builtin.assert:
that:
- openbao_version | length > 0
- openbao_download_checksum | length > 0
- openbao_fqdn | length > 0
- openbao_lan_ip | length > 0
- (not openbao_auto_unseal) or (openbao_transit_address | length > 0 and openbao_transit_token | length > 0)
fail_msg: >-
Set openbao_version + openbao_download_checksum (sha256:...), openbao_fqdn and
openbao_lan_ip. If openbao_auto_unseal is true you must also provide
openbao_transit_address and openbao_transit_token (from vault).
- name: Install runtime dependencies
ansible.builtin.apt:
name:
- tar
- python3-cryptography # community.crypto (bootstrap TLS cert)
state: present
update_cache: true
# --- Service account + directories --------------------------------------------
- name: Create openbao group
ansible.builtin.group:
name: "{{ openbao_group }}"
system: true
- name: Create openbao system user
ansible.builtin.user:
name: "{{ openbao_user }}"
group: "{{ openbao_group }}"
system: true
shell: /usr/sbin/nologin
home: "{{ openbao_data_dir }}"
create_home: false
- name: Create config, tls and data directories
ansible.builtin.file:
path: "{{ item.path }}"
state: directory
owner: "{{ item.owner }}"
group: "{{ openbao_group }}"
mode: "{{ item.mode }}"
loop:
- { path: "{{ openbao_config_dir }}", owner: "root", mode: "0755" }
- { path: "{{ openbao_tls_dir }}", owner: "root", mode: "0750" }
- { path: "{{ openbao_data_dir }}", owner: "{{ openbao_user }}", mode: "0700" }
# --- Install binary (checksum-verified release tarball) ------------------------
- name: Check installed bao version
ansible.builtin.command: "{{ openbao_bin }} version"
register: bao_installed
changed_when: false
failed_when: false
- name: Install OpenBao when missing or version mismatch
when: openbao_version not in (bao_installed.stdout | default(''))
block:
- name: Download OpenBao release tarball (checksum-verified)
ansible.builtin.get_url:
url: "{{ openbao_download_url }}"
dest: "/tmp/openbao_{{ openbao_version }}.tar.gz"
checksum: "{{ openbao_download_checksum }}"
mode: "0644"
- name: Create staging directory for extraction
ansible.builtin.file:
path: "/tmp/openbao_{{ openbao_version }}"
state: directory
mode: "0755"
- name: Extract tarball
ansible.builtin.unarchive:
src: "/tmp/openbao_{{ openbao_version }}.tar.gz"
dest: "/tmp/openbao_{{ openbao_version }}"
remote_src: true
- name: Install bao binary
ansible.builtin.copy:
src: "/tmp/openbao_{{ openbao_version }}/bao"
dest: "{{ openbao_bin }}"
remote_src: true
owner: root
group: root
mode: "0755"
notify: restart openbao
# --- Bootstrap TLS listener cert (replace with PKI-issued later) ---------------
- name: Generate bootstrap TLS certificate
ansible.builtin.import_tasks: tls.yml
# --- Config + service ----------------------------------------------------------
- name: Write OpenBao config
ansible.builtin.template:
src: config.hcl.j2
dest: "{{ openbao_config_dir }}/config.hcl"
owner: root
group: "{{ openbao_group }}"
mode: "0640"
no_log: "{{ openbao_auto_unseal }}" # config carries the transit token when auto-unseal is on
notify: restart openbao
- name: Install systemd unit
ansible.builtin.template:
src: openbao.service.j2
dest: /etc/systemd/system/openbao.service
owner: root
group: root
mode: "0644"
notify:
- reload systemd
- restart openbao
- name: Enable and start OpenBao
ansible.builtin.systemd:
name: openbao
state: started
enabled: true
daemon_reload: true
# --- Optional host firewall ----------------------------------------------------
- name: Restrict API/cluster ports to tailnet + cluster CIDRs
community.general.ufw:
rule: allow
port: "{{ item.0 }}"
proto: tcp
from_ip: "{{ item.1 }}"
loop: "{{ [openbao_api_port, openbao_cluster_port] | product(openbao_allowed_cidrs) | list }}"
when: openbao_manage_firewall | bool
@@ -0,0 +1,40 @@
---
# Bootstrap TLS for the OpenBao listener: a self-signed server certificate with SANs
# (FQDN + LAN IP + loopback), generated ONCE to get the listener up. It is never
# regenerated once a cert exists — so the ACME (or PKI) cert that replaces it later is
# never clobbered on a re-run. Delete cert.pem to force a fresh self-signed bootstrap.
- name: Check for an existing listener certificate
ansible.builtin.stat:
path: "{{ openbao_tls_dir }}/cert.pem"
register: openbao_listener_cert
- name: OpenBao server private key
community.crypto.openssl_privatekey:
path: "{{ openbao_tls_dir }}/key.pem"
size: 4096
owner: "{{ openbao_user }}"
group: "{{ openbao_group }}"
mode: "0640"
when: not openbao_listener_cert.stat.exists
- name: OpenBao server CSR (FQDN + SANs)
community.crypto.openssl_csr:
path: "{{ openbao_tls_dir }}/server.csr"
privatekey_path: "{{ openbao_tls_dir }}/key.pem"
common_name: "{{ openbao_fqdn }}"
subject_alt_name: "{{ openbao_tls_sans }}"
when: not openbao_listener_cert.stat.exists
- name: OpenBao server certificate (self-signed, positive serial)
community.crypto.x509_certificate:
path: "{{ openbao_tls_dir }}/cert.pem"
csr_path: "{{ openbao_tls_dir }}/server.csr"
privatekey_path: "{{ openbao_tls_dir }}/key.pem"
provider: selfsigned
selfsigned_not_after: "+825d"
owner: "{{ openbao_user }}"
group: "{{ openbao_group }}"
mode: "0644"
when: not openbao_listener_cert.stat.exists
notify: restart openbao
@@ -0,0 +1,26 @@
---
# Smoke tests — run via the `verify` tag:
# ansible-playbook provision-openbao.yml --tags verify
# Confirms the daemon is up and the API answers. A sealed/uninitialized node is the
# EXPECTED state before you run `bao operator init`, so that is treated as success.
- name: OpenBao service is active
ansible.builtin.systemd:
name: openbao
register: bao_svc
changed_when: false
failed_when: bao_svc.status.ActiveState != "active"
- name: API listener answers (sealed/uninitialized is OK)
ansible.builtin.command: "{{ openbao_bin }} status -address=https://127.0.0.1:{{ openbao_api_port }}"
environment:
BAO_SKIP_VERIFY: "true"
register: bao_status
changed_when: false
failed_when: bao_status.rc not in [0, 2] # 0 = unsealed, 2 = sealed/uninitialized
- name: Report
ansible.builtin.debug:
msg: >-
OpenBao {{ openbao_version }} is running and answering on :{{ openbao_api_port }}
(status rc={{ bao_status.rc }}; 2 = sealed/uninitialized, expected before init).
@@ -0,0 +1,27 @@
# {{ ansible_managed }} — openbao role. Do not edit by hand.
ui = {{ openbao_ui | lower }}
storage "raft" {
path = "{{ openbao_data_dir }}"
node_id = "{{ openbao_node_id }}"
}
listener "tcp" {
address = "{{ openbao_listen_address }}"
tls_cert_file = "{{ openbao_tls_dir }}/cert.pem"
tls_key_file = "{{ openbao_tls_dir }}/key.pem"
tls_min_version = "tls12"
}
api_addr = "{{ openbao_api_addr }}"
cluster_addr = "{{ openbao_cluster_addr }}"
{% if openbao_auto_unseal %}
# Auto-unseal via a Transit engine on a separate OpenBao/Vault instance.
seal "transit" {
address = "{{ openbao_transit_address }}"
token = "{{ openbao_transit_token }}"
key_name = "{{ openbao_transit_key_name }}"
mount_path = "{{ openbao_transit_mount_path }}"
}
{% endif %}
@@ -0,0 +1,31 @@
# {{ ansible_managed }} — openbao role. Do not edit by hand.
[Unit]
Description=OpenBao secrets management
Documentation=https://openbao.org/docs/
Requires=network-online.target
After=network-online.target
ConditionFileNotEmpty={{ openbao_config_dir }}/config.hcl
[Service]
User={{ openbao_user }}
Group={{ openbao_group }}
ProtectSystem=full
ProtectHome=read-only
PrivateTmp=yes
PrivateDevices=yes
SecureBits=keep-caps
AmbientCapabilities=CAP_IPC_LOCK
CapabilityBoundingSet=CAP_SYSLOG CAP_IPC_LOCK
NoNewPrivileges=yes
ExecStart={{ openbao_bin }} server -config={{ openbao_config_dir }}/config.hcl
ExecReload=/bin/kill --signal HUP $MAINPID
KillMode=process
KillSignal=SIGINT
Restart=on-failure
RestartSec=5
TimeoutStopSec=30
LimitNOFILE=65536
LimitMEMLOCK=infinity
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,41 @@
---
# openbao_acme role defaults. Cloudflare token comes from group_vars/all/vault.yml.
# --- lego (ACME client) release ---
openbao_acme_version: "5.3.1"
openbao_acme_checksum: "sha256:b3c71b122ee1947eacfe0b809b955647f6377239fe4bfc49f73b1a091ae1252a"
openbao_acme_url: "https://github.com/go-acme/lego/releases/download/v{{ openbao_acme_version }}/lego_v{{ openbao_acme_version }}_linux_amd64.tar.gz"
openbao_acme_bin: "/usr/local/bin/lego"
# --- Paths / identity ---
openbao_acme_dir: "/etc/openbao/acme" # lego state (account, certs, cloudflare.env)
openbao_acme_tls_dir: "/etc/openbao/tls" # where bao's listener reads cert.pem/key.pem
openbao_user: "openbao"
openbao_group: "openbao"
# --- Certificate ---
openbao_acme_domain: "{{ openbao_fqdn }}" # bao.ad.ddupan.top (from group_vars/all)
openbao_acme_email: "[email protected]" # ACME account / expiry-notice email
# Cloudflare API token with Zone:DNS:Edit on ddupan.top. Set vault_openbao_cf_dns_token
# in group_vars/all/vault.yml (you can copy the value from the cloudflared tunnel's
# terraform.tfvars, which is scoped the same).
openbao_acme_cf_token: "{{ vault_openbao_cf_dns_token | default('') }}"
# Empty = Let's Encrypt production. To dry-run without burning rate limits, set:
# https://acme-staging-v02.api.letsencrypt.org/directory
openbao_acme_server: ""
# Resolvers for lego's zone/apex detection. Must give the PUBLIC view: both the DC AND
# the LAN gateway forward ad.ddupan.top to the DC (split-horizon) → they'd resolve the
# zone to the non-existent CF zone "ad.ddupan.top". Only real public resolvers see that
# ad.ddupan.top isn't delegated and return the ddupan.top apex. List several so a flaky
# WAN query to one falls through to another.
openbao_acme_dns_resolvers: "1.1.1.1:53,1.0.0.1:53,8.8.8.8:53,9.9.9.9:53"
openbao_acme_dns_timeout: 30 # per-query DNS timeout (s); default 10 is tight over a flaky WAN
# Skip the 2-min propagation polling (many WAN DNS queries); just wait, then ask LE to
# validate (LE queries public DNS itself, independent of this host's WAN).
openbao_acme_propagation_wait: "120s"
# Renewal timer (lego only renews within --days of expiry).
openbao_acme_renew_oncalendar: "*-*-* 03:17:00"
@@ -0,0 +1,11 @@
---
galaxy_info:
role_name: openbao_acme
description: Publicly-trusted Let's Encrypt cert for bao's listener via lego + Cloudflare DNS-01, with SIGHUP reload and auto-renewal.
min_ansible_version: "2.15"
platforms:
- name: Debian
versions: [bookworm]
- name: Ubuntu
versions: [jammy, noble]
dependencies: []
@@ -0,0 +1,105 @@
---
# Install lego, obtain the initial cert (DNS-01 via Cloudflare), deploy it to bao's
# listener + reload, and enable a renewal timer. Idempotent.
- name: Assert a Cloudflare DNS token is available
ansible.builtin.assert:
that:
- openbao_acme_cf_token | length > 0
fail_msg: >-
Set vault_openbao_cf_dns_token in group_vars/all/vault.yml — a Cloudflare API
token with Zone:DNS:Edit on ddupan.top.
# --- Install lego (checksum-verified) -----------------------------------------
- name: Check installed lego version
ansible.builtin.command: "{{ openbao_acme_bin }} --version"
register: lego_installed
changed_when: false
failed_when: false
- name: Install lego when missing or version mismatch
when: openbao_acme_version not in (lego_installed.stdout | default(''))
block:
- name: Download lego release tarball (checksum-verified)
ansible.builtin.get_url:
url: "{{ openbao_acme_url }}"
dest: "/tmp/lego_{{ openbao_acme_version }}.tar.gz"
checksum: "{{ openbao_acme_checksum }}"
mode: "0644"
- name: Create lego staging dir
ansible.builtin.file:
path: "/tmp/lego_{{ openbao_acme_version }}"
state: directory
mode: "0755"
- name: Extract lego
ansible.builtin.unarchive:
src: "/tmp/lego_{{ openbao_acme_version }}.tar.gz"
dest: "/tmp/lego_{{ openbao_acme_version }}"
remote_src: true
- name: Install lego binary
ansible.builtin.copy:
src: "/tmp/lego_{{ openbao_acme_version }}/lego"
dest: "{{ openbao_acme_bin }}"
remote_src: true
owner: root
group: root
mode: "0755"
# --- State dir + credentials + scripts ----------------------------------------
- name: Create ACME state directory
ansible.builtin.file:
path: "{{ openbao_acme_dir }}"
state: directory
owner: root
group: root
mode: "0700"
- name: Write Cloudflare credentials env file
ansible.builtin.copy:
content: "CLOUDFLARE_DNS_API_TOKEN={{ openbao_acme_cf_token }}\n"
dest: "{{ openbao_acme_dir }}/cloudflare.env"
owner: root
group: root
mode: "0600"
no_log: true
- name: Install the obtain/renew wrapper and deploy hook
ansible.builtin.template:
src: "{{ item }}.j2"
dest: "/usr/local/bin/{{ item }}"
owner: root
group: root
mode: "0755"
loop:
- openbao-acme.sh
- openbao-acme-deploy.sh
- name: Install the ACME systemd service + timer
ansible.builtin.template:
src: "{{ item }}.j2"
dest: "/etc/systemd/system/{{ item }}"
owner: root
group: root
mode: "0644"
loop:
- openbao-acme.service
- openbao-acme.timer
# --- Obtain the first cert (deploys to bao + reloads via SIGHUP) ---------------
- name: Obtain the initial certificate and deploy it
ansible.builtin.command: /usr/local/bin/openbao-acme.sh
environment:
CLOUDFLARE_DNS_API_TOKEN: "{{ openbao_acme_cf_token }}"
args:
creates: "{{ openbao_acme_dir }}/certificates/{{ openbao_acme_domain }}.crt"
no_log: true
- name: Enable and start the renewal timer
ansible.builtin.systemd:
name: openbao-acme.timer
state: started
enabled: true
daemon_reload: true
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# {{ ansible_managed }}
# Install the freshly issued/renewed cert into bao's listener and reload (SIGHUP —
# no restart, no reseal). Invoked by lego's --deploy-hook on create/renew; lego passes
# the cert paths in LEGO_CERT_PATH / LEGO_CERT_KEY_PATH.
set -euo pipefail
CRT="${LEGO_CERT_PATH:-{{ openbao_acme_dir }}/certificates/{{ openbao_acme_domain }}.crt}"
KEY="${LEGO_CERT_KEY_PATH:-{{ openbao_acme_dir }}/certificates/{{ openbao_acme_domain }}.key}"
install -o {{ openbao_user }} -g {{ openbao_group }} -m 0644 "${CRT}" "{{ openbao_acme_tls_dir }}/cert.pem"
install -o {{ openbao_user }} -g {{ openbao_group }} -m 0640 "${KEY}" "{{ openbao_acme_tls_dir }}/key.pem"
systemctl reload openbao
@@ -0,0 +1,10 @@
# {{ ansible_managed }}
[Unit]
Description=OpenBao ACME certificate (lego, Cloudflare DNS-01)
After=network-online.target openbao.service
Wants=network-online.target
[Service]
Type=oneshot
EnvironmentFile={{ openbao_acme_dir }}/cloudflare.env
ExecStart=/usr/local/bin/openbao-acme.sh
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# {{ ansible_managed }}
# Obtain or renew bao's Let's Encrypt cert via Cloudflare DNS-01. lego's `run` does both:
# it renews only when due (ARI + --renew-days) and fires --deploy-hook on any actual
# create/renew to install the cert and reload bao. CLOUDFLARE_DNS_API_TOKEN comes from
# the environment (systemd EnvironmentFile, or Ansible on the first run).
set -euo pipefail
exec {{ openbao_acme_bin }} run \
--accept-tos \
--email "{{ openbao_acme_email }}" \
--dns cloudflare \
--dns.resolvers "{{ openbao_acme_dns_resolvers }}" \
--dns.timeout {{ openbao_acme_dns_timeout }} \
--dns.propagation.wait "{{ openbao_acme_propagation_wait }}" \
--domains "{{ openbao_acme_domain }}" \
--path "{{ openbao_acme_dir }}" \
--renew-days 30 \
--deploy-hook /usr/local/bin/openbao-acme-deploy.sh{% if openbao_acme_server %} \
--server "{{ openbao_acme_server }}"{% endif %}
@@ -0,0 +1,11 @@
# {{ ansible_managed }}
[Unit]
Description=OpenBao ACME renewal timer
[Timer]
OnCalendar={{ openbao_acme_renew_oncalendar }}
RandomizedDelaySec=3600
Persistent=true
[Install]
WantedBy=timers.target
@@ -0,0 +1,98 @@
---
# openbao_bootstrap role defaults. Secrets come from group_vars/all/vault.yml.
# --- Connection to the running, unsealed bao (local to the host) ---
openbao_api_port: 8200
openbao_tls_dir: "/etc/openbao/tls"
# ⚠ MUST be the hostname, not 127.0.0.1. Since openbao_acme gave bao a real Let's
# Encrypt cert, that cert carries a DNS SAN only — so a loopback connection dies
# with "cannot validate certificate for 127.0.0.1 because it doesn't contain any
# IP SANs". Same trap as dc1's LDAPS cert and as reaching bao on 192.168.10.8.
# bao1 resolves its own name via the DC (verified: HTTP 200, tls ok).
openbao_addr: "https://bao.ad.ddupan.top:{{ openbao_api_port }}"
openbao_token: "{{ lookup('env', 'BAO_TOKEN') }}" # export BAO_TOKEN before running
openbao_no_log: true # -e openbao_no_log=false to debug
# Reusable CLI environment (token-bearing → tasks using it set no_log).
openbao_cli_env:
BAO_ADDR: "{{ openbao_addr }}"
BAO_CACERT: "{{ openbao_tls_dir }}/cert.pem"
BAO_TOKEN: "{{ openbao_token }}"
# --- KV v2 (static agent secrets) ---
openbao_kv_path: "kv"
# --- SSH certificate authority (short-lived agent certs) ---
openbao_ssh_mount: "ssh-client-signer"
# Principals allowed at sign time. "*" is permissive — real per-node scoping is the
# `principals="..."` option on each target's `cert-authority` authorized_keys line.
# Tighten to an explicit list (e.g. "nodeA,nodeB,ansible") for a second gate.
openbao_ssh_allowed_users: "*"
openbao_ssh_default_user: "ansible"
openbao_ssh_cert_ttl: "5m"
openbao_ssh_cert_max_ttl: "15m"
openbao_ssh_ca_pub_path: "/etc/openbao/ssh-ca.pub"
# --- OIDC human login via Authelia ---
openbao_oidc_discovery_url: "https://auth.ddupan.top"
openbao_oidc_client_id: "openbao"
openbao_oidc_client_secret: "{{ vault_openbao_oidc_client_secret | default('') }}"
openbao_oidc_default_role: "admin"
openbao_oidc_admin_group: "vault-admins" # AD group whose members get the admin policy
openbao_oidc_redirect_uris:
- "https://bao.ad.ddupan.top:8200/ui/vault/auth/oidc/oidc/callback"
- "http://localhost:8250/oidc/callback"
# --- Kubernetes auth (in-cluster agents, e.g. hermes) — OFF until inputs are ready ---
# Was opt-in (false) and had therefore never run — the backend did not exist at all
# until 2026-07-28. Now ON by default: the External Secrets Operator authenticates
# through it, so every cluster Secret depends on it. Prerequisites, both satisfied:
# the cluster CA at openbao_k8s_ca_cert_file, and vault_openbao_k8s_reviewer_jwt.
openbao_enable_k8s_auth: true
# The k3s API server, as bao must reach it. NOT the address in your kubeconfig —
# k3s writes https://127.0.0.1:6443 there, which is meaningless from another host.
# Was 192.168.10.10 (wrong, nothing listens there); corrected 2026-07-28 after
# verifying from bao1: curl --cacert /etc/openbao/k8s-ca.crt -> HTTP 401, tls ok.
openbao_k8s_host: "https://192.168.10.127:6443"
openbao_k8s_ca_cert_file: "/etc/openbao/k8s-ca.crt" # cluster CA, placed on the bao host
openbao_k8s_reviewer_jwt: "{{ vault_openbao_k8s_reviewer_jwt | default('') }}"
openbao_k8s_agent_sa: "ai-agent"
openbao_k8s_agent_ns: "agents"
# --- PKI (internal TLS; can replace the self-signed listener cert) ---
openbao_pki_mount: "pki"
openbao_pki_ca_cn: "ddupan.top Internal CA"
openbao_pki_max_lease_ttl: "87600h" # 10y
openbao_pki_server_role: "bao-server"
openbao_pki_allowed_domains: "ad.ddupan.top"
# GATED: issue bao's own listener cert from this PKI and restart. Off by default so
# bootstrap never risks the listener. Clients must then trust the PKI CA (printed out).
openbao_pki_replace_listener_cert: false
# --- Raft snapshots (local timer; ship the dir off-box yourself) ---
openbao_snapshot_dir: "/var/backups/openbao"
openbao_snapshot_keep: 14
openbao_snapshot_oncalendar: "*-*-* 02:00:00"
# ── Terraform / Ansible ownership boundary ───────────────────────────────
# TRUE (default) = OpenBao's API-level CONFIGURATION lives in ../terraform:
# mounts, roles, policies, auth mounts+roles, PKI URLs/ACME. This role then
# does ONLY what Terraform cannot sensibly own:
# * the daemon, TLS files, systemd, init/unseal (openbao_deploy role)
# * ROOT-OF-TRUST KEY MATERIAL — the PKI root CA and the SSH CA signing key.
# Terraform would treat drift on those as "regenerate", silently
# invalidating every issued cert and every TrustedUserCAKeys line.
# * the OIDC client SECRET (Terraform cannot read it back; managing it would
# put plaintext in tfstate and cause a perpetual diff)
# * the snapshot token + script + systemd timer (host-level, and a secret)
#
# Set FALSE only to bootstrap a brand-new instance entirely from Ansible, or to
# fall back if Terraform is unavailable. Leaving it FALSE against an instance
# Terraform manages makes the two overwrite each other on alternate runs.
#
# FRESH-INSTALL ORDER when true:
# 1. ansible-playbook deploy-openbao.yml # daemon, TLS, systemd
# 2. bao operator init / unseal # manual, PGP-wrapped
# 3. terraform apply # mounts, roles, policies
# 4. ansible-playbook bootstrap-openbao.yml # CA material, OIDC secret, snapshots
openbao_config_managed_by_terraform: true
@@ -0,0 +1,8 @@
---
# Only fired by the gated PKI listener-cert swap.
# NOTE: a Shamir-sealed node re-seals on restart — unseal it afterwards.
# With transit auto-unseal it re-unseals automatically.
- name: restart openbao
ansible.builtin.systemd:
name: openbao
state: restarted
@@ -0,0 +1,11 @@
---
galaxy_info:
role_name: openbao_bootstrap
description: Configure a running OpenBao — engines (kv, ssh CA, pki), auth (oidc, kubernetes), policies, snapshots.
min_ansible_version: "2.15"
platforms:
- name: Debian
versions: [bookworm]
- name: Ubuntu
versions: [jammy, noble]
dependencies: []
@@ -0,0 +1,52 @@
---
# Kubernetes auth — in-cluster agents (e.g. hermes) authenticate with their SA JWT.
# External bao, so we must supply the cluster host, CA cert and a reviewer JWT.
# Get them from the cluster:
# kubectl -n agents create sa bao-reviewer
# kubectl create clusterrolebinding bao-reviewer --clusterrole=system:auth-delegator \
# --serviceaccount=agents:bao-reviewer
# kubectl -n agents create token bao-reviewer --duration=87600h → openbao_k8s_reviewer_jwt
# kubectl get cm kube-root-ca.crt -o jsonpath='{.data.ca\.crt}' > k8s-ca.crt → on the bao host
- name: Assert Kubernetes auth inputs are provided
ansible.builtin.assert:
that:
- openbao_k8s_reviewer_jwt | length > 0
- openbao_k8s_host | length > 0
fail_msg: "Set openbao_k8s_host and vault_openbao_k8s_reviewer_jwt, and place the cluster CA at openbao_k8s_ca_cert_file."
- name: Enable the Kubernetes auth method
ansible.builtin.command: "bao auth enable kubernetes"
environment: "{{ openbao_cli_env }}"
register: k8s_enable
changed_when: k8s_enable.rc == 0
failed_when:
- k8s_enable.rc != 0
- "'already in use' not in (k8s_enable.stderr | default('')) + (k8s_enable.stdout | default(''))"
no_log: "{{ openbao_no_log }}"
# RECONCILE ACTION — always reports "changed". `bao write` returns 0 whether or
# not anything differed, and detecting a real diff would mean reading the config
# back, which never returns token_reviewer_jwt. So a second run showing changed=2
# for this file means "re-applied", NOT "drift was found". See CLAUDE.md on
# idempotency being the acceptance test, and the exception for reconcile actions.
- name: Configure the Kubernetes auth method
ansible.builtin.command: >-
bao write auth/kubernetes/config
kubernetes_host={{ openbao_k8s_host }}
kubernetes_ca_cert=@{{ openbao_k8s_ca_cert_file }}
token_reviewer_jwt={{ openbao_k8s_reviewer_jwt }}
disable_local_ca_jwt=true
environment: "{{ openbao_cli_env }}"
register: k8s_config
changed_when: k8s_config.rc == 0
no_log: true # carries the reviewer JWT
- name: Create/update the ai-agent Kubernetes role
ansible.builtin.command: "bao write auth/kubernetes/role/ai-agent -"
args:
stdin: "{{ lookup('template', 'k8s-ai-agent-role.json.j2') }}"
environment: "{{ openbao_cli_env }}"
register: k8s_role
changed_when: k8s_role.rc == 0
no_log: "{{ openbao_no_log }}"
@@ -0,0 +1,44 @@
---
# OIDC auth via Authelia — human login (bao login -method=oidc / UI).
# The Authelia 'openbao' client must already exist (authelia/values.yaml).
- name: Assert the OIDC client secret is provided
ansible.builtin.assert:
that:
- openbao_oidc_client_secret | length > 0
fail_msg: "Set vault_openbao_oidc_client_secret (plaintext of the Authelia openbao client)."
- name: Enable the OIDC auth method
ansible.builtin.command: "bao auth enable -path=oidc oidc"
environment: "{{ openbao_cli_env }}"
register: oidc_enable
changed_when: oidc_enable.rc == 0
failed_when:
- oidc_enable.rc != 0
- "'already in use' not in (oidc_enable.stderr | default('')) + (oidc_enable.stdout | default(''))"
no_log: "{{ openbao_no_log }}"
# Terraform owns this (../terraform). See openbao_config_managed_by_terraform.
when: not openbao_config_managed_by_terraform | bool
- name: Configure the OIDC provider (Authelia)
ansible.builtin.command: >-
bao write auth/oidc/config
oidc_discovery_url={{ openbao_oidc_discovery_url }}
oidc_client_id={{ openbao_oidc_client_id }}
oidc_client_secret={{ openbao_oidc_client_secret }}
default_role={{ openbao_oidc_default_role }}
environment: "{{ openbao_cli_env }}"
register: oidc_config
changed_when: oidc_config.rc == 0
no_log: true # carries the client secret — always hidden
- name: Create/update the admin OIDC role (restricted to the admin AD group)
ansible.builtin.command: "bao write auth/oidc/role/{{ openbao_oidc_default_role }} -"
args:
stdin: "{{ lookup('template', 'oidc-admin-role.json.j2') }}"
environment: "{{ openbao_cli_env }}"
register: oidc_role
changed_when: oidc_role.rc == 0
no_log: "{{ openbao_no_log }}"
# Terraform owns this (../terraform). See openbao_config_managed_by_terraform.
when: not openbao_config_managed_by_terraform | bool
@@ -0,0 +1,14 @@
---
# KV v2 engine for static agent secrets.
- name: Enable KV v2 at {{ openbao_kv_path }}/
ansible.builtin.command: "bao secrets enable -path={{ openbao_kv_path }} -version=2 kv"
environment: "{{ openbao_cli_env }}"
register: kv_enable
changed_when: kv_enable.rc == 0
failed_when:
- kv_enable.rc != 0
- "'already in use' not in (kv_enable.stderr | default('')) + (kv_enable.stdout | default(''))"
no_log: "{{ openbao_no_log }}"
# Terraform owns this (../terraform). See openbao_config_managed_by_terraform.
when: not openbao_config_managed_by_terraform | bool
@@ -0,0 +1,59 @@
---
# Orchestrator. Preflight, then each concern as its own tagged task file.
- name: Assert a bao token is provided
ansible.builtin.assert:
that:
- openbao_token | length > 0
fail_msg: >-
No token. Decrypt the PGP-wrapped root token and export it:
echo "<b64>" | base64 -d | gpg -dq → export BAO_TOKEN=<plaintext>
- name: Preflight — bao is reachable, initialized and UNSEALED
ansible.builtin.command: "bao status -format=json"
environment:
BAO_ADDR: "{{ openbao_addr }}"
BAO_CACERT: "{{ openbao_tls_dir }}/cert.pem"
register: bao_status
changed_when: false
failed_when: bao_status.rc != 0 # 0 = unsealed; 2 = sealed → unseal first
- name: Preflight — the provided token is valid
ansible.builtin.command: "bao token lookup"
environment: "{{ openbao_cli_env }}"
register: bao_tok
changed_when: false
failed_when: bao_tok.rc != 0
no_log: "{{ openbao_no_log }}"
- name: Policies
# Terraform owns ALL policies (../terraform/policies.tf + policies/*.hcl).
# Kept for a Terraform-less bootstrap; see openbao_config_managed_by_terraform.
ansible.builtin.import_tasks: policies.yml
tags: [policies]
when: not openbao_config_managed_by_terraform | bool
- name: KV v2 engine
ansible.builtin.import_tasks: kv.yml
tags: [kv]
- name: SSH certificate authority
ansible.builtin.import_tasks: ssh_ca.yml
tags: [ssh_ca]
- name: OIDC auth (Authelia)
ansible.builtin.import_tasks: auth_oidc.yml
tags: [oidc]
- name: Kubernetes auth
ansible.builtin.import_tasks: auth_kubernetes.yml
tags: [k8s]
when: openbao_enable_k8s_auth | bool
- name: PKI engine
ansible.builtin.import_tasks: pki.yml
tags: [pki]
- name: Raft snapshot timer
ansible.builtin.import_tasks: snapshots.yml
tags: [snapshots]
@@ -0,0 +1,106 @@
---
# PKI engine — internal TLS CA. Optionally re-issues bao's own listener cert.
- name: Enable the PKI secrets engine at {{ openbao_pki_mount }}/
ansible.builtin.command: "bao secrets enable -path={{ openbao_pki_mount }} pki"
environment: "{{ openbao_cli_env }}"
register: pki_enable
changed_when: pki_enable.rc == 0
failed_when:
- pki_enable.rc != 0
- "'already in use' not in (pki_enable.stderr | default('')) + (pki_enable.stdout | default(''))"
no_log: "{{ openbao_no_log }}"
# Terraform owns this (../terraform). See openbao_config_managed_by_terraform.
when: not openbao_config_managed_by_terraform | bool
- name: Tune PKI max lease TTL
ansible.builtin.command: "bao secrets tune -max-lease-ttl={{ openbao_pki_max_lease_ttl }} {{ openbao_pki_mount }}"
environment: "{{ openbao_cli_env }}"
register: pki_tune
changed_when: pki_tune.rc == 0
no_log: "{{ openbao_no_log }}"
# Terraform owns this (../terraform). See openbao_config_managed_by_terraform.
when: not openbao_config_managed_by_terraform | bool
- name: Check whether the root CA already exists
ansible.builtin.command: "bao read -field=certificate {{ openbao_pki_mount }}/cert/ca"
environment: "{{ openbao_cli_env }}"
register: pki_ca_check
changed_when: false
failed_when: false
no_log: "{{ openbao_no_log }}"
- name: Generate the internal root CA (once)
ansible.builtin.command: >-
bao write {{ openbao_pki_mount }}/root/generate/internal
common_name="{{ openbao_pki_ca_cn }}" ttl={{ openbao_pki_max_lease_ttl }}
environment: "{{ openbao_cli_env }}"
when: "'BEGIN CERTIFICATE' not in (pki_ca_check.stdout | default(''))"
register: pki_root
changed_when: pki_root.rc == 0
no_log: "{{ openbao_no_log }}"
- name: Configure issuing/CRL URLs
ansible.builtin.command: >-
bao write {{ openbao_pki_mount }}/config/urls
issuing_certificates={{ openbao_addr }}/v1/{{ openbao_pki_mount }}/ca
crl_distribution_points={{ openbao_addr }}/v1/{{ openbao_pki_mount }}/crl
environment: "{{ openbao_cli_env }}"
register: pki_urls
changed_when: pki_urls.rc == 0
no_log: "{{ openbao_no_log }}"
# Terraform owns this (../terraform). See openbao_config_managed_by_terraform.
when: not openbao_config_managed_by_terraform | bool
- name: Create/update the server-cert role
ansible.builtin.command: >-
bao write {{ openbao_pki_mount }}/roles/{{ openbao_pki_server_role }}
allowed_domains={{ openbao_pki_allowed_domains }}
allow_subdomains=true allow_ip_sans=true max_ttl=8760h
environment: "{{ openbao_cli_env }}"
register: pki_role
changed_when: pki_role.rc == 0
no_log: "{{ openbao_no_log }}"
# Terraform owns this (../terraform). See openbao_config_managed_by_terraform.
when: not openbao_config_managed_by_terraform | bool
# --- GATED: replace the self-signed listener cert with a PKI-issued one ----------
- name: Replace bao's listener cert from PKI
when: openbao_pki_replace_listener_cert | bool
block:
- name: Issue a listener certificate
ansible.builtin.command: >-
bao write -format=json {{ openbao_pki_mount }}/issue/{{ openbao_pki_server_role }}
common_name={{ openbao_fqdn }}
ip_sans={{ openbao_lan_ip }},127.0.0.1
ttl=8760h
environment: "{{ openbao_cli_env }}"
register: pki_issue
changed_when: true
no_log: true
- name: Install listener private key
ansible.builtin.copy:
content: "{{ (pki_issue.stdout | from_json).data.private_key }}\n"
dest: "{{ openbao_tls_dir }}/key.pem"
owner: openbao
group: openbao
mode: "0640"
no_log: true
notify: restart openbao
- name: Install listener certificate (leaf + issuing CA chain)
ansible.builtin.copy:
content: "{{ (pki_issue.stdout | from_json).data.certificate }}\n{{ (pki_issue.stdout | from_json).data.issuing_ca }}\n"
dest: "{{ openbao_tls_dir }}/cert.pem"
owner: openbao
group: openbao
mode: "0644"
notify: restart openbao
- name: Print the PKI root CA (clients must trust this)
ansible.builtin.debug:
msg: >-
Listener now uses a PKI-issued cert. Distribute the root CA to clients:
bao read -field=certificate {{ openbao_pki_mount }}/cert/ca
(NOTE: a Shamir-sealed node re-seals on restart — unseal it afterwards.)
@@ -0,0 +1,20 @@
---
# Policies. Declaratively applied each run (bao policy write is an idempotent overwrite).
- name: Write the ai-agent-ssh policy (sign SSH certs, nothing else)
ansible.builtin.command: "bao policy write ai-agent-ssh -"
args:
stdin: "{{ lookup('template', 'ai-agent-ssh-policy.hcl.j2') }}"
environment: "{{ openbao_cli_env }}"
register: pol_agent
changed_when: pol_agent.rc == 0
no_log: "{{ openbao_no_log }}"
- name: Write the admin policy (human OIDC logins)
ansible.builtin.command: "bao policy write admin -"
args:
stdin: "{{ lookup('template', 'admin-policy.hcl.j2') }}"
environment: "{{ openbao_cli_env }}"
register: pol_admin
changed_when: pol_admin.rc == 0
no_log: "{{ openbao_no_log }}"
@@ -0,0 +1,70 @@
---
# Automated Raft snapshots via a systemd timer + a scoped periodic token.
- name: Write the snapshot policy (read raft snapshots only)
ansible.builtin.command: "bao policy write snapshot -"
args:
stdin: "{{ lookup('template', 'snapshot-policy.hcl.j2') }}"
environment: "{{ openbao_cli_env }}"
register: snap_pol
changed_when: snap_pol.rc == 0
no_log: "{{ openbao_no_log }}"
# Terraform owns this (../terraform). See openbao_config_managed_by_terraform.
when: not openbao_config_managed_by_terraform | bool
- name: Check whether the snapshot token already exists
ansible.builtin.stat:
path: /etc/openbao/snapshot.token
register: snap_tok_stat
- name: Create a periodic snapshot token (once)
ansible.builtin.command: "bao token create -policy=snapshot -period=768h -orphan -field=token"
environment: "{{ openbao_cli_env }}"
register: snap_tok_new
when: not snap_tok_stat.stat.exists
changed_when: snap_tok_new.rc == 0
no_log: true
- name: Store the snapshot token (root-only)
ansible.builtin.copy:
content: "{{ snap_tok_new.stdout }}"
dest: /etc/openbao/snapshot.token
owner: root
group: root
mode: "0600"
when: not snap_tok_stat.stat.exists
no_log: true
- name: Ensure the snapshot output directory exists
ansible.builtin.file:
path: "{{ openbao_snapshot_dir }}"
state: directory
owner: root
group: root
mode: "0700"
- name: Install the snapshot script
ansible.builtin.template:
src: bao-snapshot.sh.j2
dest: /usr/local/bin/bao-snapshot.sh
owner: root
group: root
mode: "0755"
- name: Install the snapshot systemd service + timer
ansible.builtin.template:
src: "{{ item }}.j2"
dest: "/etc/systemd/system/{{ item }}"
owner: root
group: root
mode: "0644"
loop:
- openbao-snapshot.service
- openbao-snapshot.timer
- name: Enable and start the snapshot timer
ansible.builtin.systemd:
name: openbao-snapshot.timer
state: started
enabled: true
daemon_reload: true
@@ -0,0 +1,62 @@
---
# SSH certificate authority: sign short-lived client certs for the ai-agent role.
- name: Enable the SSH secrets engine at {{ openbao_ssh_mount }}/
ansible.builtin.command: "bao secrets enable -path={{ openbao_ssh_mount }} ssh"
environment: "{{ openbao_cli_env }}"
register: ssh_enable
changed_when: ssh_enable.rc == 0
failed_when:
- ssh_enable.rc != 0
- "'already in use' not in (ssh_enable.stderr | default('')) + (ssh_enable.stdout | default(''))"
no_log: "{{ openbao_no_log }}"
# Terraform owns this (../terraform). See openbao_config_managed_by_terraform.
when: not openbao_config_managed_by_terraform | bool
- name: Check whether the SSH CA signing key already exists
ansible.builtin.command: "bao read -field=public_key {{ openbao_ssh_mount }}/config/ca"
environment: "{{ openbao_cli_env }}"
register: ssh_ca_check
changed_when: false
failed_when: false
no_log: "{{ openbao_no_log }}"
- name: Generate the SSH CA signing key (once)
ansible.builtin.command: "bao write {{ openbao_ssh_mount }}/config/ca generate_signing_key=true"
environment: "{{ openbao_cli_env }}"
when: ssh_ca_check.rc != 0
register: ssh_ca_gen
changed_when: ssh_ca_gen.rc == 0
no_log: "{{ openbao_no_log }}"
- name: Create/update the ai-agent signing role
ansible.builtin.command: "bao write {{ openbao_ssh_mount }}/roles/ai-agent -"
args:
stdin: "{{ lookup('template', 'ssh-ai-agent-role.json.j2') }}"
environment: "{{ openbao_cli_env }}"
register: ssh_role
changed_when: ssh_role.rc == 0
no_log: "{{ openbao_no_log }}"
# Terraform owns this (../terraform). See openbao_config_managed_by_terraform.
when: not openbao_config_managed_by_terraform | bool
- name: Fetch the SSH CA public key
ansible.builtin.command: "bao read -field=public_key {{ openbao_ssh_mount }}/config/ca"
environment: "{{ openbao_cli_env }}"
register: ssh_ca_public
changed_when: false
no_log: "{{ openbao_no_log }}"
- name: Save the SSH CA public key on the bao host (for cert-authority lines)
ansible.builtin.copy:
content: "{{ ssh_ca_public.stdout }}\n"
dest: "{{ openbao_ssh_ca_pub_path }}"
owner: root
group: root
mode: "0644"
- name: Show the cert-authority line for no-root target hosts
ansible.builtin.debug:
msg: >-
Add to ~/.ssh/authorized_keys on each target (scope per node):
cert-authority,principals="<node>",restrict,pty {{ ssh_ca_public.stdout }}
@@ -0,0 +1,45 @@
---
# Smoke tests — ansible-playbook bootstrap-openbao.yml --tags verify
# Confirms the engines/auth are mounted and the SSH CA actually signs.
- name: Secrets engines are mounted
ansible.builtin.command: "bao secrets list -format=json"
environment: "{{ openbao_cli_env }}"
register: v_secrets
changed_when: false
no_log: "{{ openbao_no_log }}"
- name: Auth methods are enabled
ansible.builtin.command: "bao auth list -format=json"
environment: "{{ openbao_cli_env }}"
register: v_auth
changed_when: false
no_log: "{{ openbao_no_log }}"
- name: Assert expected mounts exist
ansible.builtin.assert:
that:
- "'{{ openbao_kv_path }}/' in (v_secrets.stdout | from_json)"
- "'{{ openbao_ssh_mount }}/' in (v_secrets.stdout | from_json)"
- "'{{ openbao_pki_mount }}/' in (v_secrets.stdout | from_json)"
- "'oidc/' in (v_auth.stdout | from_json)"
fail_msg: "Expected mounts missing — check the bootstrap run."
- name: SSH CA signs a throwaway key (end-to-end)
ansible.builtin.shell: >-
set -o pipefail;
ssh-keygen -t ed25519 -f /tmp/bao-verify -N '' -q -C verify <<<y >/dev/null 2>&1;
bao write -field=signed_key {{ openbao_ssh_mount }}/sign/ai-agent
public_key=@/tmp/bao-verify.pub valid_principals={{ openbao_ssh_default_user }};
rm -f /tmp/bao-verify /tmp/bao-verify.pub
args:
executable: /bin/bash
environment: "{{ openbao_cli_env }}"
register: v_sign
changed_when: false
failed_when: "'ssh-ed25519-cert' not in (v_sign.stdout | default('')) and 'ssh-rsa-cert' not in (v_sign.stdout | default(''))"
no_log: "{{ openbao_no_log }}"
- name: Report
ansible.builtin.debug:
msg: "Bootstrap verified: kv/ ssh-client-signer/ pki/ + oidc auth mounted; SSH CA signed a test cert."
@@ -0,0 +1,6 @@
# {{ ansible_managed }}
# Broad admin for human OIDC logins (mapped from the {{ openbao_oidc_admin_group }} AD group).
# Homelab-broad on purpose; scope down to specific mounts if you want least privilege.
path "*" {
capabilities = ["create", "read", "update", "delete", "list", "sudo"]
}
@@ -0,0 +1,5 @@
# {{ ansible_managed }}
# The AI agent may sign short-lived SSH client certs for the ai-agent role — nothing else.
path "{{ openbao_ssh_mount }}/sign/ai-agent" {
capabilities = ["create", "update"]
}
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# {{ ansible_managed }}
# Take a Raft snapshot and prune old ones. Ship {{ openbao_snapshot_dir }} off-box
# separately (rsync/restic/scp) — a snapshot on the same host is not a backup.
set -euo pipefail
export BAO_ADDR="{{ openbao_addr }}"
export BAO_CACERT="{{ openbao_tls_dir }}/cert.pem"
BAO_TOKEN="$(cat /etc/openbao/snapshot.token)"
export BAO_TOKEN
dir="{{ openbao_snapshot_dir }}"
stamp="$(date +%Y%m%d-%H%M%S)"
out="${dir}/openbao-${stamp}.snap"
bao operator raft snapshot save "${out}"
chmod 600 "${out}"
# Retention: keep the newest {{ openbao_snapshot_keep }}.
ls -1t "${dir}"/openbao-*.snap 2>/dev/null | tail -n +{{ openbao_snapshot_keep + 1 }} | xargs -r rm -f
@@ -0,0 +1,6 @@
{
"bound_service_account_names": "{{ openbao_k8s_agent_sa }}",
"bound_service_account_namespaces": "{{ openbao_k8s_agent_ns }}",
"token_policies": ["ai-agent-ssh"],
"token_ttl": "10m"
}
@@ -0,0 +1,11 @@
{
"role_type": "oidc",
"user_claim": "preferred_username",
"groups_claim": "groups",
"bound_audiences": "{{ openbao_oidc_client_id }}",
"bound_claims": { "groups": "{{ openbao_oidc_admin_group }}" },
"oidc_scopes": ["profile", "email", "groups"],
"allowed_redirect_uris": {{ openbao_oidc_redirect_uris | to_json }},
"token_policies": ["admin"],
"token_ttl": "1h"
}
@@ -0,0 +1,9 @@
# {{ ansible_managed }}
[Unit]
Description=OpenBao Raft snapshot
After=openbao.service
Wants=openbao.service
[Service]
Type=oneshot
ExecStart=/usr/local/bin/bao-snapshot.sh
@@ -0,0 +1,10 @@
# {{ ansible_managed }}
[Unit]
Description=OpenBao Raft snapshot timer
[Timer]
OnCalendar={{ openbao_snapshot_oncalendar }}
Persistent=true
[Install]
WantedBy=timers.target
@@ -0,0 +1,5 @@
# {{ ansible_managed }}
# Read-only access to take Raft snapshots — used by the snapshot timer's token.
path "sys/storage/raft/snapshot" {
capabilities = ["read"]
}
@@ -0,0 +1,10 @@
{
"key_type": "ca",
"allow_user_certificates": true,
"allowed_users": "{{ openbao_ssh_allowed_users }}",
"default_user": "{{ openbao_ssh_default_user }}",
"allowed_extensions": "",
"default_extensions": { "permit-pty": "" },
"ttl": "{{ openbao_ssh_cert_ttl }}",
"max_ttl": "{{ openbao_ssh_cert_max_ttl }}"
}
@@ -0,0 +1,17 @@
---
# openbao_ssh_ca_trust — make a (root-managed) host trust bao's SSH USER CA, so sshd
# accepts the short-lived certs bao signs. Additive: normal key auth is unaffected.
# For no-root hosts, use a `cert-authority` line in ~/.ssh/authorized_keys instead.
openbao_ssh_mount: "ssh-client-signer"
openbao_addr: "https://bao.ad.ddupan.top:8200"
# The CA public key. Leave empty to fetch it (once, from the control node) from bao's
# UNAUTHENTICATED public_key endpoint — so it stays current even after a CA rotation.
# Set it explicitly to pin a key or work offline.
openbao_ssh_ca_pubkey: ""
openbao_ssh_ca_url: "{{ openbao_addr }}/v1/{{ openbao_ssh_mount }}/public_key"
openbao_ssh_ca_file: "/etc/ssh/openbao_user_ca.pub"
openbao_ssh_ca_dropin: "/etc/ssh/sshd_config.d/50-openbao-ca.conf"
openbao_ssh_service: "ssh" # Debian/Ubuntu; RHEL-family = "sshd"
@@ -0,0 +1,14 @@
---
# Validate the config BEFORE reloading — a broken sshd config must never be applied
# (lock-out risk). If `sshd -t` fails the play errors here and the reload never runs,
# so the running sshd keeps its current (good) config.
- name: reload sshd
ansible.builtin.command: sshd -t
changed_when: false
listen: reload sshd
- name: reload sshd service
ansible.builtin.service:
name: "{{ openbao_ssh_service }}"
state: reloaded
listen: reload sshd
@@ -0,0 +1,11 @@
---
galaxy_info:
role_name: openbao_ssh_ca_trust
description: Trust bao's SSH user CA on a host (TrustedUserCAKeys), so sshd accepts short-lived certs bao signs.
min_ansible_version: "2.15"
platforms:
- name: Debian
versions: [bookworm]
- name: Ubuntu
versions: [jammy, noble]
dependencies: []
@@ -0,0 +1,61 @@
---
# Trust bao's SSH user CA on this host. Requires root (writes sshd config).
- name: Fetch bao's SSH CA public key (once, on the control node)
ansible.builtin.uri:
url: "{{ openbao_ssh_ca_url }}"
return_content: true
delegate_to: localhost
run_once: true
become: false
register: _ssh_ca_fetch
when: openbao_ssh_ca_pubkey | length == 0
- name: Resolve the CA public key (an explicit var wins over the fetched one)
ansible.builtin.set_fact:
_openbao_ssh_ca: >-
{{ openbao_ssh_ca_pubkey if (openbao_ssh_ca_pubkey | length > 0)
else (_ssh_ca_fetch.content | default('') | trim) }}
- name: Assert we have a real SSH CA public key
ansible.builtin.assert:
that:
- _openbao_ssh_ca is search('^ssh-(rsa|ed25519|ecdsa)')
fail_msg: >-
No valid SSH CA public key. Set openbao_ssh_ca_pubkey, or make
{{ openbao_ssh_ca_url }} reachable from the control node.
- name: Install the SSH CA public key
ansible.builtin.copy:
content: "{{ _openbao_ssh_ca }}\n"
dest: "{{ openbao_ssh_ca_file }}"
owner: root
group: root
mode: "0644"
notify: reload sshd
- name: Detect sshd drop-in support
ansible.builtin.command: grep -qiE '^[[:space:]]*Include[[:space:]]+/etc/ssh/sshd_config.d/' /etc/ssh/sshd_config
register: _sshd_dropins
changed_when: false
failed_when: false
- name: Trust the CA via an sshd drop-in
ansible.builtin.copy:
content: |
# OpenBao SSH CA ({{ openbao_addr }}) — accept short-lived user certs it signs.
TrustedUserCAKeys {{ openbao_ssh_ca_file }}
dest: "{{ openbao_ssh_ca_dropin }}"
owner: root
group: root
mode: "0644"
when: _sshd_dropins.rc == 0
notify: reload sshd
- name: Trust the CA in the main sshd_config (no drop-in support)
ansible.builtin.blockinfile:
path: /etc/ssh/sshd_config
marker: "# {mark} OPENBAO SSH CA"
block: "TrustedUserCAKeys {{ openbao_ssh_ca_file }}"
when: _sshd_dropins.rc != 0
notify: reload sshd
@@ -0,0 +1,10 @@
---
# Make each host in the ssh_ca_trust group trust bao's SSH user CA (TrustedUserCAKeys),
# so it accepts the short-lived certs bao signs. Additive + validates before reload.
# ansible-playbook trust-ssh-ca.yml
- name: Trust the OpenBao SSH CA
hosts: ssh_ca_trust
become: true
gather_facts: false
roles:
- role: openbao_ssh_ca_trust
@@ -0,0 +1,5 @@
*.tfstate
*.tfstate.backup
*.tfplan
.terraform/
.terraform.lock.hcl
+61
View File
@@ -0,0 +1,61 @@
# OIDC auth (human logins via Authelia).
#
# SCOPE NOTE: only the auth MOUNT and the ROLE are managed here. The backend
# CONFIG (auth/oidc/config) is intentionally left to ../ansible because it
# carries `oidc_client_secret`. Terraform cannot read that value back from the
# API, so managing it here would (a) force the plaintext secret into
# terraform.tfstate and (b) produce a perpetual diff. Ansible already holds it
# in an ansible-vault file.
resource "vault_auth_backend" "oidc" {
type = "oidc"
path = "oidc"
}
resource "vault_jwt_auth_backend_role" "admin" {
backend = vault_auth_backend.oidc.path
role_name = "admin"
role_type = "oidc"
user_claim = "preferred_username"
bound_audiences = ["openbao"]
# Only members of the AD group vault-admins get the admin policy.
bound_claims = {
groups = "vault-admins"
}
groups_claim = "groups"
oidc_scopes = ["profile", "email", "groups"]
allowed_redirect_uris = [
"https://bao.ad.ddupan.top:8200/ui/vault/auth/oidc/oidc/callback", # web UI
"http://localhost:8250/oidc/callback", # CLI login
]
token_policies = ["admin"]
token_ttl = 3600
token_max_ttl = 0
}
# ── Kubernetes auth: the External Secrets Operator ─────────────────────────
# The BACKEND itself (auth/kubernetes/config) is NOT managed here — it needs the
# cluster CA and a long-lived reviewer JWT, which is key material Terraform must
# not hold. That stays in ../ansible (openbao_bootstrap/tasks/auth_kubernetes.yml),
# per the ownership split in CLAUDE.md. A ROLE is pure API config, so it lives here.
#
# This is what makes the operator credential-less: it presents its own
# ServiceAccount JWT, bao verifies it via the cluster's TokenReview API, and
# returns a short-lived token carrying only the external-secrets policy.
resource "vault_kubernetes_auth_backend_role" "external_secrets" {
backend = "kubernetes"
role_name = "external-secrets"
# Must match serviceAccount.name / namespace in
# ../../../platform/external-secrets/values.yaml and the serviceAccountRef in
# ../../../platform/external-secrets/clustersecretstore.yaml.
bound_service_account_names = ["external-secrets"]
bound_service_account_namespaces = ["external-secrets"]
token_policies = [vault_policy.external_secrets.name]
# Short-lived on purpose: ESO re-authenticates as needed, so there is no value
# in a long TTL and every extra hour is a longer-lived credential in memory.
token_ttl = 3600
}
@@ -0,0 +1,40 @@
# Remote state in SeaweedFS S3, on the LAN.
#
# WHY remote at all: local state means the only copy lives on this laptop, which is
# also the k3s node, the NFS server and the libvirt host — i.e. the single point of
# failure. It also cannot be locked, so two concurrent applies silently corrupt it.
#
# WHY s3.ad.ddupan.top and NOT obj.ddupan.top: the public name resolves to
# Cloudflare and hairpins through the WAN. On 2026-07-28 that path was blackholed
# for hours by a dead VPN tunnel. State must be reachable when the WAN is not —
# it is what you need DURING an incident. See ../../../apps/seaweedfs/httproute-s3.yaml.
#
# CREDENTIALS are not in this file. Export them before running terraform:
# export AWS_ACCESS_KEY_ID=$(bao kv get -field=... kv/k8s/seaweedfs-s3) # see README
# export AWS_SECRET_ACCESS_KEY=...
# The `terraform` S3 identity is scoped to this bucket only — it deliberately
# cannot create buckets or read anything else in the store.
terraform {
backend "s3" {
bucket = "tfstate"
key = "openbao/terraform.tfstate"
endpoints = {
s3 = "https://s3.ad.ddupan.top"
}
# SeaweedFS is not AWS: it has no regions, no IAM, no metadata service and no
# account IDs, so every AWS-specific validation has to be skipped or the
# provider fails before it ever talks to the endpoint.
region = "us-east-1"
use_path_style = true
skip_credentials_validation = true
skip_metadata_api_check = true
skip_region_validation = true
skip_requesting_account_id = true
# Native S3 locking (Terraform >= 1.10; this repo runs 1.15). Writes a
# .tflock object alongside the state — no DynamoDB table needed.
use_lockfile = true
}
}
@@ -0,0 +1,70 @@
# Adopt the already-running OpenBao configuration into Terraform state.
#
# These use TF 1.5 `import` blocks rather than `terraform import` CLI calls so
# the adoption is reviewable: `terraform plan` shows exactly what Terraform
# thinks differs from reality BEFORE anything is written.
#
# The plan should be "N to import, 0 to destroy". Anything proposing a DESTROY
# or a replace means the HCL does not match the live object — fix the HCL, never
# apply through it. Destroying the pki mount would take the root CA with it.
#
# Once applied, these blocks are inert and can be deleted.
import {
to = vault_mount.kv
id = "kv"
}
import {
to = vault_mount.pki
id = "pki"
}
import {
to = vault_mount.ssh_client_signer
id = "ssh-client-signer"
}
import {
to = vault_pki_secret_backend_role.bao_server
id = "pki/roles/bao-server"
}
import {
to = vault_ssh_secret_backend_role.ai_agent
id = "ssh-client-signer/roles/ai-agent"
}
import {
# NOTE: vault_auth_backend imports by the BARE path ("oidc"), not the
# API-prefixed "auth/oidc" — the latter gives "Cannot import non-existent
# remote object". The ROLE below does use the full path, which is the
# inconsistency that makes this easy to get wrong.
to = vault_auth_backend.oidc
id = "oidc"
}
import {
to = vault_jwt_auth_backend_role.admin
id = "auth/oidc/role/admin"
}
import {
to = vault_policy.admin
id = "admin"
}
import {
to = vault_policy.ai_agent_ssh
id = "ai-agent-ssh"
}
import {
to = vault_policy.snapshot
id = "snapshot"
}
import {
to = vault_pki_secret_backend_config_urls.this
id = "pki/config/urls"
}
@@ -0,0 +1,31 @@
# Secrets engines. IMPORTED from the running instance (see imports.tf) — these
# were originally created by ../ansible (role openbao_bootstrap).
#
# NOT MANAGED HERE, DELIBERATELY:
# * the PKI ROOT CA (pki/root/generate)
# * the SSH CA keypair (ssh-client-signer/config/ca)
# Both are root-of-trust material. A Terraform resource for them would treat any
# drift as "regenerate", which would silently invalidate every issued cert and
# every trusted SSH CA line on every host. They stay one-time Ansible bootstrap.
resource "vault_mount" "kv" {
path = "kv"
type = "kv"
options = { version = "2" }
}
resource "vault_mount" "pki" {
path = "pki"
type = "pki"
max_lease_ttl_seconds = 315360000 # 10y — must exceed the root CA's own lifetime
# Required by ACME: OpenBao strips response headers not listed here, and
# clients fail at the "new nonce" step without them. This is the ONE
# intentional change in the import plan.
allowed_response_headers = ["Replay-Nonce", "Link", "Location"]
}
resource "vault_mount" "ssh_client_signer" {
path = "ssh-client-signer"
type = "ssh"
}
@@ -0,0 +1,13 @@
output "acme_directory_url" {
value = "${var.bao_address}/v1/${var.pki_mount}/roles/${var.acme_role}/acme/directory"
description = <<-EOT
Point ACME clients here. Example, on dc1 (binds :80 only during renewal):
lego --server <this> --domains dc1.ad.ddupan.top --email [email protected] --http run
Clients must already trust the internal CA, or pass its PEM to the client.
EOT
}
output "acme_enabled" {
value = vault_pki_secret_backend_config_acme.this.enabled
description = "Whether the ACME directory is currently serving."
}
+74
View File
@@ -0,0 +1,74 @@
# ── issuing role ──────────────────────────────────────────────────────────
# Caps what ACME (and direct issuance) may mint. dc1's LDAPS cert comes from here.
resource "vault_pki_secret_backend_role" "bao_server" {
backend = vault_mount.pki.path
name = "bao-server"
allowed_domains = ["ad.ddupan.top"]
allow_subdomains = true
allow_bare_domains = false
allow_glob_domains = false
allow_any_name = false
allow_ip_sans = true # dc1's cert carries IP:192.168.10.5
server_flag = true
client_flag = true
key_type = "rsa"
key_bits = 2048
max_ttl = 31536000 # 1y
# 60d. NOT 0: ttl=0 falls back to the system default of 768h (32 days), which
# is what dc1's cert was getting. That is fine for lego (samba_ad_acme renews at
# 10 days left) but breaks Proxmox: PVE's renewal threshold is hardcoded at "30
# days to expiry" (PVE/API2/ACME.pm), so a 32-day cert renews every ~2 days and
# restarts pveproxy each time. 60d leaves PVE a full 30-day retry window -- which
# matters given the flaky WAN -- and stays well under OpenBao's 90d ACME cap.
ttl = 5184000 # 60d
use_csr_common_name = true
}
# ── cluster paths ─────────────────────────────────────────────────────────
# ACME directory/order URLs are built from these and embedded in issued certs as
# AIA URLs, so they must be reachable by clients exactly as written. Uses the
# public hostname (real Let's Encrypt cert via the openbao_acme Ansible role),
# not the bare IP.
resource "vault_pki_secret_backend_config_cluster" "this" {
backend = vault_mount.pki.path
path = "${var.bao_address}/v1/${vault_mount.pki.path}"
aia_path = "${var.bao_address}/v1/${vault_mount.pki.path}"
}
# ── ACME ──────────────────────────────────────────────────────────────────
# WHY: dc1's LDAPS cert was hand-issued 2026-07-25 and expires 2027-07-25 with
# nothing to renew it. If it lapses, Authelia loses its LDAPS backend and every
# SSO consumer fails at once. ACME takes the human out of that loop.
resource "vault_pki_secret_backend_config_acme" "this" {
backend = vault_mount.pki.path
enabled = var.acme_enabled
# SECURITY: OpenBao's default is "sign-verbatim" — it would issue ANY name a
# client asks for, meaning anything able to reach bao could mint a cert for
# dc1.ad.ddupan.top from the ROOT CA. Pinning to the role caps issuance at
# that role's allowed_domains.
default_directory_policy = "role:${vault_pki_secret_backend_role.bao_server.name}"
allowed_roles = [vault_pki_secret_backend_role.bao_server.name]
allowed_issuers = ["*"]
# See variables.tf for the not-required vs EAB trade-off.
eab_policy = var.acme_eab_policy
# Empty = server's own resolver. bao resolves ad.ddupan.top correctly
# (verified), so http-01 validation against internal hosts works.
dns_resolver = ""
depends_on = [vault_pki_secret_backend_config_cluster.this]
}
# ── issuing / CRL URLs ────────────────────────────────────────────────────
# Embedded in every issued cert so clients can fetch the CA and check the CRL.
# Previously set by ../ansible (pki.yml, "Configure issuing/CRL URLs") — moved
# here as part of the Terraform-owns-configuration split.
resource "vault_pki_secret_backend_config_urls" "this" {
backend = vault_mount.pki.path
issuing_certificates = ["${var.bao_address}/v1/${vault_mount.pki.path}/ca"]
crl_distribution_points = ["${var.bao_address}/v1/${vault_mount.pki.path}/crl"]
}
@@ -0,0 +1,27 @@
# Policy bodies live in policies/*.hcl so they stay readable and diffable.
# Exported verbatim from the running instance, so importing produces no diff.
#
# OVERLAP WARNING: ../ansible (role openbao_bootstrap) also writes these — hence
# the "# Ansible managed" header still inside each file. Gate those Ansible tasks
# off before applying, or the two will overwrite each other on alternate runs.
resource "vault_policy" "admin" {
name = "admin"
policy = file("${path.module}/policies/admin.hcl")
}
resource "vault_policy" "ai_agent_ssh" {
name = "ai-agent-ssh"
policy = file("${path.module}/policies/ai-agent-ssh.hcl")
}
resource "vault_policy" "snapshot" {
name = "snapshot"
policy = file("${path.module}/policies/snapshot.hcl")
}
# Read-only kv/k8s/* for the External Secrets Operator. Unlike the three above
# this one is NOT also written by Ansible, so there is no overlap to gate off.
resource "vault_policy" "external_secrets" {
name = "external-secrets"
policy = file("${path.module}/policies/external-secrets.hcl")
}
@@ -0,0 +1,6 @@
# Ansible managed
# Broad admin for human OIDC logins (mapped from the vault-admins AD group).
# Homelab-broad on purpose; scope down to specific mounts if you want least privilege.
path "*" {
capabilities = ["create", "read", "update", "delete", "list", "sudo"]
}
@@ -0,0 +1,5 @@
# Ansible managed
# The AI agent may sign short-lived SSH client certs for the ai-agent role — nothing else.
path "ssh-client-signer/sign/ai-agent" {
capabilities = ["create", "update"]
}
@@ -0,0 +1,17 @@
# Read-only access for the External Secrets Operator in k3s.
#
# Scoped to kv/k8s/* deliberately — ESO syncs Kubernetes Secrets and has no reason
# to see the SSH CA, the PKI, or any other KV path. This is narrower than the human
# `admin` policy, which is the point: the machine identity is less privileged than
# the person.
#
# KV v2 splits data from metadata: reads go to <mount>/data/<path>, and listing or
# checking existence goes to <mount>/metadata/<path>. ESO needs both — without
# metadata it cannot resolve `dataFrom.extract`.
path "kv/data/k8s/*" {
capabilities = ["read"]
}
path "kv/metadata/k8s/*" {
capabilities = ["read", "list"]
}
@@ -0,0 +1,5 @@
# Ansible managed
# Read-only access to take Raft snapshots — used by the snapshot timer's token.
path "sys/storage/raft/snapshot" {
capabilities = ["read"]
}
+21
View File
@@ -0,0 +1,21 @@
# SSH certificate authority for machine/agent access.
# The CA KEYPAIR itself is NOT managed here (see mounts.tf) — only this role,
# which is what actually constrains what a signed cert may do.
resource "vault_ssh_secret_backend_role" "ai_agent" {
backend = vault_mount.ssh_client_signer.path
name = "ai-agent"
key_type = "ca"
allow_user_certificates = true
default_user = "ansible"
allowed_users = "*"
# Deliberately short: access is scoped by TTL + principals rather than by
# source IP, so a leaked cert expires in minutes.
ttl = 300 # 5m
max_ttl = 900 # 15m
default_extensions = {
"permit-pty" = ""
}
}
@@ -0,0 +1,45 @@
variable "bao_address" {
type = string
default = "https://bao.ad.ddupan.top:8200"
description = <<-EOT
OpenBao API address. Must be the name clients can actually reach and verify:
it is baked into ACME directory URLs and issued certs' AIA extension.
EOT
}
variable "pki_mount" {
type = string
default = "pki"
description = "Path of the PKI secrets engine. Mount itself is Ansible-owned (openbao_bootstrap)."
}
variable "acme_enabled" {
type = bool
default = true
description = "Enable the ACME directory on the PKI mount."
}
variable "acme_role" {
type = string
default = "bao-server"
description = <<-EOT
Role that constrains ACME issuance. Its allowed_domains cap what any ACME
client can obtain — currently ad.ddupan.top with subdomains, IP SANs allowed.
NEVER leave the policy as sign-verbatim; that would let ACME issue any name.
EOT
}
variable "acme_eab_policy" {
type = string
default = "not-required"
description = <<-EOT
"not-required": any host reaching bao may enroll (names still capped by acme_role).
"new-account-required": each client must present an External Account Binding
credential from `bao write -f pki/acme/new-eab`. Tighter, but needs per-host
provisioning and rotation.
EOT
validation {
condition = contains(["not-required", "new-account-required", "always-required"], var.acme_eab_policy)
error_message = "Must be not-required, new-account-required, or always-required."
}
}
@@ -0,0 +1,25 @@
terraform {
required_version = ">= 1.5"
required_providers {
# NOTE: this targets OpenBao, but uses the HASHICORP VAULT provider.
# The native `openbao/openbao` provider is published to the OpenTofu
# registry, NOT registry.terraform.io, so it cannot be resolved by the
# HashiCorp `terraform` CLI in use here ("provider registry
# registry.terraform.io does not have a provider named openbao/openbao").
# OpenBao is API-compatible with Vault, so this provider drives it fine.
# If this repo ever switches to `tofu`, swap to openbao/openbao and rename
# the vault_* resources to openbao_*.
vault = {
source = "hashicorp/vault"
version = "~> 4.0"
}
}
}
# Authenticates from the ambient CLI session, same pattern as smtp-relay/terraform
# uses `az login`: run `bao login -method=oidc` first, which writes ~/.vault-token.
# VAULT_ADDR/VAULT_TOKEN (or BAO_ADDR/BAO_TOKEN exported into them) override.
# No credentials are stored in this config.
provider "vault" {
address = var.bao_address
}
+8
View File
@@ -0,0 +1,8 @@
# Answer files embed the root password hash + host network config — keep out of git.
# They're regenerated from the eventual Ansible role / vault.
pxe/answer/*.toml
pxe/secrets.env
# Build scratch (large PXE artifacts live on the netboot host, not here)
pxe/build/
vyos/credentials.yml
+94
View File
@@ -0,0 +1,94 @@
# Proxmox HA — and the watchdog that makes it real
HA was enabled for **one** guest on 2026-07-26. The cluster's posture is otherwise
unchanged: no HA, guests disposable.
| | |
|---|---|
| resource | `vm:100` (`vyos-rtr`), `state started`, `max_restart 3`, `max_relocate 2` |
| why | it is the gateway for **both** SDN VNets and an OSPF speaker; losing it takes `labnet` + `retronet` offline (the main LAN is unaffected — the NEC IX is its gateway) |
| storage | `pve-rg` (DRBD, place-count 2): `pve1` + `pve2` hold **UpToDate diskful** replicas, `pve3` attaches **diskless** — so it can start on any node |
| codified | `ansible/roles/pve_ha` + `ansible/ha.yml` (separate from `site.yml`, like `cluster.yml`) |
**What this buys, precisely:** crash *restart* on another node, roughly **1–3 minutes**
of gateway downtime. It is **not** seamless failover. Real gateway HA is a second VyOS
with VRRP owning `10.60.0.1`/`10.61.0.1`; that was considered and deferred.
**What it costs:** adding any HA resource **arms fencing cluster-wide**. A node that
loses quorum now self-reboots. Corosync here runs a **single ring** on the flat 1G LAN,
so a network blip is now a reboot rather than a shrug.
Also set at the same time: `qm set 100 --agent 0`. PVE had `agent: enabled=1` while
VyOS 2025.11 ships **no `qemu-ga` binary and no apt sources** — the virtio channel was
wired but nothing could answer, so snapshots could not filesystem-freeze and no guest
IPs were reported. Installing it would mean adding Debian repos to the only router, and
would be wiped by the next `add system image`.
## ⚠ The watchdog is still `softdog` — finish this
`softdog` is a **software** watchdog: a kernel timer. **It cannot fire when the kernel
itself is frozen**, which is exactly the failure this cluster has actually had (pve2's
Raven Ridge idle freeze). So today's HA covers clean crashes, power loss and network
partition — but *not* the freeze case that motivated it.
Hardware watchdogs are present and verified (2026-07-26):
| node | module | timeout |
|---|---|---|
| pve1 (Intel i3-6100U) | `iTCO_wdt` | 30s |
| pve2 / pve3 (Ryzen 2400GE) | `sp5100_tco` | 60s |
`ansible/ha.yml` already writes the config (`WATCHDOG_MODULE` + a `blacklist softdog`
in `/etc/modprobe.d/pve-ha-watchdog.conf`). **Both are needed**: `watchdog-mux` opens
`/dev/watchdog`, which belongs to whichever watchdog registered *first*, and PVE loads
softdog at boot — so setting `WATCHDOG_MODULE` alone leaves the hardware module sitting
unused as `watchdog1`.
It takes effect on **the next reboot of each node**. The play deliberately does not
reboot: a surprise rolling reboot of all three nodes is not something an idempotent
baseline run should do.
### Live swap, without rebooting (do it in this order)
The danger is stopping `watchdog-mux` while an LRM holds the watchdog — that self-fences
the node. Parking the resource first removes every active LRM, which makes the rest safe.
```bash
# 1. park the resource; all LRMs go idle and release their watchdogs
ha-manager set vm:100 --state ignored
# wait until NO line says "watchdog active" — vm:100 keeps running throughout
ha-manager status
# 2. per node (pve3, pve2, pve1 — least critical first):
systemctl stop watchdog-mux
rmmod softdog
modprobe -r iTCO_wdt 2>/dev/null; modprobe iTCO_wdt # pve1
# modprobe -r sp5100_tco; modprobe sp5100_tco # pve2 / pve3
cat /sys/class/watchdog/watchdog0/identity # must be the HW one now
systemctl start watchdog-mux # Restart=no: start it explicitly
ls -l /proc/$(pidof watchdog-mux)/fd | grep watchdog # must hold /dev/watchdog
# 3. hand the resource back
ha-manager set vm:100 --state started
ha-manager status
```
If a step fails, leave `vm:100` in `ignored` and fix it — the VM keeps running and
nothing gets fenced. `ignored` means HA does not touch the guest, not that it stops.
## Verify
```bash
ha-manager status # "fencing armed", "service vm:100 (pveN, started)"
ha-manager config
qm config 100 | grep -E 'agent|onboot'
linstor r l | grep pm- # replica placement
```
## Undo
```bash
ha-manager remove vm:100 # disarms fencing once no resources remain
rm /etc/modprobe.d/pve-ha-watchdog.conf
# and revert WATCHDOG_MODULE in /etc/default/pve-ha-manager
```
@@ -0,0 +1,18 @@
[defaults]
inventory = inventory/hosts.yml
roles_path = roles
host_key_checking = False
callback_result_format = yaml
nocows = True
# Every host here is a Proxmox node reached as root, so become is unnecessary
# (unlike samba-ad/, whose inventory is mixed Linux + Windows).
# 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
[ssh_connection]
# The homelab LAN and the home uplink are both flaky; keep connections warm and
# retry rather than failing a long play halfway through.
pipelining = True
retries = 3
+10
View File
@@ -0,0 +1,10 @@
---
# AD realm + user/group sync. Kept separate from site.yml because it writes
# cluster-wide config (/etc/pve/domains.cfg) and needs the bind password.
#
# ansible-playbook auth.yml -e @../../samba-ad/ansible/group_vars/all/vault.yml
- name: Proxmox AD authentication realm
hosts: pve
gather_facts: false
roles:
- pve_auth
@@ -0,0 +1,16 @@
---
# Form the "homelab" cluster. SEPARATE from site.yml on purpose: site.yml is
# safely re-runnable baseline config, whereas cluster formation is effectively
# one-way (undoing a join means reinstalling the node). Run it deliberately.
#
# ansible-playbook cluster.yml
#
# serial:1 with the primary first — a joining node needs the primary quorate,
# and joining all three at once is how you get a split-brain corosync config.
- name: Form the Proxmox cluster
hosts: pve
gather_facts: true
serial: 1
order: sorted # pve1 (the primary) first
roles:
- pve_cluster
@@ -0,0 +1,50 @@
---
ansible_user: root
ansible_python_interpreter: /usr/bin/python3
# Internal domain. NOTE: ad.ddupan.top, NOT lab.ddupan.top -- the installer
# answer files originally wrote "lab" and it was corrected post-install on
# 2026-07-25. This is the Samba AD realm (see services/samba-ad/).
pve_domain: ad.ddupan.top
# ── apt / repositories ──────────────────────────────────────────────────
# Debian codename PVE 9.2 is built on. Derived at runtime, but pinned here as a
# fallback so a failed fact-gather cannot silently point apt at the wrong suite.
pve_suite: trixie
# The enterprise repos 401 without a subscription and make every apt run noisy.
pve_disable_enterprise_repo: true
pve_enable_no_subscription_repo: true
# Ceph is deliberately NOT used on this cluster -- it was tried on HDD OSDs and
# was far too slow. LINSTOR/DRBD replaces it, so the Ceph repo is disabled.
pve_disable_ceph_repo: true
# Strip the "No valid subscription" web-UI dialog.
pve_remove_subscription_nag: true
# Run a full dist-upgrade. Off by default: the home uplink is unstable and a
# half-applied upgrade across a live cluster is worse than a stale one. Enable
# deliberately: -e pve_dist_upgrade=true
pve_dist_upgrade: false
# ── outbound mail ───────────────────────────────────────────────────────
# These nodes cannot hand mail to M365 directly from a residential IP, so all
# mail relays through the k3s smtp-relay on the laptop. See services/smtp-relay/.
pve_mail_relayhost: "[192.168.10.127]:25"
# M365 authenticates as this mailbox and REFUSES to send as anything else
# (5.7.60 SendAsDenied), so every local sender is rewritten to it.
pve_mail_from: [email protected]
# ── internal PKI ────────────────────────────────────────────────────────
# OpenBao (192.168.10.8) is the homelab root-of-trust. This endpoint is
# unauthenticated by design, so nodes can (re)fetch the CA without a token.
pve_internal_ca_url: https://bao.ad.ddupan.top:8200/v1/pki/ca/pem
# ── DNS ─────────────────────────────────────────────────────────────────
# See roles/pve_dns for WHY the DC must come first.
pve_dns_search: ad.ddupan.top
pve_nameservers:
- 192.168.10.5 # Samba AD DC: internal zone + external forwarding
- 192.168.10.1 # router: external only, NO internal zone
pve_dns_probe_name: bao.ad.ddupan.top
+16
View File
@@ -0,0 +1,16 @@
---
# Proxmox HA. SEPARATE from site.yml deliberately, like cluster.yml: registering an
# HA resource arms fencing CLUSTER-WIDE, so a node that loses quorum will self-reboot.
# That is a behaviour change for a cluster whose documented posture is "no HA", and it
# should be run on purpose rather than as a side effect of a baseline run.
#
# ansible-playbook ha.yml
# ansible-playbook ha.yml --tags watchdog # watchdog config only
#
# Preconditions (all verified 2026-07-26): cluster quorate, guest disk on replicated
# storage (pve-rg / DRBD place-count 2), and `onboot: 1` on the guest.
- name: Proxmox HA
hosts: pve
gather_facts: true
roles:
- pve_ha
@@ -0,0 +1,3 @@
# Hardware watchdog for HA fencing. Verified present 2026-07-26 (iTCO_wdt).
# softdog cannot fence a frozen kernel; see roles/pve_ha.
pve_ha_watchdog_module: "iTCO_wdt"
@@ -0,0 +1,3 @@
# Hardware watchdog for HA fencing. Verified present 2026-07-26 (sp5100_tco).
# softdog cannot fence a frozen kernel; see roles/pve_ha.
pve_ha_watchdog_module: "sp5100_tco"
@@ -0,0 +1,3 @@
# Hardware watchdog for HA fencing. Verified present 2026-07-26 (sp5100_tco).
# softdog cannot fence a frozen kernel; see roles/pve_ha.
pve_ha_watchdog_module: "sp5100_tco"
@@ -0,0 +1,57 @@
---
# 3-node Proxmox VE 9.2 cluster "homelab". All were netboot auto-installed on
# 2026-07-25 (see ../pxe/ and the project memory for the PXE recipe).
#
# IPs are all BELOW the router's DHCP pool (192.168.10.10-250) on purpose.
# .5 = samba AD DC (dc1, libvirt VM on the laptop), .8 = reserved for OpenBao,
# .127 = laptop ("core" node: NFS + netboot.xyz + k3s + smtp-relay).
all:
children:
pve:
hosts:
pve1:
ansible_host: 192.168.10.4
# Intel NUC i3-6100, 16G. Most RAM of the three -> LINSTOR controller.
# Its UEFI cannot unpack a large initramfs, so it needs the special
# PXE path in ../pxe/ if it is ever rebuilt.
pve_install_disk: sdb
pve_bulk_disk: sda
pve2:
ansible_host: 192.168.10.7
# ThinkCentre, Ryzen 2400GE (Raven Ridge), 8G. THIS is the node that
# randomly froze. Kernel workaround below; also set BIOS
# "Power Supply Idle Control" -> "Typical Current Idle" (the more
# reliable half of the fix, and it can only be done physically).
pve_kernel_cmdline_extra: "idle=nomwait processor.max_cstate=1"
pve_install_disk: nvme0n1
pve_bulk_disk: sda
pve3:
ansible_host: 192.168.10.9
# Same Raven Ridge silicon as pve2 but has never frozen. Deliberately
# left WITHOUT the C-state workaround: processor.max_cstate=1 blocks
# deeper idle states and costs power, so it is not applied pre-emptively.
# If pve3 ever freezes, set pve_kernel_cmdline_extra here too.
pve_install_disk: nvme0n1
pve_bulk_disk: sda
# Network devices driven over network_cli (NOT ssh/python) — they have no
# Python interpreter, so normal modules do not apply.
network:
hosts:
vyos-rtr:
ansible_host: 192.168.10.2
ansible_user: vyos
ansible_connection: ansible.netcommon.network_cli
ansible_network_os: vyos.vyos.vyos
# Key auth; the vyos password is in ../vyos/credentials.yml (gitignored).
ansible_ssh_private_key_file: ~/.ssh/id_ed25519
# Lab VMs on the cluster (normal ssh/python hosts, unlike the `network` group).
labvms:
hosts:
retrolab:
ansible_host: 10.60.0.10
ansible_user: panxiao81
ansible_become: true
@@ -0,0 +1,11 @@
---
# LINSTOR/DRBD storage. Separate from site.yml because it partitions and wipes
# disks. The HDD wipe additionally requires -e pve_linstor_wipe_hdd=true.
#
# ansible-playbook linstor.yml --tags linstor_pkgs # packages only
# ansible-playbook linstor.yml -e pve_linstor_wipe_hdd=true
- name: LINSTOR hyperconverged storage
hosts: pve
gather_facts: true
roles:
- pve_linstor
@@ -0,0 +1,15 @@
---
# Install with: ansible-galaxy collection install -r requirements.yml
#
# This file did not exist until ansible-lint flagged unresolvable modules: the
# other two projects (samba-ad/, openbao/) declared their collections but this
# one relied on whatever happened to be in the operator's environment. A fresh
# checkout or a CI runner could not reproduce it.
collections:
- name: ansible.netcommon # network_cli connection for VyOS — it has no Python
# interpreter, so the usual ssh+python path cannot work
- name: vyos.vyos # vyos_config; see CLAUDE.md on preferring explicit
# `set` lines over the resource modules, which lag upstream
- name: ansible.posix # mount, authorized_key
- name: community.general # proxmox_kvm (guest lifecycle). NOTE: deb822_repository
# is ansible.builtin, NOT community.general — see CLAUDE.md
@@ -0,0 +1,41 @@
---
# 86Box host: desktop + RDP. AD join is NOT here — that uses the shared
# `ad_sssd_join` role in ../../samba-ad/ansible (join-sssd.yml), so every
# Linux host joins the domain the same way.
- name: Retro lab desktop
hosts: retrolab
gather_facts: true
environment:
# DO NOT REMOVE. This host holds long-lived interactive desktop sessions.
# Ubuntu's needrestart hooks apt and, in non-interactive mode, RESTARTS any
# service linked against an upgraded library. On 2026-07-25 an unrelated
# `apt install patchelf libcap2-bin` pulled in a libcap2 upgrade, which made
# needrestart restart xrdp-sesman. sesman came back with an empty session
# table and could no longer reattach the running :10 session, stranding a
# live 86Box VM: every reconnect created a NEW session, and xfce4-session
# refuses to start twice for one user, so each new session died instantly.
# `l` = list what would need restarting, restart nothing.
NEEDRESTART_MODE: l
vars:
# ISO library, served from the laptop (the same export PVE mounts as its
# `laptop` storage). Mounted READ-ONLY: it is where 86Box picks CD images
# from, and nothing on the retro side should ever write to the library.
nfs_mounts:
- src: "192.168.10.127:/mnt/pool/proxmox/template/iso"
path: /mnt/iso
# The bridge 86Box attaches its TAP interfaces to. net1 (retronet, VLAN
# 110) deliberately has no IP on the host — see roles/tap_bridge/defaults.
tap_bridge_name: br-retro
tap_bridge_member: enp6s19
tap_bridge_member_mac: "bc:24:11:9f:63:ed"
retro_86box_tap_bridge: br-retro
roles:
- guest_base
- retro_desktop
- nfs_mounts
- tap_bridge
- retro_86box
@@ -0,0 +1,61 @@
---
# Baseline every lab VM needs to be manageable. Cloud-init installs these on a
# FRESH VM; this role covers VMs that already exist (cloud-init's package module
# runs once per instance) and repairs any that failed a WAN blip on first boot.
# The WAN drops mid-transaction often enough that dpkg gets left interrupted
# ("dpkg was interrupted, you must manually run 'sudo dpkg --configure -a'"),
# after which EVERY later apt run fails. Repair it rather than fail the play.
- name: Detect an interrupted dpkg
ansible.builtin.command:
cmd: dpkg --audit
register: _dpkg_audit
changed_when: false
failed_when: false
check_mode: false
- name: Repair interrupted dpkg
ansible.builtin.command:
cmd: dpkg --configure -a
when: (_dpkg_audit.stdout | default('') | trim) | length > 0
changed_when: true
# Repair dpkg if a previous apt run was cut short. NOTE the usual cause is an
# operator stopping/rebooting the VM while cloud-init or apt is still running --
# not a network fault. Check `cloud-init status` before power-cycling a fresh VM.
- name: Detect an interrupted dpkg
ansible.builtin.command:
cmd: dpkg --audit
register: _dpkg_audit
changed_when: false
failed_when: false
check_mode: false
- name: Repair interrupted dpkg
ansible.builtin.command:
cmd: dpkg --configure -a
when: (_dpkg_audit.stdout | default('') | trim) | length > 0
changed_when: true
- name: Install guest baseline packages
ansible.builtin.apt:
name:
# Gives PVE the guest's IPs, clean shutdown, and fsfreeze for snapshots.
# Without it `qm agent <id> ping` fails and PVE is blind inside the guest.
- qemu-guest-agent
- openssh-server
state: present
update_cache: true
cache_valid_time: 3600
register: _gb
retries: 3
delay: 15
until: _gb is succeeded
- name: Enable the guest agent and sshd
ansible.builtin.systemd_service:
name: "{{ item }}"
enabled: true
state: started
loop: [qemu-guest-agent, ssh]
@@ -0,0 +1,16 @@
---
# Generic NFS client mounts. Shareable: pass a list, get idempotent mounts.
#
# Each entry: { src, path, opts (optional), state (optional) }
nfs_mounts: []
# Defaults chosen for read-mostly media (ISO libraries), NOT for VM disks:
# ro - the share is a library; nothing here should write to it. Also
# the only real protection available, because the server exports
# it to <world> with no_root_squash.
# soft - a hard mount wedges every process that touches the path when the
# server or the WAN-adjacent link blips, including the desktop
# file manager. Soft returns EIO instead of hanging forever.
# _netdev - do not attempt the mount before the network is up.
# nofail - a missing NFS server must never stop this host from booting.
nfs_mounts_default_opts: "ro,soft,timeo=100,retrans=3,_netdev,nofail,noatime"

Some files were not shown because too many files have changed in this diff Show More