Establish clean homelab infrastructure baseline
Reorganize the brownfield repository, remove retired and generated artifacts, harden ignore rules, and record the GitOps/IaC redesign.
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
---
|
||||
# openbao_bootstrap role defaults. Secrets come from group_vars/all/vault.yml.
|
||||
|
||||
# --- Connection to the running, unsealed bao (local to the host) ---
|
||||
openbao_api_port: 8200
|
||||
openbao_tls_dir: "/etc/openbao/tls"
|
||||
# ⚠ MUST be the hostname, not 127.0.0.1. Since openbao_acme gave bao a real Let's
|
||||
# Encrypt cert, that cert carries a DNS SAN only — so a loopback connection dies
|
||||
# with "cannot validate certificate for 127.0.0.1 because it doesn't contain any
|
||||
# IP SANs". Same trap as dc1's LDAPS cert and as reaching bao on 192.168.10.8.
|
||||
# bao1 resolves its own name via the DC (verified: HTTP 200, tls ok).
|
||||
openbao_addr: "https://bao.ad.ddupan.top:{{ openbao_api_port }}"
|
||||
openbao_token: "{{ lookup('env', 'BAO_TOKEN') }}" # export BAO_TOKEN before running
|
||||
openbao_no_log: true # -e openbao_no_log=false to debug
|
||||
|
||||
# Reusable CLI environment (token-bearing → tasks using it set no_log).
|
||||
openbao_cli_env:
|
||||
BAO_ADDR: "{{ openbao_addr }}"
|
||||
BAO_CACERT: "{{ openbao_tls_dir }}/cert.pem"
|
||||
BAO_TOKEN: "{{ openbao_token }}"
|
||||
|
||||
# --- KV v2 (static agent secrets) ---
|
||||
openbao_kv_path: "kv"
|
||||
|
||||
# --- SSH certificate authority (short-lived agent certs) ---
|
||||
openbao_ssh_mount: "ssh-client-signer"
|
||||
# Principals allowed at sign time. "*" is permissive — real per-node scoping is the
|
||||
# `principals="..."` option on each target's `cert-authority` authorized_keys line.
|
||||
# Tighten to an explicit list (e.g. "nodeA,nodeB,ansible") for a second gate.
|
||||
openbao_ssh_allowed_users: "*"
|
||||
openbao_ssh_default_user: "ansible"
|
||||
openbao_ssh_cert_ttl: "5m"
|
||||
openbao_ssh_cert_max_ttl: "15m"
|
||||
openbao_ssh_ca_pub_path: "/etc/openbao/ssh-ca.pub"
|
||||
|
||||
# --- OIDC human login via Authelia ---
|
||||
openbao_oidc_discovery_url: "https://auth.ddupan.top"
|
||||
openbao_oidc_client_id: "openbao"
|
||||
openbao_oidc_client_secret: "{{ vault_openbao_oidc_client_secret | default('') }}"
|
||||
openbao_oidc_default_role: "admin"
|
||||
openbao_oidc_admin_group: "vault-admins" # AD group whose members get the admin policy
|
||||
openbao_oidc_redirect_uris:
|
||||
- "https://bao.ad.ddupan.top:8200/ui/vault/auth/oidc/oidc/callback"
|
||||
- "http://localhost:8250/oidc/callback"
|
||||
|
||||
# --- Kubernetes auth (in-cluster agents, e.g. hermes) — OFF until inputs are ready ---
|
||||
# Was opt-in (false) and had therefore never run — the backend did not exist at all
|
||||
# until 2026-07-28. Now ON by default: the External Secrets Operator authenticates
|
||||
# through it, so every cluster Secret depends on it. Prerequisites, both satisfied:
|
||||
# the cluster CA at openbao_k8s_ca_cert_file, and vault_openbao_k8s_reviewer_jwt.
|
||||
openbao_enable_k8s_auth: true
|
||||
# The k3s API server, as bao must reach it. NOT the address in your kubeconfig —
|
||||
# k3s writes https://127.0.0.1:6443 there, which is meaningless from another host.
|
||||
# Was 192.168.10.10 (wrong, nothing listens there); corrected 2026-07-28 after
|
||||
# verifying from bao1: curl --cacert /etc/openbao/k8s-ca.crt -> HTTP 401, tls ok.
|
||||
openbao_k8s_host: "https://192.168.10.127:6443"
|
||||
openbao_k8s_ca_cert_file: "/etc/openbao/k8s-ca.crt" # cluster CA, placed on the bao host
|
||||
openbao_k8s_reviewer_jwt: "{{ vault_openbao_k8s_reviewer_jwt | default('') }}"
|
||||
openbao_k8s_agent_sa: "ai-agent"
|
||||
openbao_k8s_agent_ns: "agents"
|
||||
|
||||
# --- PKI (internal TLS; can replace the self-signed listener cert) ---
|
||||
openbao_pki_mount: "pki"
|
||||
openbao_pki_ca_cn: "ddupan.top Internal CA"
|
||||
openbao_pki_max_lease_ttl: "87600h" # 10y
|
||||
openbao_pki_server_role: "bao-server"
|
||||
openbao_pki_allowed_domains: "ad.ddupan.top"
|
||||
# GATED: issue bao's own listener cert from this PKI and restart. Off by default so
|
||||
# bootstrap never risks the listener. Clients must then trust the PKI CA (printed out).
|
||||
openbao_pki_replace_listener_cert: false
|
||||
|
||||
# --- Raft snapshots (local timer; ship the dir off-box yourself) ---
|
||||
openbao_snapshot_dir: "/var/backups/openbao"
|
||||
openbao_snapshot_keep: 14
|
||||
openbao_snapshot_oncalendar: "*-*-* 02:00:00"
|
||||
|
||||
# ── Terraform / Ansible ownership boundary ───────────────────────────────
|
||||
# TRUE (default) = OpenBao's API-level CONFIGURATION lives in ../terraform:
|
||||
# mounts, roles, policies, auth mounts+roles, PKI URLs/ACME. This role then
|
||||
# does ONLY what Terraform cannot sensibly own:
|
||||
# * the daemon, TLS files, systemd, init/unseal (openbao_deploy role)
|
||||
# * ROOT-OF-TRUST KEY MATERIAL — the PKI root CA and the SSH CA signing key.
|
||||
# Terraform would treat drift on those as "regenerate", silently
|
||||
# invalidating every issued cert and every TrustedUserCAKeys line.
|
||||
# * the OIDC client SECRET (Terraform cannot read it back; managing it would
|
||||
# put plaintext in tfstate and cause a perpetual diff)
|
||||
# * the snapshot token + script + systemd timer (host-level, and a secret)
|
||||
#
|
||||
# Set FALSE only to bootstrap a brand-new instance entirely from Ansible, or to
|
||||
# fall back if Terraform is unavailable. Leaving it FALSE against an instance
|
||||
# Terraform manages makes the two overwrite each other on alternate runs.
|
||||
#
|
||||
# FRESH-INSTALL ORDER when true:
|
||||
# 1. ansible-playbook deploy-openbao.yml # daemon, TLS, systemd
|
||||
# 2. bao operator init / unseal # manual, PGP-wrapped
|
||||
# 3. terraform apply # mounts, roles, policies
|
||||
# 4. ansible-playbook bootstrap-openbao.yml # CA material, OIDC secret, snapshots
|
||||
openbao_config_managed_by_terraform: true
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
# Only fired by the gated PKI listener-cert swap.
|
||||
# NOTE: a Shamir-sealed node re-seals on restart — unseal it afterwards.
|
||||
# With transit auto-unseal it re-unseals automatically.
|
||||
- name: restart openbao
|
||||
ansible.builtin.systemd:
|
||||
name: openbao
|
||||
state: restarted
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
galaxy_info:
|
||||
role_name: openbao_bootstrap
|
||||
description: Configure a running OpenBao — engines (kv, ssh CA, pki), auth (oidc, kubernetes), policies, snapshots.
|
||||
min_ansible_version: "2.15"
|
||||
platforms:
|
||||
- name: Debian
|
||||
versions: [bookworm]
|
||||
- name: Ubuntu
|
||||
versions: [jammy, noble]
|
||||
dependencies: []
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
# Kubernetes auth — in-cluster agents (e.g. hermes) authenticate with their SA JWT.
|
||||
# External bao, so we must supply the cluster host, CA cert and a reviewer JWT.
|
||||
# Get them from the cluster:
|
||||
# kubectl -n agents create sa bao-reviewer
|
||||
# kubectl create clusterrolebinding bao-reviewer --clusterrole=system:auth-delegator \
|
||||
# --serviceaccount=agents:bao-reviewer
|
||||
# kubectl -n agents create token bao-reviewer --duration=87600h → openbao_k8s_reviewer_jwt
|
||||
# kubectl get cm kube-root-ca.crt -o jsonpath='{.data.ca\.crt}' > k8s-ca.crt → on the bao host
|
||||
|
||||
- name: Assert Kubernetes auth inputs are provided
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- openbao_k8s_reviewer_jwt | length > 0
|
||||
- openbao_k8s_host | length > 0
|
||||
fail_msg: "Set openbao_k8s_host and vault_openbao_k8s_reviewer_jwt, and place the cluster CA at openbao_k8s_ca_cert_file."
|
||||
|
||||
- name: Enable the Kubernetes auth method
|
||||
ansible.builtin.command: "bao auth enable kubernetes"
|
||||
environment: "{{ openbao_cli_env }}"
|
||||
register: k8s_enable
|
||||
changed_when: k8s_enable.rc == 0
|
||||
failed_when:
|
||||
- k8s_enable.rc != 0
|
||||
- "'already in use' not in (k8s_enable.stderr | default('')) + (k8s_enable.stdout | default(''))"
|
||||
no_log: "{{ openbao_no_log }}"
|
||||
|
||||
# RECONCILE ACTION — always reports "changed". `bao write` returns 0 whether or
|
||||
# not anything differed, and detecting a real diff would mean reading the config
|
||||
# back, which never returns token_reviewer_jwt. So a second run showing changed=2
|
||||
# for this file means "re-applied", NOT "drift was found". See CLAUDE.md on
|
||||
# idempotency being the acceptance test, and the exception for reconcile actions.
|
||||
- name: Configure the Kubernetes auth method
|
||||
ansible.builtin.command: >-
|
||||
bao write auth/kubernetes/config
|
||||
kubernetes_host={{ openbao_k8s_host }}
|
||||
kubernetes_ca_cert=@{{ openbao_k8s_ca_cert_file }}
|
||||
token_reviewer_jwt={{ openbao_k8s_reviewer_jwt }}
|
||||
disable_local_ca_jwt=true
|
||||
environment: "{{ openbao_cli_env }}"
|
||||
register: k8s_config
|
||||
changed_when: k8s_config.rc == 0
|
||||
no_log: true # carries the reviewer JWT
|
||||
|
||||
- name: Create/update the ai-agent Kubernetes role
|
||||
ansible.builtin.command: "bao write auth/kubernetes/role/ai-agent -"
|
||||
args:
|
||||
stdin: "{{ lookup('template', 'k8s-ai-agent-role.json.j2') }}"
|
||||
environment: "{{ openbao_cli_env }}"
|
||||
register: k8s_role
|
||||
changed_when: k8s_role.rc == 0
|
||||
no_log: "{{ openbao_no_log }}"
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
# OIDC auth via Authelia — human login (bao login -method=oidc / UI).
|
||||
# The Authelia 'openbao' client must already exist (authelia/values.yaml).
|
||||
|
||||
- name: Assert the OIDC client secret is provided
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- openbao_oidc_client_secret | length > 0
|
||||
fail_msg: "Set vault_openbao_oidc_client_secret (plaintext of the Authelia openbao client)."
|
||||
|
||||
- name: Enable the OIDC auth method
|
||||
ansible.builtin.command: "bao auth enable -path=oidc oidc"
|
||||
environment: "{{ openbao_cli_env }}"
|
||||
register: oidc_enable
|
||||
changed_when: oidc_enable.rc == 0
|
||||
failed_when:
|
||||
- oidc_enable.rc != 0
|
||||
- "'already in use' not in (oidc_enable.stderr | default('')) + (oidc_enable.stdout | default(''))"
|
||||
no_log: "{{ openbao_no_log }}"
|
||||
# Terraform owns this (../terraform). See openbao_config_managed_by_terraform.
|
||||
when: not openbao_config_managed_by_terraform | bool
|
||||
|
||||
- name: Configure the OIDC provider (Authelia)
|
||||
ansible.builtin.command: >-
|
||||
bao write auth/oidc/config
|
||||
oidc_discovery_url={{ openbao_oidc_discovery_url }}
|
||||
oidc_client_id={{ openbao_oidc_client_id }}
|
||||
oidc_client_secret={{ openbao_oidc_client_secret }}
|
||||
default_role={{ openbao_oidc_default_role }}
|
||||
environment: "{{ openbao_cli_env }}"
|
||||
register: oidc_config
|
||||
changed_when: oidc_config.rc == 0
|
||||
no_log: true # carries the client secret — always hidden
|
||||
|
||||
- name: Create/update the admin OIDC role (restricted to the admin AD group)
|
||||
ansible.builtin.command: "bao write auth/oidc/role/{{ openbao_oidc_default_role }} -"
|
||||
args:
|
||||
stdin: "{{ lookup('template', 'oidc-admin-role.json.j2') }}"
|
||||
environment: "{{ openbao_cli_env }}"
|
||||
register: oidc_role
|
||||
changed_when: oidc_role.rc == 0
|
||||
no_log: "{{ openbao_no_log }}"
|
||||
# Terraform owns this (../terraform). See openbao_config_managed_by_terraform.
|
||||
when: not openbao_config_managed_by_terraform | bool
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
# KV v2 engine for static agent secrets.
|
||||
|
||||
- name: Enable KV v2 at {{ openbao_kv_path }}/
|
||||
ansible.builtin.command: "bao secrets enable -path={{ openbao_kv_path }} -version=2 kv"
|
||||
environment: "{{ openbao_cli_env }}"
|
||||
register: kv_enable
|
||||
changed_when: kv_enable.rc == 0
|
||||
failed_when:
|
||||
- kv_enable.rc != 0
|
||||
- "'already in use' not in (kv_enable.stderr | default('')) + (kv_enable.stdout | default(''))"
|
||||
no_log: "{{ openbao_no_log }}"
|
||||
# Terraform owns this (../terraform). See openbao_config_managed_by_terraform.
|
||||
when: not openbao_config_managed_by_terraform | bool
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
# Orchestrator. Preflight, then each concern as its own tagged task file.
|
||||
|
||||
- name: Assert a bao token is provided
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- openbao_token | length > 0
|
||||
fail_msg: >-
|
||||
No token. Decrypt the PGP-wrapped root token and export it:
|
||||
echo "<b64>" | base64 -d | gpg -dq → export BAO_TOKEN=<plaintext>
|
||||
|
||||
- name: Preflight — bao is reachable, initialized and UNSEALED
|
||||
ansible.builtin.command: "bao status -format=json"
|
||||
environment:
|
||||
BAO_ADDR: "{{ openbao_addr }}"
|
||||
BAO_CACERT: "{{ openbao_tls_dir }}/cert.pem"
|
||||
register: bao_status
|
||||
changed_when: false
|
||||
failed_when: bao_status.rc != 0 # 0 = unsealed; 2 = sealed → unseal first
|
||||
|
||||
- name: Preflight — the provided token is valid
|
||||
ansible.builtin.command: "bao token lookup"
|
||||
environment: "{{ openbao_cli_env }}"
|
||||
register: bao_tok
|
||||
changed_when: false
|
||||
failed_when: bao_tok.rc != 0
|
||||
no_log: "{{ openbao_no_log }}"
|
||||
|
||||
- name: Policies
|
||||
# Terraform owns ALL policies (../terraform/policies.tf + policies/*.hcl).
|
||||
# Kept for a Terraform-less bootstrap; see openbao_config_managed_by_terraform.
|
||||
ansible.builtin.import_tasks: policies.yml
|
||||
tags: [policies]
|
||||
when: not openbao_config_managed_by_terraform | bool
|
||||
|
||||
- name: KV v2 engine
|
||||
ansible.builtin.import_tasks: kv.yml
|
||||
tags: [kv]
|
||||
|
||||
- name: SSH certificate authority
|
||||
ansible.builtin.import_tasks: ssh_ca.yml
|
||||
tags: [ssh_ca]
|
||||
|
||||
- name: OIDC auth (Authelia)
|
||||
ansible.builtin.import_tasks: auth_oidc.yml
|
||||
tags: [oidc]
|
||||
|
||||
- name: Kubernetes auth
|
||||
ansible.builtin.import_tasks: auth_kubernetes.yml
|
||||
tags: [k8s]
|
||||
when: openbao_enable_k8s_auth | bool
|
||||
|
||||
- name: PKI engine
|
||||
ansible.builtin.import_tasks: pki.yml
|
||||
tags: [pki]
|
||||
|
||||
- name: Raft snapshot timer
|
||||
ansible.builtin.import_tasks: snapshots.yml
|
||||
tags: [snapshots]
|
||||
@@ -0,0 +1,106 @@
|
||||
---
|
||||
# PKI engine — internal TLS CA. Optionally re-issues bao's own listener cert.
|
||||
|
||||
- name: Enable the PKI secrets engine at {{ openbao_pki_mount }}/
|
||||
ansible.builtin.command: "bao secrets enable -path={{ openbao_pki_mount }} pki"
|
||||
environment: "{{ openbao_cli_env }}"
|
||||
register: pki_enable
|
||||
changed_when: pki_enable.rc == 0
|
||||
failed_when:
|
||||
- pki_enable.rc != 0
|
||||
- "'already in use' not in (pki_enable.stderr | default('')) + (pki_enable.stdout | default(''))"
|
||||
no_log: "{{ openbao_no_log }}"
|
||||
# Terraform owns this (../terraform). See openbao_config_managed_by_terraform.
|
||||
when: not openbao_config_managed_by_terraform | bool
|
||||
|
||||
- name: Tune PKI max lease TTL
|
||||
ansible.builtin.command: "bao secrets tune -max-lease-ttl={{ openbao_pki_max_lease_ttl }} {{ openbao_pki_mount }}"
|
||||
environment: "{{ openbao_cli_env }}"
|
||||
register: pki_tune
|
||||
changed_when: pki_tune.rc == 0
|
||||
no_log: "{{ openbao_no_log }}"
|
||||
# Terraform owns this (../terraform). See openbao_config_managed_by_terraform.
|
||||
when: not openbao_config_managed_by_terraform | bool
|
||||
|
||||
- name: Check whether the root CA already exists
|
||||
ansible.builtin.command: "bao read -field=certificate {{ openbao_pki_mount }}/cert/ca"
|
||||
environment: "{{ openbao_cli_env }}"
|
||||
register: pki_ca_check
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
no_log: "{{ openbao_no_log }}"
|
||||
|
||||
- name: Generate the internal root CA (once)
|
||||
ansible.builtin.command: >-
|
||||
bao write {{ openbao_pki_mount }}/root/generate/internal
|
||||
common_name="{{ openbao_pki_ca_cn }}" ttl={{ openbao_pki_max_lease_ttl }}
|
||||
environment: "{{ openbao_cli_env }}"
|
||||
when: "'BEGIN CERTIFICATE' not in (pki_ca_check.stdout | default(''))"
|
||||
register: pki_root
|
||||
changed_when: pki_root.rc == 0
|
||||
no_log: "{{ openbao_no_log }}"
|
||||
|
||||
- name: Configure issuing/CRL URLs
|
||||
ansible.builtin.command: >-
|
||||
bao write {{ openbao_pki_mount }}/config/urls
|
||||
issuing_certificates={{ openbao_addr }}/v1/{{ openbao_pki_mount }}/ca
|
||||
crl_distribution_points={{ openbao_addr }}/v1/{{ openbao_pki_mount }}/crl
|
||||
environment: "{{ openbao_cli_env }}"
|
||||
register: pki_urls
|
||||
changed_when: pki_urls.rc == 0
|
||||
no_log: "{{ openbao_no_log }}"
|
||||
# Terraform owns this (../terraform). See openbao_config_managed_by_terraform.
|
||||
when: not openbao_config_managed_by_terraform | bool
|
||||
|
||||
- name: Create/update the server-cert role
|
||||
ansible.builtin.command: >-
|
||||
bao write {{ openbao_pki_mount }}/roles/{{ openbao_pki_server_role }}
|
||||
allowed_domains={{ openbao_pki_allowed_domains }}
|
||||
allow_subdomains=true allow_ip_sans=true max_ttl=8760h
|
||||
environment: "{{ openbao_cli_env }}"
|
||||
register: pki_role
|
||||
changed_when: pki_role.rc == 0
|
||||
no_log: "{{ openbao_no_log }}"
|
||||
# Terraform owns this (../terraform). See openbao_config_managed_by_terraform.
|
||||
when: not openbao_config_managed_by_terraform | bool
|
||||
|
||||
# --- GATED: replace the self-signed listener cert with a PKI-issued one ----------
|
||||
- name: Replace bao's listener cert from PKI
|
||||
when: openbao_pki_replace_listener_cert | bool
|
||||
block:
|
||||
- name: Issue a listener certificate
|
||||
ansible.builtin.command: >-
|
||||
bao write -format=json {{ openbao_pki_mount }}/issue/{{ openbao_pki_server_role }}
|
||||
common_name={{ openbao_fqdn }}
|
||||
ip_sans={{ openbao_lan_ip }},127.0.0.1
|
||||
ttl=8760h
|
||||
environment: "{{ openbao_cli_env }}"
|
||||
register: pki_issue
|
||||
changed_when: true
|
||||
no_log: true
|
||||
|
||||
- name: Install listener private key
|
||||
ansible.builtin.copy:
|
||||
content: "{{ (pki_issue.stdout | from_json).data.private_key }}\n"
|
||||
dest: "{{ openbao_tls_dir }}/key.pem"
|
||||
owner: openbao
|
||||
group: openbao
|
||||
mode: "0640"
|
||||
no_log: true
|
||||
notify: restart openbao
|
||||
|
||||
- name: Install listener certificate (leaf + issuing CA chain)
|
||||
ansible.builtin.copy:
|
||||
content: "{{ (pki_issue.stdout | from_json).data.certificate }}\n{{ (pki_issue.stdout | from_json).data.issuing_ca }}\n"
|
||||
dest: "{{ openbao_tls_dir }}/cert.pem"
|
||||
owner: openbao
|
||||
group: openbao
|
||||
mode: "0644"
|
||||
notify: restart openbao
|
||||
|
||||
- name: Print the PKI root CA (clients must trust this)
|
||||
ansible.builtin.debug:
|
||||
msg: >-
|
||||
Listener now uses a PKI-issued cert. Distribute the root CA to clients:
|
||||
bao read -field=certificate {{ openbao_pki_mount }}/cert/ca
|
||||
(NOTE: a Shamir-sealed node re-seals on restart — unseal it afterwards.)
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
# Policies. Declaratively applied each run (bao policy write is an idempotent overwrite).
|
||||
|
||||
- name: Write the ai-agent-ssh policy (sign SSH certs, nothing else)
|
||||
ansible.builtin.command: "bao policy write ai-agent-ssh -"
|
||||
args:
|
||||
stdin: "{{ lookup('template', 'ai-agent-ssh-policy.hcl.j2') }}"
|
||||
environment: "{{ openbao_cli_env }}"
|
||||
register: pol_agent
|
||||
changed_when: pol_agent.rc == 0
|
||||
no_log: "{{ openbao_no_log }}"
|
||||
|
||||
- name: Write the admin policy (human OIDC logins)
|
||||
ansible.builtin.command: "bao policy write admin -"
|
||||
args:
|
||||
stdin: "{{ lookup('template', 'admin-policy.hcl.j2') }}"
|
||||
environment: "{{ openbao_cli_env }}"
|
||||
register: pol_admin
|
||||
changed_when: pol_admin.rc == 0
|
||||
no_log: "{{ openbao_no_log }}"
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
# Automated Raft snapshots via a systemd timer + a scoped periodic token.
|
||||
|
||||
- name: Write the snapshot policy (read raft snapshots only)
|
||||
ansible.builtin.command: "bao policy write snapshot -"
|
||||
args:
|
||||
stdin: "{{ lookup('template', 'snapshot-policy.hcl.j2') }}"
|
||||
environment: "{{ openbao_cli_env }}"
|
||||
register: snap_pol
|
||||
changed_when: snap_pol.rc == 0
|
||||
no_log: "{{ openbao_no_log }}"
|
||||
# Terraform owns this (../terraform). See openbao_config_managed_by_terraform.
|
||||
when: not openbao_config_managed_by_terraform | bool
|
||||
|
||||
- name: Check whether the snapshot token already exists
|
||||
ansible.builtin.stat:
|
||||
path: /etc/openbao/snapshot.token
|
||||
register: snap_tok_stat
|
||||
|
||||
- name: Create a periodic snapshot token (once)
|
||||
ansible.builtin.command: "bao token create -policy=snapshot -period=768h -orphan -field=token"
|
||||
environment: "{{ openbao_cli_env }}"
|
||||
register: snap_tok_new
|
||||
when: not snap_tok_stat.stat.exists
|
||||
changed_when: snap_tok_new.rc == 0
|
||||
no_log: true
|
||||
|
||||
- name: Store the snapshot token (root-only)
|
||||
ansible.builtin.copy:
|
||||
content: "{{ snap_tok_new.stdout }}"
|
||||
dest: /etc/openbao/snapshot.token
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0600"
|
||||
when: not snap_tok_stat.stat.exists
|
||||
no_log: true
|
||||
|
||||
- name: Ensure the snapshot output directory exists
|
||||
ansible.builtin.file:
|
||||
path: "{{ openbao_snapshot_dir }}"
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0700"
|
||||
|
||||
- name: Install the snapshot script
|
||||
ansible.builtin.template:
|
||||
src: bao-snapshot.sh.j2
|
||||
dest: /usr/local/bin/bao-snapshot.sh
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
|
||||
- name: Install the snapshot systemd service + timer
|
||||
ansible.builtin.template:
|
||||
src: "{{ item }}.j2"
|
||||
dest: "/etc/systemd/system/{{ item }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
loop:
|
||||
- openbao-snapshot.service
|
||||
- openbao-snapshot.timer
|
||||
|
||||
- name: Enable and start the snapshot timer
|
||||
ansible.builtin.systemd:
|
||||
name: openbao-snapshot.timer
|
||||
state: started
|
||||
enabled: true
|
||||
daemon_reload: true
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
# SSH certificate authority: sign short-lived client certs for the ai-agent role.
|
||||
|
||||
- name: Enable the SSH secrets engine at {{ openbao_ssh_mount }}/
|
||||
ansible.builtin.command: "bao secrets enable -path={{ openbao_ssh_mount }} ssh"
|
||||
environment: "{{ openbao_cli_env }}"
|
||||
register: ssh_enable
|
||||
changed_when: ssh_enable.rc == 0
|
||||
failed_when:
|
||||
- ssh_enable.rc != 0
|
||||
- "'already in use' not in (ssh_enable.stderr | default('')) + (ssh_enable.stdout | default(''))"
|
||||
no_log: "{{ openbao_no_log }}"
|
||||
# Terraform owns this (../terraform). See openbao_config_managed_by_terraform.
|
||||
when: not openbao_config_managed_by_terraform | bool
|
||||
|
||||
- name: Check whether the SSH CA signing key already exists
|
||||
ansible.builtin.command: "bao read -field=public_key {{ openbao_ssh_mount }}/config/ca"
|
||||
environment: "{{ openbao_cli_env }}"
|
||||
register: ssh_ca_check
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
no_log: "{{ openbao_no_log }}"
|
||||
|
||||
- name: Generate the SSH CA signing key (once)
|
||||
ansible.builtin.command: "bao write {{ openbao_ssh_mount }}/config/ca generate_signing_key=true"
|
||||
environment: "{{ openbao_cli_env }}"
|
||||
when: ssh_ca_check.rc != 0
|
||||
register: ssh_ca_gen
|
||||
changed_when: ssh_ca_gen.rc == 0
|
||||
no_log: "{{ openbao_no_log }}"
|
||||
|
||||
- name: Create/update the ai-agent signing role
|
||||
ansible.builtin.command: "bao write {{ openbao_ssh_mount }}/roles/ai-agent -"
|
||||
args:
|
||||
stdin: "{{ lookup('template', 'ssh-ai-agent-role.json.j2') }}"
|
||||
environment: "{{ openbao_cli_env }}"
|
||||
register: ssh_role
|
||||
changed_when: ssh_role.rc == 0
|
||||
no_log: "{{ openbao_no_log }}"
|
||||
# Terraform owns this (../terraform). See openbao_config_managed_by_terraform.
|
||||
when: not openbao_config_managed_by_terraform | bool
|
||||
|
||||
- name: Fetch the SSH CA public key
|
||||
ansible.builtin.command: "bao read -field=public_key {{ openbao_ssh_mount }}/config/ca"
|
||||
environment: "{{ openbao_cli_env }}"
|
||||
register: ssh_ca_public
|
||||
changed_when: false
|
||||
no_log: "{{ openbao_no_log }}"
|
||||
|
||||
- name: Save the SSH CA public key on the bao host (for cert-authority lines)
|
||||
ansible.builtin.copy:
|
||||
content: "{{ ssh_ca_public.stdout }}\n"
|
||||
dest: "{{ openbao_ssh_ca_pub_path }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
|
||||
- name: Show the cert-authority line for no-root target hosts
|
||||
ansible.builtin.debug:
|
||||
msg: >-
|
||||
Add to ~/.ssh/authorized_keys on each target (scope per node):
|
||||
cert-authority,principals="<node>",restrict,pty {{ ssh_ca_public.stdout }}
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
# Smoke tests — ansible-playbook bootstrap-openbao.yml --tags verify
|
||||
# Confirms the engines/auth are mounted and the SSH CA actually signs.
|
||||
|
||||
- name: Secrets engines are mounted
|
||||
ansible.builtin.command: "bao secrets list -format=json"
|
||||
environment: "{{ openbao_cli_env }}"
|
||||
register: v_secrets
|
||||
changed_when: false
|
||||
no_log: "{{ openbao_no_log }}"
|
||||
|
||||
- name: Auth methods are enabled
|
||||
ansible.builtin.command: "bao auth list -format=json"
|
||||
environment: "{{ openbao_cli_env }}"
|
||||
register: v_auth
|
||||
changed_when: false
|
||||
no_log: "{{ openbao_no_log }}"
|
||||
|
||||
- name: Assert expected mounts exist
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "'{{ openbao_kv_path }}/' in (v_secrets.stdout | from_json)"
|
||||
- "'{{ openbao_ssh_mount }}/' in (v_secrets.stdout | from_json)"
|
||||
- "'{{ openbao_pki_mount }}/' in (v_secrets.stdout | from_json)"
|
||||
- "'oidc/' in (v_auth.stdout | from_json)"
|
||||
fail_msg: "Expected mounts missing — check the bootstrap run."
|
||||
|
||||
- name: SSH CA signs a throwaway key (end-to-end)
|
||||
ansible.builtin.shell: >-
|
||||
set -o pipefail;
|
||||
ssh-keygen -t ed25519 -f /tmp/bao-verify -N '' -q -C verify <<<y >/dev/null 2>&1;
|
||||
bao write -field=signed_key {{ openbao_ssh_mount }}/sign/ai-agent
|
||||
public_key=@/tmp/bao-verify.pub valid_principals={{ openbao_ssh_default_user }};
|
||||
rm -f /tmp/bao-verify /tmp/bao-verify.pub
|
||||
args:
|
||||
executable: /bin/bash
|
||||
environment: "{{ openbao_cli_env }}"
|
||||
register: v_sign
|
||||
changed_when: false
|
||||
failed_when: "'ssh-ed25519-cert' not in (v_sign.stdout | default('')) and 'ssh-rsa-cert' not in (v_sign.stdout | default(''))"
|
||||
no_log: "{{ openbao_no_log }}"
|
||||
|
||||
- name: Report
|
||||
ansible.builtin.debug:
|
||||
msg: "Bootstrap verified: kv/ ssh-client-signer/ pki/ + oidc auth mounted; SSH CA signed a test cert."
|
||||
@@ -0,0 +1,6 @@
|
||||
# {{ ansible_managed }}
|
||||
# Broad admin for human OIDC logins (mapped from the {{ openbao_oidc_admin_group }} AD group).
|
||||
# Homelab-broad on purpose; scope down to specific mounts if you want least privilege.
|
||||
path "*" {
|
||||
capabilities = ["create", "read", "update", "delete", "list", "sudo"]
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
# {{ ansible_managed }}
|
||||
# The AI agent may sign short-lived SSH client certs for the ai-agent role — nothing else.
|
||||
path "{{ openbao_ssh_mount }}/sign/ai-agent" {
|
||||
capabilities = ["create", "update"]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
# {{ ansible_managed }}
|
||||
# Take a Raft snapshot and prune old ones. Ship {{ openbao_snapshot_dir }} off-box
|
||||
# separately (rsync/restic/scp) — a snapshot on the same host is not a backup.
|
||||
set -euo pipefail
|
||||
|
||||
export BAO_ADDR="{{ openbao_addr }}"
|
||||
export BAO_CACERT="{{ openbao_tls_dir }}/cert.pem"
|
||||
BAO_TOKEN="$(cat /etc/openbao/snapshot.token)"
|
||||
export BAO_TOKEN
|
||||
|
||||
dir="{{ openbao_snapshot_dir }}"
|
||||
stamp="$(date +%Y%m%d-%H%M%S)"
|
||||
out="${dir}/openbao-${stamp}.snap"
|
||||
|
||||
bao operator raft snapshot save "${out}"
|
||||
chmod 600 "${out}"
|
||||
|
||||
# Retention: keep the newest {{ openbao_snapshot_keep }}.
|
||||
ls -1t "${dir}"/openbao-*.snap 2>/dev/null | tail -n +{{ openbao_snapshot_keep + 1 }} | xargs -r rm -f
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"bound_service_account_names": "{{ openbao_k8s_agent_sa }}",
|
||||
"bound_service_account_namespaces": "{{ openbao_k8s_agent_ns }}",
|
||||
"token_policies": ["ai-agent-ssh"],
|
||||
"token_ttl": "10m"
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"role_type": "oidc",
|
||||
"user_claim": "preferred_username",
|
||||
"groups_claim": "groups",
|
||||
"bound_audiences": "{{ openbao_oidc_client_id }}",
|
||||
"bound_claims": { "groups": "{{ openbao_oidc_admin_group }}" },
|
||||
"oidc_scopes": ["profile", "email", "groups"],
|
||||
"allowed_redirect_uris": {{ openbao_oidc_redirect_uris | to_json }},
|
||||
"token_policies": ["admin"],
|
||||
"token_ttl": "1h"
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
# {{ ansible_managed }}
|
||||
[Unit]
|
||||
Description=OpenBao Raft snapshot
|
||||
After=openbao.service
|
||||
Wants=openbao.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/bin/bao-snapshot.sh
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
# {{ ansible_managed }}
|
||||
[Unit]
|
||||
Description=OpenBao Raft snapshot timer
|
||||
|
||||
[Timer]
|
||||
OnCalendar={{ openbao_snapshot_oncalendar }}
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
# {{ ansible_managed }}
|
||||
# Read-only access to take Raft snapshots — used by the snapshot timer's token.
|
||||
path "sys/storage/raft/snapshot" {
|
||||
capabilities = ["read"]
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"key_type": "ca",
|
||||
"allow_user_certificates": true,
|
||||
"allowed_users": "{{ openbao_ssh_allowed_users }}",
|
||||
"default_user": "{{ openbao_ssh_default_user }}",
|
||||
"allowed_extensions": "",
|
||||
"default_extensions": { "permit-pty": "" },
|
||||
"ttl": "{{ openbao_ssh_cert_ttl }}",
|
||||
"max_ttl": "{{ openbao_ssh_cert_max_ttl }}"
|
||||
}
|
||||
Reference in New Issue
Block a user