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,5 @@
|
||||
*.tfstate
|
||||
*.tfstate.backup
|
||||
*.tfplan
|
||||
.terraform/
|
||||
.terraform.lock.hcl
|
||||
@@ -0,0 +1,61 @@
|
||||
# OIDC auth (human logins via Authelia).
|
||||
#
|
||||
# SCOPE NOTE: only the auth MOUNT and the ROLE are managed here. The backend
|
||||
# CONFIG (auth/oidc/config) is intentionally left to ../ansible because it
|
||||
# carries `oidc_client_secret`. Terraform cannot read that value back from the
|
||||
# API, so managing it here would (a) force the plaintext secret into
|
||||
# terraform.tfstate and (b) produce a perpetual diff. Ansible already holds it
|
||||
# in an ansible-vault file.
|
||||
resource "vault_auth_backend" "oidc" {
|
||||
type = "oidc"
|
||||
path = "oidc"
|
||||
}
|
||||
|
||||
resource "vault_jwt_auth_backend_role" "admin" {
|
||||
backend = vault_auth_backend.oidc.path
|
||||
role_name = "admin"
|
||||
role_type = "oidc"
|
||||
|
||||
user_claim = "preferred_username"
|
||||
bound_audiences = ["openbao"]
|
||||
# Only members of the AD group vault-admins get the admin policy.
|
||||
bound_claims = {
|
||||
groups = "vault-admins"
|
||||
}
|
||||
groups_claim = "groups"
|
||||
oidc_scopes = ["profile", "email", "groups"]
|
||||
|
||||
allowed_redirect_uris = [
|
||||
"https://bao.ad.ddupan.top:8200/ui/vault/auth/oidc/oidc/callback", # web UI
|
||||
"http://localhost:8250/oidc/callback", # CLI login
|
||||
]
|
||||
|
||||
token_policies = ["admin"]
|
||||
token_ttl = 3600
|
||||
token_max_ttl = 0
|
||||
}
|
||||
|
||||
# ── Kubernetes auth: the External Secrets Operator ─────────────────────────
|
||||
# The BACKEND itself (auth/kubernetes/config) is NOT managed here — it needs the
|
||||
# cluster CA and a long-lived reviewer JWT, which is key material Terraform must
|
||||
# not hold. That stays in ../ansible (openbao_bootstrap/tasks/auth_kubernetes.yml),
|
||||
# per the ownership split in CLAUDE.md. A ROLE is pure API config, so it lives here.
|
||||
#
|
||||
# This is what makes the operator credential-less: it presents its own
|
||||
# ServiceAccount JWT, bao verifies it via the cluster's TokenReview API, and
|
||||
# returns a short-lived token carrying only the external-secrets policy.
|
||||
resource "vault_kubernetes_auth_backend_role" "external_secrets" {
|
||||
backend = "kubernetes"
|
||||
role_name = "external-secrets"
|
||||
|
||||
# Must match serviceAccount.name / namespace in
|
||||
# ../../../platform/external-secrets/values.yaml and the serviceAccountRef in
|
||||
# ../../../platform/external-secrets/clustersecretstore.yaml.
|
||||
bound_service_account_names = ["external-secrets"]
|
||||
bound_service_account_namespaces = ["external-secrets"]
|
||||
|
||||
token_policies = [vault_policy.external_secrets.name]
|
||||
# Short-lived on purpose: ESO re-authenticates as needed, so there is no value
|
||||
# in a long TTL and every extra hour is a longer-lived credential in memory.
|
||||
token_ttl = 3600
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
# Remote state in SeaweedFS S3, on the LAN.
|
||||
#
|
||||
# WHY remote at all: local state means the only copy lives on this laptop, which is
|
||||
# also the k3s node, the NFS server and the libvirt host — i.e. the single point of
|
||||
# failure. It also cannot be locked, so two concurrent applies silently corrupt it.
|
||||
#
|
||||
# WHY s3.ad.ddupan.top and NOT obj.ddupan.top: the public name resolves to
|
||||
# Cloudflare and hairpins through the WAN. On 2026-07-28 that path was blackholed
|
||||
# for hours by a dead VPN tunnel. State must be reachable when the WAN is not —
|
||||
# it is what you need DURING an incident. See ../../../apps/seaweedfs/httproute-s3.yaml.
|
||||
#
|
||||
# CREDENTIALS are not in this file. Export them before running terraform:
|
||||
# export AWS_ACCESS_KEY_ID=$(bao kv get -field=... kv/k8s/seaweedfs-s3) # see README
|
||||
# export AWS_SECRET_ACCESS_KEY=...
|
||||
# The `terraform` S3 identity is scoped to this bucket only — it deliberately
|
||||
# cannot create buckets or read anything else in the store.
|
||||
terraform {
|
||||
backend "s3" {
|
||||
bucket = "tfstate"
|
||||
key = "openbao/terraform.tfstate"
|
||||
|
||||
endpoints = {
|
||||
s3 = "https://s3.ad.ddupan.top"
|
||||
}
|
||||
|
||||
# SeaweedFS is not AWS: it has no regions, no IAM, no metadata service and no
|
||||
# account IDs, so every AWS-specific validation has to be skipped or the
|
||||
# provider fails before it ever talks to the endpoint.
|
||||
region = "us-east-1"
|
||||
use_path_style = true
|
||||
skip_credentials_validation = true
|
||||
skip_metadata_api_check = true
|
||||
skip_region_validation = true
|
||||
skip_requesting_account_id = true
|
||||
|
||||
# Native S3 locking (Terraform >= 1.10; this repo runs 1.15). Writes a
|
||||
# .tflock object alongside the state — no DynamoDB table needed.
|
||||
use_lockfile = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
# Adopt the already-running OpenBao configuration into Terraform state.
|
||||
#
|
||||
# These use TF 1.5 `import` blocks rather than `terraform import` CLI calls so
|
||||
# the adoption is reviewable: `terraform plan` shows exactly what Terraform
|
||||
# thinks differs from reality BEFORE anything is written.
|
||||
#
|
||||
# The plan should be "N to import, 0 to destroy". Anything proposing a DESTROY
|
||||
# or a replace means the HCL does not match the live object — fix the HCL, never
|
||||
# apply through it. Destroying the pki mount would take the root CA with it.
|
||||
#
|
||||
# Once applied, these blocks are inert and can be deleted.
|
||||
|
||||
import {
|
||||
to = vault_mount.kv
|
||||
id = "kv"
|
||||
}
|
||||
|
||||
import {
|
||||
to = vault_mount.pki
|
||||
id = "pki"
|
||||
}
|
||||
|
||||
import {
|
||||
to = vault_mount.ssh_client_signer
|
||||
id = "ssh-client-signer"
|
||||
}
|
||||
|
||||
import {
|
||||
to = vault_pki_secret_backend_role.bao_server
|
||||
id = "pki/roles/bao-server"
|
||||
}
|
||||
|
||||
import {
|
||||
to = vault_ssh_secret_backend_role.ai_agent
|
||||
id = "ssh-client-signer/roles/ai-agent"
|
||||
}
|
||||
|
||||
import {
|
||||
# NOTE: vault_auth_backend imports by the BARE path ("oidc"), not the
|
||||
# API-prefixed "auth/oidc" — the latter gives "Cannot import non-existent
|
||||
# remote object". The ROLE below does use the full path, which is the
|
||||
# inconsistency that makes this easy to get wrong.
|
||||
to = vault_auth_backend.oidc
|
||||
id = "oidc"
|
||||
}
|
||||
|
||||
import {
|
||||
to = vault_jwt_auth_backend_role.admin
|
||||
id = "auth/oidc/role/admin"
|
||||
}
|
||||
|
||||
import {
|
||||
to = vault_policy.admin
|
||||
id = "admin"
|
||||
}
|
||||
|
||||
import {
|
||||
to = vault_policy.ai_agent_ssh
|
||||
id = "ai-agent-ssh"
|
||||
}
|
||||
|
||||
import {
|
||||
to = vault_policy.snapshot
|
||||
id = "snapshot"
|
||||
}
|
||||
|
||||
import {
|
||||
to = vault_pki_secret_backend_config_urls.this
|
||||
id = "pki/config/urls"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
# Secrets engines. IMPORTED from the running instance (see imports.tf) — these
|
||||
# were originally created by ../ansible (role openbao_bootstrap).
|
||||
#
|
||||
# NOT MANAGED HERE, DELIBERATELY:
|
||||
# * the PKI ROOT CA (pki/root/generate)
|
||||
# * the SSH CA keypair (ssh-client-signer/config/ca)
|
||||
# Both are root-of-trust material. A Terraform resource for them would treat any
|
||||
# drift as "regenerate", which would silently invalidate every issued cert and
|
||||
# every trusted SSH CA line on every host. They stay one-time Ansible bootstrap.
|
||||
|
||||
resource "vault_mount" "kv" {
|
||||
path = "kv"
|
||||
type = "kv"
|
||||
options = { version = "2" }
|
||||
}
|
||||
|
||||
resource "vault_mount" "pki" {
|
||||
path = "pki"
|
||||
type = "pki"
|
||||
max_lease_ttl_seconds = 315360000 # 10y — must exceed the root CA's own lifetime
|
||||
|
||||
# Required by ACME: OpenBao strips response headers not listed here, and
|
||||
# clients fail at the "new nonce" step without them. This is the ONE
|
||||
# intentional change in the import plan.
|
||||
allowed_response_headers = ["Replay-Nonce", "Link", "Location"]
|
||||
}
|
||||
|
||||
resource "vault_mount" "ssh_client_signer" {
|
||||
path = "ssh-client-signer"
|
||||
type = "ssh"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
output "acme_directory_url" {
|
||||
value = "${var.bao_address}/v1/${var.pki_mount}/roles/${var.acme_role}/acme/directory"
|
||||
description = <<-EOT
|
||||
Point ACME clients here. Example, on dc1 (binds :80 only during renewal):
|
||||
lego --server <this> --domains dc1.ad.ddupan.top --email [email protected] --http run
|
||||
Clients must already trust the internal CA, or pass its PEM to the client.
|
||||
EOT
|
||||
}
|
||||
|
||||
output "acme_enabled" {
|
||||
value = vault_pki_secret_backend_config_acme.this.enabled
|
||||
description = "Whether the ACME directory is currently serving."
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
# ── issuing role ──────────────────────────────────────────────────────────
|
||||
# Caps what ACME (and direct issuance) may mint. dc1's LDAPS cert comes from here.
|
||||
resource "vault_pki_secret_backend_role" "bao_server" {
|
||||
backend = vault_mount.pki.path
|
||||
name = "bao-server"
|
||||
|
||||
allowed_domains = ["ad.ddupan.top"]
|
||||
allow_subdomains = true
|
||||
allow_bare_domains = false
|
||||
allow_glob_domains = false
|
||||
allow_any_name = false
|
||||
allow_ip_sans = true # dc1's cert carries IP:192.168.10.5
|
||||
server_flag = true
|
||||
client_flag = true
|
||||
key_type = "rsa"
|
||||
key_bits = 2048
|
||||
max_ttl = 31536000 # 1y
|
||||
# 60d. NOT 0: ttl=0 falls back to the system default of 768h (32 days), which
|
||||
# is what dc1's cert was getting. That is fine for lego (samba_ad_acme renews at
|
||||
# 10 days left) but breaks Proxmox: PVE's renewal threshold is hardcoded at "30
|
||||
# days to expiry" (PVE/API2/ACME.pm), so a 32-day cert renews every ~2 days and
|
||||
# restarts pveproxy each time. 60d leaves PVE a full 30-day retry window -- which
|
||||
# matters given the flaky WAN -- and stays well under OpenBao's 90d ACME cap.
|
||||
ttl = 5184000 # 60d
|
||||
use_csr_common_name = true
|
||||
}
|
||||
|
||||
# ── cluster paths ─────────────────────────────────────────────────────────
|
||||
# ACME directory/order URLs are built from these and embedded in issued certs as
|
||||
# AIA URLs, so they must be reachable by clients exactly as written. Uses the
|
||||
# public hostname (real Let's Encrypt cert via the openbao_acme Ansible role),
|
||||
# not the bare IP.
|
||||
resource "vault_pki_secret_backend_config_cluster" "this" {
|
||||
backend = vault_mount.pki.path
|
||||
path = "${var.bao_address}/v1/${vault_mount.pki.path}"
|
||||
aia_path = "${var.bao_address}/v1/${vault_mount.pki.path}"
|
||||
}
|
||||
|
||||
# ── ACME ──────────────────────────────────────────────────────────────────
|
||||
# WHY: dc1's LDAPS cert was hand-issued 2026-07-25 and expires 2027-07-25 with
|
||||
# nothing to renew it. If it lapses, Authelia loses its LDAPS backend and every
|
||||
# SSO consumer fails at once. ACME takes the human out of that loop.
|
||||
resource "vault_pki_secret_backend_config_acme" "this" {
|
||||
backend = vault_mount.pki.path
|
||||
enabled = var.acme_enabled
|
||||
|
||||
# SECURITY: OpenBao's default is "sign-verbatim" — it would issue ANY name a
|
||||
# client asks for, meaning anything able to reach bao could mint a cert for
|
||||
# dc1.ad.ddupan.top from the ROOT CA. Pinning to the role caps issuance at
|
||||
# that role's allowed_domains.
|
||||
default_directory_policy = "role:${vault_pki_secret_backend_role.bao_server.name}"
|
||||
allowed_roles = [vault_pki_secret_backend_role.bao_server.name]
|
||||
allowed_issuers = ["*"]
|
||||
|
||||
# See variables.tf for the not-required vs EAB trade-off.
|
||||
eab_policy = var.acme_eab_policy
|
||||
|
||||
# Empty = server's own resolver. bao resolves ad.ddupan.top correctly
|
||||
# (verified), so http-01 validation against internal hosts works.
|
||||
dns_resolver = ""
|
||||
|
||||
depends_on = [vault_pki_secret_backend_config_cluster.this]
|
||||
}
|
||||
|
||||
# ── issuing / CRL URLs ────────────────────────────────────────────────────
|
||||
# Embedded in every issued cert so clients can fetch the CA and check the CRL.
|
||||
# Previously set by ../ansible (pki.yml, "Configure issuing/CRL URLs") — moved
|
||||
# here as part of the Terraform-owns-configuration split.
|
||||
resource "vault_pki_secret_backend_config_urls" "this" {
|
||||
backend = vault_mount.pki.path
|
||||
|
||||
issuing_certificates = ["${var.bao_address}/v1/${vault_mount.pki.path}/ca"]
|
||||
crl_distribution_points = ["${var.bao_address}/v1/${vault_mount.pki.path}/crl"]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
# Policy bodies live in policies/*.hcl so they stay readable and diffable.
|
||||
# Exported verbatim from the running instance, so importing produces no diff.
|
||||
#
|
||||
# OVERLAP WARNING: ../ansible (role openbao_bootstrap) also writes these — hence
|
||||
# the "# Ansible managed" header still inside each file. Gate those Ansible tasks
|
||||
# off before applying, or the two will overwrite each other on alternate runs.
|
||||
resource "vault_policy" "admin" {
|
||||
name = "admin"
|
||||
policy = file("${path.module}/policies/admin.hcl")
|
||||
}
|
||||
|
||||
resource "vault_policy" "ai_agent_ssh" {
|
||||
name = "ai-agent-ssh"
|
||||
policy = file("${path.module}/policies/ai-agent-ssh.hcl")
|
||||
}
|
||||
|
||||
resource "vault_policy" "snapshot" {
|
||||
name = "snapshot"
|
||||
policy = file("${path.module}/policies/snapshot.hcl")
|
||||
}
|
||||
|
||||
# Read-only kv/k8s/* for the External Secrets Operator. Unlike the three above
|
||||
# this one is NOT also written by Ansible, so there is no overlap to gate off.
|
||||
resource "vault_policy" "external_secrets" {
|
||||
name = "external-secrets"
|
||||
policy = file("${path.module}/policies/external-secrets.hcl")
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
# Ansible managed
|
||||
# Broad admin for human OIDC logins (mapped from the vault-admins AD group).
|
||||
# Homelab-broad on purpose; scope down to specific mounts if you want least privilege.
|
||||
path "*" {
|
||||
capabilities = ["create", "read", "update", "delete", "list", "sudo"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# Ansible managed
|
||||
# The AI agent may sign short-lived SSH client certs for the ai-agent role — nothing else.
|
||||
path "ssh-client-signer/sign/ai-agent" {
|
||||
capabilities = ["create", "update"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
# Read-only access for the External Secrets Operator in k3s.
|
||||
#
|
||||
# Scoped to kv/k8s/* deliberately — ESO syncs Kubernetes Secrets and has no reason
|
||||
# to see the SSH CA, the PKI, or any other KV path. This is narrower than the human
|
||||
# `admin` policy, which is the point: the machine identity is less privileged than
|
||||
# the person.
|
||||
#
|
||||
# KV v2 splits data from metadata: reads go to <mount>/data/<path>, and listing or
|
||||
# checking existence goes to <mount>/metadata/<path>. ESO needs both — without
|
||||
# metadata it cannot resolve `dataFrom.extract`.
|
||||
path "kv/data/k8s/*" {
|
||||
capabilities = ["read"]
|
||||
}
|
||||
|
||||
path "kv/metadata/k8s/*" {
|
||||
capabilities = ["read", "list"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# Ansible managed
|
||||
# Read-only access to take Raft snapshots — used by the snapshot timer's token.
|
||||
path "sys/storage/raft/snapshot" {
|
||||
capabilities = ["read"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# SSH certificate authority for machine/agent access.
|
||||
# The CA KEYPAIR itself is NOT managed here (see mounts.tf) — only this role,
|
||||
# which is what actually constrains what a signed cert may do.
|
||||
resource "vault_ssh_secret_backend_role" "ai_agent" {
|
||||
backend = vault_mount.ssh_client_signer.path
|
||||
name = "ai-agent"
|
||||
key_type = "ca"
|
||||
|
||||
allow_user_certificates = true
|
||||
default_user = "ansible"
|
||||
allowed_users = "*"
|
||||
|
||||
# Deliberately short: access is scoped by TTL + principals rather than by
|
||||
# source IP, so a leaked cert expires in minutes.
|
||||
ttl = 300 # 5m
|
||||
max_ttl = 900 # 15m
|
||||
|
||||
default_extensions = {
|
||||
"permit-pty" = ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
variable "bao_address" {
|
||||
type = string
|
||||
default = "https://bao.ad.ddupan.top:8200"
|
||||
description = <<-EOT
|
||||
OpenBao API address. Must be the name clients can actually reach and verify:
|
||||
it is baked into ACME directory URLs and issued certs' AIA extension.
|
||||
EOT
|
||||
}
|
||||
|
||||
variable "pki_mount" {
|
||||
type = string
|
||||
default = "pki"
|
||||
description = "Path of the PKI secrets engine. Mount itself is Ansible-owned (openbao_bootstrap)."
|
||||
}
|
||||
|
||||
variable "acme_enabled" {
|
||||
type = bool
|
||||
default = true
|
||||
description = "Enable the ACME directory on the PKI mount."
|
||||
}
|
||||
|
||||
variable "acme_role" {
|
||||
type = string
|
||||
default = "bao-server"
|
||||
description = <<-EOT
|
||||
Role that constrains ACME issuance. Its allowed_domains cap what any ACME
|
||||
client can obtain — currently ad.ddupan.top with subdomains, IP SANs allowed.
|
||||
NEVER leave the policy as sign-verbatim; that would let ACME issue any name.
|
||||
EOT
|
||||
}
|
||||
|
||||
variable "acme_eab_policy" {
|
||||
type = string
|
||||
default = "not-required"
|
||||
description = <<-EOT
|
||||
"not-required": any host reaching bao may enroll (names still capped by acme_role).
|
||||
"new-account-required": each client must present an External Account Binding
|
||||
credential from `bao write -f pki/acme/new-eab`. Tighter, but needs per-host
|
||||
provisioning and rotation.
|
||||
EOT
|
||||
validation {
|
||||
condition = contains(["not-required", "new-account-required", "always-required"], var.acme_eab_policy)
|
||||
error_message = "Must be not-required, new-account-required, or always-required."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
terraform {
|
||||
required_version = ">= 1.5"
|
||||
required_providers {
|
||||
# NOTE: this targets OpenBao, but uses the HASHICORP VAULT provider.
|
||||
# The native `openbao/openbao` provider is published to the OpenTofu
|
||||
# registry, NOT registry.terraform.io, so it cannot be resolved by the
|
||||
# HashiCorp `terraform` CLI in use here ("provider registry
|
||||
# registry.terraform.io does not have a provider named openbao/openbao").
|
||||
# OpenBao is API-compatible with Vault, so this provider drives it fine.
|
||||
# If this repo ever switches to `tofu`, swap to openbao/openbao and rename
|
||||
# the vault_* resources to openbao_*.
|
||||
vault = {
|
||||
source = "hashicorp/vault"
|
||||
version = "~> 4.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Authenticates from the ambient CLI session, same pattern as smtp-relay/terraform
|
||||
# uses `az login`: run `bao login -method=oidc` first, which writes ~/.vault-token.
|
||||
# VAULT_ADDR/VAULT_TOKEN (or BAO_ADDR/BAO_TOKEN exported into them) override.
|
||||
# No credentials are stored in this config.
|
||||
provider "vault" {
|
||||
address = var.bao_address
|
||||
}
|
||||
Reference in New Issue
Block a user