16 KiB
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/ |
<component>/ansible/{ansible.cfg,inventory/hosts.yml,group_vars/,roles/,*.yml} |
| Terraform | per-component roots under apps/ and infrastructure/ |
<component>/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 toplan, 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-eflag.
Tooling
- Python CLIs via uv. Ansible specifically:
uv tool install ansible-core --with ansible --with paramiko --with pywinrm⚠️uv tool install ansiblealone exposes onlyansible-community, notansible-playbook. ⚠️pywinrmis not optional if you touchwindows_adminhosts — without it everyansible.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'sntlmtransport, comes in transitively with pywinrm.) deb822_repositoryisansible.builtin, notcommunity.general.- Network devices (VyOS) use
ansible.netcommon.network_cli, not ssh/python — they have no Python interpreter. Prefervyos_configwith explicitsetlines over the collection's resource modules, which lag upstream syntax. ⚠ A set-lines-only role cannot change a multi-value node —setappends. Changing e.g.option wins-serverorname-serverleaves the old value live and saved toconfig.boot; the diff only shows the addition, so it reads as a clean replace. Grep the running config (show configuration commands | match <node>) after any value change anddeletethe stale one out of band.
Secrets
- OpenBao (
bao.ad.ddupan.top, host .8) is the real secrets store and the internal CA. Authenticate withbao login -method=oidc. ⚠ Always by HOSTNAME — its Let's Encrypt cert has a DNS SAN only, so192.168.10.8and127.0.0.1both 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<svc>/secret.yamlfiles remain as break-glass for when bao is down. - Ansible vaults are
ansible-vaultENCRYPTED and committed (infrastructure/samba-ad/andinfrastructure/openbao/ansible/group_vars/all/vault.yml). The password is.vault_pass(gitignored), wired into everyansible.cfgasvault_password_file. - Still plaintext-but-gitignored, because nothing consumes them as Ansible vars:
infrastructure/proxmox/vyos/credentials.yml(referenced only in an inventory comment) andinfrastructure/proxmox/pxe/answer/*.toml(read by the PXE installer). - Never print, copy, or commit live credentials.
apps/tailscale/helm.shcontains live OAuth values — leave them where they are.
Rebuild order — bao comes first. The whole chain is deliberately rooted in one hardware key:
- repo + YubiKey →
gpg -dq .vault_pass.gpg > .vault_pass(committed ciphertext, encrypted to cv255195A6A04D1B216C64E, the [E] subkey of0166F47B5400ECC2; expires 2027-04-07, re-encrypt when the subkey is rotated). .vault_passdecryptsinfrastructure/openbao/ansible/group_vars/all/vault.yml→ provision + bootstrap bao. infrastructure/openbao/ must never read its own secrets from bao —vault_openbao_cf_dns_tokenis 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 usecommunity.hashi_vaultlookups.- 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 (seeflaky-wan-ispmemory). - ⚠ 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), Microsoft13.107.x, AWS CloudFront and Akamai. When the tunnel dies, systemd still reportsactive runningand those routes stay installed, blackholing everything that matches whilegithub.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 brokepypi.orgon 2026-07-28. Diagnose withip route get <failing-ip>(dev tun0= the VPN ate it) andping -c2 -I tun0 163.221.48.1; fix withsystemctl restart openvpn-client@naist.~/scripts/netrestart/main.pybounces the WAN uplink and cannot fix this. - ⚠ IPv6 is broken on the laptop, and it looks like a VPN problem but is not.
br0has no global IPv6 address —net.ipv6.conf.all.forwarding=1(libvirt/k3s) makes the kernel defaultaccept_rato0, so SLAAC never runs, while NetworkManager still installs a v6 default route. The only global v6 address on the box istun0's, so the kernel hands it to routes that egressbr0: packets leave the LAN with the VPN's source address and nothing comes back, leaving sockets inSYN-SENTforever.ip route getshowsdev br0and looks innocent — the tell is the source address, not the device. Diagnose withss -tnp | grep SYN-SENTandip -6 addr show scope global.curlhides it (Happy Eyeballs);.NET/pwshdoes not — henceDOTNET_SYSTEM_NET_DISABLEIPV6=1for 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 <script>" /tmp/.../logbackgrounded, then grep the log for the code. Used for the M365 DKIM scripts on 2026-07-28. - Internal name resolution must never depend on the WAN. k3s CoreDNS routes
ad.ddupan.topstraight to the DC; PVE nodes use the DC first. If a pod times out resolving anything, suspect DNS search-domain fallout before the service itself. - The Proxmox cluster is almost entirely HA-free and holds nothing critical; guests are
disposable. One exception (2026-07-26):
vm:100(vyos-rtr) is an HA resource, because it gateways both SDN VNets and speaks OSPF. Consequence to remember: fencing is now armed cluster-wide, so a node losing quorum self-reboots — and corosync has a single ring on the flat 1G LAN. Seeinfrastructure/proxmox/README-ha.md; the watchdog is stillsoftdog(cannot fence a frozen kernel) until each node is rebooted or hand-swapped.
Working rules
- Do not update
CHANGELOG.md. It is a frozen historical snapshot; requiring every PR to append to one shared text file caused needless conflicts and duplicated Git/PR history. Put durable service state and operational knowledge in the component README or runbook, agent-facing traps here, and let commits/PRs record individual changes. - Verify, don't assert. Check the end state (
pvesm status,linstor node list,kubectl get pod,show ip route) rather than trusting that a command "should have" worked. Several confident diagnoses in this repo's history were wrong until measured. Corollary: test from a second host before concluding "the network is broken". A failure reproduced only on the laptop is a laptop problem —ssh [email protected]and retry there. - Scan for secrets case-INSENSITIVELY. A live Postgres password reached a commit because
the grep matched
passwordbut notPASSWD:. Match on content, not filenames: the worst finds of 2026-07-28 were in files calledvalues.yaml,accounts.jsonandauth.json. .gitignoredoes not untrack what is already staged. Adding a rule aftergit addleaves the file in the index and it will be committed. Worse,git check-ignoreskips indexed files unless you pass--no-index, so the obvious verification returns a false all-clear. Usegit check-ignore --no-indexandgit rm --cachedto actually remove it.- A
.tfplanis a zip containing a fulltfstate. It walks straight past*.tfstateignore rules. Ignore*.tfplaneverywhere. - SPIRE CLI JSON can be an array of response blocks.
spire-agent api fetch jwt -output jsonin 1.15.3 returns blocks containingsvidsandbundles. Capture stdout privately and type-check before extracting fields;list(response)prints full tokens when the response is already an array. Never inspect credential payloads by printing their containers, and never put fetched JWTs in command arguments or Pod logs. - Quoting does not survive two ssh hops.
ssh pve1 "ssh pve3 'cmd | qm monitor 103'"loses the inner quotes — ssh re-joins argv with spaces, so the pipeline splits and the tail runs on the jump host. It fails silently if you discard stderr: ascreendumpran on pve1 for ten minutes while I re-read one stale frame. Base64 the payload into a single token (echo <b64> | base64 -d | bash) and have the remote print something that proves freshness (anmtime, a timestamp). - ⚠
pkill -f <pattern>matches the shell that is running it. Bitten three times in one session, including when the pattern only appears in the start command sitting on the same line. Bracket a character (atmodem[.]py --conn[e]ct) — but note that only works if the literal bracketed form is what is on your own command line, so put the kill in a separate invocation from the start. - Guard destructive commands. Before
sgdisk/wipefs/vgremove/dd, assert the target is what you think it is (lsblk -dno TYPE==disk). A wrong device once destroyed the LVM metadata on two live nodes. Derive parent disks from sysfs, neverlsblk -no PKNAMEwithout--nodeps. - Don't sit in poll loops. Query the result directly; background genuinely long jobs and
carry on. Waiting on a
serial: 1playbook to answer a question onesshwould settle is wasted time. - Edit YAML with YAML-aware tools. A regex sweep over a file with multiple literal blocks
silently corrupted a Helm values file here. For Helm releases,
helm get values <rel> -n <ns>is the reliable backup. - Reach for
--check --difffirst on anything touching a live system.
Service-specific notes
infrastructure/proxmox/— PVE has no floppy drive, in the UI or the config schema, and-nodefaultsmeans QEMU does not create one either. Retro guests get it through theargsfield:qm set <vmid> -args "-drive if=floppy,format=raw,file=/mnt/pve/laptop/template/iso/<x>.img".argsis appended last, so a trailing-boot order=athere also overrides PVE's own-bootif you need to boot the floppy. Changingargsneeds a power cycle (qm rebootis not enough); swapping the medium afterwards does not —echo "change floppy0 /path/y.img" | qm monitor <vmid>(eject floppy0first if you want to write the image from the host). Do not edit the image underneath a running guest.roles/pve_floppyputs a small web UI for exactly this on pve1 (https://pve1.ad.ddupan.top:8088— by hostname, the cert has no IP SAN). One instance covers the cluster becausepveshproxies to whichever node owns the VM. It shells out as root rather than using an API token on purpose:argsis root@pam-only — the check is a literal$authuser eq 'root@pam'and a token's authuser isroot@pam!name, so no token, however privileged, can set it. Login is delegated to PVE's/access/ticket(via pvedaemon on127.0.0.1:85, so no password ever lands in a process argv), which means PAM and AD-over-LDAPS both work with no PAM or LDAP code in the app — plus aSys.Modifyon/check, because authenticating only proves who you are and this app points VMs at host files.platform/envoy-gateway/— the LAN ingress. Gateway API; Envoy holds 192.168.10.127 and routes*.ad.ddupan.topby Host header. Adding a service = anHTTPRoute+ a DNS A record insamba-ad; no cert work (seeplatform/cert-manager/). Authelia forward-auth is available per-route via aSecurityPolicy—apps/netbox/securitypolicy.yamlis the worked example, and it is what makes AD-group→role mapping possible for apps whose own SSO cannot do it.platform/cert-manager/— two ClusterIssuers:letsencrypt(DNS-01 via Cloudflare) andbao-acme(OpenBao internal PKI). One*.ad.ddupan.topwildcard serves every LAN service, deliberately, so per-host names stay out of Certificate Transparency logs.apps/netbox/— deployed atnetbox.ad.ddupan.top, still an evaluation: nothing consumes it yet, so deleting it breaks nothing.terraform/topology.ymlis the authoritative data (git → NetBox, a derived mirror — never edit via the UI);apps/netbox/terraform/applies it.generate/*.pyread back out and--diffagainst live systems. Readapps/netbox/README.md;CONTEXT.mdis the original brief.archive/keycloak/andarchive/casdoor/are retired IdP experiments; Authelia is the only identity stack.