# Working in this repo Homelab infrastructure-as-code. Independent service folders, no shared build or workspace manifest. Most of what runs here is **live** — treat it as production for a household, not a sandbox. Persistent notes live in `~/.claude/projects/-home-panxiao81-services/memory/`. **Read `MEMORY.md` first**; it indexes the topology, the incident log, and the traps. Do not re-derive what is already recorded there. ## Layout | stack | folders | pattern | |---|---|---| | Ansible | `infrastructure/proxmox/`, `infrastructure/samba-ad/`, `infrastructure/openbao/` | `/ansible/{ansible.cfg,inventory/hosts.yml,group_vars/,roles/,*.yml}` | | Terraform | per-component roots under `apps/` and `infrastructure/` | `/terraform/{versions,main,variables,outputs}.tf`; states remain isolated | | Kubernetes | `platform/` and `apps/` | manifests or Helm `values.yaml` owned by each component | Single-node **k3s runs on the laptop (192.168.10.127)**, which is deliberately *not* a Proxmox cluster member. It is also the NFS server, libvirt host (AD DC, OpenBao, Windows), and netboot.xyz appliance — i.e. the single point of failure for most of the lab. ## Conventions - **Comment the WHY, not the what.** Roles here explain why a setting exists and what breaks without it. Match that density; it is the main defence against re-learning the same traps. - **Idempotency is the acceptance test.** A second run must report `changed=0`. If a task cannot be idempotent (a reconcile action), say so in a comment rather than leaving it ambiguous. - **Terraform roots stay per-service, never merged into one central root.** Considered and rejected 2026-07-26: the roots use different providers *and* different interactive auth (`bao login -method=oidc`, `az login`, API tokens), so one shared root would need every credential valid simultaneously just to `plan`, and would put OpenBao's PKI in the blast radius of every apply. - **Ownership boundary** (established for OpenBao, copy it): Terraform owns API-level configuration; Ansible owns the machine and anything Terraform must not own — key material, and secrets it cannot read back. - Play separation: safely re-runnable baseline in `site.yml`; one-way or destructive operations get their own playbook (`cluster.yml`, `linstor.yml`) and often an extra `-e` flag. ## Tooling - Python CLIs via **uv**. Ansible specifically: `uv tool install ansible-core --with ansible --with paramiko --with pywinrm` ⚠️ `uv tool install ansible` alone exposes only `ansible-community`, **not** `ansible-playbook`. ⚠️ `pywinrm` is not optional if you touch `windows_admin` hosts — without it every `ansible.windows.*` task dies with "No module named 'winrm'". It was missing from the installed env on 2026-07-26 because this line used to omit it. (`requests-ntlm`, needed for the inventory's `ntlm` transport, comes in transitively with pywinrm.) - `deb822_repository` is **`ansible.builtin`**, not `community.general`. - Network devices (VyOS) use `ansible.netcommon.network_cli`, not ssh/python — they have no Python interpreter. Prefer `vyos_config` with explicit `set` lines over the collection's resource modules, which lag upstream syntax. ⚠ A set-lines-only role **cannot change a multi-value node** — `set` appends. Changing e.g. `option wins-server` or `name-server` leaves the old value live *and* saved to `config.boot`; the diff only shows the addition, so it reads as a clean replace. Grep the running config (`show configuration commands | match `) after any value change and `delete` the stale one out of band. ## Secrets - **OpenBao** (`bao.ad.ddupan.top`, host .8) is the real secrets store and the **internal CA**. Authenticate with `bao login -method=oidc`. ⚠ Always by HOSTNAME — its Let's Encrypt cert has a DNS SAN only, so `192.168.10.8` and `127.0.0.1` both fail TLS verification. - **Kubernetes Secrets come from OpenBao** via External Secrets Operator (`platform/external-secrets/`), which authenticates with its own ServiceAccount JWT — no credential is stored in the cluster. The gitignored `/secret.yaml` files remain as **break-glass** for when bao is down. - **Ansible vaults are `ansible-vault` ENCRYPTED and committed** (`infrastructure/samba-ad/` and `infrastructure/openbao/` `ansible/group_vars/all/vault.yml`). The password is `.vault_pass` (gitignored), wired into every `ansible.cfg` as `vault_password_file`. - Still plaintext-but-**gitignored**, because nothing consumes them as Ansible vars: `infrastructure/proxmox/vyos/credentials.yml` (referenced only in an inventory comment) and `infrastructure/proxmox/pxe/answer/*.toml` (read by the PXE installer). - **Never** print, copy, or commit live credentials. `apps/tailscale/helm.sh` contains live OAuth values — leave them where they are. **Rebuild order — bao comes first.** The whole chain is deliberately rooted in one hardware key: 1. **repo + YubiKey** → `gpg -dq .vault_pass.gpg > .vault_pass` (committed ciphertext, encrypted to cv25519 `5A6A04D1B216C64E`, the [E] subkey of `0166F47B5400ECC2`; **expires 2027-04-07**, re-encrypt when the subkey is rotated). 2. `.vault_pass` decrypts `infrastructure/openbao/ansible/group_vars/all/vault.yml` → provision + bootstrap bao. **infrastructure/openbao/ must never read its own secrets from bao** — `vault_openbao_cf_dns_token` is what gets bao its TLS cert, so that dependency cannot be inverted. This is why infrastructure/openbao/ stays on ansible-vault while everything built later may use `community.hashi_vault` lookups. 3. bao up → ESO syncs every Kubernetes Secret; other projects can look secrets up directly. The bao root token is PGP-wrapped to the same key (`gpg -dq`, touch YubiKey) — see `infrastructure/openbao/ansible/bootstrap-openbao.yml`. A copy of the vault password also lives at `kv/infra/ansible-vault`, but that is convenience only: it is *inside* the thing being recovered, so `.vault_pass.gpg` is the authoritative recovery path. ## Environment constraints - **The WAN fails at random.** Bad ISP, cannot be changed. Anything that fetches from the internet needs `retries`/`until`. **Do not go debugging the router for this** — it has been checked thoroughly (see `flaky-wan-isp` memory). - ⚠ **But check the VPN before blaming the WAN.** `openvpn-client@naist` (tun0) installs **58 split-tunnel routes** capturing Cloudflare (`104.21/16`, `172.67/16`), Fastly (`151.101/16`), Microsoft `13.107.x`, AWS CloudFront and Akamai. When the tunnel dies, systemd still reports `active running` and **those routes stay installed**, blackholing everything that matches while `github.com` — not in the route set — keeps working, so it looks like selective CDN blocking or a bad ISP. **Pods inherit this**, since they use the host routing table. It crash-looped Gitea and broke `pypi.org` on 2026-07-28. Diagnose with `ip route get ` (`dev tun0` = the VPN ate it) and `ping -c2 -I tun0 163.221.48.1`; fix with `systemctl restart openvpn-client@naist`. `~/scripts/netrestart/main.py` bounces the WAN uplink and **cannot** fix this. - ⚠ **IPv6 is broken on the laptop, and it looks like a VPN problem but is not.** `br0` has **no global IPv6 address** — `net.ipv6.conf.all.forwarding=1` (libvirt/k3s) makes the kernel default `accept_ra` to `0`, so SLAAC never runs, while NetworkManager still installs a v6 default route. The only global v6 address on the box is `tun0`'s, so the kernel hands it to routes that egress `br0`: packets leave the LAN with the VPN's source address and nothing comes back, leaving sockets in `SYN-SENT` forever. **`ip route get` shows `dev br0` and looks innocent — the tell is the source address, not the device.** Diagnose with `ss -tnp | grep SYN-SENT` and `ip -6 addr show scope global`. `curl` hides it (Happy Eyeballs); **`.NET`/`pwsh` does not** — hence `DOTNET_SYSTEM_NET_DISABLEIPV6=1` for anything PowerShell. Real fix (unapplied, needs a change window): `net.ipv6.conf.br0.accept_ra=2`. - **Interactive device-code logins deadlock under `!` and under plain redirection.** A `!` command's output is not shown until it exits, so a login code never appears and the process waits forever for a code you cannot see. PowerShell also buffers when redirected to a file. Run these under a PTY and read the log: `script -qfc "pwsh -NoProfile -File