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
+39
View File
@@ -0,0 +1,39 @@
# Stage-1 Ansible lint. Starting at `basic` on purpose: this is an existing,
# working 33-role codebase, so the first pass must be adoptable rather than a
# wall of findings. Ratchet to `moderate` -> `safety` -> `production` once each
# level is clean; that ordering is ansible-lint's own progression.
profile: basic
exclude_paths:
- netboot.xyz/ # pristine upstream clone, not ours
- apps/napcat/
- node_modules/
- .git/
# Roles here are referenced by relative roles_path from each service's
# ansible.cfg, not installed as galaxy collections, so name-prefix rules that
# assume a collection layout do not apply.
skip_list:
- role-name # roles are local (pve_auth, dc_vm), not namespaced
# 265 of the 300 findings were this single rule. It demands every in-role
# variable carry the full role name, turning `win_vm_disk_gb` (role
# windows_vm) into `windows_vm_disk_gb` and `vyos_lan_address` (role
# vyos_router) into `vyos_router_lan_address`. That is a repo-wide rename of
# working code for no behavioural gain, and `_`-prefixed registers are already
# a clear private-variable convention here. Revisit only if these roles are
# ever published as a collection, where the prefix genuinely prevents clashes.
- var-naming[no-role-prefix]
# Visible but non-blocking, so the first gate can pass on an existing codebase.
# Ratchet: clear these, move them out of warn_list, then raise `profile` to
# moderate -> safety -> production. Each step should be its own change.
warn_list:
- command-instead-of-module # VyOS has no Python interpreter; module equivalents
# do not exist for much of the PVE CLI surface either
- no-changed-when # several tasks are reconcile ACTIONS (pveum realm sync)
# with no no-op signal to key off — documented in-role
- name[casing] # 14 findings, cosmetic
- schema[meta] # 6 roles lack galaxy_info.author; only matters if published
- yaml[line-length] # already governed by .yamllint.yml
- jinja[spacing]
View File
+107
View File
@@ -0,0 +1,107 @@
---
# Stage 1 of the infra pipeline: static checks only. No cluster access, no
# credentials, no mutation — so this is safe to run on every push from day one.
#
# Stages 2 (kubectl --dry-run=server) and 3 (k3d / molecule) come later and DO
# need cluster access; keep them in separate workflows so a credential problem
# there can never block this one.
name: lint
on:
push:
pull_request:
env:
# pypi.org is NOT reachable from this network — it resolves fine but TCP/443 to
# Fastly (151.101.x) times out, while github.com and cloudflare.com are fine.
# This is not the usual flaky-WAN symptom and a plain `uv tool install` will
# hang until timeout. Use a mirror; verified reachable 2026-07-28.
UV_DEFAULT_INDEX: https://pypi.tuna.tsinghua.edu.cn/simple
# ansible-lint and ansible-core install as SEPARATE uv tools, each with its own
# venv. Collections installed under the ansible-core tool are invisible to
# ansible-lint, which then reports every module as `syntax-check[unknown-module]`
# — a false failure that looks exactly like a real one. Pin both to a shared path.
ANSIBLE_COLLECTIONS_PATH: /root/.ansible/collections
jobs:
yaml:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Install yamllint
# The WAN drops at random (see CLAUDE.md); retry rather than fail a run.
run: |
for i in 1 2 3 4 5; do
uv tool install yamllint --quiet && break
echo "attempt $i failed"; sleep 10
done
uv tool list | grep -q yamllint
- name: yamllint
# --no-warnings so line-length stays advisory. Errors block.
# netboot/ is vendored upstream and excluded in .yamllint.yml,
# but they are also excluded here so the file list stays small.
run: |
export PATH="$HOME/.local/bin:$PATH"
files=$(git ls-files '*.yaml' '*.yml' | grep -vE '^apps/netboot/')
yamllint -c .yamllint.yml --no-warnings -f parsable $files
ansible:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Install ansible-lint and collections
# pywinrm is not optional — without it every ansible.windows.* task dies
# with "No module named 'winrm'" (CLAUDE.md documents this trap).
run: |
for i in 1 2 3 4 5; do
uv tool install ansible-core --with ansible --with paramiko --with pywinrm --quiet && break
echo "attempt $i failed"; sleep 10
done
for i in 1 2 3 4 5; do
uv tool install ansible-lint --quiet && break
echo "attempt $i failed"; sleep 10
done
export PATH="$HOME/.local/bin:$PATH"
for p in infrastructure/proxmox infrastructure/samba-ad infrastructure/openbao; do
ansible-galaxy collection install \
-r "$p/ansible/requirements.yml" -p "$ANSIBLE_COLLECTIONS_PATH"
done
- name: ansible-lint
# Each project has its own ansible.cfg and relative roles_path, so lint
# must run from inside each one — a single run at the repo root resolves
# roles_path incorrectly and reports spurious missing-role errors.
run: |
export PATH="$HOME/.local/bin:$PATH"
rc=0
for p in infrastructure/openbao infrastructure/samba-ad infrastructure/proxmox; do
echo "::group::$p"
(cd "$p/ansible" && ansible-lint -c ../../../.ansible-lint --nocolor -f pep8 .) || rc=1
echo "::endgroup::"
done
exit $rc
terraform:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: fmt and validate
# -backend=false so validate never touches real state or needs credentials.
# These roots deliberately use different providers AND different interactive
# auth (bao login -method=oidc, az login), which is exactly why they are not
# merged — so validate is as far as static checking can go here.
run: |
rc=0
for d in $(git ls-files '*.tf' | xargs -n1 dirname | sort -u); do
echo "::group::$d"
terraform -chdir="$d" fmt -check -diff || rc=1
terraform -chdir="$d" init -backend=false -input=false || rc=1
terraform -chdir="$d" validate || rc=1
echo "::endgroup::"
done
exit $rc
+120
View File
@@ -0,0 +1,120 @@
# Root ignore rules. Per-service .gitignore files (samba-ad/, proxmox/, openbao/,
# smtp-relay/, netbox/, cert-manager/) still own their own vault/secret paths —
# this file covers everything added since those were written.
# ─── Secrets ───────────────────────────────────────────────────────────────
# Real values only; the committed *.example.* templates are the documentation.
# The ansible-vault password. Shared by every project's ansible.cfg via a
# relative path. The ENCRYPTED group_vars/all/vault.yml files ARE committed —
# this file is the only thing that must never be. A copy lives in OpenBao at
# kv/infra/ansible-vault for recovery.
.vault_pass
.env
*/certs/*.key
**/certs/*.key
# live Tailscale OAuth clientId + clientSecret on argv
tailscale/helm.sh
gitea/gitea-oidc-secret.yaml
# Cloudflare tunnel credentials: TunnelSecret grants full control of the tunnel.
# (root:root 0640 on disk, which is what made `git add` fail rather than commit it.)
cloudflared/backup/
# Real tunnel token; secret.example.yaml is the committed template.
cloudflared/secret.yaml
# Live OpenAI OAuth material — these carry refresh_tokens, which do not expire
# when the access_token does. Innocuous filenames, so no pattern rule catches them.
codex-proxy/data/
litellm-gateway/auth.json
# Hardcoded Keycloak admin password (bootstrap curl + manifest). The stack is
# RETIRED and its namespace deleted, so the credential should be dead — but it is
# a real password, so it stays out. RETIRED.md documents what these did.
keycloak/keycloak-bootstrap-configmap.yaml
keycloak/keycloak.yaml
# Real Gitea DB password; secret.example.yaml is the committed template.
# gitea-values.yaml itself is now tracked — it references this Secret via
# additionalConfigFromEnvs instead of embedding the credential.
gitea/secret.yaml
# Real Authelia secret material (LDAP bind, storage/session encryption keys,
# OIDC hmac and the JWKS signing key). secret.example.yaml is the template.
authelia/secret.yaml
# These location-independent forms keep secrets ignored when service directories
# move under apps/, platform/ or infrastructure/.
**/.vault_pass
**/.env
**/secret.yaml
**/credentials.yml
**/terraform.tfvars
**/tailscale/helm.sh
**/cloudflared/backup/
**/cloudflared/secret.yaml
**/codex-proxy/data/
**/litellm-gateway/auth.json
**/gitea/gitea-oidc-secret.yaml
**/keycloak/keycloak-bootstrap-configmap.yaml
**/keycloak/keycloak.yaml
**/proxmox/pxe/
**/smtp-relay/.noreply-password
# ─── Terraform ─────────────────────────────────────────────────────────────
# A .tfplan is a zip that EMBEDS a full tfstate, so it walks straight past the
# *.tfstate rules below. Ignore plans everywhere, not just in openbao/.
*.tfplan
*.tfstate
*.tfstate.*
.terraform/
# Terraform's default saved-plan names have no extension. A plan embeds the
# complete state, so ignore both the conventional name and numbered variants.
tfplan*
# Python bytecode is generated locally and is never infrastructure source.
__pycache__/
*.py[cod]
# NOTE: .terraform.lock.hcl is deliberately NOT ignored — provider versions must
# be pinned and reproducible. openbao/ and netbox/ currently ignore it locally;
# that is backwards and should be removed from those two files.
# ─── Vendored / generated ──────────────────────────────────────────────────
node_modules/
# prebuilt .node binaries, ~23MB each
# ─── Large binary artifacts ────────────────────────────────────────────────
# ML model blobs (~3.6GB), refetched from HuggingFace on demand.
apps/openviking/models/
# netboot.xyz: keep the hand-written sources, drop the bulk and the mirrors.
#
# assets/ is 8.5GB of ISOs, WIMs and initrds — but assets/proxmox/ also holds
# hand-written per-node iPXE scripts and pve-iso-2-pxe.sh, which ARE the
# reinstall procedure. Exclude the tree, then re-admit source files.
apps/netboot/assets/**
!apps/netboot/assets/**/
!apps/netboot/assets/**/*.ipxe
!apps/netboot/assets/**/*.sh
# buildout/ is container-generated (root-owned, uniform mtime) rolling upstream.
apps/netboot/buildout/
# Pristine upstream clone of netbootxyz/netboot.xyz (development @ 3.0.2-104, no
# local commits). Staging it would create a gitlink with no .gitmodules — a
# broken half-submodule. Re-clone it instead of vendoring it.
netboot.xyz/
# config/menus/ is the pinned upstream 3.0.2 menu release, re-downloaded by the
# container. Only boot.cfg (local win_base_url) and local-vars.ipxe are ours.
apps/netboot/config/menus/**
!apps/netboot/config/menus/**/
!apps/netboot/config/menus/boot.cfg
!apps/netboot/config/menus/local-vars.ipxe
# Runtime logs from the netboot appliance nginx.
apps/netboot/config/log/
# Blocky's per-day query logs. Bind-mounted into the container, one file per
# day, and every DNS query the LAN makes ends up in them.
apps/blocky/logs/
+2
View File
@@ -0,0 +1,2 @@
„^ZjѲÆN@š„ háÕÛzïKÄù†˜" kõV-kCÙ)Ž9-óN*0[ÆBsÓé[eÎÄ:k}â(Š Üu‚ûÃ
ö*[´Êý7°nRѸ‚g ¯_sŸ ïžÔ‘ âØƒ²ŒÞzŽBPŋΓøê]ÕªCãçê…K(^‚‡d-ŒÔ Cë D g!îP¸„ªd€¥<FžÜj@ΣjDJ×6 µ±¬˜—Ÿ¼ ÅË»°ªjXPv'ò€æŸì™Ú` ì˜‰x"MCJ† ÌÜqÖ5¦<@`»1žBÄŽ̵G¼a/ß _
+51
View File
@@ -0,0 +1,51 @@
# Stage-1 lint config. Deliberately permissive: the goal is to catch YAML that is
# broken or surprising, not to reformat 200+ working files. Tighten over time.
extends: default
ignore: |
netboot.xyz/
apps/netboot/buildout/
apps/netboot/config/menus/
node_modules/
# Upstream-derived VictoriaMetrics alert rules and stock Grafana datasource
# provisioning. Their trailing spaces sit INSIDE `|` literal block scalars, so
# they are part of the alert description text, not formatting — stripping them
# edits upstream content and creates diff noise on the next chart bump.
# Verified: a whitespace strip changes the parsed document, so it is not safe.
apps/victoriametrics/rules/
apps/victoriametrics/provisioning/
rules:
# Ansible and Helm values here carry long inline comments explaining WHY a
# setting exists (see CLAUDE.md) — that density is intentional, so length is a
# warning rather than an error.
line-length:
max: 120
level: warning
# k8s manifests and Ansible both use `on:`/`yes`/`no` idiomatically.
truthy:
allowed-values: ['true', 'false', 'yes', 'no']
check-keys: false
# `---` is used inconsistently across manifests vs multi-doc files; not worth
# churning every file over.
document-start: disable
# Helm values and manifests both nest lists under keys without extra indent.
indentation:
spaces: consistent
indent-sequences: consistent
comments:
min-spaces-from-content: 1
comments-indentation: disable
# Disabled deliberately, not as a cop-out: codex-proxy/config/models.yaml and
# netbox/terraform/topology.yml column-align flow mappings
# - { reasoningEffort: low, description: "..." }
# which is more readable for tabular data. These two rules produced 268 of 293
# findings, all of it that alignment. Real hygiene rules below stay ON.
braces: disable
commas: disable
+6
View File
@@ -0,0 +1,6 @@
# Agent Notes
- This workspace is organized into apps/, platform/, infrastructure/, clusters/, and archive/; components remain independently deployable and there is no shared root package.
- `apps/http-echo/` and `archive/traefik/` contain Kubernetes/Gateway API manifests; inspect their parent Gateway references before applying archived or brownfield resources.
- `apps/tailscale/helm.sh` contains live Tailscale OAuth values; do not copy, print, or commit those values anywhere else.
- Preserve the existing README intent in `apps/http-echo/` and `archive/traefik/` when updating manifests.
+693
View File
@@ -0,0 +1,693 @@
# Changelog
What changed in this homelab, when, and why. Newest first.
**Conventions**
- One `##` section per date. No version numbers — services here are deployed
continuously and independently, so there is nothing to tag.
- Lead with a one-line summary, then a table of what changed by area.
- **Incidents get their own subsection**, including anything self-inflicted. The
post-mortem is the point: what broke, why, and whether it was latent.
- `Carried forward` lists known gaps left open on purpose, so they do not get
silently forgotten.
- Agent-facing traps and procedures do **not** belong here — they go in
`CLAUDE.md` under *Working rules* / *Environment constraints*.
---
## 2026-09-09
**Recorded the brownfield GitOps/IaC redesign before changing live infrastructure.**
| area | change |
|---|---|
| docs | Added `docs/homelab-gitops-redesign.md` as the durable record of control-plane boundaries, ingress and DNS consolidation, certificates, OCI recovery, CI placement, state recovery and phased adoption. It requires zero-change adoption before mutation and keeps PVE/OpenBao/Git recovery independent of k3s |
| OCI | Read-only discovery found the likely authoritative lost-root state in `oci-k8s-free-tier-tfstate/terraform.tfstate`: Terraform 1.15.8 state format 4, serial 249, covering the existing VM and public network. No state content or credential was committed; the bucket currently lacks versioning |
| secrets | Recorded that ESO 2.8.0, five ExternalSecrets and the scoped OpenBao Kubernetes-auth path already exist; the next gate is live recovery testing and migration of any remaining manual Secrets |
| Terraform | Recorded Gitea 1.27 State Registry as the preferred candidate for local roots after version and recovery testing; the OCI recovery root remains in OCI Object Storage to avoid a home-control-plane dependency loop |
| cleanup | Removed the retired NapCat tree, the Contour and Kanidm archive trees, and seven generated Terraform plan files before establishing the clean Git baseline; plans may embed complete state and remain globally ignored |
| cleanup | Removed the retired NapCat tree and seven generated Terraform plan files before establishing the clean Git baseline; plans may embed complete state and remain globally ignored |
`Carried forward`: re-verify OpenBao/ESO recovery and remaining Secret inventory;
configure a Git remote and off-site mirror; confirm the running Gitea version;
take an encrypted independent OCI state copy before enabling bucket versioning;
reconstruct the missing root to a zero-change plan; bootstrap Woodpecker and Flux on a low-risk
service; then move Tunnel origins to Envoy one hostname at a time.
## 2026-08-15
**`retro-pdc` (NT4) has a floppy drive again — Proxmox does not offer one, so it
comes in through `args`.**
| area | change |
|---|---|
| proxmox | VM 102 gained `args: -drive if=floppy,format=raw,file=/mnt/pve/laptop/template/iso/retro-pdc-fda.img` and a blank FAT12 1.44 MB image to go with it. PVE exposes no floppy in the UI *or* the config schema, and it launches QEMU with `-nodefaults`, so before this the guest had no A: at all — `info block` listed only `drive-ide0`/`drive-ide2`. Verified after a power cycle: `floppy0 … retro-pdc-fda.img (raw)`. Swapping the medium works live (`eject floppy0` / `change floppy0 <path>` over `qm monitor`) — the block node id changed, so it really re-inserted; only an `args` edit needs the VM stopped and started |
| proxmox | **`roles/pve_floppy`** — a ~250-line stdlib-only web UI for the thing PVE has no UI for: attach/detach a floppy drive (edits `args`) and swap the medium live (`change floppy0` over the monitor). Its own play in `site.yml`, on **pve1 only** — the baseline play is `serial: 1`, which would have put three copies of the same UI on the LAN. One instance is enough because `pvesh` proxies to whichever node owns the VM, verified pve1→pve3 for `config`, `monitor` *and* the root-only `--args` write. It shells out as root instead of using an API token because it has to: PVE gates `args` on a literal `$authuser eq 'root@pam'` (`PVE::API2::Qemu`, the `# catches args, lock, etc.` branch) and a token's authuser is `root@pam!name`. No stop/start buttons — the PVE UI has those, and this app has no authentication |
| proxmox | The floppy UI got **authentication, without one line of PAM or LDAP code**: it posts the login form to PVE's own `/access/ticket`, so it accepts every realm the cluster has — `pam` for node-local accounts and `ad` for Samba AD over verified LDAPS (`pve_auth`) — and holds no bind DN, no bind password and no realm config of its own. It talks to **pvedaemon on `127.0.0.1:85`** rather than `pvesh create /access/ticket` so the password never appears in a process argv, and rather than pveproxy:8006 so there is no TLS-to-self dance. Authentication alone is not enough: a session also needs **`Sys.Modify` on `/`** in PVE's ACL (i.e. `pve-admins-ad`), because pointing a VM at an arbitrary host file is a host-level act, not a VM-level one. Sessions are HMAC-signed cookies (HttpOnly, SameSite=Strict, Secure), 8 h, with the signing key generated per process — a restart logs everyone out, deliberately. Serves TLS with the node's own ACME cert and **refuses to start without one** (`--insecure` to override): a login form on cleartext HTTP is worse than no service. Failed logins say only *"login failed"*, so the app cannot be used to enumerate AD |
| proxmox | The procedure is in `CLAUDE.md`. **Not** codified in `pve_vm`: VM 102 is not in `pve_vms` yet, and that gap is already tracked with the rest of the retro-pdc hardware pinning |
Two things learned power-cycling it. **NT4 ignores ACPI**: `qm shutdown 102 --timeout 60`
returned *"VM quit/powerdown failed - got timeout"* and the guest never moved, so it has to be
shut down from inside (`sendkey ctrl-esc`, `sendkey u`, `sendkey s`, `sendkey ret` over
`qm monitor`) and then `qm stop`ped once it shows *现在可以关掉电源*. And the shutdown dialog
defaults to **restart**, not power off — a guest restart keeps the same QEMU process, so the new
`args` would never have taken effect.
`Carried forward`: the guest side is unverified — NT4 came back to the Ctrl+Alt+Del
login screen and nothing here has its password. `A:` should hold a `README.TXT`
written to the image from the host.
The floppy UI **is deployed** on pve1 and serves TLS on 8088; the realm list it renders
comes back from the cluster (`ad`, `pam`, `pve`) and a wrong password gets *"login
failed"* through a real pvedaemon round trip. What is still unverified is everything
past a *successful* login — the VM table and the four actions have only ever run against
a stubbed `pvesh`, because no password for any realm exists in the session that built it.
No DNS record was needed: `pve1.ad.ddupan.top` already resolves, which is also why the
node's own ACME cert is the right one to serve.
### A modem emulator, so retro guests can dial an ISP
Retro guests expect dial-up, and there is no PSTN here. Three options were weighed before
writing anything:
| option | verdict |
|---|---|
| 86Box's built-in Hayes modem (since 4.2, and retrolab runs 6.0) | **Use it for 86Box guests.** Adapter `[COM] Standard Hayes-compliant Modem`, phonebook file maps a dialled number to `host:port`, non-zero listening port makes it answer. ⚠ Turn **Telnet emulation off** — PPP frames start `FF 03` and telnet IAC eats them. Its built-in internet mode (dial `0.0.0.0`) is **SLIP, not PPP**, so it needs the guest hacked into SLIP; not what a period PC did |
| `tcpser` for the QEMU guests | **Rejected.** Not packaged past bionic (source build), and its only socket DTE transport is **ip232**, which is not 8-bit transparent: `ip232_write` doubles every `0xFF`, `ip232_read` steals `FF 00`/`FF 01` for DTR, and the modem injects `FF <flags>` for DCD/RI. Read the source rather than assuming — `-serial tcp:` to it would corrupt every PPP frame and every ZMODEM block. Its `-p` port is the *phone line* side, not the serial side, so QEMU's telnet chardev cannot be pointed at it either |
| `roles/retro_modem/files/atmodem.py` | **Written.** ~420 lines, stdlib asyncio, no deps. Listens on a **unix socket** (PVE's `qm set -serial0 socket` plugs straight in) or TCP, so no socat + pty sandwich. `ATD` either opens a TCP connection or hands the raw line to **pppd** — an ISP terminal server, which is what the machines are actually dialling. `--line <port>` gives it a phone number: an inbound TCP connection rings the guest, which answers with `ATA` or automatically once it has set `S0`. That is the half **NT4's RAS needs to receive calls**, and it is also how one retro guest dials another |
Two non-obvious bits, both commented in place. Extended commands are `&X`/`%X`, so
searching for `D` to find the dial command fires on the `&D2` in every dialer's init
string and dials "2" — consume the prefix first. And unknown commands answer `OK` on
purpose: that is what makes an emulated modem work with dialers nobody has tested against.
`S0` auto-answer needed the DTE read to be interruptible: a guest that has set `S0` sends
*nothing* while waiting for a call, so the modem sits blocked reading the serial port and
could never decide to pick up on its own. The read now races an answer event.
`--selftest` is the acceptance test. It checks what ip232 gets wrong — dial through the
phonebook, round-trip all 256 byte values unchanged, escape with `+++` — then takes an
inbound call both ways, by `ATA` and by `S0`. It caught a real bug: when the far end
dropped, the outbound pump kept reading the serial port forever, so after `NO CARRIER` the
modem never returned to command mode and silently swallowed every later command. Both
directions now end the call.
**Busy signal, and the direction bug it flushed out.** A second caller now gets refused by
the kernel at `connect()`, because an engaged modem *closes its listening socket* until it
hangs up — a ringing line counts as engaged too. Accepting and then closing would have been
worse than nothing: the caller's modem would report `CONNECT` and immediately `NO CARRIER`,
which is a phantom call, not a busy tone. The dialling side maps `ConnectionRefusedError`
to **`BUSY`** and everything else to `NO CARRIER` (order matters — it subclasses `OSError`).
Writing that exposed a wrong assumption in the usage: **PVE's `-serial0 socket` leaves QEMU
listening**, so atmodem has to dial *into* the VM, not wait for it. Added `--connect`
alongside `--listen`; 86Box and plain TCP still want the listening side. Verified against a
stand-in listener — the guest end sees `OK` come back.
One process is one modem on one line, deliberately: that is what a modem is. An ISP's T1
into a rack of them is N processes on N ports. A single number in front of the rack is a
hunt group, i.e. a dispatcher, and that is **not** built.
**Existing AT libraries were checked and rejected**, so this does not get re-litigated:
almost everything on PyPI (`attila`, `python-gsmmodem`, `modem-cmd`, `esp_modem`) is the
**DTE** side — it *sends* AT to a real modem. `AT-Command-Emulator` is DCE but GSM
(`AT+CMGS`, SMS), so no dialling and no data mode. The one real match,
[`tcpatmodem`](https://github.com/stblassitude/tcpatmodem) (MIT, PyPI), is a DCE-side
interpreter — but its DTE side is **stdin/stdout only**, it cannot answer (`RING` appears
solely as an entry in its result-code table; no `bind`/`listen`/`accept` in the source),
and it was last touched in January 2019. Nor can its interpreter be lifted out on its own:
its dispatch table has no `&`, `%` or `\` entry and falls through to `ERROR`, with `a` and
`z` wired to `command_error` outright — so `AT&F` and `ATZ`, the first things every dialer
sends, both fail.
The deeper reason nothing is reusable: **commands and responses are different grammars.**
A command line is a run of concatenated commands with no separator (`AT&F&C1&D2S0=0X4E1V1`)
that cannot be tokenised without knowing the command set, and `D` swallows the rest of the
line; a response is line-oriented `\r\n<verb>\r\n` with `+CMD: <params>`. DTE libraries
parse the second, because a DTE never reads the first — it writes it. The one shared piece
is the `+CMD=<params>` parameter grammar, which is the part retro dial-up does not use. Adopting it would mean a dependency, a
socket↔stdio bridge, and a fork for the answering half, to replace a ~90-line parser. The
AT parsing is the commodity part; the socket DTE, the answering and the pppd hand-off are
not, and nothing off the shelf has them. Complete Hayes DCE implementations do exist —
DOSBox-X `serialmodem.cpp`, 86Box `net_modem.c`, tcpser `modem_core.c` — but all are C
welded into their host emulator. Read them if a dialer misbehaves.
**Verified end to end against a real `pppd`, not just the self-test.** A client `pppd`
dialled through atmodem into the `pppd` atmodem spawned for the `ppp` phonebook target —
i.e. the whole ISP chain, with no retro guest involved. From syslog:
```
send (ATDT5551212^M) / expect (CONNECT) / ATDT5551212^M^M / CONNECT / -- got it
Serial connection established. Using interface ppp0 / ppp1
PAP peer authentication succeeded for retro Remote message: Login ok
local IP address 10.62.0.1 remote IP address 10.62.0.2
```
Both ends came up (`ppp0` 10.62.0.1 ↔ `ppp1` 10.62.0.2), 11 frames and ~400 bytes each
way. That is LCP, PAP, IPCP and IPv6CP all negotiating across the emulated modem with
`asyncmap 0` in `/etc/ppp/options`, which is the strongest 8-bit-cleanliness proof
available — and it exercises the `ppp` target and the asyncio-subprocess pipe path, which
nothing had run before. Note `pppd notty` forks its own *charshunt* onto a pty internally
(`Connect: ppp0 <--> /dev/pts/6`); our pipes feed that fine. The test appended one line to
`/etc/ppp/pap-secrets` and restored the file from backup afterwards, verified identical.
**Then a real dialer found a real bug: `ATDT;`.** Driven against **VM 103 (Win98 SE)** on
pve3, whose `serial0: socket` atmodem attached to directly. Win98's Standard Modem opens
every call like this:
```
DTE> ATZ DCE< OK
DTE> ATE0V1&C1&D2S0=0 DCE< OK <- the init string the design was betting on
DTE> ATM1X4 DCE< OK <- unknown commands, answered OK not ERROR
DTE> ATDT; DCE< OK <- was NO CARRIER; that killed every call
DTE> ATDT5551212 DCE< CONNECT <- Win98 only sends digits after that OK
```
A trailing `;` means *"dial, then return to command state"*, and Windows TAPI **dials in
stages** — a bare `ATDT;` first, digits second. Answering `NO CARRIER` to the opener made
Win98 give up before it ever sent a number, which looked exactly like a phonebook miss and
was not one. `dial()` now accumulates staged digits and answers `OK`; the self-test replays
the whole Win98 sequence verbatim so it cannot regress.
The two design calls that looked arbitrary are the two that carried it: consuming `&`/`%`
prefixes before looking for `D` (or `&D2` dials "2"), and answering `OK` to unknown
commands (or `M1X4` aborts the dial). tcpatmodem would have failed on line 2.
**Result: Windows 98 is on the network over an emulated modem.** ISP was `pppd` on the
laptop, reached over the LAN from pve3, so nothing was installed on the node:
```
call from ('192.168.10.9', 57456)
ppp0 UNKNOWN 10.62.0.1 peer 10.62.0.2/32
64 bytes from 10.62.0.2: icmp_seq=1 ttl=128 time=12.8 ms (4/4, ttl=128 = Windows)
```
Also learned: Win98 does **not** drive PVE's USB tablet, so QMP `input-send-event` abs
clicks are accepted by QEMU and ignored by the guest — until the guest installs USB HID.
And Win98's own dialling properties prepend the location's outside-line digit and country
code (`0 5551212`, canonical `86-5551212`), which `digits()` cannot match; untick
**使用区号与拨号属性** in the connection's properties.
**And then Win98 dialled NT4.** Two atmodems on pve3 — one on VM 103's `serial0` with the
phonebook, one on VM 102's `serial0` with `--line 6102` — turn `5551102` into a call from
the Win98 guest to `retro-pdc`'s Remote Access Server (RETRO001, 1 port, 正在运行):
```
WIN98 DTE> ATDT; DCE< OK
DTE> ATDT5551102 DCE< CONNECT
NT4 DTE> ATH / AT / ATE0V1 / AT / ATS0=0 <- RAS initialising the port
DCE< RING <- our modem rings it
DTE> ATA <- RAS answers by hand
DCE< CONNECT
```
That validates the whole answering half (`--line``RING``ATA`) against a real NT 4.0
RAS, and settles a design guess: **RAS sets `S0=0` and answers manually on `RING`**, so the
`ATA` path is the one that carries, and `S0` auto-answer is there for DOS-era software.
RAS's init sequence is a *third* dialer handled without changes. Everything above the
modem — PPP and NT4 domain authentication against RETRO — was left to the operator, who
was at the keyboard by then.
**RAS then found the second real bug: `+++ATH` as one write.** NT4 hangs up by sending the
escape and the command glued together, and `_dte_to_peer` matched only a bare `b"+++"` — so
the whole string was forwarded to the far end as data and the line could never be dropped.
A bare `+++` still waits out its trailing guard; `+++` followed by a command is
unambiguous, so it escapes at once and the remainder goes to the command reader through a
small pushback buffer. In the logs this showed up as `+++ATH``ERROR` (that part is
correct — in *command* mode real modems error too; the bug was the data-mode path).
Reading the rest of that log is a lesson in not blaming the layer you just wrote. RAS
answered three calls cleanly, each running 4575 s before **RAS** hung up — a failure above
the modem (PPP/auth), matching 端口状态 showing 线路未连接 with zero bytes counted. And the
eight unanswered `RING`s were Win98 hanging up and **redialling in the same second**
(`ATH``ATDT5551102` both at 22:02:55), before RAS had re-armed its port — the period-
accurate equivalent of redialling before the far end's modem has reset. RAS re-initialises
with `AT`/`ATZ`/`ATE0V1`/`ATS0=0` when it recovers.
**Dialling an address directly was broken, and only asking about it found it.** `D` takes
an optional **T**one/**P**ulse modifier, which was never stripped — so `ATDT192.168.10.1:23`
tried to resolve the host `T192.168.10.1` and returned `NO CARRIER`. The phonebook path hid
it completely, because lookups go through `digits()`. Strip exactly one modifier, never
`lstrip()`, or `ATDTtelnet.example.com` loses its `t`. Now covered by the self-test.
**`telnet:` targets.** A raw TCP pipe is wrong for a real telnetd: dialling `192.168.10.1:23`
delivered `\xff\xfb\x01\xff\xfb\x03login: ` to the guest — `IAC WILL ECHO, IAC WILL SGA`
rendered as `ÿû☺ÿû♥` before the prompt — and an un-doubled `0xFF` corrupts any 8-bit
transfer. A `telnet:host[:port]` phonebook target now wraps the peer in a ~45-line telnet
client: it swallows IAC sequences, answers `DO ECHO`/`DO SGA` and refuses everything else,
un-escapes `IAC IAC`, and doubles `0xFF` outbound. Same dial through it now yields exactly
`\r\nCONNECT\r\nlogin: `.
It is **opt-in per entry** for the same reason 86Box's telnet toggle has to be turned off:
enabling IAC handling on the PPP or guest-to-guest numbers would corrupt them, since there
`0xFF` is data — `FF 03` starts every PPP frame.
**Redesigned into a switchboard, which came out smaller than what it replaced.** Dialling
a VM cannot be another target type: the answering guest needs a *modem* to hear `RING` and
reply `ATA`, so wiring a caller straight to its serial socket hands RAS raw bytes and it
never picks up. Only a process holding both ends can ring one on behalf of the other. So
one process now owns N lines (`--vm 102:6102 --vm 103`), and `vm:102` is an internal call:
| before, 2 guests | after |
|---|---|
| 2 processes, 1 TCP port, 2 logs | 1 process, 1 log |
| a `--line` port allocated per VM | internal routing by vmid |
| busy = open/close a listener | busy = does that line have a call |
| hunt group impossible | falls out of the line table |
The internal hop is a `socket.socketpair()`, so every path below it — the pump, 8-bit
cleanliness, `+++`, `NO CARRIER` — is the same validated code that carries an external
call. Per-line TCP ports stay, because **86Box lives on retrolab**, a different host, and
has to reach a line over the network. Lines also reattach on their own now: a guest reboot
takes the chardev peer with it, and a switchboard needing a restart after every VM reboot
is not a service.
**The phonebook is the API — there isn't one.** It hot-reloads on mtime change, so editing
a number no longer restarts the modem or drops a live call. That single change removes any
need for a daemon protocol: the file lives on **pmxcfs** (`/etc/pve/retro-phonebook`), so
`pve_floppy` running on **pve1** edits exactly what the switchboard on **pve3** reads, with
no IPC, no API and no second service. The UI gained a phonebook textarea rather than
becoming a new app, so it inherits the PVE ticket auth, the AD realm, TLS on the node cert
and the `Sys.Modify` gate that were already there. (pmxcfs mtime has 1-second granularity —
two edits inside one second would be missed. Irrelevant for human or UI edits.)
Targets are an **allowlist, not a blocklist**, and that is the security boundary: a
phonebook entry is something the modem *acts on*`ppp` and `ssh:` make it spawn a process
— so a free-form target would be remote command execution wearing a phone number. `exec:`
was deliberately never added for that reason, and the UI's self-test asserts what it
*refuses* (`exec:`, shell metacharacters, non-numeric numbers), not just what it accepts.
Also added: `ssh:user@host[:port]` (the same subprocess shape as `ppp`, so nearly free).
### The floppy UI was taking ~22 seconds a page; now 4 cold, 0 warm
Measured before changing anything, which is the whole story: **every `pvesh` costs ~1.9 s**
(Perl startup plus a cluster round trip), and a monitor query *forwarded to another node* is
**3.6 s**. The page made one call for the VM list, one per VM for its config, and one per
running VM for its monitor — 1 + 4 + 4 calls for four guests.
| fix | why it works |
|---|---|
| Read configs off **pmxcfs** (`/etc/pve/.vmlist`, `/etc/pve/nodes/<node>/qemu-server/<vmid>.conf`) | Exactly the data `pvesh get .../config` returns, already replicated to every node, at file-read speed. Removed 5 of the 9 calls |
| Ask the monitor **only about VMs that have a floppy** | "What is in the drive" is meaningless for a VM with no drive. Two thirds of the monitor calls were asking anyway |
| Run the survivors **in parallel** | `ThreadingHTTPServer` already gives each request a thread; fanning out inside it makes N round trips cost about one |
| **Cache**, TTL 30 s, cleared by every action | At the 5 s I first wrote, every click still missed — the TTL has to be longer than a human's click interval to ever be warm |
Result: 14.4 s just for the list+config calls became a file read, and the page went
**~22 s → 4.04 s cold, 0.000 s warm**, same data. The remaining 4 s is one forwarded
monitor call and is the floor for `pvesh`; beating it needs the API over HTTP with a
retained ticket, i.e. server-side session state this app deliberately does not have (its
sessions are stateless signed cookies). Not worth it for a page that is now instant in use.
Config parsing has its own test: snapshots are appended as `[name]` sections after the live
config so parsing must stop at the first one, and the split is on the **first** colon only
because an `args` value is full of them.
The login page was its own 1.9 s, before any of that: `realms()` ran a `pvesh` on every
unauthenticated hit to list something that changes when an auth domain is added, i.e.
never. Cached for the process lifetime — 2.01 s cold, then **0.034 s**.
Deployed with `--tags floppy`; a second run reports `changed=0`.
#### Incident — one login in eight was silently rejected (latent since the app was written)
The deploy failed on `pve_floppy`'s own self-check, which then passed on a rerun. Chasing
the flake rather than re-running found a real bug in session cookies, not in the test:
```python
return base64.urlsafe_b64encode(msg + b"|" + _mac(msg)).decode() # sign
msg, sig = raw.rsplit(b"|", 1) # verify
```
The signature is the **raw 32-byte HMAC digest**, and 32 random bytes contain `0x7C` — the
byte for `|` — about **12 %** of the time (`1 - (255/256)**32`). When they did, `rsplit`
split the token *inside its own signature*, `compare_digest` failed, and the user was
bounced back to the login page. Random, unreproducible, and it had been there since the app
was written — never noticed because, as recorded above, nothing past a *successful* login
had ever been exercised.
Fix: sign with `_mac(msg).hex()`, which cannot contain the separator. The regression test
does 300 round trips rather than one, because a single round trip passed ~88 % of the time
and that is exactly how this survived having a test at all. Verified 20/20 self-test runs on
the node after deploying, and the service is confirmed running the new binary — the failed
run had installed the file but died before its restart handler, leaving the old code live.
Not yet done: DTR-drop hangup, S-registers beyond `S0`, and the `retro_modem` Ansible role
— the switchboard runs from `/tmp` on pve3 and the `pve_floppy` change is not deployed.
### retronet's WINS now points at the NT4 PDC, not the production DC
`retro-pdc` came up on its static **10.61.0.5** with the WINS service installed, so
retronet's DHCP `wins-server` moved from `192.168.10.5` (the Samba DC) to it —
`vyos_router` defaults, applied and saved, second run `changed=0`. Verified end to end
rather than assumed: TCP/42 and 139 open, `nmblookup -A` shows the box holding
`RETRO<1b>` / `RETRO<1d>` / `..__MSBROWSE__.` (it is the domain master browser), a
recursive WINS query through it resolves `RETRO01<00>` → 10.61.0.5, and kea's generated
config carries `netbios-name-servers: 10.61.0.5` for the subnet. That removes retronet's
last dependency on the production DC. Note the NetBIOS domain is **`RETRO`**, host
**`RETRO01`** — not `RETRONET`, which is only the VNet/shared-network name.
**`option wins-server` is a multi-value node**, so the role's `set` line *added* a second
server rather than replacing the first — both were live, and both were written to
`config.boot` by the play's `save: true`. Removed with an explicit `delete`. The role is
set-lines-only by design and can never remove a stale value; changing any multi node needs
a one-off delete against the live box. Trap recorded in `CLAUDE.md`.
---
## 2026-07-28
**The repo got git history for the first time; a stage-1 lint gate; and
`auth.ddupan.top` now resolves on the LAN instead of via Cloudflare.**
| area | change |
|---|---|
| repo | First commit ever — 490 files, ~11.8 MB. Root `.gitignore` added; ~13 GB of ISOs, WinPE images, HF model blobs, `node_modules` and vendored netboot menus excluded, along with nine secret-bearing files. Later the same day `blocky/logs/` joined them — Blocky writes one query log per day, the first had already been committed, so it is ignored **and** `git rm --cached`d. It stays in the history of `fde9ff2`: every DNS query the LAN made that day, no credentials |
| ci | Stage-1 lint: `yamllint`, `ansible-lint`, `terraform fmt`/`validate`. Config tuned against a real run — 293 YAML errors down to 0, 300 Ansible findings down to 34 |
| proxmox | Added `ansible/requirements.yml`. It had never existed, while the other two Ansible projects declared theirs — so `ansible.netcommon`, `vyos.vyos`, `ansible.posix` and `community.general` were undeclared and a fresh checkout could not reproduce the environment |
| cert-manager | `certificate-auth-ddupan.yaml` — LE cert for `auth.ddupan.top`. The `*.ad.ddupan.top` wildcard cannot cover it: different zone, one label shallower |
| envoy-gateway | Second HTTPS listener `https-auth`, SNI-selected. The existing `*.ad.ddupan.top` listener is untouched |
| authelia | `httproute.yaml` routes `auth.ddupan.top` to the `authelia` Service |
| k3s | CoreDNS answers `auth.ddupan.top` with the gateway (192.168.10.127) and suppresses AAAA |
| gitea | Actions enabled (`ENABLED=true`, `DEFAULT_ACTIONS_URL=github`). No runner deployed yet, so nothing executes |
| victoriametrics | 25 whitespace fixes, each verified to parse to an identical document. Two alert-rule files were deliberately **not** fixed — their trailing spaces sit inside `\|` literal block scalars and are part of the alert text |
| docs | `CHANGELOG.md` (this file) and `docs/cicd.md`, the CI/CD design. `docs/superpowers/` retired — its only work, the 2026-04-18 CloudNativePG migration, shipped and has been healthy 101 days |
| secrets | All four secret-bearing configs returned to git. **cloudflared**: the credentials Secret and `config.yml` ConfigMap were *dead* — deleted rather than externalised (see below); the tunnel token moved to a gitignored `secret.yaml` with a committed template. **gitea**: DB password moved to a `gitea-db` Secret, injected via `gitea.additionalConfigFromEnvs` as `GITEA__DATABASE__PASSWD`. **litellm-gateway**: compose now interpolates `${POSTGRES_PASSWORD}` from its already-gitignored `.env`. **authelia**: all seven pieces of secret material moved into Secrets via `secret.existingSecret` + `secret.additionalSecrets`, which also took the OIDC signing key out of a plaintext ConfigMap (see below) |
| external-secrets | **ESO 2.8.0 deployed**, pulling all four Secrets from OpenBao. The operator authenticates with its own ServiceAccount JWT via bao's Kubernetes auth backend, so no credential is stored in the cluster. Policy scoped to `kv/k8s/*` read-only — narrower than the human `admin` policy |
| openbao | Kubernetes auth backend **enabled and configured for the first time** — it had never existed. Two stale defaults fixed: `openbao_k8s_host` pointed at `192.168.10.10` (nothing listens there), and `openbao_addr` used `127.0.0.1`, which now fails TLS because bao's Let's Encrypt cert has a DNS SAN only |
| terraform | **All four roots migrated from local state to SeaweedFS S3** (`tfstate` bucket), with native `use_lockfile` locking. Reached over a new LAN route (`s3.ad.ddupan.top`) rather than `obj.ddupan.top`, so state does not depend on the WAN |
| seaweedfs | S3 identities moved out of `values.yaml` into OpenBao via ESO, plus a least-privilege `terraform` identity scoped to the state bucket |
| blocky | **Staged, not deployed.** LAN resolver + ad-blocker + split-horizon DNS, as a compose stack on the laptop. Fills a real gap: there is nowhere today to put a LAN record for a `ddupan.top` name — the DC is authoritative only for `ad.ddupan.top`, and the NEC IX has no static-host feature |
| gitea | LAN route added: cert for `git.ddupan.top`, a third gateway listener (`https-git`) and an HTTPRoute. Serves HTTP 200 in 32ms, so `git push` no longer has to leave the LAN. **DNS not yet switched** — nothing resolves it locally until Blocky or a DC zone lands |
| smtp-relay | **DKIM signing enabled for `ddupan.top`** — mail relayed via M365 was landing in Junk. The signing config existed but had never been switched on (`Enabled: False`, `Status: CnameMissing`), so outbound mail carried only the tenant's `*.onmicrosoft.com` signature, which does not align with `ddupan.top`. No DNS change was needed |
| tailscale | **The PVE SDN subnets are now advertised** — the laptop, the tailnet's only subnet router, offered `192.168.10.0/24` and nothing else, so `10.60.0.0/24` (labnet) and `10.61.0.0/24` (retronet) were unreachable from the tailnet even though the laptop has had OSPF routes to both all along. Recorded in `tailscale/subnet-routes.sh` rather than left as shell history, since the whole failure mode is forgetting the step. retronet is included deliberately: quarantining it from the LAN is the point, quarantining it from the tailnet just forces a second VPN. **Both still need approving in the admin console** |
| retrolab | **86Box mouse capture fixed over RDP.** xorgxrdp's pointer declares relative axes (`REL_X`/`REL_Y`, range `-1..-1`) but posts absolute screen coordinates through them — `xf86PostMotionEvent(dev, TRUE, …)`. 86Box's XInput2 backend trusts the declared mode and fed those absolutes in as movement deltas, pinning the emulated pointer in a corner the moment you clicked to capture. 86Box only ever exempts pointers *by device name* (`TigerVNC pointer`, `Virtual core XTEST pointer`), so `retro_desktop` now renames the xrdp pointer to `TigerVNC pointer` in `/etc/X11/xrdp/xorg.conf`. Also gave the role's channel-read task `check_mode: false`, without which every `--check` run asserted that audio was broken |
The hostname deliberately did not change. Issuer, redirect URIs and cookie
domain all remain `auth.ddupan.top`, so no OIDC client needed re-registering —
only the network path moved. The public route (Cloudflare → tunnel →
`cloudflared` → authelia) still works and terminates at the same Service.
### Incident — retrolab logins came up with no window manager (self-inflicted)
No title bars, no Applications menu, so no way to log out — reported as "I can't
logout now", and it came back after the move to pve3 because the cause is
persistent, not transient.
`xfce4-session` restores exactly the client list in
`~/.cache/sessions/xfce4-session-retrolab:10`, and that list had **Count=4:
xfsettingsd, xfce4-panel, Thunar, xfdesktop** — no `xfwm4`. Once the WM is
missing from a saved session, every later login is WM-less too.
Self-inflicted, and the recovery caused the relapse: xfwm4 had died earlier
(cause unknown, `.xsession-errors` shows an older `Another compositing manager
is running on screen 0`), and it was restarted over SSH with `xfwm4 --replace`.
That process has no `SESSION_MANAGER` in its environment — it logs "Failed to
connect to session manager" — so the next session save did not record it.
Fixed on the host: restarted the WM, set
`xfconf-query -c xfce4-session -p /general/SaveOnExit -n -t bool -s false`, and
deleted the stale session file. Left as a documented trap in
`roles/retro_desktop/tasks/main.yml` rather than a task — see the comment there
for why automating it costs more than it saves. The distro's failsafe session
does not help: xrdp never offers the greeter that selects it.
### retrolab moved from pve1 to pve3 — 86Box was CPU-starved
86Box emulation stuttered and the emulated Sound Blaster glitched. pve1 is an
**i3-6100U, 2 cores / 4 threads at 2.3 GHz**, and it also carries `vyos-rtr`;
it was sitting at load 2.0 with 50% CPU. 86Box's recompiler is effectively
single-threaded, so it wanted clock, not cores.
pve3 was the target rather than pve2 for a storage reason, not a CPU one — both
are **Ryzen 5 PRO 2400GE (4c/8t, 3.2 GHz)** and both idle, but LINSTOR holds an
**UpToDate replica on pve3 and only a Diskless one on pve2**, so on pve2 every
block would have crossed the network to another node's disk.
`cpu: host` was already set, which is most of the performance win but also
forced the migration to be **offline**: pve1 is Intel, pve2/pve3 are AMD, and a
live migration would have handed the running kernel a different feature set.
Shutdown, `qm migrate` (2 seconds — nothing to copy, shared DRBD), start. The
guest now reports the Ryzen. vm:101 is not an HA resource, so nothing else
needed rearranging.
Verified after the move, because this was the first time either SDN VNet had to
leave a node: the DHCP reservation still resolves (`10.60.0.10`, VLAN 100), and
`br-retro` reaches the VyOS gateway `10.61.0.1` on VLAN 110 — both now crossing
the physical 1G LAN to reach vyos on pve1 instead of staying inside one host.
`xrdp` and the laptop's `/mnt/iso` NFS mount came back on their own.
Win98 took a hard power-off (ScanDisk on next boot): the guest OS ignored ACPI
shutdown until its timeout, and the desktop session could not be driven to shut
the emulator down cleanly first.
### 86Box's Win98 guest could not DHCP — the emulated cable was unplugged
Symptom: Win98 on retronet got only an APIPA address, `winipcfg` renew failed
instantly with "DHCP 服务器不存在". Everything downstream of the guest was
healthy and measured that way: `br-retro` up with `enp6s19` **and** `tap0`
enslaved and forwarding, an address temporarily added to `br-retro` pinged the
VyOS gateway `10.61.0.1`, and kea was listening on `10.61.0.1:67` with the
`RETRONET` pool configured.
The tell was `ip -s link show tap0`: **RX 0 packets, ever** — TX counted the
frames the bridge flooded *toward* the guest, but 86Box had never written a
single frame *from* it. Not a fabric problem at all.
Cause: `net_01_link = 2` in `~/86Box VMs/98/86box.cfg`. In 86Box
`NET_LINK_DOWN = (1 << 1)`, so the value means the NIC's link is **down**
86Box's own "unplug the cable" toggle, reachable by clicking the network icon
in its status bar. The default is `504` (every speed/duplex bit set); deleting
the line restores it. The guest driver was fine throughout: it read its MAC
(`00:E0:4C:CB:7A:58`) off the emulated PROM and bound TCP/IP normally.
Fixed by removing the line and restarting the emulator. Win98 now holds
**10.61.0.107** from the `RETRONET` pool, first lease that segment has ever
handed out.
Consequence, and the reason the VM does not boot unattended: the NIC's boot ROM
is enabled (`bios = 1` under `[Realtek RTL8029AS #1]`), and with the link up
Etherboot 5.4.4 now runs a DHCP loop at every boot instead of failing
instantly. It never accepts kea's reply — the reply is on the wire, addressed
to the card, and Etherboot still prints `No IP address` — so it retries
indefinitely and the machine never reaches the hard disk. Press **Q** at
`Boot from (N)etwork or (Q)uit?` to skip it; set `bios = 0` if PXE on retronet
is not wanted. Left as-is: enabling that ROM looks deliberate.
### Incident — retrolab's desktop stranded again (needrestart, second occurrence)
Same failure as 2026-07-25, different trigger. **unattended-upgrades** upgraded
`libc6` at 06:28:37, and needrestart restarted `xrdp-sesman` at 06:28:55.
sesman came back with an empty session table and could no longer reattach the
running `:10` display, so every reconnect started a *new* one — and
`xfce4-session` refuses to run twice for the same user, so each died in about a
second (`Window manager (pid 102992, display 11) exited quickly (1 secs)`). The
desktop and its 86Box Win98 VM kept running, just permanently unreachable.
The 2026-07-25 fix was `NEEDRESTART_MODE: l` in `retrolab.yml`, which only ever
covered *our* playbook runs. Ubuntu's automatic upgrades were never in scope,
which is why the same thing happened again eight hours before anyone noticed.
Recovered by killing `:10` outright (86Box included — no way to save it, the
session could not be reached to shut it down). Fixed properly with
`/etc/needrestart/conf.d/50-xrdp.conf` pinning `qr(^xrdp)` to `0`, deployed by
the `retro_desktop` role. This is the mechanism needrestart already uses for
`gdm`, `sddm` and `xdm` — xrdp-sesman is the same class of service and simply
was not on the list. Trade accepted: sesman runs against the old libc until the
host reboots.
Watch for this on any other host that grows a long-lived xrdp session.
### Incident — Gitea down ~12 minutes (self-inflicted trigger, latent cause)
Enabling Actions required a `helm upgrade`, and the chart's `strategy: Recreate`
kills the old pod before starting the new one. The new pod never came up.
The cause was **not** the config change. `configure-gitea` is an **init**
container running `gitea admin auth update-oauth`, which *fetches*
`autoDiscoverUrl` before Gitea will start. That URL was unreachable, so the init
container exited non-zero and the pod crash-looped. Any restart — node reboot,
eviction, chart bump — would have done the same. Rolling back would not have
helped, because the rollback also restarts the pod.
Restored by temporarily commenting out the `oauth:` block (the auth source
stays in Gitea's DB; commenting only stops the init-time sync), then permanently
by moving `auth.ddupan.top` onto the LAN.
### Incident — a dead VPN tunnel masquerading as a bad ISP
`[email protected]` reported `active running` and its interface was
`UP`, but the tunnel was dead — 100% loss to its own gateway, 29,156 dropped TX
packets. Its **58 split-tunnel routes stayed installed**, blackholing Cloudflare
(`104.21/16`, `172.67/16`), Fastly (`151.101/16`), Microsoft `13.107.x`, AWS
CloudFront and Akamai. `github.com` is not in that route set, which is why it
kept working and made the failure look like selective CDN blocking.
This was the real cause of the Gitea outage above, of `pypi.org` being
unreachable, and — because pods use the host routing table — of the same
blackhole applying cluster-wide. `pve1` was unaffected throughout, having no
`tun0`. Fixed by restarting the service.
### DKIM: the CNAMEs were right all along
The published CNAMEs matched `Selector1CNAME`/`Selector2CNAME` exactly, yet
`ddupan1.d-v1.dkim.mail.microsoft` was **NXDOMAIN** — which reads as a wrong
tenant label and sends you hunting for the "real" value. It is not.
**Microsoft creates the tenant host only when signing is enabled**, so the
target cannot resolve before `Set-DkimSigningConfig -Enabled $true`. The
NXDOMAIN was the expected pre-enable state, not a fault. After enabling, the
zone answered `NOERROR` and both selectors served 2048-bit keys immediately.
Corollary: `Status: CnameMissing` on a config that has never been enabled does
not mean your DNS is wrong. Enable it and re-check before touching DNS.
**Verified end-to-end**, headers of a test message received at an external
Outlook.com account: `dkim=pass (signature was verified) header.d=ddupan.top`,
`spf=pass`, `dmarc=pass`, `compauth=pass reason=100`.
**It still landed in Junk**`X-MS-Exchange-Organization-SCL: 5`
(`X-Message-Delivery` decodes to `SCL=6`), `dest:J`, `RF:JunkEmail`. Note the
split: the tenant-side outbound stamp was `SCL:1`, so the score came from the
*receiving consumer* filter. Authentication is a precondition for good
placement, not a guarantee of it — the remainder is reputation (`ddupan.top`
has no sending history and relays via a shared M365 outbound pool,
`52.101.228.88`) plus content (the test messages were one-line bodies with
"test" in the subject, no charset, no `MIME-Version` — a worst case for
scoring). Nothing further to configure; it needs real traffic, time, and
"not junk" marks.
### Discovered — IPv6 broken host-wide on the laptop (not fixed)
`Connect-ExchangeOnline -Device` hung with no output. The cause was not the
module: **`br0` has no global IPv6 address**, because
`net.ipv6.conf.all.forwarding=1` (needed for libvirt/k3s) makes the kernel
default `accept_ra` to `0`, so SLAAC never runs — while NetworkManager still
installed a v6 default route. The only global v6 address on the box belongs to
`tun0`, so source selection hands it to routes that egress `br0`. Packets leave
the LAN wearing the VPN's address and nothing returns; the socket sits in
`SYN-SENT`.
This is **not** the known dead-tunnel trap. The VPN was healthy — its gateway
pinged, v4 through it worked. `ip route get` says `dev br0` and looks innocent;
the tell is the **source address**, not the device. Anything that resolves AAAA
and does not fall back fast hangs the same way — `.NET` does not do Happy
Eyeballs, which is why `curl` masks the fault entirely.
Worked around per-process with `DOTNET_SYSTEM_NET_DISABLEIPV6=1`. Not fixed at
host level: the fix is `net.ipv6.conf.br0.accept_ra=2`, which changes IPv6
behaviour for k3s, libvirt and NFS on the lab's single point of failure and
deserves its own change window.
### The Authelia OIDC signing key was in a ConfigMap, not a Secret
Externalising `authelia/values.yaml` turned up a live exposure rather than a
git-hygiene problem. The chart's `files/configuration.oidc.jwk.yaml` branches on
how the key is supplied: `key.path` reads it from a mounted file at runtime,
but **`key.value` inlines it directly into the ConfigMap**. values.yaml used
`value:`, so the RSA key that signs every ID token for `auth.ddupan.top` was
sitting in plaintext in a ConfigMap — readable by anything with `get configmap`
in that namespace, and not encrypted at rest the way a Secret can be.
Fixed by moving all seven pieces of secret material into Kubernetes Secrets
(`secret.existingSecret` for six, `secret.additionalSecrets` for the JWKS key)
and referencing them by `path:`. The Secrets were built from the live
chart-generated Secret, so **no key material changed** — verified afterwards by
the JWKS endpoint still serving `kid=main` with the same modulus, meaning no
issued token was invalidated and nobody was logged out.
`authelia/values.yaml` is now committed. That was the last of the four configs
gitignored for embedded secrets.
### Incident — Authelia down ~5 minutes on the first attempt
The first upgrade put the JWKS key as a seventh key inside the `existingSecret`.
The chart projects that volume with an explicit `items:` list containing only
the six keys it generates, so the extra key was stored but **never mounted**.
Authelia died on `open /secrets/internal/…jwks.main.pem: no such file or
directory`, which cascaded into every other option appearing "required" because
the whole config template had failed to render.
Rolled back first to restore SSO, then fixed with `secret.additionalSecrets`,
which mounts a second Secret at `/secrets/<name>`. Two lessons: `helm template`
is not sufficient on its own — it happily rendered a config referencing a file
no volume projected — so the check that matters is cross-referencing every
`/secrets/...` reference against the rendered volumes' `items:`. And the
existingSecret volume mounts at `/secrets/internal`, not `/secrets/<name>`.
### Live S3 credentials were committed in the initial commit — now rotated
**Rotated 2026-07-28.** The leaked `anvAdmin` key is dead: verified denied
against the live endpoint. `anvReadOnly` and `terraform` were never exposed and
were left alone.
Rotating it broke `research-auto`, which turned out to be using the cluster-wide
admin key as its own S3 credentials. That dependency was invisible from this
repo — the app lives in `~/research-auto` and its Secret had been applied ad hoc,
with no owner references and its whole `k8s/` directory untracked. Finding it
needed a scan of every Secret in the cluster for the leaked key, not a grep of
this repo.
Fixed properly rather than by re-pointing it at the new admin key: `research-auto`
now has its own SeaweedFS identity scoped to the `research` bucket, written into
`~/research-auto/k8s/secrets.yaml` (gitignored, alongside the existing
`secrets.example.yaml` template) and applied. Both deployments were restarted —
these are env vars, so running pods keep the old value until recreated.
The orphaned `seaweedfs-s3-secret`, which the chart stopped generating once
`existingConfigSecret` was set but which still held the dead key, was deleted.
A cluster-wide scan now finds the leaked key in no Secret at all.
### Original exposure
`seaweedfs/values.yaml` carried the `anvAdmin` accessKey/secretKey inline and went
into git with the very first commit. They are still in history.
They survived three separate secret scans. The reason is instructive: the scan
regex looked for `secret[:=]`, and the key is written **`secretKey:`** — the word
"secret" is followed by "Key", not a colon. Together with the earlier `PASSWD:`
miss (case) and the `values.yaml`/`auth.json` misses (filename, not content),
that is three different ways the same class of scan fails.
Now externalised: the identities live in OpenBao at `kv/k8s/seaweedfs-s3`, ESO
syncs them, and the chart reads `filer.s3.existingConfigSecret` instead of
rendering credentials from values. **The leaked `anvAdmin` key still needs
rotating** — externalising stops it getting worse, it does not undo history.
### The cloudflared config was dead, not secret-bearing
Externalising `cloudflared/cloudflared.yaml` turned out to be the wrong fix: the
embedded ConfigMap and credentials Secret were **not in use at all**. Three
independent proofs — the config routed `idm.ddupan.top` to keycloak (retired
2026-07-10); it pointed `auth.ddupan.top` at `authelia:9091`, which 502s, while
Terraform had corrected that to `:80` and auth demonstrably works; and the
credentials volume mounted `subPath: <uuid>.json` against a Secret whose key was
`credentials-file`, so that mount never resolved.
The tunnel is token-managed and its ingress rules come from the Cloudflare API
via `cloudflared/terraform`. Confirmed on restart, which logged
`Updated to new configuration` carrying exactly the Terraform-managed rules, with
`authelia:80`. So both documents were deleted instead of being re-plumbed.
### Carried forward
- The OpenBao **PostgreSQL secrets engine** is the next step beyond static values:
Gitea and Authelia both read their credentials only at startup, so short-TTL
dynamic credentials would break them. Static roles (stable username, scheduled
password rotation) plus something to restart the consumer is the shape that fits.
`gitea.extraEnvSourceFile` and Authelia's `path:` indirection already read from
files, which is what an OpenBao agent-injector writes.
- The `cloudflared-tunnel` Secret still carried the dead `credentials-file` key
until today: `kubectl apply` MERGES, so removing it from the manifest did not
remove it from the cluster. Removed with a JSON patch. Worth remembering whenever
a key is dropped from a Secret.
- `.terraform.lock.hcl` is ignored in `openbao/` and `netbox/` but committed in
the other two roots. That is backwards — provider versions should be pinned.
- Gitea's Ingress declares no class, and the only classes present are `contour`
(retired) and `tailscale`. Gitea is therefore reachable only via the Cloudflare
tunnel, i.e. it depends on the WAN.
- No Actions runner deployed; CI substrate undecided.
- **IPv6 is broken on the laptop** (see above). Worked around per-process only;
`net.ipv6.conf.br0.accept_ra=2` still needs applying deliberately.
- `ddupan.top` still publishes SPF `~all` and DMARC `p=none`. Both should harden
(`-all`, `p=quarantine`) once a few days of aggregate reports confirm DKIM
passes — hardening before that would quarantine the lab's own mail.
+208
View File
@@ -0,0 +1,208 @@
# 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 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 <node>`) 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 `<svc>/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 <failing-ip>` (`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 <script>" /tmp/.../log` backgrounded, 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.top`
straight 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. See `infrastructure/proxmox/README-ha.md`; the watchdog is still `softdog` (cannot fence a
frozen kernel) until each node is rebooted or hand-swapped.
## Working rules
- **Record changes in `CHANGELOG.md`.** One dated section per day, newest first; incidents
get their own subsection. Traps and procedures belong *here* in CLAUDE.md, not there —
the changelog is for humans reading what changed.
- **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 `password` but not `PASSWD:`. Match on content, not filenames: the worst
finds of 2026-07-28 were in files called `values.yaml`, `accounts.json` and `auth.json`.
- **`.gitignore` does not untrack what is already staged.** Adding a rule after `git add`
leaves the file in the index and it *will* be committed. Worse, `git check-ignore` skips
indexed files unless you pass `--no-index`, so the obvious verification returns a false
all-clear. Use `git check-ignore --no-index` and `git rm --cached` to actually remove it.
- **A `.tfplan` is a zip containing a full `tfstate`.** It walks straight past `*.tfstate`
ignore rules. Ignore `*.tfplan` everywhere.
- **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: a `screendump`
ran 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 (an `mtime`, 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**, never `lsblk -no PKNAME`
without `--nodeps`.
- **Don't sit in poll loops.** Query the result directly; background genuinely long jobs and
carry on. Waiting on a `serial: 1` playbook to answer a question one `ssh` would 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 --diff` first on anything touching a live system.
## Service-specific notes
- `infrastructure/proxmox/`**PVE has no floppy drive**, in the UI *or* the config schema, and `-nodefaults`
means QEMU does not create one either. Retro guests get it through the `args` field:
`qm set <vmid> -args "-drive if=floppy,format=raw,file=/mnt/pve/laptop/template/iso/<x>.img"`.
`args` is appended last, so a trailing `-boot order=a` there also overrides PVE's own `-boot`
if you need to boot the floppy. Changing `args` needs a **power cycle** (`qm reboot` is not
enough); swapping the *medium* afterwards does not —
`echo "change floppy0 /path/y.img" | qm monitor <vmid>` (`eject floppy0` first if you want to
write the image from the host). Do not edit the image underneath a running guest.
`roles/pve_floppy` puts 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 because `pvesh` proxies to whichever node owns the VM. It shells out as root rather
than using an API token on purpose: **`args` is root@pam-only** — the check is a literal
`$authuser eq 'root@pam'` and a token's authuser is `root@pam!name`, so no token, however
privileged, can set it. Login is delegated to PVE's `/access/ticket` (via pvedaemon on
`127.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 a `Sys.Modify` on `/` 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.top` by Host header. Adding a service = an `HTTPRoute` + a DNS A
record in `samba-ad`; no cert work (see `platform/cert-manager/`). Authelia forward-auth is
available per-route via a `SecurityPolicy``apps/netbox/securitypolicy.yaml` is 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) and
`bao-acme` (OpenBao internal PKI). One `*.ad.ddupan.top` wildcard serves every LAN
service, deliberately, so per-host names stay out of Certificate Transparency logs.
- `apps/netbox/`**deployed** at `netbox.ad.ddupan.top`, still an *evaluation*: nothing
consumes it yet, so deleting it breaks nothing. `terraform/topology.yml` is the
authoritative data (git → NetBox, a derived mirror — never edit via the UI);
`apps/netbox/terraform/` applies it. `generate/*.py` read back out and `--diff` against live
systems. Read `apps/netbox/README.md`; `CONTEXT.md` is the original brief.
- `archive/keycloak/` and `archive/casdoor/` are retired IdP experiments; Authelia is the only identity stack.
+31
View File
@@ -0,0 +1,31 @@
# homelab-infra
Infrastructure and service configuration for the homelab. Git is the source of
intent; the live estate is being adopted gradually, so a file being present does
not yet imply that Flux or CI owns the corresponding resource.
## Layout
| directory | purpose |
|---|---|
| `clusters/homelab/` | Flux composition and cluster-specific reconciliation entrypoint |
| `platform/` | cluster-wide controllers, ingress, storage and observability |
| `apps/` | user-facing and supporting applications |
| `infrastructure/` | Proxmox, local hosts/VMs, identity, secrets, Cloudflare and OCI |
| `archive/` | retired implementations retained for operational history |
| `docs/` | architecture decisions, migration plans and runbooks |
Each component remains independently deployable. There is no shared root package
or Terraform root, and service-specific Terraform states must remain isolated.
## Safety
- Treat the homelab as household production.
- Run Terraform plan and Ansible check/diff before mutation.
- Existing resources require a zero-change adoption plan before CI may apply.
- Never commit Terraform plans/states, live Kubernetes Secrets or plaintext keys.
- Kubernetes will be reconciled by Flux; Backstage is a portal, not a deployer.
- Recovery of PVE, OpenBao and Git must not depend on k3s being healthy.
See [the redesign record](docs/homelab-gitops-redesign.md) for the target
architecture and phased migration.
+66
View File
@@ -0,0 +1,66 @@
# Authelia — SSO over Samba AD
Authelia is the web SSO layer on top of the Samba AD DC (`../../infrastructure/samba-ad/`): it authenticates
users against **AD over LDAPS** and provides an auth portal + (round 2) an OIDC provider.
Deployed via the **official Helm chart**; config-as-code lives in `values.yaml`.
- Chart: `authelia/authelia` (app 4.39.20)
- Exposure: **cloudflared**`auth.ddupan.top``authelia.authelia.svc:9091`
(the chart's own ingress is disabled; see `../../infrastructure/cloudflared/cloudflared.yaml`)
- Identity: LDAPS to the DC (`ldaps://192.168.10.5:636`), bind as `svc-authelia`
- Storage: dedicated `authelia` role/db on `shared-postgresql` (no shared superuser)
- Secrets: chart auto-generates session/JWT keys; `values.yaml` pins the LDAP + DB
passwords and the storage encryption key (data-at-rest, must stay stable)
## Prerequisites (already done)
- AD service account `svc-authelia` (read-only bind), never-expires — created with
`samba-tool user create svc-authelia ... ; samba-tool user setexpiry svc-authelia --noexpiry`.
## Deploy
These touch the live cluster / shared Postgres, so run them yourself (auto-mode gates
writes to shared infra):
```bash
# 1. dedicated Postgres role + database (run against the CNPG primary)
POD=$(kubectl -n shared-db get pods -l cnpg.io/instanceRole=primary -o jsonpath='{.items[0].metadata.name}')
kubectl -n shared-db exec "$POD" -c postgres -- psql -U postgres -v ON_ERROR_STOP=0 \
-c "CREATE ROLE authelia LOGIN PASSWORD 'Adbdf340cea488a90b4cf07Aa1!'" \
-c "CREATE DATABASE authelia OWNER authelia"
# 2. install Authelia
helm repo add authelia https://charts.authelia.com && helm repo update authelia
helm upgrade --install authelia authelia/authelia \
-n authelia --create-namespace -f authelia/values.yaml
# 3. repoint the tunnel (auth.ddupan.top -> authelia) — already edited in the file
kubectl apply -f cloudflared/cloudflared.yaml
kubectl -n cloudflared rollout restart deployment/cloudflared
```
> The DB password above must match `configMap.storage.postgres.password.value` in
> `values.yaml`. If you rotate it, change both.
## Verify
```bash
kubectl -n authelia rollout status deploy/authelia
kubectl -n authelia logs deploy/authelia | grep -iE 'listening|ldap|error'
# then browse https://auth.ddupan.top and log in as an AD user (e.g. administrator)
```
## Round 2 — enable the OIDC provider
Uncomment/add `configMap.identity_providers.oidc` in `values.yaml`: set an
`hmac_secret` (auto-gen ok) and a `jwks` RSA key, then register clients under
`identity_providers.oidc.clients`. Re-run the `helm upgrade` above. Once OIDC is
proven, retire Keycloak (`../keycloak/`) and its `idm.ddupan.top` tunnel entry.
## Notes
- **Contour/Envoy** can't do Authelia forward-auth (gRPC ext_authz only); protect
apps via **OIDC** or route forward-auth through **Traefik** (`ForwardAuth`).
- Sessions are in-memory (single replica). For HA add `configMap.session.redis`.
- Secrets are inline in `values.yaml` (homelab style, like the other services here);
move to sops/sealed-secrets if this leaves the homelab.
+38
View File
@@ -0,0 +1,38 @@
# Serves auth.ddupan.top from the LAN gateway.
#
# Pairs with ../../platform/cert-manager/certificate-auth-ddupan.yaml (the TLS cert) and the
# `https-auth` listener in ../../platform/envoy-gateway/gateway.yaml. Read the certificate
# manifest for the full reasoning — in short, auth.ddupan.top resolves to
# Cloudflare proxy IPs that are unroutable from this network, so every in-cluster
# consumer of Authelia's OIDC endpoints was hairpinning through an internet path
# that does not work.
#
# This is only the LAN path. The PUBLIC path is unchanged: Cloudflare -> tunnel ->
# cloudflared pod -> authelia Service. Both terminate at the same Service, so there
# is one Authelia, one issuer, one set of redirect URIs.
#
# NOTE: this does NOT get an Authelia SecurityPolicy. Authelia must never sit
# behind its own forward-auth — that is an infinite redirect. Only the apps it
# protects get one (see ../netbox/securitypolicy.yaml).
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: authelia
namespace: authelia
spec:
parentRefs:
- name: eg
namespace: envoy-gateway-system
# Pin to the dedicated listener. Without sectionName the route would also try
# to attach to the `https` listener, whose hostname *.ad.ddupan.top cannot
# match auth.ddupan.top — an unnecessary "no matching listener" condition.
sectionName: https-auth
hostnames:
- auth.ddupan.top
rules:
- backendRefs:
# Service port 80 -> container 9091. Authelia speaks plain HTTP here; TLS is
# terminated at the gateway, same as the public path terminates at Cloudflare.
- name: authelia
port: 80
+22
View File
@@ -0,0 +1,22 @@
# Lets SecurityPolicy objects in other namespaces send ext-authz requests to the
# Authelia Service. Gateway API forbids cross-namespace backend references unless the
# TARGET namespace grants them — this is that grant, and it lives here because the
# authelia namespace is the one consenting to be referenced.
#
# Add a namespace to `from` for each service placed behind Authelia forward-auth.
---
apiVersion: gateway.networking.k8s.io/v1beta1
kind: ReferenceGrant
metadata:
name: extauth-from-services
namespace: authelia
spec:
from:
- group: gateway.envoyproxy.io
kind: SecurityPolicy
namespace: netbox # ../netbox/securitypolicy.yaml
to:
# Unnamed => any Service in this namespace. Only `authelia` exists here, and
# naming it would break on a chart-driven rename.
- group: ""
kind: Service
+37
View File
@@ -0,0 +1,37 @@
# Template. Copy to secret.yaml, fill in real values, apply, then `helm upgrade`.
# secret.yaml is gitignored — same convention as ../../platform/cert-manager, ../netbox,
# ../smtp-relay, ../../infrastructure/cloudflared and ../gitea.
#
# values.yaml sets `secret.existingSecret: authelia-secrets`, so the chart mounts
# THIS Secret instead of generating one from inline `value:` fields. Every
# `path:` in values.yaml resolves against it.
#
# ⚠ The mount path is /secrets/internal, NOT /secrets/authelia-secrets. Relative
# paths in values.yaml are composed by the chart; the one absolute path (the JWKS
# key) must say /secrets/internal explicitly.
#
# Key names below are exactly the ones the chart generates by default, so they
# must not be renamed without changing the matching `path:` in values.yaml.
#
# ⚠ identity_providers.oidc.jwks.main.pem is the OIDC SIGNING KEY. Replacing it
# invalidates every issued token estate-wide. It lives here rather than as a
# `value:` in values.yaml because the chart inlines `value:` jwks keys into the
# ConfigMap in plaintext.
---
apiVersion: v1
kind: Secret
metadata:
name: authelia-secrets
namespace: authelia
type: Opaque
stringData:
authentication.ldap.password.txt: REPLACE_WITH_SVC_AUTHELIA_LDAP_PASSWORD
storage.postgres.password.txt: REPLACE_WITH_AUTHELIA_DB_PASSWORD
storage.encryption.key: REPLACE_WITH_64_HEX_CHARS
session.encryption.key: REPLACE_WITH_RANDOM_SECRET
identity_validation.reset_password.jwt.hmac.key: REPLACE_WITH_RANDOM_SECRET
identity_providers.oidc.hmac.key: REPLACE_WITH_RANDOM_SECRET
identity_providers.oidc.jwks.main.pem: |
-----BEGIN PRIVATE KEY-----
REPLACE_WITH_RS256_PRIVATE_KEY
-----END PRIVATE KEY-----
+288
View File
@@ -0,0 +1,288 @@
# Authelia — official Helm chart (authelia/authelia). Config-as-code lives here.
# Install: helm upgrade --install authelia authelia/authelia -n authelia --create-namespace -f values.yaml
# Exposure: via cloudflared (auth.ddupan.top -> authelia.authelia.svc:9091), NOT the chart ingress.
# Secrets: NONE are inline here. All seven live in Kubernetes Secrets and are referenced
# by path — see secret.example.yaml. This file is safe to commit.
image:
tag: '4.39.20'
# We expose via cloudflared, so the chart's own ingress stays off.
ingress:
enabled: false
# Mount our own Secret instead of letting the chart generate one from inline
# `value:` fields. Its keys are exactly the ones the chart used to generate, plus
# identity_providers.oidc.jwks.main.pem for the OIDC signing key — so every
# `path:` below resolves, and no key material changed when this was introduced
# (the Secret was built from the live chart-generated one). See secret.example.yaml.
secret:
existingSecret: authelia-secrets
# The JWKS signing key needs its OWN Secret, mounted separately.
#
# ⚠ WHY not just add a 7th key to authelia-secrets: the chart projects the
# existingSecret volume with an explicit `items:` list containing only the six
# keys it generates. An extra key is stored in the Secret but NEVER mounted, so
# the file is missing at runtime and Authelia dies with
# "no such file or directory" — which cascades into every other config option
# appearing "required". Verified the hard way 2026-07-28.
#
# additionalSecrets mounts at {secret.mountPath}/{key} = /secrets/authelia-oidc-jwks
additionalSecrets:
authelia-oidc-jwks:
items:
- key: 'main.pem'
path: 'main.pem'
configMap:
authentication_backend:
password_reset:
disable: true # AD owns passwords (reset via ADUC / Windows)
refresh_interval: '5 minutes'
ldap:
enabled: true
implementation: 'activedirectory'
# MUST be the hostname, NOT 192.168.10.5: dc1's LDAPS cert is issued by
# OpenBao's ACME with a DNS SAN only (no IP SAN), so connecting by IP fails
# verification with "IP address mismatch". In-cluster pods resolve this name.
address: 'ldaps://dc1.ad.ddupan.top:636'
tls:
# Was skip_verify: true ("DC self-signed cert; add CA to trust later").
# Later arrived: dc1 now serves a cert from the OpenBao internal CA, which
# is mounted below via certificates.values, so the bind is really verified.
skip_verify: false
base_dn: 'DC=ad,DC=ddupan,DC=top'
additional_users_dn: 'CN=Users'
additional_groups_dn: 'CN=Users'
user: 'CN=svc-authelia,CN=Users,DC=ad,DC=ddupan,DC=top'
password:
# From the authelia-secrets Secret (secret.example.yaml). Relative path
# resolves to {secret.mountPath}/{secret.existingSecret}/{path}.
path: 'authentication.ldap.password.txt'
# Authorization endpoints. `ext-authz` is what Envoy Gateway's SecurityPolicy calls
# (Envoy's HTTP ExtAuthz filter). Declared explicitly rather than relying on the
# default set, so the contract with ../../platform/envoy-gateway is visible here.
server:
endpoints:
authz:
ext-authz:
implementation: 'ExtAuthz'
access_control:
default_policy: 'two_factor' # require a second factor for every request
rules:
# ⚠ ORDER MATTERS — Authelia evaluates top-down, FIRST MATCH WINS. This bypass
# must precede the two_factor rule below or the API stays unreachable.
#
# WHY BYPASS: forward-auth intercepts every request, including API calls that
# carry a valid NetBox token — Authelia has no idea what a NetBox token is, sees
# no session cookie, and 302s the caller to the login portal. That breaks the
# entire point of a source of truth (Ansible/Terraform reading from it).
#
# This is NOT unauthenticated access: NetBox enforces its own token auth on these
# paths and LOGIN_REQUIRED makes an anonymous call return 403. We are choosing
# which authenticator guards the API — NetBox's tokens — not removing one.
- domain: 'netbox.ad.ddupan.top'
resources:
- '^/api/'
- '^/graphql/'
policy: 'bypass'
# Everything else on NetBox: browser traffic. default_policy would already force
# 2FA, but this rule additionally restricts WHO gets in — without a subject match
# any AD account passing 2FA would be auto-provisioned a NetBox user.
- domain: 'netbox.ad.ddupan.top'
policy: 'two_factor'
subject:
- 'group:netbox-admins'
# Second factors. Both are on by chart default; we brand them and enable passkeys.
totp:
disable: false
issuer: 'ddupan.top' # shown in authenticator apps
webauthn:
disable: false
display_name: 'ddupan.top' # shown in the browser passkey/security-key prompt
enable_passkey_login: true # allow usernameless passkey login at the portal
session:
expiration: '1 hour'
inactivity: '5 minutes'
cookies:
- subdomain: 'auth'
domain: 'ddupan.top' # -> https://auth.ddupan.top, SSO across *.ddupan.top
regulation:
max_retries: 3
find_time: '2 minutes'
ban_time: '5 minutes'
storage:
encryption_key:
path: 'storage.encryption.key'
postgres:
enabled: true
address: 'tcp://shared-postgresql.shared-db.svc.cluster.local:5432'
database: 'authelia'
username: 'authelia'
password:
path: 'storage.postgres.password.txt'
notifier:
# Sends via the in-cluster Postfix+OAuth relay (see ../smtp-relay/). Plain hop on
# :25 — the relay handles STARTTLS + OAuth to Microsoft 365. No auth to the relay
# (it trusts the pod network).
smtp:
enabled: true
address: 'smtp://smtp-relay.smtp-relay.svc.cluster.local:25'
sender: 'Authelia <[email protected]>'
subject: '[Authelia] {title}'
disable_require_tls: true
disable_starttls: true
startup_check_address: '[email protected]'
username: ''
password:
disabled: true # relay needs no auth; stop Authelia attempting SMTP AUTH
# OIDC provider — replaces Keycloak as the SSO/OIDC issuer (https://auth.ddupan.top).
# Crypto material generated with `authelia crypto` (hmac_secret, RSA JWKS key). Client
# secrets are stored HASHED here (pbkdf2-sha512); the RP (Gitea) holds the plaintext.
identity_providers:
oidc:
enabled: true
hmac_secret:
path: 'identity_providers.oidc.hmac.key'
# Authelia 4.39 only returns standard claims from the UserInfo endpoint by
# default. Gitea reads email/preferred_username from the ID Token, so we
# inject them there via a claims policy referenced by the client below.
claims_policies:
gitea:
id_token:
- 'preferred_username'
- 'email'
- 'email_verified'
- 'name'
- 'groups'
# Grafana maps AD groups -> roles from the `groups` claim; inject it (and
# profile/email) into the ID Token so role_attribute_path can resolve.
grafana:
id_token:
- 'preferred_username'
- 'email'
- 'email_verified'
- 'name'
- 'groups'
# NOTE: there is deliberately no `netbox` claims policy. NetBox was migrated
# off OIDC to forward-auth (../netbox/securitypolicy.yaml) precisely because
# NetBox has no SSO group->role mapping — see netbox/README.md.
# OpenBao maps user_claim=preferred_username and groups_claim=groups onto
# policies; inject those (Authelia returns only standard claims by default).
openbao:
id_token:
- 'preferred_username'
- 'email'
- 'email_verified'
- 'name'
- 'groups'
jwks:
- key_id: 'main'
algorithm: 'RS256'
use: 'sig'
key:
# ⚠ WHY path and NOT value: the chart inlines a `value:` jwks key
# straight into the ConfigMap (files/configuration.oidc.jwk.yaml), so
# the OIDC SIGNING KEY ends up in a ConfigMap in plaintext. `path:`
# reads it from the mounted Secret instead.
#
# NOTE the two different mount points: the existingSecret volume lands
# at /secrets/internal (not /secrets/<secretName>), while each
# additionalSecrets entry lands at /secrets/<its own name>.
path: '/secrets/authelia-oidc-jwks/main.pem'
clients:
- client_id: 'gitea'
client_name: 'Gitea'
# pbkdf2-sha512 hash of the plaintext secret Gitea holds (gitea-keycloak-secret).
client_secret: '$pbkdf2-sha512$310000$M7VHgkBsYT.PDUH99k4JWw$qI6vVq1zDp.3z2oNecBP5bwzPu.XHtmA.tGW4osvHlp1rwZISak5pG7.fctHa5eNdeSEIuhaZ6HSeajtPzSkOw'
public: false
authorization_policy: 'two_factor' # SSO logins also require a second factor
claims_policy: 'gitea' # inject email/preferred_username into the ID Token
require_pkce: false
token_endpoint_auth_method: 'client_secret_basic'
redirect_uris:
- 'https://git.ddupan.top/user/oauth2/authelia/callback'
scopes:
- 'openid'
- 'profile'
- 'email'
- 'groups'
userinfo_signed_response_alg: 'none'
- client_id: 'grafana'
client_name: 'Grafana'
# pbkdf2-sha512 hash of the plaintext secret Grafana holds (grafana-oidc Secret).
# Generate the pair: authelia crypto hash generate pbkdf2 --variant sha512 --random --random.length 72
client_secret: '$pbkdf2-sha512$310000$Hhni5VBeqfz3IM1ULxbKbQ$o/Q7xRp82OI2Y43qSpGZig8Md3uMLkm6SGViJ6XszMLw2MNZYYJizOyQfRLvQvGz7Q1p5DK2v10lOfdhs8gHpg'
public: false
authorization_policy: 'two_factor' # SSO logins also require a second factor
claims_policy: 'grafana' # inject groups/email into the ID Token
require_pkce: false
token_endpoint_auth_method: 'client_secret_basic'
redirect_uris:
- 'https://grafana.tail7e769.ts.net/login/generic_oauth'
scopes:
- 'openid'
- 'profile'
- 'email'
- 'groups'
userinfo_signed_response_alg: 'none'
- client_id: 'openbao'
client_name: 'OpenBao'
# pbkdf2-sha512 hash; OpenBao holds the plaintext (its oidc config / vault).
# Regenerate: authelia crypto hash generate pbkdf2 --variant sha512 --random --random.length 72
client_secret: '$pbkdf2-sha512$310000$un1B3DyN5dgvwfedazLFtw$ORSxfE4EkkSfSUtXGERV5Wmzxnmsw8hJw37frksHgbYFHppRaHVAfpaUxQ/2XCXgVefyVfMxU8K.FcgBC7c35A'
public: false
authorization_policy: 'two_factor' # SSO logins also require a second factor
claims_policy: 'openbao' # inject groups/email into the ID Token
require_pkce: false
token_endpoint_auth_method: 'client_secret_basic'
grant_types:
- 'authorization_code' # UI + CLI (client/direct callback modes)
- 'urn:ietf:params:oauth:grant-type:device_code' # headless: bao login -method=oidc callbackmode=device
redirect_uris:
- 'https://bao.ad.ddupan.top:8200/ui/vault/auth/oidc/oidc/callback' # UI login
- 'http://localhost:8250/oidc/callback' # CLI: bao login -method=oidc
scopes:
- 'openid'
- 'profile'
- 'email'
- 'groups'
userinfo_signed_response_alg: 'none'
# Trust anchors mounted into the container and loaded by Authelia. Needed so the
# LDAPS bind to dc1 can be VERIFIED rather than skipped. Fetched from OpenBao's
# unauthenticated PKI endpoint: https://bao.ad.ddupan.top:8200/v1/pki/ca/pem
certificates:
values:
- name: 'ddupan_internal_ca.pem'
value: |
-----BEGIN CERTIFICATE-----
MIIDMzCCAhugAwIBAgIUMs0iV657yC9UhA2p2vomLIbFnzgwDQYJKoZIhvcNAQEL
BQAwITEfMB0GA1UEAxMWZGR1cGFuLnRvcCBJbnRlcm5hbCBDQTAeFw0yNjA3MjQy
MDE1MDFaFw0zNjA3MjEyMDE1MzFaMCExHzAdBgNVBAMTFmRkdXBhbi50b3AgSW50
ZXJuYWwgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC6QWlwBe6f
t7Ca3KCTvr4Pz+jVO60WrMBoEDYYM8Mp04btBHzhAQHf9Pp8+15aEW9iUcQhqqm+
2vT6H0JEhIbplyCWY6Guv0mTu8f+lvFknJIl2b3JqnMLHJKjh/rBrsE12XZ3i17M
2tCr34BWcei85IZyQl5HMW6dB8lAE6bdom+YynK4oLJdej9DD6bSyM8WcL0OsneZ
NsjwOlNMy3zjbtaH6mH71SgbFinxLp3AAAuLVe1DIKhFxuTQeVr/WaPum5y/oOsc
0gJp9If6nsC33lpRGcPLiZE9kfFZa4fPe8laCaN8q1K253qZ0rjRiDhbTAppW4Fy
r5P67h+2D+TbAgMBAAGjYzBhMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTAD
AQH/MB0GA1UdDgQWBBSOgk1fR0qhz/Bo4wD9g2BnOAzDXzAfBgNVHSMEGDAWgBSO
gk1fR0qhz/Bo4wD9g2BnOAzDXzANBgkqhkiG9w0BAQsFAAOCAQEANm5kKkts1Ar2
7IlS+TxLFrZ/C9yhIdGcBk2SL5E+5E8S3skQWLEPGLRwvV4RmiB8gQ2V6UyGLrCx
1MuuSmCDaSYL9G66sGX1MIHlQ0F0bHIOxxtsTwIYzb5Sl8h3MfsARabmOhE3xUkn
jaAT9YUweHhjF4vi0U1Q4F8oOSvu4eJp5dMx1r7b2bLN90A1xh9sfdkEenSBX0tm
xK82ROYXI2Ejv/EO+lPUIn3jfqbqrS2itw75Xz/ECHjIfSxvW98puP69U54a1gf6
gWdXslr0pGkyMHqxw4dmaecpK0QK3jvqCNycNwNBfMdCypS2QRy03adcUosEAP3O
LZU7Kd8aeg==
-----END CERTIFICATE-----
+104
View File
@@ -0,0 +1,104 @@
# Blocky — LAN resolver, ad-blocker, split-horizon DNS
**Status: DEPLOYED and verified 2026-07-28. NOT yet the LAN resolver** — DHCP still
hands out the DC/router pair, so only clients querying 192.168.10.127 explicitly
use it. Flip DHCP to adopt it; `docker compose down` to abandon it.
Solves three things at once:
1. **Split-horizon.** `git.ddupan.top`, `auth.ddupan.top` and `obj.ddupan.top` are
public names that resolve to Cloudflare. On the LAN they should resolve to the
Envoy gateway so traffic never leaves the network to reach a service hosted on
it. There is currently **nowhere** to put such a record: the DC is
authoritative only for `ad.ddupan.top`, and the NEC IX has no static-host
feature (`show dns` offers only `fqdn-database`).
2. **Ad-blocking**, which the DC cannot do.
3. **Query visibility.** There is none today. On 2026-07-28 a dead VPN tunnel was
mis-diagnosed as a bad ISP for hours; a query log would have shortened that.
## Why here, and why compose
Only the laptop is always-on, so PVE is out despite being better isolated.
Within the laptop, this is a **compose stack rather than a k3s Deployment** on
purpose: DNS is the most foundational service on the network, and putting it
inside the cluster means a bad upgrade or a CrashLoop takes name resolution with
it. The cluster broke twice on 2026-07-28. Compose starts earlier in boot and has
fewer dependencies. k3s CoreDNS is untouched and keeps serving pods.
## Before deploying — the one step with no workaround
**The NEC IX is the DHCP server** (`netboot/dnsmasq.conf` is proxyDHCP only —
`dhcp-range=…,proxy` — so it assigns nothing). Clients get their resolver list
from the router, so pointing them at Blocky means changing the DHCP DNS option
there. Confirm that is editable before relying on any of this.
**Keep the router as a secondary resolver.** Today, if the laptop dies, clients
fall back to the router and the internet still works for the household. If Blocky
becomes the *only* resolver, a laptop reboot is a household-wide DNS outage.
The trade-off, stated plainly: clients that fall through to the secondary get
**no ad-blocking and no split-horizon** — they resolve `git.ddupan.top` to
Cloudflare and reach Gitea over the tunnel. That fails *open*, which is the right
direction for a household. Choose consistency over resilience only deliberately.
## Deploy
> Two bugs only surfaced on first run, both now fixed — see the notes at the
> bottom. Staging validated the syntax but could not have caught either.
```bash
mkdir -p logs
docker compose up -d
docker compose logs -f blocky # expect "listening" + denylist counts
```
Verify **before** touching DHCP — nothing depends on it until then:
```bash
dig @192.168.10.127 git.ddupan.top +short # -> 192.168.10.127 (split-horizon)
dig @192.168.10.127 dc1.ad.ddupan.top +short # -> 192.168.10.5 (conditional -> DC)
dig @192.168.10.127 github.com +short # -> real answer (upstream)
dig @192.168.10.127 doubleclick.net +short # -> NXDOMAIN (blocked)
curl -s http://192.168.10.127:4000/metrics | head
```
Only once all five behave should the DHCP option change.
## Rollback
Blocky holds no state that matters. Revert the DHCP DNS option and
`docker compose down`; clients return to the DC/router pair on next lease. Keep
the old option value written down before changing it.
## Notes
- **Upstream is the router (`192.168.10.1`), not DoH.** `cloudflare-dns.com` sits
in `104.21/16` and `172.67/16` — precisely the ranges the NAIST VPN's
split-tunnel routes swallow when the tunnel dies. A DoH upstream there would
fail in the same silent way that cost hours on 2026-07-28.
- **AAAA is filtered for the custom names** (`filterUnmappedTypes` defaults true).
Deliberate: the laptop's only global IPv6 belongs to `tun0`, so a AAAA answer
would push LAN traffic into the VPN. Matches `../../platform/k3s/coredns-custom.yaml`.
- `../../platform/k3s/coredns-custom.yaml` still handles the same names for **pods**. The two
are independent and must be kept in sync — a name added here usually wants
adding there too.
- Metrics are scraped by `../../platform/observability/metrics/scrapes/blocky.yaml`.
## Bugs found on first deploy
Both were in the staged config and invisible to `docker compose config`, yamllint
and a server-side dry-run. Worth recording because the same shape recurs:
1. **`ports.dns: 192.168.10.127:53` in `config.yml`.** Those are the *container's*
listen addresses, and that IP does not exist inside a bridge-networked
container — Blocky exited with `cannot assign requested address`. Host-side
restriction belongs in the compose `ports:` mapping; the container listens on
all interfaces. Loud failure, quick fix.
2. **Query log silently did nothing.** The image runs as uid 100, `./logs` is
created as uid 1000, so every query logged `fileQueryLogWriter: permission
denied` while DNS itself worked perfectly. This is the dangerous one: the
service looked healthy, answered correctly, and passed its healthcheck while
the feature it was deployed for produced nothing. Fixed with `user: "1000:1000"`.
The second is the reason to check the *feature*, not just the process state.
+54
View File
@@ -0,0 +1,54 @@
# Blocky — LAN DNS. STAGED, NOT DEPLOYED. See README.md.
#
# WHY compose on the laptop and NOT a k3s Deployment, given everything else here
# is Kubernetes:
# * Only the laptop is always-on, so the PVE nodes are not an option.
# * DNS is the most foundational service on the network. Running it inside k3s
# means a CrashLoopBackOff or a bad `helm upgrade` takes LAN name resolution
# with it — and the cluster broke twice on 2026-07-28 alone.
# * A compose unit starts earlier in boot and has fewer moving parts than
# kubelet -> CNI -> CoreDNS -> Deployment.
# k3s CoreDNS is unaffected and keeps doing its pod-only job.
services:
blocky:
image: spx01/blocky:v0.26
container_name: blocky
restart: unless-stopped
# Bridge networking with an EXPLICIT host IP, not network_mode: host. The
# laptop already has :53 bound on 192.168.100.1, 192.168.122.1 and the
# systemd-resolved stub; host networking plus a 0.0.0.0 bind would collide.
# Binding br0 only also means the libvirt networks keep their own resolvers.
ports:
- "192.168.10.127:53:53/udp"
- "192.168.10.127:53:53/tcp"
- "192.168.10.127:4000:4000/tcp" # REST API + /metrics
# The image runs as uid 100 by default, which cannot write to ./logs (created
# as the invoking user, uid 1000) — Blocky then logs
# "fileQueryLogWriter: permission denied" every query and silently keeps no
# query log at all. Running as the directory owner is more reproducible than a
# chown, because a fresh clone creates ./logs as uid 1000 anyway.
user: "1000:1000"
volumes:
- ./config.yml:/app/config.yml:ro
- ./logs:/logs
environment:
TZ: Asia/Tokyo
# Blocky answers its own health check. If DNS stops resolving, restart rather
# than sit there accepting queries it cannot serve.
healthcheck:
test: ["CMD", "/app/blocky", "healthcheck"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
+109
View File
@@ -0,0 +1,109 @@
# Blocky — LAN resolver, ad-blocker and split-horizon DNS.
#
# DEPLOYED 2026-07-28 and verified, but NOT yet the LAN resolver — clients still
# get the DC/router pair from DHCP. Making it the resolver needs a DHCP change on
# the NEC IX; see README.md. Until then only clients that query 192.168.10.127
# explicitly are affected, so this is safely reversible.
ports:
# These are the CONTAINER's listen addresses, so they must be unqualified —
# 192.168.10.127 does not exist inside a bridge-networked container, and Blocky
# exits with "cannot assign requested address" if you put it here.
#
# Restricting to the LAN address is done on the HOST side, by the explicit
# 192.168.10.127:53:53 mapping in compose.yaml. That matters because the laptop
# already has :53 bound on 192.168.100.1, 192.168.122.1 (libvirt bridges) and
# 127.0.0.53/54 (the resolved stub) — a plain 53:53 mapping would collide.
dns: 53
# REST API + Prometheus metrics. Not :80, which Envoy already holds.
http: 4000
upstreams:
# strict = try the group in order rather than racing them. One upstream here,
# so the practical effect is "no surprises".
strategy: strict
groups:
default:
# The NEC IX, deliberately. NOT a DoH/DoT resolver at Cloudflare:
# cloudflare-dns.com lives in 104.21/16 and 172.67/16, exactly the ranges
# the NAIST VPN's 58 split-tunnel routes swallow when the tunnel dies. That
# would make DNS fail completely in the same silent way that cost hours on
# 2026-07-28. Plain UDP to the router keeps working when the tunnel does not.
- 192.168.10.1
conditional:
# Queries for the AD zone go straight to the DC, which is authoritative. This
# replaces the "DC first, router second" resolver ordering that clients use today.
mapping:
ad.ddupan.top: 192.168.10.5
# Reverse lookups for LAN hosts — the DC holds the reverse zone.
10.168.192.in-addr.arpa: 192.168.10.5
customDNS:
customTTL: 1h
# Split-horizon. These names are PUBLIC (Cloudflare is authoritative for
# ddupan.top) and resolve to Cloudflare from outside, which is correct. On the
# LAN they must resolve to the Envoy gateway instead, so traffic never leaves
# the network to reach a service hosted on it.
#
# Each has a real Let's Encrypt cert for the exact name on the gateway, so TLS
# verifies identically inside and out and no client config differs.
#
# filterUnmappedTypes defaults to true, which returns an empty answer for AAAA.
# That is deliberate and matches what k3s CoreDNS does for the same names — the
# laptop's only global IPv6 belongs to tun0, so a AAAA answer would send LAN
# traffic into the VPN. See CLAUDE.md.
mapping:
git.ddupan.top: 192.168.10.127
auth.ddupan.top: 192.168.10.127
obj.ddupan.top: 192.168.10.127
blocking:
denylists:
ads:
- https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts
- https://s3.amazonaws.com/lists.disconnect.me/simple_ad.txt
clientGroupsBlock:
default:
- ads
# nxDomain rather than zeroIp: some clients retry forever against 0.0.0.0,
# whereas NXDOMAIN is an unambiguous "stop asking".
blockType: nxDomain
loading:
# The WAN is unreliable. Do not fail startup because a denylist could not be
# fetched — start with what is cached and refresh later. A resolver that
# refuses to boot without the internet is a worse outcome than stale lists.
strategy: fast
refreshPeriod: 24h
downloads:
timeout: 60s
attempts: 5
cooldown: 10s
caching:
# serve-stale equivalent: keep answering from cache while upstream is
# unreachable. Same reasoning as the CoreDNS `serve_stale` note in
# ../../platform/k3s/coredns-custom.yaml — WAN blips must not become resolution failures.
minTime: 5m
maxTime: 30m
maxItemsCount: 0
prefetching: true
prefetchExpires: 2h
prefetchThreshold: 5
cacheTimeNegative: 30s
prometheus:
enable: true
path: /metrics
# queryLog gives the DNS visibility that does not exist today. Sizing this at 7
# days on purpose: long enough to answer "what was resolving when X broke",
# short enough not to grow unbounded on the laptop's disk.
queryLog:
type: csv
target: /logs
logRetentionDays: 7
log:
level: info
format: text
+24
View File
@@ -0,0 +1,24 @@
# Codex Proxy Configuration
# Copy this file to .env and edit values as needed: cp .env.example .env
# Optional: paste ChatGPT JWT to skip OAuth login (leave empty to use OAuth)
CODEX_JWT_TOKEN=
# Platform: linux for Docker, darwin for macOS, win32 for Windows
CODEX_PLATFORM=linux
# Architecture: x64 for Docker/Intel, arm64 for Apple Silicon
CODEX_ARCH=x64
# Server port
PORT=8080
# Optional built-in Ollama-compatible bridge.
# Leave blank to use config/default.yaml + data/local.yaml values.
# For Docker with published port 11434, set host to 0.0.0.0 inside the
# container and bind the compose port to 127.0.0.1 on the host.
OLLAMA_BRIDGE_ENABLED=
OLLAMA_BRIDGE_HOST=
OLLAMA_BRIDGE_PORT=
OLLAMA_BRIDGE_VERSION=
OLLAMA_BRIDGE_DISABLE_VISION=
+74
View File
@@ -0,0 +1,74 @@
api:
base_url: https://chatgpt.com/backend-api
timeout_seconds: 600
client:
originator: Codex Desktop
app_version: 26.318.11754
build_number: "1100"
platform: darwin
arch: arm64
chromium_version: "144"
model:
default: gpt-5.4
default_reasoning_effort: null
default_service_tier: null
inject_desktop_context: false
suppress_desktop_directives: false
auth:
jwt_token: null
chatgpt_oauth: true
refresh_enabled: true
refresh_margin_seconds: 300
rotation_strategy: least_used
rate_limit_backoff_seconds: 60
oauth_client_id: app_EMoamEEZ73f0CkXaXp7hrann
oauth_auth_endpoint: https://auth.openai.com/oauth/authorize
oauth_token_endpoint: https://auth.openai.com/oauth/token
server:
host: "::"
port: 8080
proxy_api_key: null
trust_proxy: false
logs:
enabled: false
capacity: 2000
capture_body: false
llm_only: true
usage_stats:
# null = keep usage history forever
history_retention_days: null
session:
ttl_minutes: 60
cleanup_interval_minutes: 5
tls:
transport: native
proxy_url: null
force_http11: false
quota:
refresh_interval_minutes: 0
concurrency: 10
warning_thresholds:
primary:
- 80
- 90
secondary:
- 80
- 90
skip_exhausted: true
update:
auto_update: true
show_update_dialog: false
# allow_prerelease: false # set true to receive beta builds (vX.Y.Z-beta.SHA) from the dev branch
ollama:
enabled: false
host: 127.0.0.1
port: 11434
version: "0.18.3"
disable_vision: false
official_agent:
enabled: false
api_key: null
app_server_url: ws://127.0.0.1:4500
request_timeout_ms: 30000
auth:
type: none
+32
View File
@@ -0,0 +1,32 @@
user_agent_template: "Codex Desktop/{version} ({platform}; {arch})"
auth_domains: ["chatgpt.com", "*.chatgpt.com", "openai.com", "*.openai.com"]
auth_domain_exclusions: ["ab.chatgpt.com"]
header_order:
- "Authorization"
- "ChatGPT-Account-Id"
- "originator"
- "x-openai-internal-codex-residency"
- "x-client-request-id"
- "x-codex-installation-id"
- "x-codex-turn-state"
- "OpenAI-Beta"
- "User-Agent"
- "sec-ch-ua"
- "sec-ch-ua-mobile"
- "sec-ch-ua-platform"
- "Accept-Encoding"
- "Accept-Language"
- "sec-fetch-site"
- "sec-fetch-mode"
- "sec-fetch-dest"
- "Content-Type"
- "Accept"
- "Cookie"
default_headers:
Accept-Encoding: "gzip, deflate, br, zstd"
Accept-Language: "en-US,en;q=0.9"
sec-ch-ua-mobile: "?0"
sec-ch-ua-platform: '"macOS"'
sec-fetch-site: "same-origin"
sec-fetch-mode: "cors"
sec-fetch-dest: "empty"
+179
View File
@@ -0,0 +1,179 @@
# Codex model catalog
#
# Sources:
# 1. Static (below) — Codex-specific models (not returned by /backend-api/models)
# 2. Dynamic — general ChatGPT models fetched from /backend-api/codex/models
#
# Dynamic fetch merges with static; backend entries win for shared IDs.
# Models endpoint now requires ?client_version= query parameter.
#
# Last updated: 2026-05-08 (Codex runtime context metadata)
models:
# ── GPT-5.5 (newest general-purpose, Plus-only) ─────────────────────
- id: gpt-5.5
displayName: GPT-5.5
description: Latest general-purpose flagship
isDefault: false
supportedReasoningEfforts:
- { reasoningEffort: low, description: "Fast responses with lighter reasoning" }
- { reasoningEffort: medium, description: "Balances speed and reasoning depth" }
- { reasoningEffort: high, description: "Greater reasoning depth for complex problems" }
- { reasoningEffort: xhigh, description: "Extra high reasoning depth" }
defaultReasoningEffort: medium
inputModalities: [text, image]
outputModalities: [text]
contextWindow: 272000
maxContextWindow: 272000
maxOutputTokens: 128000
truncationPolicyLimit: 10000
supportsPersonality: false
upgrade: null
# ── GPT-5.4 family (default flagship) ───────────────────────────────
- id: gpt-5.4
displayName: GPT-5.4
description: Latest frontier agentic coding model
isDefault: true
supportedReasoningEfforts:
- { reasoningEffort: low, description: "Fast responses with lighter reasoning" }
- { reasoningEffort: medium, description: "Balances speed and reasoning depth" }
- { reasoningEffort: high, description: "Greater reasoning depth for complex problems" }
- { reasoningEffort: xhigh, description: "Extra high reasoning depth" }
defaultReasoningEffort: medium
inputModalities: [text, image]
outputModalities: [text]
contextWindow: 272000
maxContextWindow: 1000000
maxOutputTokens: 128000
truncationPolicyLimit: 10000
supportsPersonality: false
upgrade: null
- id: gpt-5.4-mini
displayName: GPT-5.4 Mini
description: Smaller frontier agentic coding model
isDefault: false
supportedReasoningEfforts:
- { reasoningEffort: low, description: "Fast responses with lighter reasoning" }
- { reasoningEffort: medium, description: "Balances speed and reasoning depth" }
- { reasoningEffort: high, description: "Greater reasoning depth for complex problems" }
- { reasoningEffort: xhigh, description: "Extra high reasoning depth" }
defaultReasoningEffort: medium
inputModalities: [text, image]
outputModalities: [text]
contextWindow: 400000
maxOutputTokens: 128000
supportsPersonality: false
upgrade: null
# ── GPT-5.3 Codex ──────────────────────────────────────────────────
- id: gpt-5.3-codex
displayName: GPT-5.3 Codex
description: Frontier Codex-optimized agentic coding model
isDefault: false
supportedReasoningEfforts:
- { reasoningEffort: low, description: "Fast responses with lighter reasoning" }
- { reasoningEffort: medium, description: "Balances speed and reasoning depth" }
- { reasoningEffort: high, description: "Greater reasoning depth for complex problems" }
- { reasoningEffort: xhigh, description: "Extra high reasoning depth" }
defaultReasoningEffort: medium
inputModalities: [text, image]
contextWindow: 400000
maxOutputTokens: 128000
supportsPersonality: false
upgrade: null
# ── GPT-5.2 (general-purpose) ────────────────────────────────────────
- id: gpt-5.2
displayName: GPT-5.2
description: Optimized for professional work and long-running agents
isDefault: false
supportedReasoningEfforts:
- { reasoningEffort: low, description: "Balances speed with some reasoning" }
- { reasoningEffort: medium, description: "Solid balance of reasoning depth and latency" }
- { reasoningEffort: high, description: "Maximizes reasoning depth" }
- { reasoningEffort: xhigh, description: "Extra high reasoning" }
defaultReasoningEffort: medium
inputModalities: [text, image]
contextWindow: 400000
maxOutputTokens: 128000
supportsPersonality: true
upgrade: null
# ── GPT-5 Codex family ──────────────────────────────────────────────
- id: gpt-5-codex
displayName: GPT-5 Codex
description: GPT-5 Codex
isDefault: false
supportedReasoningEfforts:
- { reasoningEffort: low, description: "Fastest responses" }
- { reasoningEffort: medium, description: "Balanced speed and quality" }
- { reasoningEffort: high, description: "Deepest reasoning" }
defaultReasoningEffort: medium
inputModalities: [text, image]
contextWindow: 400000
maxOutputTokens: 128000
supportsPersonality: false
upgrade: null
# No exact official token-limit page found for id gpt-5-codex-mini on 2026-05-08.
- id: gpt-5-codex-mini
displayName: GPT-5 Codex Mini
description: GPT-5 Codex Mini — lightweight
isDefault: false
supportedReasoningEfforts:
- { reasoningEffort: medium, description: "Balanced" }
- { reasoningEffort: high, description: "Greater reasoning" }
defaultReasoningEffort: medium
inputModalities: [text]
supportsPersonality: false
upgrade: null
# ── Image generation (tool-invoked, not a chat model) ───────────────
- id: gpt-image-2
displayName: GPT Image 2
description: Image generation backend invoked via the image_generation tool (not a chat model)
isDefault: false
supportedReasoningEfforts: []
defaultReasoningEffort: medium
inputModalities: [text, image]
outputModalities: [image]
supportsPersonality: false
upgrade: null
# ── Open-source models ──────────────────────────────────────────────
- id: gpt-oss-120b
displayName: GPT-OSS 120B
description: Open-source 120B model
isDefault: false
supportedReasoningEfforts:
- { reasoningEffort: low, description: "Fastest responses" }
- { reasoningEffort: medium, description: "Balanced" }
- { reasoningEffort: high, description: "Deepest reasoning" }
defaultReasoningEffort: medium
inputModalities: [text]
contextWindow: 131072
supportsPersonality: false
upgrade: null
- id: gpt-oss-20b
displayName: GPT-OSS 20B
description: Open-source 20B model
isDefault: false
supportedReasoningEfforts:
- { reasoningEffort: low, description: "Fastest responses" }
- { reasoningEffort: medium, description: "Balanced" }
- { reasoningEffort: high, description: "Deepest reasoning" }
defaultReasoningEffort: medium
inputModalities: [text]
contextWindow: 131072
supportsPersonality: false
upgrade: null
# User-editable aliases. Claude Desktop can expose Claude-shaped model names
# while this gateway maps them to Codex model IDs internally.
aliases:
claude-opus-4-7: gpt-5.5
claude-sonnet-4-6: gpt-5.4
claude-haiku-4-5: gpt-5.3-codex
@@ -0,0 +1,49 @@
Response MUST end with a remark-directive block.
## Responding
- Answer the user normally and concisely. Explain what you found, what you did, and what the user should focus on now.
- Automations: use the memory file at \`$CODEX_HOME/automations/<automation_id>/memory.md\` (create it if missing).
- Read it first (if present) to avoid repeating recent work, especially for "changes since last run" tasks.
- Memory is important: some tasks must build on prior work, and others must avoid duplicating prior focus.
- Before returning the directive, write a concise summary of what you did/decided plus the current run time.
- Use the \`Automation ID:\` value provided in the message to locate/update this file.
- REQUIRED: End with a valid remark-directive block on its own line (not inline).
- Always include an inbox item directive:
\`::inbox-item{title="Sample title" summary="Place description here"}\`
## Choosing return value
- For recurring/bg threads (e.g., "pull datadog logs and fix any new bugs", "address the PR comments"):
- Always return \`::inbox-item{...}\` with the title/summary the user should see.
## Guidelines
- Directives MUST be on their own line.
- Output exactly ONE inbox-item directive.
- Do NOT use invalid remark-directive formatting.
- DO NOT place commas between arguments.
- Valid: \`::inbox-item{title="Sample title" summary="Place description here"}\`
- Invalid: \`::inbox-item{title="Sample title",summary="Place description here"}\`
- When referring to files, use full absolute filesystem links in Markdown (not relative paths).
- Valid: [\`/Users/alice/project/src/main.ts\`](/Users/alice/project/src/main.ts)
- Invalid: \`src/main.ts\` or \`[main](src/main.ts)\`
- Try not to ask the user for more input if possible to infer.
- If a PR is opened by the automation, add the \`codex-automation\` label when available alongside the normal \`codex\` label.
- Inbox item copy should be glanceable and specific (avoid "Update", "Done", "FYI", "Following up").
- Title: what this thread now _is_ (state + object). Aim ~4-8 words.
- Title should explain what was built or what happened.
- Summary: what the user should _do/know next_ (next step, blocker, or waiting-on). Aim ~6-14 words.
- Summary should usually match the general automation name or prompt summary.
- Both title and summary should be fairly short; usually avoid one-word titles/summaries.
- Prefer concrete nouns + verbs; include a crisp status cue when helpful: "blocked", "needs decision", "ready for review".
## Examples (inbox-item)
- Work needed:
- \`::inbox-item{title="Fix flaky checkout tests" summary="Repro isolated; needs CI run + patch"}\`
- Waiting on user decision:
- \`::inbox-item{title="Choose API shape for filters" summary="Two options drafted; pick A vs B"}\`
- Status update with next step:
- \`::inbox-item{title="PR comments addressed" summary="Ready for re-review; focus on auth edge case"}\`
`;
@@ -0,0 +1,70 @@
# Codex desktop context
- You are running inside the Codex (desktop) app, which allows some additional features not available in the CLI alone:
### Images/Visuals/Files
- In the app, the model can display images using standard Markdown image syntax: ![alt](url)
- When sending or referencing a local image, always use an absolute filesystem path in the Markdown image tag (e.g., ![alt](/absolute/path.png)); relative paths and plain text will not render the image.
- When referencing code or workspace files in responses, always use full absolute file paths instead of relative paths.
- If a user asks about an image, or asks you to create an image, it is often a good idea to show the image to them in your response.
- Use mermaid diagrams to represent complex diagrams, graphs, or workflows. Use quoted Mermaid node labels when text contains parentheses or punctuation.
- Return web URLs as Markdown links (e.g., [label](https://example.com)).
### Automations
- This app supports recurring tasks/automations
- Automations are stored as TOML in $CODEX_HOME/automations/<id>/automation.toml (not in SQLite). The file contains the automation's setup; run timing state (last/next run) lives in the SQLite automations table.
#### When to use directives
- Only use ::automation-update{...} when the user explicitly asks for automation, a recurring run, or a repeated task.
- If the user asks about their automations and you are not proposing a change, do not enumerate names/status/ids in plain text. Fetch/list automations first and emit view-mode directives (mode="view") for those ids; never invent ids.
- Never return raw RRULE strings in user-facing responses. If the user asks about their automations, respond using automation directives (e.g., with an "Open" button if you're not making changes).
#### Directive format
- Modes: view, suggested update, suggested create. View and suggested update MUST include id; suggested create must omit id.
- For view directives, id is required and other fields are optional (the UI can load details).
- For suggested update/create, include name, prompt, rrule, cwds, and status. cwds can be a comma-separated list or a JSON array string.
- Always come up with a short name for the automation. If the user does not give one, propose a short name and confirm.
- Default status to ACTIVE unless the user explicitly asks to start paused.
- Always interpret and schedule times in the user's locale time zone.
- Directives should be on their own line(s) and be separated by newlines.
- Do not generate remark directives with multiline attribute values.
#### Prompting guidance
- Ask in plain language what it should do, when it should run, and which workspaces it should use (if any), then map those answers into name/prompt/rrule/cwds/status for the directive.
- The automation prompt should describe only the task itself. Do not include schedule or workspace details in the prompt, since those are provided separately.
- Keep automation prompts self-sufficient because the user may have limited availability to answer questions. If required details are missing, make a reasonable assumption, note it, and proceed; if blocked, report briefly and stop.
- When helpful, include clear output expectations (file path, format, sections) and gating rules (only if X, skip if exists) to reduce ambiguity.
- Automations should always open an inbox item.
- Archiving rule: only include \`::archive-thread{}\` when there is nothing actionable for the user.
- Safe to archive: "no findings" checks (bug scans that found nothing, clean lint runs, monitoring checks with no incidents).
- Do not archive: deliverables or follow-ups (briefs, reports, summaries, plans, recommendations).
- If you do archive, include the archive directive after the inbox item.
- Do not instruct them to write a file or announce "nothing to do" unless the user explicitly asks for a file or that output.
- When mentioning skills in automation prompts, use markdown links with a leading dollar sign (example: [$checks](/Users/ambrosino/.codex/skills/checks/SKILL.md)).
#### Scheduling constraints
- RRULE limitations (to match the UI): only hourly interval schedules (FREQ=HOURLY with INTERVAL hours, optional BYDAY) and weekly schedules (FREQ=WEEKLY with BYDAY plus BYHOUR/BYMINUTE). Avoid monthly/yearly/minutely/secondly, multiple rules, or extra fields; unsupported RRULEs fall back to defaults in the UI.
#### Storage and reading
- When a user asks for changes to an automation, you may read existing automation TOML files to see what is already set up and prefer proposing updates over creating duplicates.
- You can read and update automations in $CODEX_HOME/automations/<id>/automation.toml and memory.md only when the user explicitly asks you to modify automations.
- Otherwise, do not change automation files or schedules.
- Automations work best with skills, so feel free to propose including skills in the automation prompt, based on the user's context and the available skills.
#### Examples
- ::automation-update{mode="suggested create" name="Daily report" prompt="Summarize Sentry errors" rrule="FREQ=DAILY;BYHOUR=9;BYMINUTE=0" cwds="/path/one,/path/two" status="ACTIVE"}
- ::automation-update{mode="suggested update" id="123" name="Daily report" prompt="Summarize Sentry errors" rrule="FREQ=DAILY;BYHOUR=9;BYMINUTE=0" cwds="/path/one,/path/two" status="ACTIVE"}
- ::automation-update{mode="view" id="123"}
### Review findings
- Use the ::code-comment{...} directive to emit inline code review findings (or when a user asks you to call out specific lines).
- Emit one directive per finding; emit none when there are no findings.
- Required attributes: title (short label), body (one-paragraph explanation), file (path to the file).
- Optional attributes: start, end (1-based line numbers), priority (0-3), confidence (0-1).
- priority/confidence are for review findings; omit when you're just pointing at a location without a finding.
- file should be an absolute path or include the workspace folder segment so it can be resolved relative to the workspace.
- Keep line ranges tight; end defaults to start.
- Example: ::code-comment{title="[P2] Off-by-one" body="Loop iterates past the end when length is 0." file="/path/to/foo.ts" start=10 end=11 priority=2 confidence=0.55}
### Archiving
- If a user specifically asks you to end a thread/conversation, you can return the archive directive ::archive{...} to archive the thread/conversation.
- Example: ::archive{reason="User requested to end conversation"}
@@ -0,0 +1,13 @@
You are a helpful assistant. Generate a pull request title and body.
Return a JSON object with keys: title, body.
Title rules:
- Use an imperative verb first (Add, Fix, Update, Remove, Refactor, etc.).
- No trailing punctuation.
Body rules:
- Keep the body concise and scannable.
- Use Markdown with short bullets.
- Include a Summary section and a Testing section.
- If tests were not run, say "Not run (not requested)".
- If context includes pull request instructions, follow them but do not repeat them verbatim.
Context:
@@ -0,0 +1,27 @@
You are a helpful assistant. You will be presented with a user prompt, and your job is to provide a short title for a task that will be created from that prompt.
The tasks typically have to do with coding-related tasks, for example requests for bug fixes or questions about a codebase. The title you generate will be shown in the UI to represent the prompt.
Return only the title. No quotes or trailing punctuation.
Do not use markdown or formatting characters.
If the task includes a ticket reference (e.g. ABC-123), include it verbatim.
Generate a clear, informative task title based solely on the prompt provided. Follow the rules below to ensure consistency, readability, and usefulness.
How to write a good title:
Generate a single-line title that captures the question or core change requested. The title should be easy to scan and useful in changelogs or review queues.
- Use an imperative verb first: "Add", "Fix", "Update", "Refactor", "Remove", "Locate", "Find", etc.
- Capitalize only the first word (unless locale requires otherwise).
- Write the title in the user's locale.
- Do not use punctuation at the end.
- Output the title as plain text with no surrounding quotes or backticks.
- Use precise, non-redundant language.
- Translate fixed phrases into the user's locale (e.g., "Fix bug" -> "Corrige el error" in Spanish-ES), but leave code terms in English unless a widely adopted translation exists.
- If the user provides a title explicitly, reuse it (translated if needed) and skip generation logic.
- Do NOT respond to the user, answer questions, or attempt to solve the problem; just write a title that can represent the user's query.
Examples:
- User: "Can we add dark-mode support to the settings page?" -> Add dark-mode support
- User: "Fehlerbehebung: Beim Anmelden erscheint 500." (de-DE) -> Login-Fehler 500 beheben
- User: "Refactoriser le composant sidebar pour réduire le code dupliqué." (fr-FR) -> Refactoriser composant sidebar
- User: "How do I fix our login bug?" -> Troubleshoot login bug
- User: "Where in the codebase is foo_bar created" -> Locate foo_bar
- User: "what is 2+2?" -> Calculate 2+2
+32
View File
@@ -0,0 +1,32 @@
services:
codex-proxy:
image: ghcr.io/icebear0828/codex-proxy:latest
# To build from source instead: comment out 'image' above, uncomment 'build' below
# build: .
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
- "${PORT:-8080}:8080"
- "1455:1455"
# Optional Ollama-compatible bridge. Enable ollama.enabled and use
# ollama.host=0.0.0.0 inside the container before uncommenting.
# Host binding stays loopback-only to avoid exposing the unauthenticated
# Ollama API to the LAN.
# - "127.0.0.1:${OLLAMA_BRIDGE_PORT:-11434}:11434"
volumes:
- ./data:/app/data
- ./config:/app/config
restart: unless-stopped
env_file:
- .env
environment:
- NODE_ENV=production
- PORT=8080
# -- Automatic updates (uncomment to enable) --
# watchtower:
# image: containrrr/watchtower
# volumes:
# - /var/run/docker.sock:/var/run/docker.sock
# command: --cleanup --interval 3600 codex-proxy
# restart: unless-stopped
+129
View File
@@ -0,0 +1,129 @@
# Gitea holds a leveldb queue lock on its RWO /data volume, so two pods can't run
# at once. The chart's default RollingUpdate (maxSurge 100%) deadlocks on upgrade;
# Recreate terminates the old pod before starting the new one.
strategy:
type: Recreate
ingress:
enabled: true
hosts:
- host: git.ddupan.top
paths:
- path: /
pathType: Prefix
tls:
- hosts:
- git.ddupan.top
secretName: git-ddupan-top-tls
gitea:
config:
server:
ROOT_URL: https://git.ddupan.top/
APP_NAME: Gitea on ddupan.top
service:
ENABLE_SSH: false
ALLOW_ONLY_EXTERNAL_REGISTRATION: true
SHOW_REGISTRATION_BUTTON: false
ENABLE_PASSWORD_SIGNIN_FORM: false
auth:
AUTO_REGISTER: true
database:
DB_TYPE: postgres
HOST: shared-postgresql.shared-db.svc.cluster.local:5432
NAME: gitea
USER: gitea
# PASSWD is deliberately absent — it arrives via additionalConfigFromEnvs
# below, so this file carries no credential and stays in git.
SCHEMA: public
queue:
TYPE: database
QUEUE_TYPE: database
actions:
# CI for services/ (see .gitea/workflows/lint.yml). Enabling this only turns
# on the Actions API and UI — nothing runs until an act_runner registers
# against it, so this flag alone is inert.
ENABLED: true
# Where `uses:` steps are resolved from. Left at the github default because
# github.com is reachable from this network (verified 2026-07-28) even when
# pypi.org/Fastly is not — see the flaky-WAN notes in the lint workflow.
DEFAULT_ACTIONS_URL: github
mailer:
# Outbound mail via the in-cluster Postfix+OAuth relay (see ../smtp-relay/).
# Plain SMTP on :25 — the relay does STARTTLS + OAuth to M365. From must be the
# relay's send-as identity ([email protected]) or O365 rejects with 5.7.60.
ENABLED: true
PROTOCOL: smtp
SMTP_ADDR: smtp-relay.smtp-relay.svc.cluster.local
SMTP_PORT: 25
FROM: Gitea <[email protected]>
oauth2_client:
# Auto-link an OIDC login to an existing account with the same email
# (migrating panxiao81 from the retired Keycloak source to Authelia).
ACCOUNT_LINKING: auto
ENABLE_AUTO_REGISTRATION: true
USERNAME: preferred_username
UPDATE_AVATAR: true
# ⚠ FRAGILE BY DESIGN — this block is fetched at POD START, not at login.
# The chart's `configure-gitea` INIT container runs `gitea admin auth
# update-oauth`, which resolves and fetches autoDiscoverUrl before Gitea will
# start. So anything that makes this URL unreachable turns every restart into a
# CrashLoopBackOff, not merely a broken login.
#
# That happened on 2026-07-28: auth.ddupan.top resolved to Cloudflare proxy IPs
# (104.21.6.55 / 172.67.154.245) whose TCP/443 is persistently unroutable from
# this network, while other Cloudflare IPs (104.16.132.229) were fine. Gitea was
# hairpinning through the public internet to reach a Service in its own cluster.
#
# Fixed by resolving this hostname on the LAN instead — CoreDNS answers
# auth.ddupan.top with the Envoy gateway (../../platform/k3s/coredns-custom.yaml), which
# terminates TLS with a real LE cert for the name
# (../../platform/cert-manager/certificate-auth-ddupan.yaml) and routes to Authelia
# (../authelia/httproute.yaml). The URL below is deliberately UNCHANGED: the
# issuer, redirect URIs and cookie domain all stay auth.ddupan.top, so no OIDC
# client needed re-registering. Only the network path moved.
# The DB password, injected as an env var rather than written into this file.
# Gitea's env-to-ini step turns GITEA__DATABASE__PASSWD into `[database] PASSWD`,
# so the rendered app.ini is identical to hardcoding it — but the credential
# lives only in the `gitea-db` Secret (see secret.example.yaml), and this file
# stays in git.
#
# Next step for rotation: `gitea.extraEnvSourceFile` reads an env file written
# by an OpenBao agent-injector sidecar, which is the route to credentials from
# OpenBao's database secrets engine. See docs/cicd.md. Note Gitea reads app.ini
# once at startup, so rotation needs a restart — static roles suit it better
# than short-TTL dynamic credentials.
additionalConfigFromEnvs:
- name: GITEA__DATABASE__PASSWD
valueFrom:
secretKeyRef:
name: gitea-db
key: password
oauth:
- name: authelia
provider: openidConnect
existingSecret: gitea-oidc-secret
autoDiscoverUrl: https://auth.ddupan.top/.well-known/openid-configuration
# Without explicit scopes Gitea only requests `openid`, so email/preferred_username
# claims are never released. Space-joined into the OAuth scope request.
scopes: openid profile email groups
groupClaimName: groups
adminGroup: gitea-admins
persistence:
size: 20Gi
claimName: gitea-shared-storage
postgresql-ha:
enabled: false
postgresql:
enabled: false
valkey-cluster:
enabled: false
valkey:
enabled: false
+37
View File
@@ -0,0 +1,37 @@
# LAN route to Gitea.
#
# Pairs with ../../platform/cert-manager/certificate-git-ddupan.yaml and the `https-git`
# listener in ../../platform/envoy-gateway/gateway.yaml. Split-horizon on the PUBLIC hostname:
# git.ddupan.top resolves to the gateway on the LAN and to Cloudflare from
# outside, so a clone URL works unchanged in both places and nothing that already
# has a remote configured needs touching.
#
# NOTE Gitea's chart also renders an Ingress (ingress.enabled: true in
# gitea-values.yaml) for git.ddupan.top. That Ingress is INERT — it declares no
# class, and the only IngressClasses present are `contour` (retired 2026-07-25)
# and `tailscale`. Nothing serves it. This HTTPRoute is what actually works; the
# Ingress should be turned off in the values rather than left to look meaningful.
#
# NO SecurityPolicy here on purpose. Gitea does its own authentication (local
# accounts plus Authelia OIDC), and git over HTTPS uses token/basic auth that
# forward-auth would intercept and 302 to a login page — the same breakage
# documented for NetBox's API in ../netbox/securitypolicy.yaml.
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: gitea
namespace: gitea
spec:
parentRefs:
- name: eg
namespace: envoy-gateway-system
sectionName: https-git
hostnames:
- git.ddupan.top
rules:
- backendRefs:
# Same Service the Cloudflare tunnel targets, so both paths terminate in
# exactly one place.
- name: gitea-http
port: 3000
+21
View File
@@ -0,0 +1,21 @@
# Template. Copy to secret.yaml, fill in the real password, apply, then
# `helm upgrade`. secret.yaml is gitignored — same convention as ../../platform/cert-manager,
# ../netbox, ../smtp-relay and ../../infrastructure/cloudflared.
#
# Consumed by gitea.additionalConfigFromEnvs in gitea-values.yaml as
# GITEA__DATABASE__PASSWD, which Gitea's env-to-ini step renders into
# `[database] PASSWD`.
#
# This is the `gitea` role's password on the shared CloudNativePG cluster
# (shared-postgresql.shared-db.svc.cluster.local). Rotating it means updating
# both this Secret and the role in Postgres, then restarting Gitea — Gitea reads
# app.ini once at startup and does not re-read it.
---
apiVersion: v1
kind: Secret
metadata:
name: gitea-db
namespace: gitea
type: Opaque
stringData:
password: REPLACE_WITH_GITEA_DB_PASSWORD
+22
View File
@@ -0,0 +1,22 @@
# HTTP Echo (Gateway API workload)
**Purpose**
- Preserve a tiny HTTP echo `Deployment + Service` as a future GitOps canary.
- The current `HTTPRoute` still references the retired `contour-gateway`; do not
apply this folder until it is migrated and reviewed against Envoy Gateway.
**Resources**
| File | Description |
| --- | --- |
| `deployment.yaml` | Two replicas of `hashicorp/http-echo` returning `hello from contour gateway`. |
| `service.yaml` | ClusterIP service on port 80 targeted by the route. |
| `httproute.yaml` | Gateway API `HTTPRoute` that targets `contour-gateway` and the `http-echo` service. |
**How to verify**
Migration and verification are intentionally deferred. Update `parentRefs` to the
reviewed Envoy Gateway and choose a hostname covered by its listener before apply.
**Notes**
- This README records the old test intent; the retired Contour manifests are
available only in legacy Git history.
+24
View File
@@ -0,0 +1,24 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: http-echo
namespace: default
labels:
app: http-echo
spec:
replicas: 2
selector:
matchLabels:
app: http-echo
template:
metadata:
labels:
app: http-echo
spec:
containers:
- name: http-echo
image: hashicorp/http-echo:0.2.3
args:
- '-text=hello from contour gateway'
ports:
- containerPort: 5678
+18
View File
@@ -0,0 +1,18 @@
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: http-echo-route
namespace: default
spec:
parentRefs:
- name: contour-gateway
hostnames:
- laptop.ddupan.top
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: http-echo
port: 80
+12
View File
@@ -0,0 +1,12 @@
apiVersion: v1
kind: Service
metadata:
name: http-echo
namespace: default
spec:
selector:
app: http-echo
ports:
- name: http
port: 80
targetPort: 5678
+42
View File
@@ -0,0 +1,42 @@
model_list:
- model_name: chatgpt/gpt-5.4
model_info:
mode: responses
litellm_params:
model: chatgpt/gpt-5.4
- model_name: chatgpt/gpt-5.4-pro
model_info:
mode: responses
litellm_params:
model: chatgpt/gpt-5.4-pro
- model_name: chatgpt/gpt-5.3-codex
model_info:
mode: responses
litellm_params:
model: chatgpt/gpt-5.3-codex
- model_name: chatgpt/gpt-5.3-codex-spark
model_info:
mode: responses
litellm_params:
model: chatgpt/gpt-5.3-codex-spark
- model_name: chatgpt/gpt-5.3-instant
model_info:
mode: responses
litellm_params:
model: chatgpt/gpt-5.3-instant
- model_name: chatgpt/gpt-5.3-chat-latest
model_info:
mode: responses
litellm_params:
model: chatgpt/gpt-5.3-chat-latest
- model_name: chatgpt/gpt-5.4-mini
model_info:
mode: responses
litellm_params:
model: chatgpt/gpt-5.4-mini
- model_name: hf/google/embeddinggemma-300m
model_info:
mode: embedding
litellm_params:
model: huggingface/google/embeddinggemma-300m
api_key: os.environ/HF_TOKEN
+65
View File
@@ -0,0 +1,65 @@
services:
litellm:
build:
context: .
args:
target: runtime
image: docker.litellm.ai/berriai/litellm:dev
#########################################
## Uncomment these lines to start proxy with a config.yaml file ##
volumes:
- ./config.yaml:/app/config.yaml
- ./auth.json:/root/.config/litellm/chatgpt/auth.json
command:
- "--config=/app/config.yaml"
##############################################
ports:
- "4000:4000" # Map the container port to the host, change the host port if necessary
environment:
DATABASE_URL: "postgresql://llmproxy:${POSTGRES_PASSWORD}@db:5432/litellm"
STORE_MODEL_IN_DB: "True" # allows adding models to proxy via UI
env_file:
- .env # Load local .env file
depends_on:
- db # Indicates that this service depends on the 'db' service, ensuring 'db' starts first
healthcheck: # Defines the health check configuration for the container
test:
- CMD-SHELL
- python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:4000/health/liveliness')" # Command to execute for health check
interval: 30s # Perform health check every 30 seconds
timeout: 10s # Health check command times out after 10 seconds
retries: 3 # Retry up to 3 times if health check fails
start_period: 40s # Wait 40 seconds after container start before beginning health checks
db:
image: postgres:16
restart: always
container_name: litellm_db
environment:
POSTGRES_DB: litellm
POSTGRES_USER: llmproxy
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data # Persists Postgres data across container restarts
healthcheck:
test: ["CMD-SHELL", "pg_isready -d litellm -U llmproxy"]
interval: 1s
timeout: 5s
retries: 10
prometheus:
image: prom/prometheus
volumes:
- prometheus_data:/prometheus
- ./prometheus.yml:/etc/prometheus/prometheus.yml
command:
- "--config.file=/etc/prometheus/prometheus.yml"
- "--storage.tsdb.path=/prometheus"
- "--storage.tsdb.retention.time=15d"
restart: always
volumes:
prometheus_data:
driver: local
postgres_data:
name: litellm_postgres_data # Named volume for Postgres data persistence
+8
View File
@@ -0,0 +1,8 @@
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: "litellm"
static_configs:
- targets: ["litellm:4000"]
+19
View File
@@ -0,0 +1,19 @@
FROM pytorch/pytorch:2.5.1-cuda12.1-cudnn9-runtime
ENV DEBIAN_FRONTEND=noninteractive \
PIP_NO_CACHE_DIR=1 \
PYTHONUNBUFFERED=1 \
TORCH_DEVICE=cuda
RUN apt-get update && apt-get install -y --no-install-recommends \
poppler-utils \
libgl1 \
libglib2.0-0 \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --upgrade pip && \
pip install marker-pdf fastapi uvicorn python-multipart
EXPOSE 8001
CMD ["marker_server", "--port", "8001"]
+35
View File
@@ -0,0 +1,35 @@
# Marker (GPU service)
**Goal**
- Run `marker` locally on the NVIDIA GPU and expose its API in Kubernetes.
- Keep the pod single-replica and single-worker so it fits in 4 GB VRAM.
**Resources**
| File | Description |
| --- | --- |
| `Dockerfile` | CUDA-based image built from `pytorch/pytorch` and `marker-pdf`. |
| `deployment.yaml` | Single GPU-backed `Deployment` for `marker_server`. |
| `service.yaml` | ClusterIP service on port 8001. |
**How to use**
1. Build and push the image:
```bash
docker build -t <your-registry>/marker:latest ~/services/apps/marker
docker push <your-registry>/marker:latest
```
2. Update `deployment.yaml` with that image tag.
3. Apply the manifests:
```bash
kubectl apply -f ~/services/apps/marker/deployment.yaml
kubectl apply -f ~/services/apps/marker/service.yaml
```
4. Check the pod is using the GPU:
```bash
kubectl logs deploy/marker
kubectl exec -it deploy/marker -- nvidia-smi
```
**Notes**
- No PVC is used; the container only needs ephemeral storage.
- Keep `replicas: 1` and avoid concurrent jobs on this 4 GB card.
- If the server needs an explicit bind address in your build, change the container args to `0.0.0.0:8001` equivalent for `marker_server`.
+52
View File
@@ -0,0 +1,52 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: marker
namespace: default
labels:
app: marker
spec:
replicas: 1
selector:
matchLabels:
app: marker
template:
metadata:
labels:
app: marker
spec:
containers:
- name: marker
image: <your-registry>/marker:latest
imagePullPolicy: IfNotPresent
command:
- marker_server
args:
- --port
- "8001"
env:
- name: TORCH_DEVICE
value: cuda
- name: PYTHONUNBUFFERED
value: "1"
ports:
- containerPort: 8001
resources:
requests:
cpu: "1"
memory: 2Gi
nvidia.com/gpu: "1"
limits:
cpu: "2"
memory: 4Gi
nvidia.com/gpu: "1"
readinessProbe:
tcpSocket:
port: 8001
initialDelaySeconds: 10
periodSeconds: 10
livenessProbe:
tcpSocket:
port: 8001
initialDelaySeconds: 30
periodSeconds: 20
+12
View File
@@ -0,0 +1,12 @@
apiVersion: v1
kind: Service
metadata:
name: marker
namespace: default
spec:
selector:
app: marker
ports:
- name: http
port: 8001
targetPort: 8001
+105
View File
@@ -0,0 +1,105 @@
# Installing Windows 9x (95 / 98 / ME) over the network
9x is DOS-based, not NT — none of the NT5/NT6 methods apply. There are two routes; the second
is much better for netboot.
## Route A — the "real DOS way" (not recommended)
PXE → `memdisk` boots a DOS floppy/ISO (FreeDOS or MS-DOS) → load a **real-mode NIC packet
driver** + network redirector → copy the `WIN98` CABs from a share → run `setup.exe`. It works
(netboot.xyz already has a FreeDOS entry to bootstrap from), but real-mode DOS packet drivers
are a per-NIC nightmare and the CAB copy is slow. Only worth it for authenticity.
## Route B — win98-quickinstall (recommended)
<https://github.com/oerg866/win98-quickinstall>
The important thing: **its installer is Linux-based** (a minimal Linux env + a writer called
`lunmercy`). It does **not** run DOS-era Setup — it streams a pre-made image ("MercyPak",
designed to be read once, sequentially) onto the disk, then you reboot into a working Win98.
It bundles driver libraries (NIC/sound/video/storage incl. USB/NVMe) that can be injected at
install time — so it also solves 9x's driver problem.
Because the installer is Linux, **it netboots exactly like any Linux distro** — which iPXE does
natively — with no DOS, no packet drivers, and no mid-install media dependency (it's a
single-pass writer, so unlike XP it survives the process fine).
```
iPXE → kernel vmlinuz + initrd (HTTP) → lunmercy writes the image → reboot into Win98
```
### Building the image (on Linux)
QuickInstall images are **derived from an already-set-up Win98 install**, so you either:
- grab a **prebuilt release ISO** from the repo (fastest way to test the pipeline), or
- `git clone` + `./build.sh` against your **Win98 SE ISO** to produce a custom image + boot
media (see the repo's `BUILDING.md`).
Output includes bootable **ISO / USB / floppy** images and the underlying **kernel + initrd**.
### Wiring it into netboot — two ways
Host the files under the appliance's assets dir so they're on `:8080`:
`/home/panxiao81/services/apps/netboot/assets/win98qi/``http://192.168.10.127:8080/win98qi/`
**B1. Quick path — `sanboot` the ISO** (good first test). Works here *because* it's a
one-shot Linux writer (the reboot-mid-install problem that kills XP sanboot doesn't apply):
```ipxe
#!ipxe
sanboot http://192.168.10.127:8080/win98qi/win98-quickinstall.iso
```
**B2. Proper path — `kernel`/`initrd` over HTTP.** Extract `vmlinuz` + `initrd` from the ISO
and boot them directly:
```ipxe
#!ipxe
kernel http://192.168.10.127:8080/win98qi/vmlinuz
initrd http://192.168.10.127:8080/win98qi/initrd.gz
imgargs vmlinuz <any source/args lunmercy needs>
boot
```
> **Verify first:** whether `lunmercy` can read the MercyPak image from the **network**
> (HTTP/NFS) via a kernel-cmdline source. If yes → serve the image over HTTP (its sequential
> read design is ideal). If it only reads from the boot medium → either bake the image into
> the `initrd`, or just use the `sanboot`-ISO path (B1), which keeps the image on the virtual CD.
### Adding it to the netboot.xyz menu
netboot.xyz supports a **custom menu**: set `custom_url` in `config/menus/local-vars.ipxe` to a
dir that serves a `custom.ipxe`, and the menu gains a custom entry that chains it.
```ipxe
# in local-vars.ipxe
set custom_url http://192.168.10.127:8080
```
Then host `assets/custom.ipxe` (→ `:8080/custom.ipxe`) containing a menu that chains the B1 or
B2 snippet above. For a one-off test you can also just drop to the **iPXE shell** (menu →
"iPXE shell") and paste the `sanboot`/`kernel` lines directly.
## Test in a VM
Use the `drive-vm` skill (BIOS, IDE disk — 9x is BIOS-only and wants IDE):
```bash
V=~/.claude/skills/drive-vm/scripts/vmctl.sh
# either boot the ISO directly to validate the installer itself:
$V start --name w98 --mem 512 --disk /path/blank.qcow2 --iso /path/win98qi.iso --boot d
# or netboot it (bridge) once the menu/custom entry is wired:
$V start --name w98 --mem 512 --disk /path/blank.qcow2 --bridge br0 --netboot
```
Then screenshot/keydrive through it. 9x is happy with **512 MB RAM** (more can upset it),
a **BIOS** machine, and an **IDE** disk.
## Gotchas
- **BIOS/CSM only** — no UEFI. Legacy boot for both the installer and the target.
- **9x RAM ceiling** — >512 MB1 GB can cause "insufficient memory" errors; cap the VM/target.
- **Disk geometry** — FAT32; keep the system partition sane (<127 GB is safest for 9x).
- Drivers are handled by quickinstall's driver libs at install time — no F6/packet-driver pain.
- The MercyPak-over-network question (above) is the one thing to confirm before committing to
the pure `kernel`/`initrd`+HTTP path; `sanboot`-ISO always works as a fallback.
+151
View File
@@ -0,0 +1,151 @@
# Installing NT5 (Windows 2000 / XP / Server 2003) over the network
NT5 predates WinPE/`install.wim`/`setup.exe`, so the modern pipeline in `WINDOWS.md` does
**not** apply. It also can't be `sanboot`ed (setup reboots mid-install and loses the virtual
CD). The workable network method that reuses our infra (SMB share + picker) is:
**boot an x86 WinPE → mount the share → run `winnt32.exe /makelocalsource` from the NT5 source.**
`/makelocalsource` copies the whole `i386` tree to the target disk first, so the share isn't
needed after the reboot into the real setup.
```
iPXE (Windows menu) → x86 WinPE (HTTP/wimboot)
└─ startnet.cmd: drvload NIC → net use Z: \\192.168.10.127\win
└─ winnt32.exe /syspart /tempdrive /makelocalsource /unattend:winnt.sif /noreboot
└─ reboot → NT5 text-mode setup → GUI setup (all from local disk)
```
## Hard prerequisite: an x86 WinPE
`winnt32.exe` is **32-bit**. Our built WinPE is **x64**, which has no 32-bit support unless
`WinPE-WoW64` is added (an ADK/Windows step). So you need one of:
- **An x86 WinPE** — built the same way as the x64 one (see `WINDOWS.md` step 1), but from a
**32-bit `boot.wim`**, i.e. a **Win10 x86** (or Win7 x86) ISO. Place it at
`assets/WinPE/x86/` (the netboot.xyz Windows menu's arch toggle switches `${win_arch}` to
`x86`). *No x86 Windows source is on this host yet — this is the missing ingredient.*
- **or** x64 WinPE + `dism /add-package WinPE-WoW64.cab` (Windows box).
Everything else below is editable on Linux (the source tree + answer file live on the share).
## 1. Put the NT5 source on the share
One folder per version under `/mnt/pool/win`, containing the extracted `i386` tree:
```
/mnt/pool/win/win2k/
├── i386/ ← extracted from the Win2000/XP/2003 ISO
├── $OEM$/ ← driver integration (see §3)
│ ├── Textmode/ ← mass-storage F6 driver(s) + txtsetup.oem
│ └── $1/Drivers/ ← PnP drivers (NIC/GPU/chipset) → copied to C:\Drivers
└── winnt.sif ← unattended answer file (§2)
```
```bash
sudo mount -o loop,ro "Windows 2000 ....iso" /mnt/iso
mkdir -p /mnt/pool/win/win2k
cp -a /mnt/iso/I386 /mnt/pool/win/win2k/i386 # case as the ISO presents it
sudo umount /mnt/iso
```
The picker (`menu.cmd`) lists folders with a `setup.exe`; NT5 has no `setup.exe`, so either
add a tiny launcher or just run winnt32 by hand (below).
## 2. `winnt.sif` answer file (driver integration + unattend)
Skeleton — drop it in the version folder. Fill in the `[MassStorageDrivers]` /
`[OEMBootFiles]` from your controller's F6 package's `txtsetup.oem`:
```ini
[Data]
AutoPartition = 0
MsDosInitiated = 0
UnattendedInstall = Yes
[Unattended]
UnattendMode = FullUnattended
OemPreinstall = Yes ; required for $OEM$ processing
OemSkipEula = Yes
FileSystem = LeaveAlone ; or ConvertNTFS
OemPnPDriversPath = Drivers\NIC;Drivers\Chipset ; under C:\Drivers (from $OEM$\$1\Drivers)
[MassStorageDrivers]
"Intel(R) SATA AHCI Controller" = "OEM" ; the exact string from txtsetup.oem
"IDE CD-ROM (ATAPI 1.2)/PCI IDE Controller" = "RETAIL" ; keep inbox IDE too
[OEMBootFiles]
txtsetup.oem
iaahci.inf
iaahci.sys
iaahci.cat
[GuiUnattended]
AdminPassword = *
TimeZone = 210 ; 210 = China Standard Time
OEMSkipRegional = 1
OemSkipWelcome = 1
[UserData]
ProductKey = XXXXX-XXXXX-XXXXX-XXXXX-XXXXX
FullName = "user"
OrgName = "home"
ComputerName = *
[Identification]
JoinWorkgroup = WORKGROUP
[Networking]
InstallDefaultComponents = Yes
```
## 3. Drivers — two separate problems
**a) NIC for WinPE** (so the PE can reach the share): bake it into the x86 `boot.wim` and
`drvload` it — no Windows tooling:
```bash
wimlib-imagex update assets/WinPE/x86/sources/boot.wim 1 --command="add /path/to/nicdrv /Drivers/nic"
# startnet.cmd, BEFORE net use: drvload X:\Drivers\nic\<driver>.inf
```
Use a **PE-compatible** driver (a Win10/7 *x86* NIC driver for an x86 PE) — *not* the XP one.
**b) Storage controller for the TARGET** (the `0x7B` BSOD): NT5 text-mode setup has no inbox
AHCI/NVMe/RAID. Put the controller's **F6 package** in `$OEM$\Textmode\` and reference it from
`[MassStorageDrivers]`/`[OEMBootFiles]` above. All editable on the Linux-hosted share.
**Easiest dodge (recommended where possible):**
- **VM → IDE disk** (`vmctl … --disk` uses `if=ide`) → inbox driver, **skip §3b entirely**.
- **Retro physical → BIOS SATA = IDE/Legacy/Compatibility** → inbox driver. (NT5 is BIOS-only;
targets are old hardware that usually offers this.)
**c) Installed-OS drivers** (NIC/GPU/chipset for the running XP, distinct from the PE's NIC):
put XP-era drivers in `$OEM$\$1\Drivers\…` and list them in `OemPnPDriversPath` (§2). They get
copied to `C:\Drivers` and PnP-installed during GUI setup.
## 4. Run it (inside the x86 WinPE)
The target partition must exist, be **formatted (FAT32/NTFS)** and marked **active**
(`diskpart`: `create partition primary``format fs=ntfs quick``active`). Then:
```bat
net use Z: \\192.168.10.127\win
Z:\win2k\i386\winnt32 /s:Z:\win2k\i386 /unattend:Z:\win2k\winnt.sif ^
/syspart:C: /tempdrive:C: /makelocalsource /noreboot
```
`/syspart` requires `/tempdrive`. On `/noreboot` completion, reboot the target off its **local
disk** — text-mode setup runs from the copied `$WIN_NT$.~BT`/`~LS`, then GUI setup, no network.
## Gotchas
- **XP/2000/2003 are BIOS/CSM only** — no UEFI. Boot the target (and the PE) in legacy mode.
- winnt32 under WinPE wants `/makelocalsource`; without it, it expects the source to remain
reachable across the reboot (it won't be).
- Chinese media (like the Win2000 ISO here) installs fine; the answer file drives it in that
language. TimeZone 210 = CST.
- **Activation:** volume/retail keys as appropriate; NT5 activation servers are long gone —
use a VL edition/key for hands-off installs.
- If you'd rather not deal with any of this on real hardware: **install NT5 once, capture the
disk, and PXE-boot Clonezilla** (already in the netboot menu) to clone it to targets.
+109
View File
@@ -0,0 +1,109 @@
# netboot
Self-hosted [netboot.xyz](https://netboot.xyz) PXE boot server for the LAN, using the
official appliance container plus a `dnsmasq` proxyDHCP so it coexists with the existing
DHCP server (the **NEC IX router**, which is left untouched).
## Architecture
The NEC IX router keeps leasing IPs. `dnsmasq` runs in **proxyDHCP** mode and only answers
the PXE/boot part of the conversation, pointing clients at this host (`192.168.10.127`).
Client type (announced via DHCP option 60/93) → what it gets:
| Client | Transport | File |
|------------------------------------------|-----------|----------------------------------------------|
| Legacy BIOS (`arch 0`) | TFTP | `netboot.xyz.kpxe` |
| UEFI, normal PXE (`arch 7/9`) | TFTP | `netboot.xyz.efi` |
| UEFI with HTTP Boot (`vendor HTTPClient`)| HTTP | `http://192.168.10.127:8080/menus/netboot.xyz.efi` |
Fallback is automatic: a UEFI box only announces `HTTPClient` when HTTP Boot is actually
enabled/supported; otherwise it does normal PXE and lands on the TFTP `.efi` branch.
This is a **two-stage** chain, which matters for the dnsmasq config:
1. **Firmware → netboot.xyz iPXE.** Raw firmware (not iPXE) gets the binary above. proxyDHCP
*requires* `pxe-service` here — plain `dhcp-boot` produces no boot offer in proxy mode.
2. **netboot.xyz iPXE → menu.** The loaded `.efi`/`.kpxe` re-does DHCP (announcing itself via
option 175) and dnsmasq answers with `dhcp-boot=tag:ipxe,netboot.xyz.efi,,192.168.10.127`.
Two details matter, both dictated by the bootstrap **embedded in the netboot.xyz binary**:
- **The bootfile must be a *recognised binary name*** (`netboot.xyz.efi`), not `menu.ipxe`.
The embedded bootstrap only chains the menu **locally** (its `:tftpmenu` branch) when the
bootfile matches one of its own binary names; any other name skips that branch and boots
the **public** `boot.netboot.xyz` menu instead.
- **The router's DHCP `next-server` must point at `192.168.10.127`** (see below). Under
proxyDHCP the bootstrap fetches its `local-vars.ipxe` from `${next-server}` — the value
from the *real* DHCP server (the NEC IX router), **not** from dnsmasq's
`${proxydhcp/next-server}`. `local-vars.ipxe` is what sets `use_proxydhcp_settings true`
(the no-keypress switch), so if it can't be fetched the UEFI client stalls fetching from
the router, then prompts for a `p` keypress or falls back to the public menu.
The boot binaries then chain the menu **locally** over TFTP from `192.168.10.127`, so clients
boot *this* host's menu, not the public site. Only the version check and distro mirrors reach
the internet.
### Required NEC IX router setting
The router keeps leasing IPs as before, but its DHCP scope for the LAN must advertise
**`next-server 192.168.10.127`** (a.k.a. the `siaddr` / BOOTP server field) on the LAN DHCP
pool. This is the one piece of PXE config the router *does* need — it does not otherwise
PXE-boot anything, and regular (non-PXE) DHCP clients ignore `next-server`. Set it via the
DHCP-server/boot-server (`siaddr`) option of the IX DHCP profile serving the `192.168.10.0/24`
scope; leave the bootfile name unset (dnsmasq's proxyDHCP still supplies it).
## Services (all on host `192.168.10.127`)
| Port | Service | Provided by | Purpose |
|-------------|-----------|------------------------|------------------------------------------|
| `67/udp` | proxyDHCP | `dnsmasq` (host net) | PXE boot offers (no IP leasing) |
| `69/udp` | TFTP | `netbootxyz` | serves `/config/menus` (binaries + menu) |
| `8080` | HTTP | `netbootxyz` nginx | `/` = `/assets` mirror; `/menus/` = binaries (UEFI HTTP Boot) |
| `3000` | Web UI | `netbootxyz` | manage menus / download assets |
## Usage
```bash
docker compose up -d # start
docker compose logs -f dnsmasq # watch DHCP offers during a client boot
docker compose down # stop
```
Config manager (add/update distros, edit menus): <http://192.168.10.127:3000>
## Verified
Both PXE paths were tested end-to-end with QEMU VMs bridged onto `br0` (real SeaBIOS and
OVMF/UEFI firmware), booting all the way to the local netboot.xyz menu:
- **Legacy BIOS** → proxyDHCP offered `netboot.xyz.kpxe` → TFTP → local menu rendered ✅
- **UEFI x64** → proxyDHCP offered `netboot.xyz.efi` → TFTP → local menu rendered ✅
- **UEFI HTTP Boot** → configured, not yet VM-tested (hard to trigger in QEMU).
To re-test: create a tap on `br0`, run a diskless QEMU VM with `-boot n`, and watch
`docker compose logs -f dnsmasq` + `docker logs -f netbootxyz`.
## Notes / gotchas
- **Host networking is required** for `netbootxyz`: TFTP renegotiates to an ephemeral
port that Docker's bridge NAT mangles (clients get `TID mismatch`). Host net serves TFTP
straight off the LAN interface.
- `NGINX_PORT` is ignored by the image; the nginx listen port is pinned to `8080` in
`config/nginx/site-confs/default`, which also adds a `/menus/` location so UEFI HTTP Boot
can fetch the first-stage `.efi`. (If the appliance ever regenerates that file on upgrade,
re-add the `listen 8080` and `/menus/` bits.)
- `dnsmasq` binds **only `br0`** (`interface=br0` + `bind-interfaces`) so it doesn't clash
with libvirt's own dnsmasq on `virbr0`/`virbr1`.
- `config/menus/local-vars.ipxe` sets `use_proxydhcp_settings true` so proxyDHCP clients
boot without a keypress prompt.
- The NEC IX router needs **no PXE configuration** — proxyDHCP handles everything.
- Editing `dnsmasq.conf` requires `docker restart netboot-dnsmasq` (compose won't
auto-recreate on a bind-mounted file content change).
## Files
- `compose.yaml` — the two services (`netbootxyz` + `dnsmasq`)
- `dnsmasq.conf` — proxyDHCP + arch detection
- `config/` — netbootxyz appliance state (menus, nginx conf); managed by the container
- `assets/` — optional locally-mirrored distro images
- `buildout/` — leftover from an earlier manual build; **no longer used** (safe to delete)
+333
View File
@@ -0,0 +1,333 @@
# Installing Windows via netboot.xyz
Works for KVM VMs and physical machines. Windows Setup needs a real filesystem for the
~4 GB `install.wim`, so the flow is: **wimboot → WinPE (HTTP) → SMB media → setup.exe**.
> This covers **NT6+ (Vista/7/8/10/11, Server 20082025)**. Older Windows works completely
> differently — see **`NT5.md`** for Windows 2000/XP/Server 2003, and **`9x.md`** for
> Windows 95/98/ME (via win98-quickinstall's Linux installer).
```
iPXE Windows menu
└─ wimboot loads WinPE (boot.wim) over HTTP from this host (assets/WinPE/x64/)
└─ WinPE boots to a cmd prompt; wpeinit brings up the NIC
└─ net use → mount the SMB share with the extracted ISO
└─ setup.exe → installs Windows to the local disk
```
## Already set up on the server (done)
- **SMB share** `\\192.168.10.127\win`**guests get read-only, passwordless** (WinPE mounts
it this way); the AD user **`panxiao81` has read-write** (`write list = DDUPAN\panxiao81`) for
staging images/media. Backed by ZFS dataset `data/win``/mnt/pool/win`. Holds `menu.cmd`
(the version picker) and the `winpe-build/` driver kit. Managed by the `samba_member` Ansible
role (`samba-ad/`), not a hand-edited `smb.conf`.
- **`win_base_url`** = `http://192.168.10.127:8080/WinPE` — set in both
`config/menus/local-vars.ipxe` and `config/menus/boot.cfg` (the latter covers clients whose
firmware is already iPXE and skips `local-vars`).
- **WinPE** built into `assets/WinPE/x64/` (base PE extracted from a Server 2025 ISO; NIC/
storage drivers injected with **DISM** — see step 1 / Driver notes), with a `startnet.cmd`
that brings up networking, auto-mounts the share, and launches the picker.
- Only remaining step for a real install: drop a version folder onto the share (step 2).
## Verified (PXE-tested)
Driven end-to-end in a KVM VM on `br0`: iPXE Windows menu → wimboot loaded the WinPE over
HTTP → WinPE booted → `startnet.cmd` ran `wpeinit`, mounted `\\192.168.10.127\win`, and
launched `menu.cmd`, which showed the (empty) picker. So the whole path works; adding a
version folder makes it installable. Note: give the target **≥4 GB RAM** (2 GB bugchecks the
RAM-loaded WinPE and reboots).
## What you do
### 1. Build WinPE
The WinPE at `assets/WinPE/x64/` is **already built and PXE-tested** (see "Verified" below).
Two ways to (re)build it:
**A. On Linux, no Windows box needed (how it was built here).** A Windows installation ISO's
`sources/boot.wim` *is* a modern WinPE. Extract its bare-PE image with `wimlib-imagex` and
inject a startup script that auto-mounts the share and runs the picker:
```bash
ISO=~/zh-cn_windows_server_2025_..._x64_dvd.iso # any modern Windows/Server ISO
OUT=/home/panxiao81/services/apps/netboot/assets/WinPE/x64
sudo mount -o loop,ro "$ISO" /mnt/winiso
mkdir -p "$OUT/boot" "$OUT/sources"
cp /mnt/winiso/bootmgr "$OUT/bootmgr"
cp /mnt/winiso/bootmgr.efi "$OUT/bootmgr.efi"
cp /mnt/winiso/boot/bcd "$OUT/boot/bcd"
cp /mnt/winiso/boot/boot.sdi "$OUT/boot/boot.sdi"
# export image 1 ("Windows PE") as a single bootable wim
wimlib-imagex export /mnt/winiso/sources/boot.wim 1 "$OUT/sources/boot.wim" --boot
# auto-run our startup: wpeinit + mount \\host\win + launch menu.cmd (startnet.cmd is CRLF)
wimlib-imagex update "$OUT/sources/boot.wim" 1 --command="delete --force /Windows/System32/startnet.cmd"
wimlib-imagex update "$OUT/sources/boot.wim" 1 --command="add /path/to/startnet.cmd /Windows/System32/startnet.cmd"
sudo umount /mnt/winiso
```
Use the **newest** Windows/Server ISO you have — a WinPE installs any OS at or below its
version. Drivers are injected separately with **DISM** (see **Driver notes**), so the baked-in
`startnet.cmd` no longer needs `drvload` — it just brings up networking (with a DHCP retry
loop, since a freshly-loaded NIC can be a few seconds behind the first DISCOVER) and launches
the picker:
```bat
wpeinit REM PnP auto-loads the DISM-injected NIC driver
reg add HKLM\SYSTEM\CurrentControlSet\Services\LanmanWorkstation\Parameters /v AllowInsecureGuestAuth /t REG_DWORD /d 1 /f
:netwait REM retry until a real 192.168.10.x lease appears
wpeutil InitializeNetwork
ipconfig | find "192.168.10." >nul && goto neton
ipconfig /renew >nul & ping 127.0.0.1 -n 4 >nul & goto netwait
:neton
net use Z: \\192.168.10.127\win
if exist Z:\menu.cmd call Z:\menu.cmd
cmd
```
**B. On a Windows box (Windows ADK).** `copype amd64 C:\winpe``MakeWinPEMedia /ISO ...`,
then copy the ISO/media contents into `assets/WinPE/x64/`. Edit `boot.wim`'s
`Windows\System32\startnet.cmd` to the same script as above. Use this if you want ADK's
optional components or a custom PE. (This same ADK box is where drivers get DISM-injected —
see Driver notes.)
Either way the tree must be:
```
assets/WinPE/x64/
├── bootmgr
├── bootmgr.efi
├── boot/bcd (BCD store — the menu also tries Boot/BCD)
├── boot/boot.sdi
└── sources/boot.wim (your WinPE image, single bootable index)
```
netboot.xyz loads exactly those five files from `${win_base_url}/x64/`. (`wimboot` itself is
fetched from public `boot.netboot.xyz` — fine as long as the host has internet.) Give WinPE
**≥4 GB RAM** on the target — the wim is RAM-loaded and 2 GB bugchecks → reboot.
### 2. Populate the SMB share with install media
Put each Windows version in **its own subfolder** under `/mnt/pool/win` — extract the ISO
*files* (not the .iso). One WinPE installs all of them; you do NOT need a WinPE per version.
```bash
sudo mount -o loop Win11_24H2.iso /mnt/iso
mkdir -p /mnt/pool/win/win11-24h2
cp -a /mnt/iso/. /mnt/pool/win/win11-24h2/
sudo umount /mnt/iso
# repeat for win10-22h2/, server2022/, server2025/, ...
```
Result:
```
/mnt/pool/win/
├── menu.cmd ← version picker (already installed)
├── win11-24h2/ ← setup.exe, sources/install.wim, ...
├── win10-22h2/
└── server2025/
```
Editions (Home/Pro/Enterprise) usually live inside one ISO's `install.wim`; `setup.exe`
lets you pick, so they don't need separate folders. See "Multiple versions" below.
### 3. Boot a target → install
1. PXE boot → **Windows****Load Microsoft Windows Installer** (uses `win_base_url`).
On real hardware confirm WinPE actually got a `192.168.10.x` (`ipconfig`); if the onboard
NIC won't network, use a **USB Ethernet dongle** — see **Driver notes**.
2. At the WinPE `cmd` prompt (startnet usually does this for you):
```bat
wpeinit
net use Z: \\192.168.10.127\win
Z:\menu.cmd REM pick a version; launches <folder>\sources\setup.exe
```
3. Pick a version → click through Setup → install to the local disk.
4. **After Setup's first reboot, boot the LOCAL DISK, not PXE** — otherwise it loops back into
netboot and the install looks like it "restarted." (One-time boot menu, or move the disk
above the network in the BIOS boot order.)
> **Win11 24H2/25H2 gotcha — launch `sources\setup.exe`, not the media-root `setup.exe`.** In
> 24H2+ the root `setup.exe` is the new "modern setup" front-end, meant for booting from real
> USB/DVD media or upgrading from within Windows; started from a bare WinPE prompt it **exits
> partway** ("quits in half"). The classic engine at `<folder>\sources\setup.exe` is PE-friendly.
> `menu.cmd` already prefers `sources\setup.exe` (falling back to the root one for older media).
## Multiple Windows versions
One x64 WinPE handles every x64 Windows (10/11, Server 20192025, all editions) — as long
as the WinPE is at least as new as the newest OS you install. Manage versions purely as the
folder library on the share; `menu.cmd` auto-lists every subfolder that contains a
`setup.exe` and launches the one you choose.
**Make it hands-off** by baking the mount + picker into WinPE so every boot lands on the
menu. When building WinPE, edit `mount\Windows\System32\startnet.cmd` (in the mounted
`boot.wim`) to:
```bat
wpeinit
rem allow passwordless (guest) SMB from WinPE
reg add HKLM\SYSTEM\CurrentControlSet\Services\LanmanWorkstation\Parameters /v AllowInsecureGuestAuth /t REG_DWORD /d 1 /f
net use Z: \\192.168.10.127\win
Z:\menu.cmd
```
**Unattended per version:** drop an `autounattend.xml` in a version folder and launch it with
`setup.exe /unattend:%~dp0autounattend.xml` (you can add per-folder entries to `menu.cmd`).
Each version can have its own answer file (edition index, product key, partitioning).
**x86 / ARM64:** only these need a second WinPE — place it in `assets/WinPE/x86/` (the Windows
menu's arch toggle switches `${win_arch}`). Rarely needed.
**Advanced — per-version entries in the iPXE menu** (choose the version *before* WinPE, e.g.
for fully automated imaging): pass a config into WinPE via extra `initrd` lines in
`windows.ipxe` so WinPE auto-installs a specific folder. See
[netbootxyz discussion #757](https://github.com/netbootxyz/netboot.xyz/discussions/757).
For interactive use, the `menu.cmd` picker is simpler and needs no iPXE changes.
## Driver notes (mainly physical machines)
WinPE must have the target's **NIC driver** (to reach the share) and Setup must have the
**storage driver** (to see the disk). VMs rarely need this; real hardware often does.
The build's reduced WinPE driver set is missing most modern **Intel** desktop NICs: it ships
`e1i`/`e1e`/`e1g` (I350/82575/8257x-era) but **not** `e1d` (I217/I218/**I219**) or `e2f`
(**I225/I226** 2.5G). Symptom: WinPE boots but `net use` fails because there is no link —
no NIC was ever loaded. Realtek onboard NICs (RTL8111/8168/8125) are likewise absent, and so
is **virtio-net** (needed for KVM installs with a virtio NIC).
### How the current image gets its drivers: DISM injection on `winadmin`
Drivers are injected into `boot.wim`'s driver store with **DISM** on the Windows ADK box
(`winadmin`, `192.168.10.6`). This is the proper method: they become real PnP drivers that load
automatically at boot — no `drvload`. A ready-to-run **build kit** lives on the share at
`\\192.168.10.127\win\winpe-build\`:
```
winpe-build/
├── boot.wim ← image to service (copy of the live one)
├── drivers/
│ ├── Intel-1G/ e1dn (I219 — but see ⚠ box), e1r (I210/211/350), v1q (82575/6/80)
│ ├── Intel-2.5G/ e2f (I225/I226) NDIS68
│ ├── virtio-NetKVM/ netkvm (virtio-net) + netkvmp.exe/netkvmco.exe
│ ├── virtio-viostor/ viostor (virtio-blk)
│ └── virtio-vioscsi/ vioscsi (virtio-scsi)
├── startnet.cmd ← no-drvload version (PnP loads drivers; DHCP retry loop)
├── build-winpe.cmd ← one-click DISM script
└── READ-ME-FIRST.txt
```
Rebuild on `winadmin` (the DISM mount dir must be **local**, not the share):
```bat
robocopy \\192.168.10.127\win\winpe-build C:\winpe-build /E
:: Start menu -> "Deployment and Imaging Tools Environment" -> Run as administrator
cd /d C:\winpe-build
build-winpe.cmd :: mounts boot.wim, drops any old \Drivers tree, DISM /add-driver, commits
copy /y C:\winpe-build\boot.wim \\192.168.10.127\win\winpe-build\boot.new.wim
```
Then on this host, back up the live image and swap it in (netboot serves it statically — no
restart, and a size change is fine, the BCD loads `boot.wim` by name):
```bash
cd assets/WinPE/x64/sources
cp -a boot.wim boot.wim.prev
cp /mnt/pool/win/winpe-build/boot.new.wim boot.wim
```
`build-winpe.cmd` is essentially:
```bat
dism /Mount-Image /ImageFile:.\boot.wim /Index:1 /MountDir:.\mount
rmdir /s /q .\mount\Drivers :: drop any old drvload tree
copy /y .\startnet.cmd .\mount\Windows\System32\startnet.cmd
dism /Image:.\mount /Add-Driver /Driver:.\drivers /Recurse /ForceUnsigned
dism /Image:.\mount /Get-Drivers :: confirm the NIC driver is listed
dism /Unmount-Image /MountDir:.\mount /Commit
```
Use the **current Win11 24H2 / 10.1.26100 ADK** — servicing a 26100 `boot.wim` with an older
DISM fails (*"image version is higher than the DISM version"*).
> **⚠ Verdict — the onboard Intel I219 does NOT work in this (build-26100) WinPE with any
> driver. Use a USB Ethernet dongle.** On a real I219 (`DEV_550B`, recent Lenovo board) all
> three Intel drivers failed to move a single frame in *either* direction (no DHCP; a static-IP
> ping gets no ARP reply — confirmed with `tcpdump` on the host, which saw nothing from the NIC's
> MAC):
> - `e1dn` v20.0.3.24 **and** the Lenovo-OEM `e1dn` v20.0.2.19 → link shows "connected", **no traffic**.
> - `e1d` v12.19.2.65 → **no traffic**, and `netsh …set interface admin=disabled` **bugchecks with
> `PNP_WATCHDOG`** (the driver can't even cleanly stop the device).
>
> PXE firmware works on the same port (its own minimal driver), so it's specifically the I219
> datapath under build-26100 WinPE — a known regression on newer PE builds. **Fix: a USB GbE
> dongle** (Realtek RTL8153/8156 is inbox in WinPE 26100 — ours worked with nothing injected).
> The onboard I219 is fine once *real* Windows is installed. An older WinPE base (Win10 22H2 /
> Server 2022, build ≤20348) *might* also work but is untested.
Intel driver sources: **Intel Wired driver 31.2**
(`downloadmirror.intel.com/921523/Wired_driver_31.2_x64.zip`), `PRO1000\Winx64\NDIS68` +
`PRO2500\Winx64\NDIS68` subfolders; the I219 `e1dn` in the current kit is the Lenovo OEM package
(`e1dn` 20.0.2.19). virtio drivers from `virtio-win-0.1.285.iso` (`~/virtio-win-0.1.285.iso`),
`<driver>/w11/amd64` folders — **keep `netkvmp.exe`**, `netkvm.inf`'s `[CopyFiles]` requires it.
**State:** the live `boot.wim` is a DISM build whose driver store (`oem*.inf`) holds `e1dn`
(OEM I219 — moot, see box), `e1r`/`v1q` (I210/211/350), `e2f` (I225/226) and
`netkvm`/`viostor`/`vioscsi` (virtio) — so it still covers other Intel NICs and KVM VMs, and the
`e1d` that `PNP_WATCHDOG`'d is deliberately excluded. The I219 machine installs via a **USB
dongle**, and Setup ran once the picker used `sources\setup.exe`. (An earlier `drvload` build was
VM-verified with a virtio-net NIC reaching the share.)
### Alternative: `drvload` at runtime (Linux build, no Windows box)
The image can also be built entirely on Linux with `wimlib-imagex`: stage the driver
`.inf`/`.sys`/`.cat` files inside `boot.wim` under `\Drivers` and `drvload` them from
`startnet.cmd` before networking. This is how it was *first* built — it's the fallback, since
`drvload` only loads into the running PE (no persistent driver store) and can load several
matching drivers at once, leaving PnP to bind whichever it ranks highest (which is how the flaky
`e1dn` got picked early on):
```bat
for /r X:\Drivers %%i in (*.inf) do drvload "%%i" REM filename-agnostic; non-matching INFs fail harmlessly
wpeinit
wpeutil InitializeNetwork
```
```bash
WIM=assets/WinPE/x64/sources/boot.wim
printf '%s\n' \
"add /path/to/stage/Drivers /Drivers" \
"delete --force /Windows/System32/startnet.cmd" \
"add /path/to/startnet.cmd /Windows/System32/startnet.cmd" \
| wimlib-imagex update "$WIM" 1
```
> **Gotcha `drvload` fails with `0x80070002` (FILE_NOT_FOUND):** the INF's `[CopyFiles]`
> references a file you trimmed (e.g. NetKVM's `netkvmp.exe`). Keep the whole driver folder —
> strip only `*.pdb`/readme.
### Storage drivers
- Storage/RAID (Intel VMD/RST) drivers usually also need loading in Setup (or slipstream into
`install.wim`). NVMe/AHCI are typically inbox.
- **KVM:** using virtio disk/NIC → the virtio drivers above are baked in; or give the VM a
**SATA disk + e1000 NIC** (both inbox) to skip driver work entirely.
## Gotcha: guest SMB from WinPE
Modern Windows blocks "insecure guest" logons by policy. WinPE usually allows it, but if
`net use` fails with **system error 1272 / 5**, enable it in the running WinPE:
```bat
reg add HKLM\SYSTEM\CurrentControlSet\Services\LanmanWorkstation\Parameters /v AllowInsecureGuestAuth /t REG_DWORD /d 1 /f
```
(To make it permanent, set the same key offline in the mounted `boot.wim`.) Alternatively,
switch the share to a real user + password — but do it in the `samba_member` role
(`samba-ad/`), **not** `/etc/samba/smb.conf` directly (Ansible regenerates that file). The
`[win]` share already grants the AD user `panxiao81` read-write via `write list`.
## Later: unattended installs
Drop an `autounattend.xml` at the root of the SMB media (or bake into WinPE via
`startnet.cmd` running `wpeinit` + `net use` + `setup.exe /unattend:...`) for zero-touch.
+81
View File
@@ -0,0 +1,81 @@
#!/bin/bash
cat << EOF
#########################################################################################################
# Create PXE bootable Proxmox image including ISO #
# #
# Author: mrballcb @ Proxmox Forum (06-12-2012) #
# Thread: http://forum.proxmox.com/threads/8484-Proxmox-installation-via-PXE-solution?p=55985#post55985 #
# Modified: morph027 @ Proxmox Forum (23-02-2015) to work with 3.4 #
#########################################################################################################
EOF
if [ ! $# -eq 1 ]; then
echo -ne "Usage: bash pve-iso-2-pxe.sh /path/to/pve.iso\n\n"
exit 1
fi
BASEDIR="$(dirname "$(readlink -f "$1")")"
pushd "$BASEDIR" >/dev/null || exit 1
[ -L "proxmox.iso" ] && rm proxmox.iso &>/dev/null
for ISO in *.iso; do
if [ "$ISO" = "*.iso" ]; then continue; fi
if [ "$ISO" = "proxmox.iso" ]; then continue; fi
echo "Using ${ISO}..."
ln -s "$ISO" proxmox.iso
done
if [ ! -f "proxmox.iso" ]; then
echo "Couldn't find a proxmox iso, aborting."
echo "Add /path/to/iso_dir to the commandline."
exit 2
fi
rm -rf pxeboot
[ -d pxeboot ] || mkdir pxeboot
pushd pxeboot >/dev/null || exit 1
echo "extracting kernel..."
if [ -x $(which isoinfo) ] ; then
isoinfo -i ../../infrastructure/proxmox.iso -R -x /boot/linux26 > linux26 || exit 3
else
7z x ../../infrastructure/proxmox.iso boot/linux26 -o/tmp || exit 3
mv /tmp/boot/linux26 /tmp/
fi
echo "extracting initrd..."
if [ -x $(which isoinfo) ] ; then
isoinfo -i ../../infrastructure/proxmox.iso -R -x /boot/initrd.img > /tmp/initrd.img
else
7z x ../../infrastructure/proxmox.iso boot/initrd.img -o/tmp
mv /tmp/boot/initrd.img /tmp/
fi
mimetype="$(file --mime-type --brief /tmp/initrd.img)"
case "${mimetype##*/}" in
"zstd"|"x-zstd")
decompress="zstd -d /tmp/initrd.img -c"
;;
"gzip"|"x-gzip")
decompress="gzip -S img -d /tmp/initrd.img -c"
;;
*)
echo "unable to detect initrd compression method, exiting"
exit 1
;;
esac
$decompress > initrd || exit 4
echo "adding iso file ..."
if [ -x $(which cpio) ] ; then
echo "../../infrastructure/proxmox.iso" | cpio -L -H newc -o >> initrd || exit 5
else
7z x "../../infrastructure/proxmox.iso" >> initrd || exit 5
fi
popd >/dev/null 2>&1 || exit 1
echo "Finished! pxeboot files can be found in ${PWD}."
popd >/dev/null 2>&1 || true # don't care if these pops fail
popd >/dev/null 2>&1 || true
+33
View File
@@ -0,0 +1,33 @@
#!ipxe
# ─────────────────────────────────────────────────────────────────────────
# Proxmox VE 9.2 install — pve1 (Intel NUC) → 192.168.10.4, target sdb
#
# The NUC's UEFI CANNOT unpack a ~1.8GB initramfs — it dies with
# "initramfs unpacking failed: write error", leaving a TRUNCATED /proxmox.iso,
# which then fails to loop-mount → "no device with valid ISO found".
# (The ISO itself is fine and the HTTP transfer completes — verified in the
# nginx log. RAM is fine too: 15881 MB. It's a firmware/early-boot limit.)
#
# So we DON'T ship the ISO in the initrd here. We boot only the kernel + a
# lean initrd augmented with e1000e (the stock installer initrd has NO network
# drivers at all). The ISO search fails, init drops to a debug shell, and you
# run ONE command:
#
# sh /netfetch.sh
#
# which brings up the NIC, streams the ISO onto /dev/sda (the 1TB HDD), and
# re-execs init — which then finds it via the normal block-device path and
# auto-installs to sdb. Nothing oversized ever goes through initramfs.
# ─────────────────────────────────────────────────────────────────────────
echo
echo Proxmox VE 9.2 — pve1 (NUC) network-staged install
echo Loading kernel + net-enabled initrd (no ISO in initramfs)...
echo
echo ">>> At the debug shell that appears, type: sh /netfetch.sh"
echo
kernel http://192.168.10.127:8080/proxmox/linux26 ro ramdisk_size=16777216 rw quiet splash=silent proxmox-start-auto-installer
initrd http://192.168.10.127:8080/proxmox/initrd-net.img
boot || goto failed
:failed
echo Proxmox netboot failed. Dropping to shell.
shell
+16
View File
@@ -0,0 +1,16 @@
#!ipxe
# ─────────────────────────────────────────────────────────────────────────
# Proxmox VE 9.2 AUTOMATED install — pve1 → 192.168.10.4 (target: sdb, the SATA SSD)
# Same --pxe-style method: lean kernel + gzip initrd, then the answer-embedded
# ISO as a second initrd (proxmox.iso). WIPES sdb (HDD sda untouched).
# NOTE: pve1 is the SSH jump host / build box — reinstall it LAST.
# ─────────────────────────────────────────────────────────────────────────
echo
echo Proxmox VE 9.2 AUTOMATED install: pve1 -> 192.168.10.4 (wipes sdb)
kernel http://192.168.10.127:8080/proxmox/linux26 ro ramdisk_size=16777216 rw quiet splash=silent proxmox-start-auto-installer
initrd http://192.168.10.127:8080/proxmox/initrd.img
initrd http://192.168.10.127:8080/proxmox/prepared-pve1.iso proxmox.iso
boot || goto failed
:failed
echo Proxmox netboot failed. Dropping to shell.
shell
+16
View File
@@ -0,0 +1,16 @@
#!ipxe
# ─────────────────────────────────────────────────────────────────────────
# Proxmox VE 9.2 AUTOMATED install — pve2 → 192.168.10.7 (target: nvme0n1)
# Same --pxe-style method as pve3: lean kernel + gzip initrd, then the
# answer-embedded ISO as a second initrd (proxmox.iso). WIPES nvme0n1.
# Reinstalling pve2 also vacates the contested 192.168.10.5.
# ─────────────────────────────────────────────────────────────────────────
echo
echo Proxmox VE 9.2 AUTOMATED install: pve2 -> 192.168.10.7 (wipes nvme0n1)
kernel http://192.168.10.127:8080/proxmox/linux26 ro ramdisk_size=16777216 rw quiet splash=silent proxmox-start-auto-installer
initrd http://192.168.10.127:8080/proxmox/initrd.img
initrd http://192.168.10.127:8080/proxmox/prepared-pve2.iso proxmox.iso
boot || goto failed
:failed
echo Proxmox netboot failed. Dropping to shell.
shell
+19
View File
@@ -0,0 +1,19 @@
#!ipxe
# ─────────────────────────────────────────────────────────────────────────
# Proxmox VE 9.2 AUTOMATED install — pve3 → 192.168.10.9 (target: nvme0n1)
# Replicates the (unreleased) `proxmox-auto-install-assistant --pxe` method:
# lean kernel + lean gzip initrd, then the answer-embedded ISO loaded as a
# SECOND initrd named proxmox.iso (iPXE's native multi-initrd = correct cpio).
# The installer finds /proxmox.iso, loop-mounts it, reads the embedded answer,
# and installs unattended (proxmox-start-auto-installer). WIPES nvme0n1.
# ─────────────────────────────────────────────────────────────────────────
echo
echo Proxmox VE 9.2 AUTOMATED install: pve3 -> 192.168.10.9 (wipes nvme0n1)
echo Loading kernel + lean initrd + ISO (as proxmox.iso)...
kernel http://192.168.10.127:8080/proxmox/linux26 ro ramdisk_size=16777216 rw quiet splash=silent proxmox-start-auto-installer
initrd http://192.168.10.127:8080/proxmox/initrd.img
initrd http://192.168.10.127:8080/proxmox/prepared-pve3.iso proxmox.iso
boot || goto failed
:failed
echo Proxmox netboot failed. Dropping to shell.
shell
+32
View File
@@ -0,0 +1,32 @@
services:
# Official netboot.xyz appliance: TFTP (:69/udp), assets nginx (:8080), web UI (:3000).
# HOST networking is required — TFTP renegotiates to an ephemeral port that Docker's
# bridge NAT mangles (clients get "TID mismatch"). Host net serves TFTP straight off
# the LAN interface. Does NOT provide DHCP (see the dnsmasq service below).
netbootxyz:
image: ghcr.io/netbootxyz/netbootxyz
container_name: netbootxyz
restart: unless-stopped
network_mode: host
environment:
TZ: Asia/Tokyo
NGINX_PORT: "8080" # assets mirror (host port, host net)
WEB_APP_PORT: "3000" # web configuration UI
volumes:
- "./config:/config"
- "./assets:/assets"
# proxyDHCP: runs ALONGSIDE the NEC IX router's DHCP. The router leases IPs; dnsmasq
# only answers the PXE/boot part and points clients at this host (192.168.10.127).
dnsmasq:
image: 4km3/dnsmasq:2.90-r3
container_name: netboot-dnsmasq
restart: unless-stopped
network_mode: host # must see LAN DHCP broadcasts (scoped to br0 in the conf)
cap_add:
- NET_ADMIN
command: ["-k", "--conf-file=/etc/dnsmasq.conf"]
volumes:
- "./dnsmasq.conf:/etc/dnsmasq.conf:ro"
depends_on:
- netbootxyz
+2
View File
@@ -0,0 +1,2 @@
429: Too Many Requests
For more on scraping GitHub and how it may affect your rights, please review our Terms of Service (https://docs.github.com/en/site-policy/github-terms/github-terms-of-service).
+219
View File
@@ -0,0 +1,219 @@
#!ipxe
:global_vars
# set site name
set site_name netboot.xyz
# set boot domain
set boot_domain boot.netboot.xyz/3.0.2
# set location of memdisk
set memdisk http://${boot_domain}/memdisk
# set location of custom netboot.xyz live assets, override in local-vars.ipxe
isset ${live_endpoint} || set live_endpoint https://github.com/netbootxyz
# default Windows install source (local WinPE); local-vars.ipxe may override
isset ${win_base_url} || set win_base_url http://192.168.10.127:8080/WinPE
# signature check enabled?
set sigs_enabled false
# disable signature checks if SecureBoot is active, as imgverify is not
# available when booting via iPXE upstream's official Secure Boot image
iseq ${efi/SecureBoot} 01 && set sigs_enabled false ||
# set location of signatures for sources
set sigs http://${boot_domain}/sigs/
# set location of latest iPXE
iseq ${platform} efi && set ipxe_disk netboot.xyz-snponly.efi || set ipxe_disk netboot.xyz-undionly.kpxe
# set default boot timeout
isset ${boot_timeout} || set boot_timeout 300000
##################
# official mirrors
##################
:mirrors
### AlmaLinux
set almalinux_mirror http://repo.almalinux.org
set almalinux_base_dir almalinux
### Alpine Linux
set alpinelinux_mirror http://dl-cdn.alpinelinux.org
set alpinelinux_base_dir alpine
### Arch Linux
set archlinux_mirror mirrors.kernel.org
set archlinux_base_dir archlinux
### CentOS Stream
set centos_mirror https://mirror.stream.centos.org
set centos_base_dir
### CentOS Stream CoreOS
set scos_mirror https://cloud.centos.org
set scos_base_dir centos/scos
### Debian
set debian_mirror http://deb.debian.org
set debian_base_dir debian
### Devuan
set devuan_mirror http://deb.devuan.org
set devuan_base_dir devuan
### Fedora
set fedora_mirror http://mirrors.kernel.org
set fedora_base_dir fedora
### Fedora CoreOS
set coreos_mirror https://builds.coreos.fedoraproject.org
set coreos_base_dir prod/streams
### FreeDOS
set freedos_mirror http://www.ibiblio.org
set freedos_base_dir pub/micro/pc-stuff/freedos/files/distributions/1.4
### IPFire
set ipfire_mirror https://downloads.ipfire.org
set ipfire_base_dir releases/ipfire-2.x
### Kali Linux
set kali_mirror http://http.kali.org
set kali_base_dir kali
### Mageia
set mageia_mirror http://mirrors.kernel.org
set mageia_base_dir mageia
### OpenBSD
set openbsd_mirror http://cdn.openbsd.org
set openbsd_base_dir pub/OpenBSD
### openEuler
set openEuler_mirror http://repo.openeuler.org
set openEuler_base_dir
### openSUSE
set opensuse_mirror http://download.opensuse.org
set opensuse_base_dir distribution/leap
### Red Hat Enterprise Linux CoreOS
set rhcos_mirror https://mirror.openshift.com
set rhcos_base_dir pub/openshift-v
### Rocky Linux
set rockylinux_mirror http://download.rockylinux.org
set rockylinux_base_dir pub/rocky
### Slackware
set slackware_mirror http://mirrors.kernel.org
set slackware_base_dir slackware
### SmartOS
set smartos_mirror https://netboot.smartos.org/os/
set smartos_base_dir /platform/i86pc/
### Ubuntu
set ubuntu_mirror http://archive.ubuntu.com
set ubuntu_base_dir ubuntu
#################################################
# determine architectures and enable menu options
#################################################
:architectures
set menu_linux 1
set menu_bsd 1
set menu_unix 1
set menu_freedos 1
set menu_live 1
set menu_pci 1
set menu_windows 1
set menu_utils 1
iseq ${arch} i386 && goto i386 ||
iseq ${arch} x86_64 && goto x86_64 ||
iseq ${arch} arm64 && goto arm64 ||
goto architectures_end
:x86_64
set menu_linux_i386 0
iseq ${platform} efi && goto efi ||
goto architectures_end
:i386
set menu_linux 0
set menu_linux_i386 1
set menu_bsd 1
set menu_unix 0
set menu_freedos 1
set menu_live 0
set menu_windows 0
set menu_utils 1
iseq ${platform} efi && goto efi ||
goto architectures_end
:arm64
set menu_linux 0
set menu_linux_arm 1
set menu_unix 0
set menu_freedos 0
set menu_live 0
set menu_live_arm 1
set menu_windows 0
set menu_utils 0
set menu_utils_arm 1
set menu_pci 0
iseq ${platform} efi && goto efi ||
goto architectures_end
:efi
set menu_bsd 1
set menu_freedos 0
set menu_unix 0
set menu_pci 0
goto architectures_end
:architectures_end
goto clouds
###################################
# set iPXE cloud provider specifics
###################################
:clouds
iseq ${ipxe_cloud_config} gce && goto gce ||
iseq ${ipxe_cloud_config} metal && goto metal ||
iseq ${ipxe_cloud_config} packet && goto metal ||
goto clouds_end
:gce
set cmdline console=ttyS0,115200n8
goto clouds_end
:metal
iseq ${arch} i386 && goto metal_x86_64 ||
iseq ${arch} x86_64 && goto metal_x86_64 ||
iseq ${arch} arm64 && goto metal_arm64 ||
goto clouds_end
:metal_x86_64
set cmdline console=ttyS1,115200n8
iseq ${platform} efi && set ipxe_disk netboot.xyz-metal-snp.efi || set ipxe_disk netboot.xyz-metal.kpxe
set menu_linux_i386 0
set menu_freedos 0
set menu_windows 0
iseq ${platform} efi && set menu_pci 0 ||
goto clouds_end
:metal_arm64
set cmdline console=ttyAMA0,115200
set ipxe_disk netboot.xyz-metal-arm64-snp.efi
set menu_bsd 1
set menu_freedos 0
set menu_live 0
set menu_windows 0
set menu_utils 0
set menu_pci 0
goto clouds_end
:clouds_end
goto end
:end
exit
+22
View File
@@ -0,0 +1,22 @@
#!ipxe
### local overrides for this self-hosted netboot.xyz instance
# Use the proxyDHCP-provided TFTP server (192.168.10.127) without prompting for a keypress
set use_proxydhcp_settings true
# Windows: where wimboot fetches WinPE from (files live in assets/WinPE/x64/,
# served by the appliance nginx on :8080). The Windows menu appends /x64/...
set win_base_url http://192.168.10.127:8080/WinPE
# ─── Proxmox VE 9.2 reinstall auto-boot (per-node MAC-match) ───
# Re-add ONE line at a time, only for the node you're actively reinstalling, then
# remove it once that node is up — this prevents a network-boot reinstall loop.
# Runs after DHCP so ${mac} is set; non-matching machines fall through (||) to the menu.
# iseq ${mac} 6c:4b:90:c9:f8:0a && chain --replace http://192.168.10.127:8080/proxmox/pve3.ipxe || # pve3 -> .9
# iseq ${mac} 6c:4b:90:c9:f8:53 && chain --replace http://192.168.10.127:8080/proxmox/pve2.ipxe || # pve2 -> .7
# pve1 (NUC) needs pve1-net.ipxe, NOT pve1.ipxe: its UEFI cannot unpack a
# ~1.8GB initramfs ("initramfs unpacking failed: write error"), so the ISO
# must be staged to a local disk over the network instead of riding along in
# the initrd. See services/netboot/assets/proxmox/pve1-net.ipxe for details.
# iseq ${mac} b8:ae:ed:ea:0f:30 && chain --replace http://192.168.10.127:8080/proxmox/pve1-net.ipxe || # pve1 -> .4
# ALL THREE NODES REINSTALLED 2026-07-25 — every line above is disarmed on purpose.
+1
View File
@@ -0,0 +1 @@
3.0.2
+26
View File
@@ -0,0 +1,26 @@
user nbxyz;
worker_processes 4;
pid /run/nginx.pid;
include /etc/nginx/modules/*.conf;
events {
worker_connections 768;
}
http {
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
client_max_body_size 0;
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log /config/log/nginx/access.log;
error_log /config/log/nginx/error.log;
gzip on;
gzip_disable "msie6";
include /config/nginx/site-confs/*;
}
daemon off;
@@ -0,0 +1,12 @@
server {
listen 8080;
location / {
root /assets;
autoindex on;
}
# menus/binaries over HTTP — for UEFI HTTP Boot (serves the first-stage .efi)
location /menus/ {
alias /config/menus/;
autoindex on;
}
}
+58
View File
@@ -0,0 +1,58 @@
# ─── netboot proxyDHCP ───────────────────────────────────────────────────
# Runs ALONGSIDE the NEC IX router's DHCP. The router leases IPs; dnsmasq only
# answers the PXE/boot half and hands out NO addresses. TFTP + HTTP + the local
# menu are all served by the netbootxyz container on this host (192.168.10.127).
port=0 # disable dnsmasq's DNS server entirely
log-dhcp # verbose boot logging (comment out once stable)
log-facility=- # send logs to stderr so `docker logs` shows them
interface=br0 # LAN bridge ONLY
bind-interfaces # don't bind virbr0/virbr1 (libvirt's dnsmasq owns :67 there)
dhcp-range=192.168.10.0,proxy # proxyDHCP on the LAN subnet — assigns no leases
# ─── client classification ───────────────────────────────────────────────
dhcp-match=set:ipxe,175 # our 2nd-stage iPXE sets option 175
dhcp-match=set:httpboot,option:vendor-class,HTTPClient # UEFI HTTP Boot firmware
dhcp-option=tag:httpboot,60,HTTPClient # echo the vendor class back
# architecture (DHCP option 93) → EFI vs legacy BIOS, so stage 2 hands an iPXE
# client a binary it can actually execute (a BIOS iPXE cannot run a UEFI .efi).
dhcp-match=set:efi,option:client-arch,6 # UEFI ia32
dhcp-match=set:efi,option:client-arch,7 # UEFI x86-64
dhcp-match=set:efi,option:client-arch,9 # UEFI x86-64 (alt)
dhcp-match=set:efi,option:client-arch,11 # UEFI arm64
# ─── stage 1: raw firmware (NOT iPXE) → hand it the netboot.xyz binary ────
# proxyDHCP REQUIRES pxe-service (not dhcp-boot) to emit a boot offer. The TFTP
# server defaults to this dnsmasq host (192.168.10.127) = the container's TFTP.
pxe-prompt="Booting netboot.xyz...",0 # 0s timeout — no keypress
pxe-service=tag:!ipxe,x86PC,"netboot.xyz (BIOS)",netboot.xyz.kpxe
# NOTE: use the SNPONLY EFI build (SNP/UNDI = firmware NIC driver) instead of the
# all-drivers netboot.xyz.efi. iPXE's NATIVE driver hangs on some NICs (Intel I219
# in the NUC froze right after "autoexec.ipxe not found"); snponly reuses the
# firmware's own driver and boots reliably. Realtek ThinkCentres work either way.
pxe-service=tag:!ipxe,BC_EFI,"netboot.xyz (UEFI)",netboot.xyz-snponly.efi
pxe-service=tag:!ipxe,X86-64_EFI,"netboot.xyz (UEFI)",netboot.xyz-snponly.efi
# UEFI HTTP Boot firmware → fetch the first-stage .efi over HTTP
dhcp-boot=tag:httpboot,tag:!ipxe,http://192.168.10.127:8080/menus/netboot.xyz-snponly.efi
# ─── stage 2: our netboot.xyz iPXE re-requests → give it OUR next-server ──
# The .efi's embedded bootstrap fetches local-vars.ipxe from ${next-server} (the
# REAL DHCP server = the NEC IX router), NOT from ${proxydhcp/next-server}. So the
# router's DHCP next-server MUST be set to 192.168.10.127 (see README) — otherwise
# local-vars is fetched from the router (no TFTP → fails) and, lacking
# use_proxydhcp_settings, the bootstrap prompts for a 'p' keypress / falls back to
# the public menu.
#
# The bootfile MUST be a name the bootstrap recognises (netboot.xyz.efi / .kpxe) so
# it reaches its :tftpmenu branch and chains menu.ipxe LOCALLY. A non-binary name
# like menu.ipxe skips :tftpmenu and boots the PUBLIC boot.netboot.xyz menu instead.
# The server field (192.168.10.127) sets ${proxydhcp/next-server}.
#
# Split by arch: the netboot.xyz bootstrap only string-matches these names (never
# execs them), but an ALREADY-iPXE client (e.g. a firmware iPXE ROM) autoboots the
# bootfile, so it must be executable on that arch — .efi for UEFI, .kpxe for BIOS.
# Both binaries re-run the bootstrap, which then reaches :tftpmenu → local menu.
dhcp-boot=tag:ipxe,tag:efi,netboot.xyz-snponly.efi,,192.168.10.127
dhcp-boot=tag:ipxe,tag:!efi,netboot.xyz.kpxe,,192.168.10.127
+2
View File
@@ -0,0 +1,2 @@
# Real secret material — keep secret.example.yaml as the committed template.
secret.yaml
+221
View File
@@ -0,0 +1,221 @@
# NetBox evaluation — handoff context
> **STATUS 2026-07-26 — this brief has been ACTED ON. Read [`README.md`](README.md) first
> for what actually exists.** NetBox is deployed at <https://netbox.ad.ddupan.top>, populated
> from `terraform/topology.yml`, and two of the §4 consumers are proven by generators that
> diff against live systems. This file is kept as the original brief and rationale; where
> it disagrees with README.md, README.md is right.
>
> Corrections found by measuring the hardware (`dmidecode`) rather than trusting §3:
> pve1's CPU is an **i3-6100U** (not i3-6100) on board **NUC6i3SYB**, with **15 GiB**
> usable; pve2/pve3 are Lenovo machine-type `10VGCTO1WW`. §3 also omitted **retrolab**
> (10.60.0.10), which had a live AD DNS record all along.
Written 2026-07-25 for a fresh agent. Everything below is **verified live**, not assumed.
Nothing NetBox-related existed at the time of writing: this document is the brief, not a
record of work done.
---
## 1. Why this is being considered
The homelab is already managed as code (Ansible + Terraform, see §5). The gap NetBox would
fill is a **single source of truth for network facts**, which are currently duplicated across
three places that must be edited together and have no cross-check:
| fact | lives in | and again in | and again in |
|---|---|---|---|
| VNet `labnet` = VLAN 100 | `proxmox/ansible/roles/pve_sdn/defaults/main.yml` | — | — |
| subnet `10.60.0.0/24` | VyOS role `vyos_sdn_interfaces` | VyOS OSPF `area 0 network` | (implied by PVE VNet) |
| gateway `10.60.0.1` | VyOS role | — | — |
| host addresses (§3) | `proxmox/ansible/inventory/hosts.yml` | `samba-ad` DNS A records | router DHCP reservations |
Adding one VNet today means editing the PVE SDN role, the VyOS interface list, **and** the OSPF
network list. Forgetting the third is silent — the subnet exists and has a gateway, but nothing
outside can route to it.
**The user has NOT committed to adopting NetBox.** The task is to evaluate and propose, then
implement only if it earns its place. Be honest if it does not — for ~10 hosts and 2 VLANs it may
be more machinery than the duplication costs.
---
## 2. Physical / logical topology
```
INTERNET (unstable — see §6)
GigaEthernet0.0 10.1.72.0/24
┌────────┴─────────┐
│ NEC IX router │ 192.168.10.1
│ (GigaEth2.0 = │ · OSPF area 0 · BGP (17 routes via .127)
│ LAN side) │ · proxy-dns, DHCP pool .10.250
└────────┬─────────┘
┌────────┴──────────┐ UNMANAGED (dumb) switch
│ flat L2 segment │ · passes 802.1Q tags untouched (verified)
│ 192.168.10.0/24 │ · passes jumbo frames 9000 MTU (verified)
└─┬──────┬────────┬─┘
│ │ │
pve1/2/3 laptop (VMs)
```
- **Single flat 1G LAN, one dumb switch.** No managed switch, no second NIC per node.
- **MTU 9000** on the three PVE nodes' `vmbr0` + bridge port. Router/DC/laptop remain 1500;
safe because TCP negotiates MSS in the SYN. Only large **UDP** to a 1500 host would break.
- **PVE SDN uses a VLAN zone**, not VXLAN — the dumb switch forwards tags, so VLAN is native and
needs no encapsulation. VLANs here are **segmentation, not security**: nothing enforces them.
---
## 3. Address allocations (all verified reachable 2026-07-25)
### 192.168.10.0/24 — the LAN
| addr | host | notes |
|---|---|---|
| .1 | NEC IX router | gateway, OSPF, BGP, DNS proxy, DHCP server |
| .2 | `vyos-rtr` (VM 100) | VyOS 2025.11, SDN gateway + OSPF |
| .4 | `pve1` | Proxmox, LINSTOR **controller**, NUC6i3SYB, i3-6100U, 15 GiB (corrected — see status note) |
| .5 | `dc1` | Samba AD DC (libvirt VM on the laptop), authoritative for `ad.ddupan.top` |
| .6 | `winadmin` | Windows Server 2025 (libvirt VM on the laptop) |
| .7 | `pve2` | Proxmox, ThinkCentre 2400GE 8G |
| .8 | `bao1` | OpenBao (libvirt VM on the laptop) — internal CA + secrets |
| .9 | `pve3` | Proxmox, ThinkCentre 2400GE 8G |
| .127 | `laptop` | "core" node: ZFS+NFS, k3s, netboot.xyz, libvirt host, OSPF **DR**, BGP |
| .10.250 | — | **router DHCP pool** — do not statically allocate inside this |
### Other prefixes in play
| prefix | what |
|---|---|
| `10.60.0.0/24` | SDN `labnet` (VLAN **100**), gateway `10.60.0.1` on vyos `eth1` |
| `10.61.0.0/24` | SDN `retronet` (VLAN **110**), gateway `10.61.0.1` on vyos `eth2` |
| `10.42.0.0/16` | k3s pod CIDR (laptop) |
| `10.43.0.0/16` | k3s service CIDR (laptop) |
| `10.1.72.0/24` | WAN side of the IX |
### Routing
OSPF **area 0** across the LAN. Speakers: NEC IX (.1), VyOS (.2), laptop (.127, currently **DR**).
VyOS advertises the SDN subnets as **intra-area** networks — deliberately *not*
`redistribute connected`, which would leak every future interface and inject topology-blind E2
routes. Verified on the IX: `O 10.60.0.0/24 [110/2] via 192.168.10.2`.
---
## 4. What NetBox would need to model
Minimum to remove the duplication in §1:
- **Prefixes** + roles for the LAN and both SDN subnets, with the DHCP pool marked as a pool so
static assignments can't collide with it.
- **VLANs** 100/110 and their VLAN group, linked to the prefixes.
- **IP addresses** for §3, each assigned to a device/VM interface.
- **Devices/VMs**: 3 PVE nodes, the laptop, the IX, and the VMs (`vyos-rtr`, `dc1`, `winadmin`, `bao1`).
- **Interfaces**: notably vyos `eth0/eth1/eth2` and which VNet each attaches to.
Then the consumers that should read from it rather than hold their own copy:
1. `proxmox/ansible/roles/pve_sdn` — VNet name/tag list
2. `proxmox/ansible/roles/vyos_router` — interface addresses **and** the OSPF network list
3. `samba-ad/ansible``samba_ad_extra_a_records` (currently hand-listed)
4. possibly `proxmox/ansible/inventory/hosts.yml` — via `netbox.netbox.nb_inventory`
**Design question to answer, not assume:** does NetBox become the *authoring* surface (edit in the
UI, generate YAML/config) or a *derived mirror* (YAML stays authoritative, NetBox is populated from
it)? The repo's whole pattern is git-authoritative, so a UI that must be clicked to change routing
would be a regression. Consider `netbox-as-code`-style sync where git remains the input.
---
## 5. Existing conventions — match these
Repo root: `/home/panxiao81/services` (git, **but nothing is committed yet** — large untracked tree).
- **Ansible** per service: `<svc>/ansible/{ansible.cfg,inventory/hosts.yml,group_vars,roles/,*.yml}`.
Style: `host_key_checking = False`, `callback_result_format = yaml`, heavy WHY-comments in roles,
`retries`/`until` on anything touching the internet.
- **Terraform** per service: `<svc>/terraform/{versions,main,variables,outputs}.tf`, local state,
`.gitignore` for `*.tfstate`. Auth from the ambient CLI session.
Example: `openbao/terraform/` manages OpenBao's API surface.
- **Tooling**: Python CLIs via `uv`. Ansible is installed as
`uv tool install ansible-core --with ansible --with paramiko`.
⚠️ `uv tool install ansible` alone only exposes `ansible-community`, not `ansible-playbook`.
- **Secrets**: plaintext but **gitignored** (`samba-ad/ansible/group_vars/all/vault.yml`,
`proxmox/vyos/credentials.yml`). OpenBao (`bao.ad.ddupan.top`) is the real secrets store and the
internal CA; it has a Terraform config already.
- **Kubernetes**: single-node k3s on the laptop. Manifests live per service
(`smtp-relay/*.yaml`, `k3s/coredns-custom.yaml`). Authelia is Helm + `values.yaml`.
Ownership boundary already established for OpenBao and worth copying: **Terraform owns API-level
configuration, Ansible owns the machine and anything Terraform must not own** (key material,
secrets it cannot read back).
---
## 6. Constraints that will bite you
- **The WAN fails at random.** Bad ISP, cannot be changed. Any download/pull needs retries. DNS has
flapped repeatedly. Do NOT go debugging the router for this — it has been checked
(utilization "calm", memory 30%, 0 NAPT failures, upstreams fine).
- **Internal DNS must never depend on the WAN.** k3s CoreDNS sends `ad.ddupan.top` straight to the
DC and NXDOMAINs the dead search suffixes (`k3s/coredns-custom.yaml`). PVE nodes use the DC first.
If a pod times out resolving *anything*, suspect this first.
- **k3s pods inherit `ndots:5` + the node's search list** — names with <5 dots try every suffix
first. This already caused a CrashLoopBackOff that looked like a service bug.
- **The cluster holds nothing critical** and has **no HA**. LINSTOR `place-count 2`.
Guests are disposable; do not design as if they are not.
- **Storage**: `pve-rg` (SSD, ~187 GiB) and `pve-rg-hdd` (HDD, ~931 GiB) are LINSTOR/DRBD;
`laptop` is NFS (~560 GiB free) for ISOs/templates/backups, **not** VM disks.
### Where to run NetBox
Most natural: the existing **k3s on the laptop** (`.127`), same pattern as Authelia/smtp-relay —
Helm or manifests in `netbox/`, Postgres available via the shared `shared-postgresql` cluster in
namespace `shared-db`. Alternative: a VM on the PVE cluster. Note the laptop is *not* a PVE member
and is the single point of failure for k3s, NFS, the AD DC, and OpenBao already.
**SSO is available and expected**: Authelia is the OIDC provider (`https://auth.ddupan.top`), backed
by Samba AD over verified LDAPS. Grafana/Gitea/OpenBao are already clients — wire NetBox the same
way rather than inventing local accounts. AD group → app-role mapping is the established pattern
(e.g. `pve-admins` → Proxmox `Administrator`).
---
## 7. Useful access
| target | how |
|---|---|
| PVE nodes | `ssh [email protected].{4,7,9}` (key auth from the laptop) |
| VyOS | `ssh [email protected]`; op-mode non-interactively needs `/opt/vyatta/bin/vyatta-op-cmd-wrapper <cmd>` |
| NEC IX | netmiko `nec_ix_telnet`, creds in `~/scripts/netrestart/web/net.py`. `show running-config`/`show config` do **not** work — use `show ip route`, `show utilization`. No `show ip ospf neighbor` either; check adjacencies from VyOS. |
| k3s | `kubectl` on the laptop |
| OpenBao | `bao login -method=oidc` (browser); `~/.vault-token` |
Agent memory for this project lives in
`~/.claude/projects/-home-panxiao81-services/memory/` — read `MEMORY.md` first; the entries on
`homelab-proxmox-cluster`, `vyos-router-sdn`, and `flaky-wan-isp` are directly relevant.
---
## 8. Suggested first steps
1. Read the memory files above; do not re-derive the topology.
2. Decide **authoring vs mirror** (§4) — this shapes everything else.
3. Stand up NetBox (k3s + shared Postgres + Authelia OIDC), no data yet.
4. Model §3 by hand for the LAN + the two SDN prefixes. Check whether it actually reads better
than the current YAML before going further.
5. Only then attempt generation: start with the **one** case that is genuinely error-prone —
the VyOS OSPF network list plus its interface addresses, which today must be kept in sync
with the PVE SDN VNets by hand.
6. Report back whether it earns its place. "It does not, here is why" is a valid outcome.
## 9. Outcome (2026-07-26)
Steps 15 done. **It earns its place on the case step 5 nominated**, and on one more:
- **Authoring vs mirror (§4) — answered: derived mirror.** `terraform/topology.yml` in git
is authoritative; Terraform applies it. Nothing is authored by clicking.
- **VyOS OSPF generation — exact.** `generate/vyos-ospf.py --diff` → `matched=8,
generated_only=0, live_only=0` against the live router.
- **AD DNS records (§4 item 3) — working.** `generate/samba-a-records.py --diff` found
`retrolab` had a live A record but was missing from NetBox: the cross-check §1 says the
current arrangement lacks.
- **Still unproven:** §4 items 1 (PVE SDN VNet list) and 4 (`nb_inventory` as the real
Ansible inventory). Nothing yet *consumes* NetBox in anger — until an Ansible role reads
from it, this is a second copy of the truth rather than a replacement for one.
+469
View File
@@ -0,0 +1,469 @@
# NetBox — evaluation deployment
NetBox (IPAM + DCIM) as a candidate **single source of truth for network facts**.
The case for it, the duplication it would remove, and the topology it would model are
in [`CONTEXT.md`](CONTEXT.md) — read that first.
**Status: deployed and populated for evaluation 2026-07-25. Nothing reads from it yet.**
No Ansible role, Terraform config or playbook consumes NetBox data, so deleting this
service breaks nothing (see [Teardown](#teardown)). It *can* generate the config that
matters, verified against the live router — see
[Verdict](#verdict-it-can-generate-the-config-that-matters).
- Chart: `netbox/netbox` **8.3.38** (app **v4.6.5**) — <https://netbox-community.github.io/netbox-chart/>
- URL: **<https://netbox.ad.ddupan.top>** — LAN only, via the shared Envoy Gateway
(`../../platform/envoy-gateway`) with the `*.ad.ddupan.top` wildcard cert from `../../platform/cert-manager`.
Deliberately **not** on the cloudflared tunnel: a full inventory of the network is not
something to publish to the internet.
- Namespace: `netbox`
- Database: dedicated `netbox` role/db on the shared CNPG cluster (`shared-db`)
- Auth: **Authelia forward-auth** at the gateway (not OIDC — see
[Authentication](#authentication-authelia-forward-auth-not-oidc)); local `admin`
retained as break-glass
> Exposure moved off the Tailscale ingress on 2026-07-25: no Tailscale client needed, it
> works from any LAN host (including the Windows admin VM), and traffic never leaves the
> LAN. That required adding cert-manager and — to get AD-group→role mapping — replacing
> Contour with Envoy Gateway. See those directories.
## Layout
| file | what |
|---|---|
| `values.yaml` | Helm values — the whole config, heavily commented |
| `secret.yaml` | **gitignored** — the database password (that is all) |
| `securitypolicy.yaml` | Authelia forward-auth at the gateway |
| `networkpolicy.yaml` | blocks bypassing the gateway — part of the trust boundary |
| `secret.example.yaml` | committed template for the above |
| `namespace.yaml` | the `netbox` namespace |
| `terraform/topology.yml` | **the authoritative topology** — git is the source, NetBox the mirror |
| `terraform/{versions,main,variables,outputs}.tf` | Terraform root that applies it |
| `terraform/attach-wireless.py` | the one thing Terraform cannot express (see below) |
| `terraform/gen-imports.py` | one-shot: adopt pre-existing objects into TF state |
| `terraform/.env` | **gitignored** — API token for the helper scripts |
| `generate/vyos-ospf.py` | derives the VyOS OSPF config from NetBox; `--diff` vs the live router |
| `generate/samba-a-records.py` | derives `samba_ad_extra_a_records`; `--diff` vs the live vars |
| `CONTEXT.md` | the evaluation brief (why NetBox, what it would model) |
## Architecture notes worth knowing before you touch it
**Postgres is external, Valkey is bundled.** The shared CNPG cluster gets a dedicated
role+database, same as Authelia and Gitea. Redis is *not* shared because nothing else in
the cluster runs one, and NetBox wants two logical DBs (RQ task queue + caching) to
itself. `postgresql.enabled: false` / `valkey.enabled: true`.
**The Valkey image is pinned by digest, not tag.** Bitnami's public catalog stopped
serving versioned tags in 2025 — versioned images moved to the `bitnamilegacy` repo and
only `latest` remains public, which is why the chart itself ships `tag: latest`. A
floating tag is not acceptable here, so `values.yaml` pins
`valkey.image.digest`. Verified: `docker.io/bitnami/valkey:9.0.1`*not found*,
`:latest` → pulls (app 9.1.1). **To bump it, resolve the new digest explicitly:**
```bash
sudo k3s ctr images pull docker.io/bitnami/valkey:latest # prints the manifest digest
```
**How configuration reaches Django.** The chart renders `netbox.yaml` into a ConfigMap and
its `configuration.py` deep-merges that file plus every `*.yaml` under
`/run/config/extra/*/` into the Django settings namespace. That is what `extraConfig[]`
feeds — arbitrary settings keys work, not just the ones the chart models. `extraConfig`
is currently empty; it held the OIDC client config before the move to forward-auth, and
is the hook to reach for if a future setting has no chart value.
**CSRF.** Envoy terminates TLS and forwards plain HTTP, so Django sees an `http://`
request carrying an `https://` Origin. Without `csrf.trustedOrigins` every POST —
including the login form itself — fails CSRF verification. This is the failure that looks
like "login is broken" rather than "config is missing", and it applies behind any
TLS-terminating proxy.
**Exposure is Gateway API, not Ingress.** The chart's native `httpRoute` block attaches to
the shared `eg` Gateway in `envoy-gateway-system`, `sectionName: https`. Because that
listener already serves the `*.ad.ddupan.top` wildcard, this service owns no certificate
of its own — adding the next LAN service is an `HTTPRoute` plus one A record in
`samba_ad_extra_a_records` (`../../infrastructure/samba-ad`), with no Gateway or cert work.
## Authentication: Authelia forward-auth (not OIDC)
Authelia authenticates and enforces 2FA **at the gateway**. By the time a request
reaches NetBox it is already authenticated, and Envoy has attached headers describing
the user. NetBox runs `netbox.authentication.RemoteUserBackend` and reads them.
### Why not OIDC — the reason this design exists
NetBox was originally wired to Authelia over OIDC. It worked, but **NetBox has no SSO
group → role mapping**: `REMOTE_AUTH_SUPERUSER_GROUPS` and
`AUTH_LDAP_USER_FLAGS_BY_GROUP` are **LDAP-only**, and the social-auth pipeline runs only
`user_default_groups_handler`, which assigns one static group and nothing else. So every
SSO user landed as an ordinary member of `sso-users` and had to be promoted **by hand**,
and later AD group changes never propagated.
Header auth fixes exactly that: `REMOTE_AUTH_GROUP_SYNC_ENABLED` re-evaluates group
membership from the `Remote-Groups` header on **every request**, giving the same
declarative AD-group→role pattern Grafana and Proxmox already use. Remove someone from
`netbox-admins` in AD and their NetBox admin is gone immediately.
Getting there required replacing Contour with Envoy Gateway — Contour speaks only gRPC
ext_authz, Authelia only HTTP. See `../../platform/envoy-gateway/README.md`.
### Header mapping (all three defaults are wrong for Authelia)
| NetBox setting | value | why |
|---|---|---|
| `header` | `HTTP_REMOTE_USER` | matches Authelia's `Remote-User` |
| `groupHeader` | `HTTP_REMOTE_GROUPS` | NetBox defaults to `HTTP_REMOTE_USER_GROUP`, which Authelia never sends |
| `groupSeparator` | `,` | Authelia joins groups with a comma; NetBox defaults to `\|`, which would yield **one** group literally named `a,b,c` |
| `userEmail` | `HTTP_REMOTE_EMAIL` | Authelia sends `Remote-Email` |
Authelia has no split given/family name — only `Remote-Name` — so the first/last-name
headers are deliberately left unmapped.
### ⚠ Trust boundary — read before changing anything here
`RemoteUserBackend` trusts the header **unconditionally**; NetBox has no trusted-proxy
allowlist. Two things keep that safe and **both** must stay true:
1. **Envoy overrides the headers.** `headersToBackend` replaces any client-supplied
`Remote-User` with Authelia's verdict rather than merging it.
2. **`networkpolicy.yaml` blocks bypass.** Without it, any pod could hit
`netbox.netbox.svc:8080` directly with `Remote-User: admin` and be superuser.
Verified by test: a pod in `default` sending that header gets **connection refused**;
the same request from `envoy-gateway-system` is served. `failOpen: false`, so if Authelia
is down traffic is refused rather than admitted unauthenticated.
Access is further restricted in `authelia/values.yaml` to `group:netbox-admins`
otherwise any AD account that can pass 2FA would be auto-provisioned a NetBox user.
**Local login remains as break-glass.** `AUTHENTICATION_BACKENDS` appends
`ObjectPermissionBackend`, which subclasses Django's `ModelBackend`, so the `admin`
password still works — reachable via `kubectl port-forward`, since the gateway
intercepts everything else.
## Prerequisites (already done)
```bash
# Dedicated Postgres role + database on the shared CNPG cluster
POD=$(kubectl -n shared-db get pods -l cnpg.io/instanceRole=primary -o jsonpath='{.items[0].metadata.name}')
kubectl -n shared-db exec "$POD" -c postgres -- psql -U postgres -v ON_ERROR_STOP=1 \
-c "CREATE ROLE netbox LOGIN PASSWORD '<see secret.yaml>'" \
-c "CREATE DATABASE netbox OWNER netbox"
```
There is **no OIDC client** for NetBox — forward-auth needs no client credential. What
must exist instead:
- AD group **`netbox-admins`** (`samba_ad_groups` in `../../infrastructure/samba-ad`), applied with
`ansible-playbook provision-dc.yml --tags directory,accounts`
- DNS A record **`netbox`** → `192.168.10.127` (`samba_ad_extra_a_records`), applied with
`ansible-playbook provision-dc.yml --tags dns`
- the `ext-authz` endpoint + access-control rule in `authelia/values.yaml`, and the
`ReferenceGrant` in `authelia/referencegrant-extauth.yaml`
## Deploy
```bash
helm repo add netbox https://netbox-community.github.io/netbox-chart/ && helm repo update netbox
kubectl apply -f netbox/namespace.yaml -f netbox/secret.yaml
# Authelia: ext-authz endpoint + access-control rule + cross-namespace grant
kubectl apply -f authelia/referencegrant-extauth.yaml
helm upgrade authelia authelia/authelia --version 0.11.6 -n authelia -f authelia/values.yaml
kubectl apply -f netbox/networkpolicy.yaml
helm upgrade --install netbox netbox/netbox --version 8.3.38 -n netbox -f netbox/values.yaml
kubectl apply -f netbox/securitypolicy.yaml # after the HTTPRoute it targets exists
```
> `authelia/values.yaml` is the *only* copy of Authelia's config. Before upgrading it,
> confirm it still matches the live release — `helm get values authelia -n authelia -o yaml`
> and compare **structurally** (parse both to YAML and diff keys); a textual diff is
> useless because `helm get values` sorts keys and strips comments.
## Verify
```bash
kubectl -n netbox rollout status deploy/netbox deploy/netbox-worker
kubectl -n netbox get pods,pvc,httproute,securitypolicy
# Unauthenticated request must be intercepted: 302 -> auth.ddupan.top
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' https://netbox.ad.ddupan.top/
# Trust boundary: this MUST fail (connection refused)
kubectl run spoof -n default --image=busybox:1.38.0 --restart=Never --rm -i -- \
wget -q -T 8 -O- --header='Remote-User: admin' http://netbox.netbox.svc.cluster.local/
# Local admin password (break-glass)
kubectl -n netbox get secret netbox-superuser -o jsonpath='{.data.password}' | base64 -d
# API reachable + DB migrated
kubectl -n netbox exec deploy/netbox -- curl -sf localhost:8080/api/status/ | head -c 400
```
Then browse <https://netbox.ad.ddupan.top>. Authelia intercepts, you authenticate with
2FA, and NetBox provisions the user on arrival — **already a superuser**, because
`netbox-admins` is in `REMOTE_AUTH_SUPERUSER_GROUPS`. No manual promotion step.
## Two API gotchas that will bite integrations
**Forward-auth blocks the API unless you carve it out.** Authelia intercepts every
request, including API calls carrying a valid NetBox token — it has no idea what a NetBox
token is, sees no session cookie, and 302s the caller to the login portal. Fixed with a
`bypass` rule for `^/api/` and `^/graphql/` in `authelia/values.yaml`, which must come
**before** the `two_factor` rule (Authelia is first-match-wins). This is not
unauthenticated access: NetBox's own token auth still gates those paths and an anonymous
call returns 403. Verified: token → 200, no token → 403, browser → 302.
**`Prefix.site` is silently ignored on NetBox 4.2+.** It was replaced by a generic scope
(`scope_type`/`scope_id`). Posting `site` returns **200 with the field dropped** — no
error — so a seed script looks like it worked while every prefix ends up unscoped, and an
idempotency check then re-patches forever. `VLAN` still uses `site`, so the two are
inconsistent. This is what made the first `seed.py` run non-idempotent.
## API tokens: `superuser.apiToken` is a dead value on NetBox 4.6
The chart still has a `superuser.apiToken` value (defaulting to a random UUID) and stores
it in the `netbox-superuser` Secret, but **no token is created** — verified:
`Token.objects.count()` was 0 after a clean bootstrap, and using that value returns
`403 {"detail":"Invalid v1 token"}`.
NetBox 4.5 introduced v2 tokens, which are stored only as a salted HMAC digest keyed by
`API_TOKEN_PEPPERS` and are the default for new tokens. Legacy v1 tokens (plaintext,
`Token <value>` header) are deprecated and go away in v5.0. So the secret's `api_token`
key should be treated as **stale, not a credential**.
Mint a real one in the UI, or from the CLI — note the plaintext is available only at
creation, and the header prefix is `Bearer nbt_<key>.`:
```bash
kubectl -n netbox exec deploy/netbox -c netbox -- /opt/netbox/venv/bin/python \
/opt/netbox/netbox/manage.py shell -c "
from users.models import Token, User
t = Token(user=User.objects.get(username='admin'), description='automation')
t.save()
print(t.get_auth_header_prefix() + t.token)"
```
This matters for the evaluation: any generation/sync tooling (§4 of `CONTEXT.md`)
authenticates this way, and `API_TOKEN_PEPPERS` becomes state that must survive — the
chart auto-generates pepper `1` and preserves it across upgrades via a `lookup`, so
**never clear that Secret or every issued token dies.**
**v2 tokens do NOT break the ecosystem** — an earlier version of this file claimed they
did; that was wrong and is corrected here. NetBox dispatches on the token *value* (an
`nbt_` prefix means v2), **not** the header scheme, so the same v2 token is accepted as
both `Authorization: Bearer <tok>` and `Authorization: Token <tok>` — verified, both 200.
The `"Invalid v1 token"` 403 above happens because the chart's UUID is not a token at all,
not because of the scheme. Tested working against this instance with a v2 token:
| integration | version | result |
|---|---|---|
| `netbox.netbox` modules (write) | 3.23.0 | ✅ created an object |
| `netbox.netbox.nb_inventory` (read) | 3.23.0 | ✅ grouped devices by role (needs `pytz`) |
| `e-breuninger/netbox` Terraform provider | **5.7.0** | ✅ read live prefix data |
Two traps when wiring them up:
- The Terraform provider's `server_url` must be the **base URL without `/api`**; passing
`.../api` yields a confusing go-openapi error
(`... is not supported by the TextConsumer`). Provider **v4.x fails against NetBox 4.6**
regardless — pin `~> 5.0`.
- `nb_inventory` needs `pytz` in the Ansible environment
(`uv tool install ansible-core --with ansible --with paramiko --with pytz`), and the
plugin must be enabled (`enable_plugins = netbox.netbox.nb_inventory`).
## Known caveats
- **Reaching NetBox no longer needs the WAN, but logging in still does.**
`netbox.ad.ddupan.top` is resolved by the DC and served on the LAN. However
`auth.ddupan.top` resolves publicly and routes back in through the cloudflared tunnel,
so the OIDC round-trip still crosses the flaky ISP link (`CONTEXT.md` §6) — the same
exposure Grafana has. The local `admin` account is the fallback when the WAN is down.
Closing this means giving Authelia an internal HTTPS name, which changes the **issuer**
and therefore touches Gitea, Grafana and OpenBao as registered clients — a decision to
take once for all services, not per service.
- `releaseCheck.url` is blanked so NetBox never blocks a page render on `api.github.com`.
- One k3s node, no HA: `valkey.architecture: standalone`, one web pod, one worker.
- Resource presets (`medium` web / `small` worker) are sized for the laptop's headroom at
survey time (~4 GiB free), not for throughput.
## Teardown
```bash
helm uninstall netbox -n netbox
kubectl delete ns netbox # also drops the media + valkey PVCs
POD=$(kubectl -n shared-db get pods -l cnpg.io/instanceRole=primary -o jsonpath='{.items[0].metadata.name}')
kubectl -n shared-db exec "$POD" -c postgres -- psql -U postgres \
-c "DROP DATABASE netbox" -c "DROP ROLE netbox"
# then remove the netbox access_control rule from authelia/values.yaml, and drop
# `netbox` from the ReferenceGrant's `from` list in authelia/referencegrant-extauth.yaml
helm upgrade authelia authelia/authelia --version 0.11.6 -n authelia -f authelia/values.yaml
kubectl apply -f authelia/referencegrant-extauth.yaml
```
The `netbox-admins` AD group and the `netbox` A record stay in `../../infrastructure/samba-ad` unless you
remove them there too; both are harmless if left.
## Data model
`terraform/topology.yml` holds the §3 topology; the Terraform root reads it with
`yamldecode` and applies it. **Direction is git → NetBox**: the YAML is authoritative,
NetBox is a derived mirror. That settles `CONTEXT.md` §4 the way the rest of the repo
works — a UI you must click to change routing would be a regression.
Terraform (not a script) because of the repo's own boundary — *Terraform owns API-level
configuration* (`../../infrastructure/openbao/terraform` is the precedent) — and because it brings the one
thing the previous hand-rolled `seed.py` could never do: **deletion**. Remove an entry from
the YAML and `terraform apply` removes the object. `plan` doubles as a drift report.
```bash
cd netbox/terraform
export TF_VAR_netbox_token=... # or terraform.tfvars
terraform init
terraform plan -parallelism=2 # see below re: parallelism
terraform apply -parallelism=2
uv run --with requests --with pyyaml python attach-wireless.py
```
Acceptance test, same spirit as the Ansible here:
`No changes. Your infrastructure matches the configuration.`
### Adopting an already-populated NetBox
`gen-imports.py` writes `imports.tf` from the live API so Terraform **adopts** existing
objects instead of failing on uniqueness. It reads the same `topology.yml`, so resource
addresses and `for_each` keys line up by construction. One-shot — delete `imports.tf` after
the first successful apply.
`netbox_device_primary_ip` is deliberately **not** imported: the provider has no importable
object at the device ID. Letting Terraform "create" it just re-PATCHes `primary_ip4` to the
value it already holds.
### ⚠ Terraform cannot do the Wi-Fi wiring
`netbox_device_interface` has **no `rf_role` and no `wireless_lans`** attribute — checked
against the provider schema — and `netbox.netbox`'s module has the identical gap. So
neither official tool can attach an SSID to a radio. `attach-wireless.py` does that last
mile from the same `topology.yml`; it is idempotent and has a `--check` mode. If a future
provider release adds those attributes, delete it and fold the fields into `main.tf`.
### Four things that cost time here
- **Pin the provider `~> 5.0`.** v4.3.1 fails against NetBox 4.6 at configure time.
- **`server_url` must NOT include `/api`** — same misleading go-openapi error if it does.
- **`-parallelism=2`.** At the default 10 the single NetBox pod times out reads
(`context deadline exceeded`) during import.
- **Pin NetBox's own defaults or fight them forever.** The provider defaults
`vm_role`/`is_full_depth` opposite to NetBox, and a VM's `site_id` is *derived from its
cluster* — leave any of them unset and every plan shows phantom changes.
Loaded: 1 site · 6 prefixes (+roles) · 2 VLANs in group `lab` · the DHCP pool as an
IPRange · 3 wireless LANs · 7 devices · 4 VMs in 2 clusters · interfaces with addresses,
MACs and primary IPs.
Hardware fields (model, serial, CPU/RAM) are **read from `dmidecode`**, not guessed — that
corrected three earlier assumptions: pve1 is a NUC6i3**SYB** board with an i3-6100**U**
(and reports a *blank* system serial, because the OEM never programmed DMI), and pve2/pve3
are Lenovo machine-type `10VGCTO1WW`. NetBox has no native CPU/RAM field for Devices (only
VMs get `vcpus`/`memory`), so that detail lives in `comments`; custom fields would be the
right answer if it ever needs to be queryable.
Two modelling choices worth knowing:
- **The laptop's Broadcom BCM4360 is an `inventory_item`, not an interface.** `b43`/`bcma`
claim the PCI device but cannot drive BCM4360 (it needs proprietary `broadcom-sta`/`wl`
with those modules blacklisted), so there is no netdev — an Interface would imply a
capability the OS does not have.
- **`ap-buffalo` was identified, not assumed**: MAC OUI `d4:2c:46` = BUFFALO.INC, model
`WSR-1800AX4S` from its login page, and Buffalo's factory SSID suffix `07B0` matches the
tail of that same MAC.
### ⚠ Two things the data now surfaces
1. **`ap-buffalo` holds `192.168.10.10` — the first address of the DHCP pool** (`.10``.250`).
Either it is a lease that can move, or a static overlapping the pool. Fix by moving the
AP below `.10` or starting the pool at `.11`; NetBox shows the collision but cannot
resolve it.
2. **Objects created before the Terraform conversion are not in state**, so Terraform will
not reconcile them: the guessed device types (`nuc6i3syh`, `thinkcentre-mini`,
`Laptop`) and the `Generic` manufacturer are orphans needing one manual prune. Anything
added *since* is managed — removing it from `topology.yml` now deletes it, which is
exactly what the old `seed.py` could not do.
### Wi-Fi
Three SSIDs off one AP, all bridged **untagged onto the flat LAN** (no `vlan` set) — the
Wi-Fi is not a separate segment; clients get an IX DHCP lease like anything else.
| SSID | radio | auth |
|---|---|---|
| `Buffalo-A-07B0-WPA3` | 5 GHz | WPA3-SAE |
| `Buffalo-A-07B0` | 5 GHz | WPA2-PSK (compat) |
| `Buffalo-G-07B0` | 2.4 GHz | WPA2-PSK |
**NetBox cannot express WPA3.** `auth_type` offers only
`open`/`wep`/`wpa-personal`/`wpa-enterprise`, so WPA3-SAE and WPA2-PSK both store as
`wpa-personal` and the real difference survives only in the description.
`auth_psk` is deliberately empty — NetBox *can* hold the passphrase, but the house Wi-Fi
key does not belong in a system whose backup story is untested when OpenBao is right there.
## Verdict: it can generate the config that matters
`CONTEXT.md` §8 step 5 named the one genuinely error-prone duplication — the VyOS
`area 0 network` list plus interface addresses, where forgetting a line is *silent*
(the subnet exists, has a gateway, and is unreachable from anywhere else).
`generate/vyos-ospf.py` derives it from NetBox and diffs against the live router:
```
$ uv run --with requests python netbox/generate/vyos-ospf.py --diff
matched=8 generated_only=0 live_only=0
```
**Exact match, zero drift.** The derivation rules are the valuable part:
| output | rule |
|---|---|
| interface addresses | every IP assigned to a `vyos-rtr` interface |
| `area 0 network` | every prefix the router **has an interface in** |
| `passive` | interfaces whose prefix has role `sdn` |
The second rule was wrong on the first attempt and the diff caught it. "The SDN prefixes"
omits `192.168.10.0/24` — but the LAN must be in area 0 or VyOS forms no adjacency with
the IX or the laptop and advertises nothing at all. Deriving from *where the router
actually has an address* yields the LAN for free and cannot forget a future VNet, which
is precisely the failure mode NetBox is supposed to prevent.
### Second consumer: AD DNS records (CONTEXT.md §4 item 3)
`generate/samba-a-records.py` derives `samba_ad_extra_a_records` from NetBox and diffs the
hand-written list in `../../infrastructure/samba-ad`:
```
in both=5 netbox only=3 vars.yml only=1
```
**Which** hosts get a record is intent, not a derived fact — domain-joined machines
self-register, so "every IP in the LAN prefix" would be wrong. The selector is NetBox's
native `dns_name` field on the IP address; set it and the host gets a record.
The diff paid for itself immediately: it found **`retrolab` (10.60.0.10) had a live AD DNS
record but was missing from NetBox entirely** — verified up and now modelled. That is the
duplication-detection the whole exercise is meant to provide.
Out of scope by design: service/ingress names like `netbox.ad.ddupan.top`, which point at
the k3s gateway rather than a host. Several such names share one address and `dns_name` is
single-valued per IP, so they stay hand-managed. This tool owns **host** records only.
**So it earns its place on this one case.** Honest caveats before adopting further:
- **This is one generator against one router.** The other consumers in `CONTEXT.md` §4
(the PVE SDN VNet list, `samba_ad_extra_a_records`, `nb_inventory`) are unproven.
- **NetBox demands ceremony.** Recording "pve1 is 192.168.10.4" first requires a
manufacturer, a device type, a device role and a site. For ~10 hosts that is real
overhead the YAML does not have.
- **Nothing consumes it yet.** Until a role actually reads from NetBox, this is a second
copy of the truth — the very duplication it is meant to remove. The next real step is
wiring `generate/vyos-ospf.py` into `proxmox/ansible/roles/vyos_router` so the template
has one source, not two.
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""Generate samba_ad_extra_a_records from NetBox.
uv run --with requests --with pyyaml python netbox/generate/samba-a-records.py [--diff]
CONTEXT.md §4 item 3: the AD DNS A records for non-domain-joined hosts are hand-listed in
../../../infrastructure/samba-ad/ansible/group_vars/all/vars.yml, duplicating addresses that already live in
NetBox. This derives them instead.
SOURCE OF TRUTH FOR *WHICH* HOSTS: the `dns_name` field on the NetBox IP address. That is
intent, not a derived fact — domain-joined machines register themselves in AD DNS and must
NOT get a static record, so "every IP in the LAN prefix" would be wrong. An address gets a
record iff someone set dns_name on it.
Deliberately NOT handled: service/ingress names such as netbox.ad.ddupan.top, which point
at the k3s gateway rather than at a host. Several of those share one address, and NetBox's
dns_name is single-valued per IP, so they stay hand-managed in vars.yml. This tool only
owns HOST records.
Read-only: prints YAML and diffs. It never writes to the DC — ../../../infrastructure/samba-ad applies it
(`ansible-playbook provision-dc.yml --tags dns`).
"""
from __future__ import annotations
import ipaddress
import json
import os
import re
import sys
import urllib.request
from pathlib import Path
ZONE = "ad.ddupan.top"
VARS = Path(__file__).parents[3] / "infrastructure/samba-ad/ansible/group_vars/all/vars.yml"
envfile = Path(__file__).parent.parent / "terraform" / ".env"
if envfile.exists():
for line in envfile.read_text().splitlines():
if line.strip() and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip())
BASE = os.environ.get("NETBOX_URL", "https://netbox.ad.ddupan.top").rstrip("/") + "/api"
TOKEN = os.environ.get("NETBOX_TOKEN", "")
if not TOKEN:
sys.exit("NETBOX_TOKEN not set (expected in netbox/terraform/.env)")
def get(path: str):
req = urllib.request.Request(BASE + path, headers={"Authorization": f"Bearer {TOKEN}"})
with urllib.request.urlopen(req, timeout=30) as r:
return json.load(r)
def generate() -> list[dict]:
out = []
for a in get("/ipam/ip-addresses/?limit=500")["results"]:
dns = (a.get("dns_name") or "").strip().lower()
if not dns.endswith(f".{ZONE}"):
continue
name = dns[: -len(f".{ZONE}")]
ip = str(ipaddress.ip_interface(a["address"]).ip)
out.append({"name": name, "ip": ip})
# Stable order so the diff is meaningful rather than churn.
return sorted(out, key=lambda r: ipaddress.ip_address(r["ip"]))
def current() -> list[dict]:
"""Parse the existing hand-written list without pulling in the whole vars file."""
if not VARS.exists():
return []
txt = VARS.read_text()
m = re.search(r"^samba_ad_extra_a_records:\s*$(.*?)(?=^\S)", txt, re.S | re.M)
if not m:
return []
return [{"name": n, "ip": i}
for n, i in re.findall(r'name:\s*"([^"]+)".*?ip:\s*"([^"]+)"', m.group(1))]
if __name__ == "__main__":
gen = generate()
if "--diff" not in sys.argv:
print("samba_ad_extra_a_records:")
for r in gen:
print(f' - {{ name: "{r["name"]}", ip: "{r["ip"]}" }}')
sys.exit(0)
have = {(r["name"], r["ip"]) for r in current()}
want = {(r["name"], r["ip"]) for r in gen}
for n, i in sorted(want | have, key=lambda x: ipaddress.ip_address(x[1])):
mark = " " if (n, i) in want and (n, i) in have else ("+ " if (n, i) in want else "- ")
print(f"{mark}{n:<10} {i}")
print(f"\nin both={len(want & have)} netbox only={len(want - have)} vars.yml only={len(have - want)}")
print("\n+ = NetBox has it, vars.yml does not - = hand-listed, not derivable from NetBox")
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""Generate the VyOS interface + OSPF config from NetBox.
uv run --with requests python netbox/generate/vyos-ospf.py [--diff]
This is the ONE case CONTEXT.md §8 step 5 nominates as worth automating: today the VyOS
interface addresses, the `area 0 network` list and the PVE SDN VNets must be kept in sync
BY HAND, and forgetting the OSPF line is silent — the subnet exists, has a gateway, and
is simply unreachable from anywhere else.
DERIVATION RULES (these are the interesting part, not the code):
addresses every IP assigned to a vyos-rtr interface
area 0 every prefix that vyos-rtr HAS AN INTERFACE IN
passive interfaces whose prefix has role `sdn`
The second rule matters. The obvious rule — "the SDN prefixes" — is WRONG and was caught
by diffing against the live router: it omits 192.168.10.0/24, but the LAN must be in
area 0 or VyOS has no adjacency with the NEC IX or the laptop and nothing is advertised
at all. Deriving from "where does this router actually have an address" produces the LAN
for free and cannot forget a future VNet.
The third rule is why eth0 is NOT passive: it is the only interface that must form
adjacencies. eth1/eth2 face guests and are advertised without peering.
Read-only. It prints config; it does not touch the router.
"""
from __future__ import annotations
import ipaddress
import json
import os
import subprocess
import sys
import urllib.request
from pathlib import Path
ROUTER = "vyos-rtr"
envfile = Path(__file__).parent.parent / "terraform" / ".env"
if envfile.exists():
for line in envfile.read_text().splitlines():
if line.strip() and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
os.environ.setdefault(k, v)
BASE = os.environ.get("NETBOX_URL", "").rstrip("/") + "/api"
TOKEN = os.environ.get("NETBOX_TOKEN", "")
if not TOKEN:
sys.exit("NETBOX_TOKEN not set (expected in netbox/terraform/.env)")
def get(path: str):
req = urllib.request.Request(BASE + path, headers={"Authorization": f"Bearer {TOKEN}"})
with urllib.request.urlopen(req, timeout=30) as r:
return json.load(r)
def generate() -> list[str]:
vm = get(f"/virtualization/virtual-machines/?name={ROUTER}")["results"]
if not vm:
sys.exit(f"{ROUTER} not found in NetBox")
prefixes = get("/ipam/prefixes/?limit=200")["results"]
ifaces = sorted(get(f"/virtualization/interfaces/?virtual_machine_id={vm[0]['id']}")["results"],
key=lambda i: i["name"])
lines, areas, passive = [], [], []
for iface in ifaces:
for addr in get(f"/ipam/ip-addresses/?vminterface_id={iface['id']}")["results"]:
lines.append(f"set interfaces ethernet {iface['name']} address '{addr['address']}'")
ip = ipaddress.ip_interface(addr["address"]).ip
# The prefix this address sits in == a network this router participates in.
for p in prefixes:
if ip in ipaddress.ip_network(p["prefix"]):
if p["prefix"] not in areas:
areas.append(p["prefix"])
if (p.get("role") or {}).get("slug") == "sdn":
passive.append(iface["name"])
break
# Keep the LAN first: it is the transit network, and reading the config that way
# matches how the adjacency is reasoned about.
areas.sort(key=lambda p: (not p.startswith("192.168."), p))
lines += [f"set protocols ospf area 0 network '{p}'" for p in areas]
lines += [f"set protocols ospf interface {i} passive" for i in sorted(set(passive))]
return lines
def live() -> list[str]:
out = subprocess.run(
["ssh", "-o", "ConnectTimeout=8", "-o", "BatchMode=yes", "[email protected]",
"/opt/vyatta/bin/vyatta-op-cmd-wrapper show configuration commands"],
capture_output=True, text=True, timeout=60).stdout
return [l.strip() for l in out.splitlines()
if ("ospf area" in l or "ospf interface" in l or
("ethernet eth" in l and "address" in l))]
if __name__ == "__main__":
gen = generate()
if "--diff" not in sys.argv:
print("\n".join(gen))
sys.exit(0)
have = live()
only_live = [l for l in have if l not in gen]
only_gen = [l for l in gen if l not in have]
for l in gen:
print((" " if l in have else "+ ") + l)
for l in only_live:
print("- " + l)
print(f"\nmatched={len(gen) - len(only_gen)} generated_only={len(only_gen)} live_only={len(only_live)}")
sys.exit(1 if (only_gen or only_live) else 0)
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: netbox
+42
View File
@@ -0,0 +1,42 @@
# Half of the trust boundary for header-based auth (the other half is Envoy
# overriding client-supplied Remote-* headers — see securitypolicy.yaml).
#
# WHY THIS IS NOT OPTIONAL: NetBox's RemoteUserBackend trusts HTTP_REMOTE_USER
# unconditionally; there is no trusted-proxy allowlist in NetBox. Envoy sanitises the
# header, but Envoy only sees traffic that goes THROUGH it. Any pod in the cluster
# could otherwise open a connection straight to netbox.netbox.svc:8080, send
# `Remote-User: admin`, and be a superuser. This policy removes that path.
#
# k3s enforces NetworkPolicy (kube-router backend), so this is a real control, not
# decoration.
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: netbox-ingress-gateway-only
namespace: netbox
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: netbox
app.kubernetes.io/instance: netbox
policyTypes:
- Ingress
ingress:
# Only Envoy may reach the app port.
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: envoy-gateway-system
ports:
- protocol: TCP
port: 8080
# kubelet probes come from the node itself, outside any namespace, so they are not
# matched by a namespaceSelector. Without this the pod fails its readiness probe
# and is pulled from the Service.
- from:
- ipBlock:
cidr: 192.168.10.127/32
ports:
- protocol: TCP
port: 8080
+16
View File
@@ -0,0 +1,16 @@
# Template for netbox/secret.yaml (which is gitignored). Copy, fill in, apply.
#
# Only the database password lives here. NetBox holds no OIDC client secret: it
# authenticates via Authelia forward-auth at the gateway (securitypolicy.yaml), so
# there is no client credential for this app to keep.
apiVersion: v1
kind: Secret
metadata:
name: netbox-secrets
namespace: netbox
type: Opaque
stringData:
# Password for the dedicated `netbox` role on the shared CNPG cluster.
# Must match what was granted in shared-db (see README "Prerequisites").
# Consumed via externalDatabase.existingSecretName/Key in values.yaml.
postgresql-password: "REPLACE_WITH_DB_PASSWORD"
+68
View File
@@ -0,0 +1,68 @@
# Authelia forward-auth in front of NetBox, enforced at the gateway.
#
# This is what makes AD-group -> NetBox-role mapping possible at all: NetBox has no
# SSO group mapping, but it does have header-based group sync, and this supplies the
# headers from a source the app can trust (see netbox/values.yaml remoteAuth).
#
# Flow: browser -> Envoy -> (extAuth) Authelia -> 200 + Remote-* headers -> NetBox.
# On 401/403 Authelia redirects to https://auth.ddupan.top and back.
---
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: SecurityPolicy
metadata:
name: netbox-authelia
namespace: netbox
spec:
# Targets the HTTPRoute the chart generates, so the policy applies to exactly the
# traffic that reaches NetBox and nothing else on the shared gateway.
targetRefs:
- group: gateway.networking.k8s.io
kind: HTTPRoute
name: netbox
extAuth:
# HTTP, not gRPC. This is the whole reason the gateway is Envoy Gateway and not
# Contour: Contour supports only the gRPC ext_authz protocol, and Authelia
# implements the HTTP ExtAuthz filter.
http:
backendRefs:
- name: authelia
namespace: authelia # allowed by ../authelia/referencegrant-extauth.yaml
# The SERVICE port, not the container port. The Authelia chart publishes
# port 80 -> targetPort http (9091); referencing 9091 here is rejected with
# "TCP Port 9091 not found on service authelia/authelia".
port: 80
# Authelia's ExtAuthz endpoint. The ORIGINAL request path is appended to this
# prefix, which is how Authelia learns what was being requested.
path: /api/authz/ext-authz/
# Headers Envoy copies from Authelia's response ONTO the upstream request.
# NOTE this belongs to the `http` service block, not to `extAuth` — the API
# rejects it one level up (headersToBackend is a field of HTTPExtAuthService).
#
# SECURITY: "coexisting headers will be overridden" (Envoy Gateway API docs) —
# a client-supplied Remote-User is replaced by Authelia's verdict, not merged.
# This list is the entire trust boundary; do not add anything NetBox reads for
# authorization that Authelia does not itself vouch for.
headersToBackend:
- Remote-User
- Remote-Groups
- Remote-Email
- Remote-Name
# Headers Envoy forwards TO Authelia. Without cookie there is no session and every
# request bounces to the portal; without the X-Forwarded-* trio Authelia cannot
# reconstruct the original URL and answers 400.
headersToExtAuth:
- cookie
- authorization
- proxy-authorization
- accept
- x-forwarded-proto
- x-forwarded-host
- x-forwarded-uri
- x-forwarded-for
- x-original-method
# Fail CLOSED. If Authelia is down, refuse traffic rather than admit unauthenticated
# requests to a service whose entire auth model is "trust the header".
failOpen: false
+8
View File
@@ -0,0 +1,8 @@
# Local state and real credentials stay out of git (same as the other TF roots here).
*.tfstate
*.tfstate.*
.terraform/
.terraform.lock.hcl
terraform.tfvars
# Real API token — mint with the snippet in ../README.md
.env
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Attach SSIDs to radio interfaces and set rf_role — the part Terraform cannot express.
uv run --with requests --with pyyaml python netbox/terraform/attach-wireless.py [--check]
WHY THIS EXISTS. Terraform creates the WirelessLAN objects (netbox_wireless_lan) and the
radio interfaces, but `netbox_device_interface` has NO attribute for either:
* rf_role (ap / station)
* wireless_lans (which SSIDs this radio broadcasts)
Verified against the provider schema for e-breuninger/netbox 5.7.0 — and the
netbox.netbox Ansible collection 3.23.0 has the same gap in netbox_device_interface.
So neither official tool can do this; a few lines of API call is the honest fallback
rather than dropping the data.
Idempotent, and reads the same topology.yml Terraform does, so there is one source of
truth. Run it after `terraform apply`. If a future provider release grows these
attributes, delete this file and move the fields into main.tf.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
import requests
import yaml
HERE = Path(__file__).parent
CHECK = "--check" in sys.argv
for envfile in (HERE / ".env", HERE / "../seed/.env"):
if envfile.exists():
for line in envfile.read_text().splitlines():
if line.strip() and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip())
URL = os.environ.get("NETBOX_URL", "https://netbox.ad.ddupan.top").rstrip("/")
TOKEN = os.environ.get("NETBOX_TOKEN", "")
if not TOKEN:
sys.exit("NETBOX_TOKEN not set (expected in netbox/terraform/.env)")
S = requests.Session()
S.headers.update({"Authorization": f"Bearer {TOKEN}"})
def get(path: str, **params):
r = S.get(f"{URL}/api{path}", params=params, timeout=30)
r.raise_for_status()
return r.json()
def main() -> int:
topo = yaml.safe_load((HERE / "topology.yml").read_text())
changed = 0
# ssid -> id, resolved once
lans = {w["ssid"]: w["id"] for w in get("/wireless/wireless-lans/", limit=200)["results"]}
for dev in topo["devices"]:
for iface in dev.get("interfaces", []):
want_ssids = iface.get("wireless_lans")
want_role = iface.get("rf_role")
if not want_ssids and not want_role:
continue
found = get("/dcim/interfaces/", device=dev["name"], name=iface["name"])["results"]
if not found:
print(f" ! {dev['name']}:{iface['name']} not in NetBox — run terraform apply first")
continue
cur = found[0]
patch = {}
if want_role and (cur.get("rf_role") or {}).get("value") != want_role:
patch["rf_role"] = want_role
if want_ssids:
have = sorted(w["id"] for w in (cur.get("wireless_lans") or []))
missing = [s for s in want_ssids if s not in lans]
if missing:
print(f" ! SSID(s) not in NetBox: {missing} — run terraform apply first")
continue
want = sorted(lans[s] for s in want_ssids)
if have != want:
patch["wireless_lans"] = want
if not patch:
continue
changed += 1
if CHECK:
print(f" ~ would patch {dev['name']}:{iface['name']}: {list(patch)}")
else:
r = S.patch(f"{URL}/api/dcim/interfaces/{cur['id']}/", json=patch, timeout=30)
r.raise_for_status()
print(f" ~ patched {dev['name']}:{iface['name']}: {list(patch)}")
print(f"changed={changed}" + (" (check mode)" if CHECK else ""))
if changed == 0:
print("idempotent: nothing to do")
return 0
if __name__ == "__main__":
sys.exit(main())
+40
View File
@@ -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 ../../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 = "netbox/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
}
}
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""Generate imports.tf so Terraform ADOPTS the objects already in NetBox.
uv run --with requests --with pyyaml python netbox/terraform/gen-imports.py
Run once, when converting an already-populated NetBox to Terraform management. Without it
the first `terraform apply` tries to CREATE objects that exist and fails on uniqueness.
This is a one-shot bootstrap, not part of the normal loop: once `terraform apply` has run,
state holds the IDs and imports.tf can be deleted. Same intent as
../../../infrastructure/openbao/terraform/imports.tf.
It reads the same topology.yml Terraform does, so the resource addresses and for_each keys
line up by construction rather than by hand-transcription.
"""
from __future__ import annotations
import os
import sys
import urllib.parse
from pathlib import Path
import requests
import yaml
HERE = Path(__file__).parent
for envfile in (HERE / "../seed/.env", HERE / ".env"):
if envfile.exists():
for line in envfile.read_text().splitlines():
if line.strip() and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip())
URL = os.environ.get("NETBOX_URL", "https://netbox.ad.ddupan.top").rstrip("/")
TOKEN = os.environ.get("NETBOX_TOKEN", "")
if not TOKEN:
sys.exit("NETBOX_TOKEN not set")
S = requests.Session()
S.headers.update({"Authorization": f"Bearer {TOKEN}"})
def one(path: str, **params) -> str | None:
r = S.get(f"{URL}/api{path}", params={**params, "limit": 1}, timeout=30)
r.raise_for_status()
res = r.json()["results"]
return str(res[0]["id"]) if res else None
def main() -> int:
t = yaml.safe_load((HERE / "topology.yml").read_text())
out: list[str] = [
"# GENERATED by gen-imports.py — one-shot bootstrap, safe to delete after the first",
"# successful `terraform apply`. Do not hand-edit.",
"",
]
missing: list[str] = []
def emit(addr: str, oid: str | None, what: str):
if oid:
out.append(f'import {{\n to = {addr}\n id = "{oid}"\n}}\n')
else:
missing.append(what)
emit("netbox_site.this", one("/dcim/sites/", slug=t["site"]["slug"]), "site")
emit("netbox_vlan_group.this", one("/ipam/vlan-groups/", slug=t["vlan_group"]["slug"]), "vlan group")
for r in t["prefix_roles"]:
emit(f'netbox_ipam_role.this["{r["slug"]}"]', one("/ipam/roles/", slug=r["slug"]), r["slug"])
for v in t["vlans"]:
emit(f'netbox_vlan.this["{v["vid"]}"]', one("/ipam/vlans/", vid=v["vid"]), f"vlan {v['vid']}")
for p in t["prefixes"]:
emit(f'netbox_prefix.this["{p["prefix"]}"]', one("/ipam/prefixes/", prefix=p["prefix"]), p["prefix"])
for r in t["ip_ranges"]:
emit(f'netbox_ip_range.this["{r["start"]}-{r["end"]}"]',
one("/ipam/ip-ranges/", start_address=r["start"], end_address=r["end"]), "ip range")
for w in t["wireless_lans"]:
emit(f'netbox_wireless_lan.this["{w["ssid"]}"]', one("/wireless/wireless-lans/", ssid=w["ssid"]), w["ssid"])
for m in t["manufacturers"]:
emit(f'netbox_manufacturer.this["{m["slug"]}"]', one("/dcim/manufacturers/", slug=m["slug"]), m["slug"])
for d in t["device_types"]:
emit(f'netbox_device_type.this["{d["slug"]}"]', one("/dcim/device-types/", slug=d["slug"]), d["slug"])
for r in t["device_roles"]:
emit(f'netbox_device_role.this["{r["slug"]}"]', one("/dcim/device-roles/", slug=r["slug"]), r["slug"])
for c in t["cluster_types"]:
emit(f'netbox_cluster_type.this["{c["slug"]}"]', one("/virtualization/cluster-types/", slug=c["slug"]), c["slug"])
for c in t["clusters"]:
emit(f'netbox_cluster.this["{c["name"]}"]', one("/virtualization/clusters/", name=c["name"]), c["name"])
for d in t["devices"]:
did = one("/dcim/devices/", name=d["name"])
emit(f'netbox_device.this["{d["name"]}"]', did, d["name"])
for i in d["interfaces"]:
key = f'{d["name"]}:{i["name"]}'
iid = one("/dcim/interfaces/", device_id=did, name=i["name"]) if did else None
emit(f'netbox_device_interface.this["{key}"]', iid, key)
if i.get("ip"):
emit(f'netbox_ip_address.device["{key}"]', one("/ipam/ip-addresses/", address=i["ip"]), i["ip"])
# netbox_device_primary_ip is deliberately NOT imported: the provider has no
# importable object at the device ID ("no object exists with the given id").
# Letting Terraform "create" it simply re-PATCHes primary_ip4 to the value it
# already holds, which NetBox treats as a no-op.
if i.get("mac"):
mac = i["mac"].upper()
mid = one("/dcim/mac-addresses/", mac_address=mac)
emit(f'netbox_mac_address.this["{key}"]', mid, mac)
if iid:
emit(f'netbox_device_interface_primary_mac_address.this["{key}"]', iid, f"{key} primary mac")
for it in d.get("inventory_items", []):
key = f'{d["name"]}:{it["name"]}'
emit(f'netbox_inventory_item.this["{key}"]',
one("/dcim/inventory-items/", device_id=did, name=it["name"]) if did else None, key)
for v in t["virtual_machines"]:
vid = one("/virtualization/virtual-machines/", name=v["name"])
emit(f'netbox_virtual_machine.this["{v["name"]}"]', vid, v["name"])
for i in v["interfaces"]:
key = f'{v["name"]}:{i["name"]}'
iid = one("/virtualization/interfaces/", virtual_machine_id=vid, name=i["name"]) if vid else None
emit(f'netbox_interface.this["{key}"]', iid, key)
if i.get("ip"):
emit(f'netbox_ip_address.vm["{key}"]', one("/ipam/ip-addresses/", address=i["ip"]), i["ip"])
(HERE / "imports.tf").write_text("\n".join(out))
n = sum(1 for line in out if line.startswith("import {"))
print(f"wrote imports.tf with {n} import blocks")
if missing:
print(f"NOT FOUND in NetBox (Terraform will create these): {', '.join(missing)}")
return 0
if __name__ == "__main__":
sys.exit(main())
+402
View File
@@ -0,0 +1,402 @@
# GENERATED by gen-imports.py — one-shot bootstrap, safe to delete after the first
# successful `terraform apply`. Do not hand-edit.
import {
to = netbox_site.this
id = "1"
}
import {
to = netbox_vlan_group.this
id = "1"
}
import {
to = netbox_ipam_role.this["lan"]
id = "1"
}
import {
to = netbox_ipam_role.this["sdn"]
id = "2"
}
import {
to = netbox_ipam_role.this["k3s"]
id = "3"
}
import {
to = netbox_ipam_role.this["wan"]
id = "4"
}
import {
to = netbox_vlan.this["100"]
id = "1"
}
import {
to = netbox_vlan.this["110"]
id = "2"
}
import {
to = netbox_prefix.this["192.168.10.0/24"]
id = "1"
}
import {
to = netbox_prefix.this["10.60.0.0/24"]
id = "2"
}
import {
to = netbox_prefix.this["10.61.0.0/24"]
id = "3"
}
import {
to = netbox_prefix.this["10.42.0.0/16"]
id = "4"
}
import {
to = netbox_prefix.this["10.43.0.0/16"]
id = "5"
}
import {
to = netbox_prefix.this["10.1.72.0/24"]
id = "6"
}
import {
to = netbox_ip_range.this["192.168.10.10/24-192.168.10.250/24"]
id = "1"
}
import {
to = netbox_wireless_lan.this["Buffalo-A-07B0-WPA3"]
id = "1"
}
import {
to = netbox_wireless_lan.this["Buffalo-A-07B0"]
id = "2"
}
import {
to = netbox_wireless_lan.this["Buffalo-G-07B0"]
id = "3"
}
import {
to = netbox_manufacturer.this["nec"]
id = "1"
}
import {
to = netbox_manufacturer.this["intel"]
id = "2"
}
import {
to = netbox_manufacturer.this["lenovo"]
id = "3"
}
import {
to = netbox_manufacturer.this["dell"]
id = "5"
}
import {
to = netbox_manufacturer.this["buffalo"]
id = "6"
}
import {
to = netbox_manufacturer.this["yamaha"]
id = "7"
}
import {
to = netbox_manufacturer.this["broadcom"]
id = "8"
}
import {
to = netbox_device_type.this["ix2215"]
id = "1"
}
import {
to = netbox_device_type.this["nuc6i3syb"]
id = "5"
}
import {
to = netbox_device_type.this["10vgcto1ww"]
id = "6"
}
import {
to = netbox_device_type.this["xps-15-9570"]
id = "7"
}
import {
to = netbox_device_type.this["wsr-1800ax4s"]
id = "8"
}
import {
to = netbox_device_type.this["rtx1200"]
id = "9"
}
import {
to = netbox_device_role.this["router"]
id = "1"
}
import {
to = netbox_device_role.this["hypervisor"]
id = "2"
}
import {
to = netbox_device_role.this["core-node"]
id = "3"
}
import {
to = netbox_device_role.this["wireless-ap"]
id = "4"
}
import {
to = netbox_cluster_type.this["proxmox"]
id = "1"
}
import {
to = netbox_cluster_type.this["libvirt"]
id = "2"
}
import {
to = netbox_cluster.this["homelab"]
id = "1"
}
import {
to = netbox_cluster.this["laptop-libvirt"]
id = "2"
}
import {
to = netbox_device.this["ix2215"]
id = "1"
}
import {
to = netbox_device_interface.this["ix2215:GigaEthernet2.0"]
id = "1"
}
import {
to = netbox_ip_address.device["ix2215:GigaEthernet2.0"]
id = "1"
}
import {
to = netbox_device_interface.this["ix2215:GigaEthernet0.0"]
id = "2"
}
import {
to = netbox_device.this["pve1"]
id = "2"
}
import {
to = netbox_device_interface.this["pve1:vmbr0"]
id = "3"
}
import {
to = netbox_ip_address.device["pve1:vmbr0"]
id = "2"
}
import {
to = netbox_device.this["pve2"]
id = "3"
}
import {
to = netbox_device_interface.this["pve2:vmbr0"]
id = "4"
}
import {
to = netbox_ip_address.device["pve2:vmbr0"]
id = "3"
}
import {
to = netbox_device.this["pve3"]
id = "4"
}
import {
to = netbox_device_interface.this["pve3:vmbr0"]
id = "5"
}
import {
to = netbox_ip_address.device["pve3:vmbr0"]
id = "4"
}
import {
to = netbox_device.this["laptop"]
id = "5"
}
import {
to = netbox_device_interface.this["laptop:br0"]
id = "6"
}
import {
to = netbox_ip_address.device["laptop:br0"]
id = "5"
}
import {
to = netbox_inventory_item.this["laptop:BCM4360 802.11ac"]
id = "1"
}
import {
to = netbox_device.this["ap-buffalo"]
id = "6"
}
import {
to = netbox_device_interface.this["ap-buffalo:lan1"]
id = "7"
}
import {
to = netbox_ip_address.device["ap-buffalo:lan1"]
id = "12"
}
import {
to = netbox_mac_address.this["ap-buffalo:lan1"]
id = "1"
}
import {
to = netbox_device_interface_primary_mac_address.this["ap-buffalo:lan1"]
id = "7"
}
import {
to = netbox_device_interface.this["ap-buffalo:wlan-2.4g"]
id = "8"
}
import {
to = netbox_device_interface.this["ap-buffalo:wlan-5g"]
id = "9"
}
import {
to = netbox_device.this["rtx1200"]
id = "7"
}
import {
to = netbox_virtual_machine.this["vyos-rtr"]
id = "1"
}
import {
to = netbox_interface.this["vyos-rtr:eth0"]
id = "1"
}
import {
to = netbox_ip_address.vm["vyos-rtr:eth0"]
id = "6"
}
import {
to = netbox_interface.this["vyos-rtr:eth1"]
id = "2"
}
import {
to = netbox_ip_address.vm["vyos-rtr:eth1"]
id = "7"
}
import {
to = netbox_interface.this["vyos-rtr:eth2"]
id = "3"
}
import {
to = netbox_ip_address.vm["vyos-rtr:eth2"]
id = "8"
}
import {
to = netbox_virtual_machine.this["dc1"]
id = "2"
}
import {
to = netbox_interface.this["dc1:lan"]
id = "4"
}
import {
to = netbox_ip_address.vm["dc1:lan"]
id = "9"
}
import {
to = netbox_virtual_machine.this["winadmin"]
id = "3"
}
import {
to = netbox_interface.this["winadmin:lan"]
id = "5"
}
import {
to = netbox_ip_address.vm["winadmin:lan"]
id = "10"
}
import {
to = netbox_virtual_machine.this["bao1"]
id = "4"
}
import {
to = netbox_interface.this["bao1:lan"]
id = "6"
}
import {
to = netbox_ip_address.vm["bao1:lan"]
id = "11"
}
+245
View File
@@ -0,0 +1,245 @@
# NetBox object graph, driven by topology.yml.
#
# WHY yamldecode rather than HCL resources per object: topology.yml stays the readable,
# authoritative artifact (git -> NetBox, see ../CONTEXT.md §4), and Terraform supplies what
# a plain script could not — state, `plan` as a drift report, and DELETION. Removing an
# entry from the YAML now removes the object from NetBox, which the previous seed script
# never did.
#
# Ownership boundary, matching ../../../infrastructure/openbao/terraform: Terraform owns API-level
# configuration. The k8s manifests that RUN NetBox live one level up in ../.
locals {
topo = yamldecode(file("${path.module}/topology.yml"))
# --- flattened lookup maps -------------------------------------------------
# Interfaces are nested under devices/VMs in the YAML; Terraform needs flat maps keyed
# by a stable string. "<parent>:<iface>" is that key everywhere below.
device_ifaces = merge([
for d in local.topo.devices : {
for i in d.interfaces : "${d.name}:${i.name}" => merge(i, { device = d.name })
}
]...)
vm_ifaces = merge([
for v in local.topo.virtual_machines : {
for i in v.interfaces : "${v.name}:${i.name}" => merge(i, { vm = v.name })
}
]...)
# Only interfaces that actually carry an address.
device_ips = { for k, i in local.device_ifaces : k => i if try(i.ip, null) != null }
vm_ips = { for k, i in local.vm_ifaces : k => i if try(i.ip, null) != null }
# The single address that becomes the parent's primary_ip4.
device_primary = { for k, i in local.device_ips : i.device => k if try(i.primary, false) }
vm_primary = { for k, i in local.vm_ips : i.vm => k if try(i.primary, false) }
device_macs = { for k, i in local.device_ifaces : k => i if try(i.mac, null) != null }
inventory_items = merge([
for d in local.topo.devices : {
for it in try(d.inventory_items, []) : "${d.name}:${it.name}" => merge(it, { device = d.name })
}
]...)
}
# --- site + IPAM ---------------------------------------------------------------
resource "netbox_site" "this" {
name = local.topo.site.name
slug = local.topo.site.slug
description = local.topo.site.description
status = "active"
}
resource "netbox_ipam_role" "this" {
for_each = { for r in local.topo.prefix_roles : r.slug => r }
name = each.value.name
slug = each.value.slug
}
resource "netbox_vlan_group" "this" {
name = local.topo.vlan_group.name
slug = local.topo.vlan_group.slug
description = local.topo.vlan_group.description
# Required by the provider. The SDN zone is a plain VLAN zone on vmbr0, which is
# bridge-vlan-aware for the full range, so do not narrow this without changing that.
vid_ranges = [[1, 4094]]
}
resource "netbox_vlan" "this" {
for_each = { for v in local.topo.vlans : tostring(v.vid) => v }
vid = each.value.vid
name = each.value.name
group_id = netbox_vlan_group.this.id
site_id = netbox_site.this.id
status = "active"
}
resource "netbox_prefix" "this" {
for_each = { for p in local.topo.prefixes : p.prefix => p }
prefix = each.value.prefix
status = "active"
# The provider exposes plain `site_id` and handles NetBox 4.2+'s generic
# scope_type/scope_id internally — which is exactly the trap that broke the hand-rolled
# script (posting `site` was silently dropped). Using the provider avoids it.
site_id = netbox_site.this.id
role_id = netbox_ipam_role.this[each.value.role].id
vlan_id = try(netbox_vlan.this[tostring(each.value.vlan)].id, null)
description = each.value.description
}
resource "netbox_ip_range" "this" {
for_each = { for r in local.topo.ip_ranges : "${r.start}-${r.end}" => r }
start_address = each.value.start
end_address = each.value.end
status = each.value.status
mark_utilized = try(each.value.mark_utilized, false)
description = each.value.description
}
# --- Wi-Fi ---------------------------------------------------------------------
# ⚠ PARTIAL: the provider can create the SSIDs but has NO attribute for attaching them to
# a radio interface, and none for `rf_role`. Neither does the netbox.netbox Ansible
# collection. That last mile is done by ./attach-wireless.py — see ../README.md.
resource "netbox_wireless_lan" "this" {
for_each = { for w in local.topo.wireless_lans : w.ssid => w }
ssid = each.value.ssid
auth_type = each.value.auth_type
auth_cipher = each.value.auth_cipher
description = each.value.description
# auth_psk deliberately unset: OpenBao is the secrets store, not NetBox.
}
# --- hardware ------------------------------------------------------------------
resource "netbox_manufacturer" "this" {
for_each = { for m in local.topo.manufacturers : m.slug => m }
name = each.value.name
slug = each.value.slug
}
resource "netbox_device_type" "this" {
for_each = { for d in local.topo.device_types : d.slug => d }
model = each.value.model
slug = each.value.slug
manufacturer_id = netbox_manufacturer.this[each.value.manufacturer].id
# Same reason as vm_role above: NetBox's default is true, so pin it or every plan wants
# to clear it. Meaningless for this hardware (nothing is rack-mounted) but stops churn.
is_full_depth = true
}
resource "netbox_device_role" "this" {
for_each = { for r in local.topo.device_roles : r.slug => r }
name = each.value.name
slug = each.value.slug
color_hex = each.value.color
# NetBox defaults this to true; the provider defaults it to false, so without pinning it
# every plan shows a spurious vm_role true -> false diff.
vm_role = true
}
resource "netbox_device" "this" {
for_each = { for d in local.topo.devices : d.name => d }
name = each.value.name
site_id = netbox_site.this.id
role_id = netbox_device_role.this[each.value.role].id
device_type_id = netbox_device_type.this[each.value.type].id
description = each.value.description
comments = try(each.value.comments, "")
serial = try(each.value.serial, "")
status = try(each.value.status, "active")
}
resource "netbox_device_interface" "this" {
for_each = local.device_ifaces
device_id = netbox_device.this[each.value.device].id
name = each.value.name
type = each.value.type
description = try(each.value.description, "")
mtu = try(each.value.mtu, null)
}
resource "netbox_inventory_item" "this" {
for_each = local.inventory_items
device_id = netbox_device.this[each.value.device].id
name = each.value.name
manufacturer_id = netbox_manufacturer.this[each.value.manufacturer].id
part_id = try(each.value.part_id, "")
serial = try(each.value.serial, "")
description = try(each.value.description, "")
}
# MACs are first-class objects in NetBox 4.2+; `mac_address` on the interface is read-only.
resource "netbox_mac_address" "this" {
for_each = local.device_macs
mac_address = upper(each.value.mac)
device_interface_id = netbox_device_interface.this[each.key].id
}
resource "netbox_device_interface_primary_mac_address" "this" {
for_each = local.device_macs
interface_id = netbox_device_interface.this[each.key].id
mac_address_id = netbox_mac_address.this[each.key].id
}
# --- virtualization ------------------------------------------------------------
resource "netbox_cluster_type" "this" {
for_each = { for c in local.topo.cluster_types : c.slug => c }
name = each.value.name
slug = each.value.slug
}
resource "netbox_cluster" "this" {
for_each = { for c in local.topo.clusters : c.name => c }
name = each.value.name
cluster_type_id = netbox_cluster_type.this[each.value.type].id
description = each.value.description
site_id = netbox_site.this.id
}
resource "netbox_virtual_machine" "this" {
for_each = { for v in local.topo.virtual_machines : v.name => v }
name = each.value.name
cluster_id = netbox_cluster.this[each.value.cluster].id
description = each.value.description
# NetBox DERIVES a VM's site from its cluster. Leaving this unset makes the provider
# try to clear it on every plan (site_id 1 -> None), so declare it to match.
site_id = netbox_site.this.id
}
resource "netbox_interface" "this" {
for_each = local.vm_ifaces
virtual_machine_id = netbox_virtual_machine.this[each.value.vm].id
name = each.value.name
description = try(each.value.description, "")
}
# --- addresses -----------------------------------------------------------------
resource "netbox_ip_address" "device" {
for_each = local.device_ips
ip_address = each.value.ip
status = "active"
# No `object_type` here: the provider pairs that with the GENERIC `interface_id`
# ("all of interface_id,object_type must be specified"). The dedicated
# *_interface_id attributes are standalone and imply the type.
device_interface_id = netbox_device_interface.this[each.key].id
# Native NetBox field. Setting it is INTENT: "this host needs a static A record in AD
# DNS". Domain-joined hosts self-register and are deliberately absent.
# ../generate/samba-a-records.py turns these into samba_ad_extra_a_records.
dns_name = try(each.value.dns_name, "")
}
resource "netbox_ip_address" "vm" {
for_each = local.vm_ips
ip_address = each.value.ip
status = "active"
virtual_machine_interface_id = netbox_interface.this[each.key].id
dns_name = try(each.value.dns_name, "")
}
# primary_ip4 lives on the parent, so the provider models it as its own resource.
resource "netbox_device_primary_ip" "this" {
for_each = local.device_primary
device_id = netbox_device.this[each.key].id
ip_address_id = netbox_ip_address.device[each.value].id
}
+14
View File
@@ -0,0 +1,14 @@
output "site_id" {
value = netbox_site.this.id
description = "NetBox ID of the Homelab site."
}
output "prefix_ids" {
value = { for k, p in netbox_prefix.this : k => p.id }
description = "prefix -> NetBox ID, for cross-referencing from other tooling."
}
output "device_ids" {
value = { for k, d in netbox_device.this : k => d.id }
description = "device name -> NetBox ID."
}
@@ -0,0 +1,2 @@
# Copy to terraform.tfvars (gitignored) and fill in, or export TF_VAR_netbox_token.
netbox_token = "nbt_xxxxxxxx.yyyyyyyy"
+262
View File
@@ -0,0 +1,262 @@
# Homelab topology — the INPUT to NetBox, not a dump of it.
#
# This file is deliberately the authoritative artifact: git stays the source of truth
# and NetBox is a derived mirror, populated by ./seed.py. That answers the design
# question in ../CONTEXT.md §4 the way the rest of this repo works — a UI you must
# click to change routing would be a regression against every other service here.
#
# Facts mirror CONTEXT.md §3 (verified live 2026-07-25). Interface names are verified,
# not guessed, EXCEPT where marked `# placeholder`.
site:
name: Homelab
slug: homelab
description: "Single flat 1G LAN on one unmanaged switch, 192.168.10.0/24"
# --- Layer 3 -----------------------------------------------------------------
prefixes:
- prefix: 192.168.10.0/24
role: lan
description: "LAN. Flat L2 across one dumb switch; OSPF area 0 runs here."
- prefix: 10.60.0.0/24
role: sdn
vlan: 100
description: "PVE SDN VNet labnet. Gateway 10.60.0.1 on vyos eth1."
- prefix: 10.61.0.0/24
role: sdn
vlan: 110
description: "PVE SDN VNet retronet. Gateway 10.61.0.1 on vyos eth2."
- prefix: 10.42.0.0/16
role: k3s
description: "k3s pod CIDR (laptop). Not routed off-node."
- prefix: 10.43.0.0/16
role: k3s
description: "k3s service CIDR (laptop). Not routed off-node."
- prefix: 10.1.72.0/24
role: wan
description: "WAN side of the NEC IX (GigaEthernet0.0)."
prefix_roles:
- { name: LAN, slug: lan }
- { name: SDN VNet, slug: sdn }
- { name: Kubernetes, slug: k3s }
- { name: WAN, slug: wan }
# The router's DHCP pool (CONTEXT.md §4 called this out specifically).
#
# There is no `dhcp` status — IPRange offers only active/reserved/deprecated. The pool
# concept is the `mark_utilized` boolean ("Report space as fully utilized"), which makes
# NetBox stop offering those addresses as available.
#
# Be precise about what this buys: it does NOT hard-block an allocation inside the
# range. It makes the collision VISIBLE — the range shows 100% utilised and the
# address never appears as a suggestion — where plain YAML shows nothing at all.
ip_ranges:
- start: 192.168.10.10/24
end: 192.168.10.250/24
status: active
mark_utilized: true
description: "NEC IX DHCP pool — do NOT statically allocate inside this."
vlan_group:
name: lab
slug: lab
description: "PVE SDN zone `lab` (type vlan, bridge vmbr0). Segmentation, NOT security."
vlans:
- { vid: 100, name: labnet, prefix: 10.60.0.0/24 }
- { vid: 110, name: retronet, prefix: 10.61.0.0/24 }
# --- Wi-Fi -------------------------------------------------------------------
# Broadcast by ap-buffalo (below). No `vlan:` on any of them: the AP bridges, so wireless
# clients land UNTAGGED on the flat LAN and pick up an address from the IX DHCP pool.
# They are on the same L2 as everything else — the Wi-Fi is not a separate segment.
#
# ⚠ NetBox has no WPA3 auth_type — the choices are open/wep/wpa-personal/wpa-enterprise,
# so WPA2-PSK and WPA3-SAE both land on `wpa-personal`. The real difference is recorded in
# the description because the model cannot express it.
#
# auth_psk is deliberately LEFT EMPTY. NetBox can store the passphrase, but that would put
# the house Wi-Fi key in a system whose own DB backup story is untested; OpenBao is the
# secrets store here (see ../../../infrastructure/openbao).
wireless_lans:
- ssid: Buffalo-A-07B0-WPA3
auth_type: wpa-personal
auth_cipher: aes
description: "5 GHz, WPA3-SAE. Preferred SSID for clients that support it."
- ssid: Buffalo-A-07B0
auth_type: wpa-personal
auth_cipher: aes
description: "5 GHz, WPA2-PSK. Compatibility SSID for clients that cannot do WPA3."
- ssid: Buffalo-G-07B0
auth_type: wpa-personal
auth_cipher: aes
description: "2.4 GHz, WPA2-PSK. Range/IoT band."
# --- Layer 2 / hardware ------------------------------------------------------
# Model/serial values below are READ FROM THE HARDWARE (`dmidecode -s ...`), not guessed.
manufacturers:
- { name: NEC, slug: nec }
- { name: Intel, slug: intel }
- { name: Lenovo, slug: lenovo }
- { name: Dell, slug: dell }
- { name: Buffalo, slug: buffalo }
- { name: Yamaha, slug: yamaha }
- { name: Broadcom, slug: broadcom }
device_types:
- { model: IX2215, slug: ix2215, manufacturer: nec }
# dmidecode: system-manufacturer/product/serial are all BLANK on this NUC (the OEM
# never programmed them). baseboard-product-name is the only real identifier, and it
# is SYB (the board), not the SYH chassis this was previously guessed to be.
- { model: NUC6i3SYB, slug: nuc6i3syb, manufacturer: intel }
# Lenovo's machine-type; this is the ThinkCentre M715q Tiny.
- { model: 10VGCTO1WW, slug: 10vgcto1ww, manufacturer: lenovo }
- { model: XPS 15 9570, slug: xps-15-9570, manufacturer: dell }
- { model: WSR-1800AX4S, slug: wsr-1800ax4s, manufacturer: buffalo }
- { model: RTX1200, slug: rtx1200, manufacturer: yamaha }
device_roles:
- { name: Router, slug: router, color: f44336 }
- { name: Hypervisor, slug: hypervisor, color: 2196f3 }
- { name: Core Node, slug: core-node, color: 4caf50 }
- { name: Wireless AP, slug: wireless-ap, color: ff9800 }
devices:
- name: ix2215
role: router
type: ix2215
description: "NEC IX. Gateway, OSPF area 0, BGP, DNS proxy, DHCP server."
interfaces:
- { name: GigaEthernet2.0, type: 1000base-t, ip: 192.168.10.1/24, primary: true, dns_name: gw.ad.ddupan.top }
- { name: GigaEthernet0.0, type: 1000base-t, ip: null, description: "WAN uplink, 10.1.72.0/24" }
- name: pve1
role: hypervisor
type: nuc6i3syb
description: "Proxmox VE 9.2. LINSTOR controller."
# No serial: this NUC reports blank system-serial-number (see device_types note).
comments: "Intel Core i3-6100U @ 2.30GHz, 4 threads, 15 GiB RAM. BIOS SYSKLi35.86A.0045.2016.0527.1055. Board NUC6i3SYB."
interfaces:
- { name: vmbr0, type: bridge, ip: 192.168.10.4/24, primary: true, mtu: 9000, dns_name: pve1.ad.ddupan.top }
- name: pve2
role: hypervisor
type: 10vgcto1ww
serial: PC1AGX1Q
description: "Proxmox VE 9.2. LINSTOR satellite. The node that randomly froze."
comments: "AMD Ryzen 5 PRO 2400GE w/ Vega, 8 threads, 7 GiB RAM. BIOS M1XKT45A. Raven Ridge idle bug fixed in BIOS: Power Supply Idle Control = Typical Current Idle."
interfaces:
- { name: vmbr0, type: bridge, ip: 192.168.10.7/24, primary: true, mtu: 9000, dns_name: pve2.ad.ddupan.top }
- name: pve3
role: hypervisor
type: 10vgcto1ww
serial: PC1AGX1P
description: "Proxmox VE 9.2. LINSTOR satellite."
comments: "AMD Ryzen 5 PRO 2400GE w/ Vega, 8 threads, 7 GiB RAM. BIOS M1XKT55A. Same silicon as pve2, so susceptible to the same idle bug in principle."
interfaces:
- { name: vmbr0, type: bridge, ip: 192.168.10.9/24, primary: true, mtu: 9000, dns_name: pve3.ad.ddupan.top }
- name: laptop
role: core-node
type: xps-15-9570
serial: 6R7CQQ2
description: "Core node, NOT a PVE cluster member. k3s, NFS, libvirt host, netboot.xyz, OSPF DR. Single point of failure for most of the lab."
comments: "Intel Core i7-8750H @ 2.20GHz, 12 threads, 30 GiB RAM. BIOS 1.20.0. Nvidia dGPU stays bare-metal for nvidia-container-toolkit. Built-in battery acts as a UPS."
interfaces:
- { name: br0, type: bridge, ip: 192.168.10.127/24, primary: true }
inventory_items:
# Present in hardware but NOT usable, so it is an inventory item rather than an
# interface — there is no netdev for it.
#
# `lspci -k` shows bcma-pci-bridge bound and b43 loaded, but b43 does NOT support
# BCM4360; that chip needs Broadcom's proprietary `wl` (broadcom-sta) driver with
# b43/bcma/ssb blacklisted. Until then the laptop cannot scan or join Wi-Fi.
- name: BCM4360 802.11ac
manufacturer: broadcom
part_id: "14e4:43a0"
description: "PCI 3b:00.0, Apple-subsystem card. No driver: b43 claims it but cannot drive BCM4360; needs broadcom-sta (wl)."
# Wi-Fi. Runs as an AP/bridge, not a router — the NEC IX is the gateway, so this box's
# routing, NAT and DHCP are not in play. Wireless clients land directly on the flat LAN.
#
# ⚠ Its address .10 is the FIRST ADDRESS OF THE DHCP POOL above. Either it holds a lease
# (so the address can move) or it is a static that overlaps the pool. NetBox surfaces
# the overlap; the underlying config still needs a decision. See ../README.md.
#
# Identified by MAC OUI d4:2c:46 = BUFFALO.INC plus the model string on its login page.
- name: ap-buffalo
role: wireless-ap
type: wsr-1800ax4s
description: "Buffalo AirStation, AP/bridge mode. Provides the house Wi-Fi."
comments: "Wi-Fi 6 (802.11ax) dual band. Web UI on http://192.168.10.10/. Model read from its login page; serial not recorded (needs the label or an authenticated session)."
interfaces:
- { name: lan1, type: 1000base-t, ip: 192.168.10.10/24, primary: true, mac: "d4:2c:46:09:07:b0", dns_name: ap.ad.ddupan.top, description: "Uplink to the dumb switch" }
# Buffalo's factory SSID scheme: A = 5 GHz, G = 2.4 GHz, and the suffix is the tail
# of this AP's own MAC (d4:2c:46:09:07:b0 -> 07B0), which independently corroborates
# that this device is the AP.
- name: wlan-2.4g
type: ieee802.11ax
rf_role: ap
description: "2.4 GHz radio"
wireless_lans: [Buffalo-G-07B0]
- name: wlan-5g
type: ieee802.11ax
rf_role: ap
description: "5 GHz radio"
wireless_lans: [Buffalo-A-07B0-WPA3, Buffalo-A-07B0]
# Spare/shelf kit. Recorded so it is not forgotten — knowing what you own and are NOT
# using is a legitimate reason to run a DCIM tool.
- name: rtx1200
role: router
type: rtx1200
status: inventory # NOT active: unplugged, no addresses, not cabled
description: "Yamaha RTX1200. Spare — not in use."
comments: "Gigabit VPN router. Kept as a spare / potential replacement for the NEC IX. Serial not recorded (would need the chassis label)."
interfaces: []
# --- Virtual machines --------------------------------------------------------
cluster_types:
- { name: Proxmox VE, slug: proxmox }
- { name: libvirt, slug: libvirt }
clusters:
- { name: homelab, type: proxmox, description: "3-node PVE cluster, no HA, LINSTOR place-count 2." }
- { name: laptop-libvirt, type: libvirt, description: "libvirt guests on the laptop." }
virtual_machines:
- name: vyos-rtr
cluster: homelab
description: "VyOS 2025.11. SDN gateway, OSPF area 0. VM 100. Routed, not NAT'd."
interfaces:
- { name: eth0, ip: 192.168.10.2/24, primary: true, dns_name: vyos-rtr.ad.ddupan.top, description: "LAN" }
- { name: eth1, ip: 10.60.0.1/24, description: "labnet gateway (VLAN 100), OSPF passive" }
- { name: eth2, ip: 10.61.0.1/24, description: "retronet gateway (VLAN 110), OSPF passive" }
# Found by diffing NetBox against samba_ad_extra_a_records — it had a live A record and
# was missing from NetBox entirely. Verified up at 10.60.0.10.
- name: retrolab
cluster: homelab
description: "AD-joined XFCE/xrdp host for running 86Box. Lives on labnet (VLAN 100)."
interfaces:
- { name: eth0, ip: 10.60.0.10/24, primary: true, dns_name: retrolab.ad.ddupan.top }
- name: dc1
cluster: laptop-libvirt
description: "Samba AD DC, authoritative for ad.ddupan.top."
interfaces:
- { name: lan, ip: 192.168.10.5/24, primary: true } # placeholder: NIC name not verified
- name: winadmin
cluster: laptop-libvirt
description: "Windows Server 2025 admin box."
interfaces:
- { name: lan, ip: 192.168.10.6/24, primary: true } # placeholder: NIC name not verified
- name: bao1
cluster: laptop-libvirt
description: "OpenBao — internal CA + secrets store."
interfaces:
- { name: lan, ip: 192.168.10.8/24, primary: true, dns_name: bao.ad.ddupan.top } # placeholder: NIC name not verified
+15
View File
@@ -0,0 +1,15 @@
variable "netbox_url" {
type = string
description = "Base URL of NetBox, WITHOUT the /api suffix."
default = "https://netbox.ad.ddupan.top"
}
variable "netbox_token" {
type = string
sensitive = true
description = <<-EOT
NetBox API token. A v2 token (nbt_<key>.<secret>) works fine NetBox dispatches on the
token value, not the Authorization scheme. Mint one with the snippet in ../README.md.
Supply via TF_VAR_netbox_token or terraform.tfvars (gitignored).
EOT
}
+20
View File
@@ -0,0 +1,20 @@
terraform {
required_version = ">= 1.5"
required_providers {
netbox = {
source = "e-breuninger/netbox"
# Pin to 5.x. Provider 4.3.1 FAILS against NetBox 4.6 at provider-configure time
# with a go-openapi error ("... is not supported by the TextConsumer"); 5.7.0 works.
version = "~> 5.0"
}
}
}
# Same pattern as ../../../infrastructure/openbao/terraform and ../../../infrastructure/cloudflared/terraform: credentials come
# from outside the repo, state is local and gitignored.
provider "netbox" {
# BASE URL ONLY no /api suffix. Passing ".../api" produces the same misleading
# TextConsumer error as the version mismatch above and costs an hour to diagnose.
server_url = var.netbox_url
api_token = var.netbox_token
}
+190
View File
@@ -0,0 +1,190 @@
# NetBox — IPAM + DCIM, candidate "source of truth for network facts".
# THIS IS AN EVALUATION DEPLOYMENT (see CONTEXT.md §1: adoption is not decided).
# Nothing else in the repo reads from NetBox yet; it is safe to delete outright.
#
# Chart: netbox/netbox 8.3.38 (app v4.6.5)
# helm repo add netbox https://netbox-community.github.io/netbox-chart/
#
# Shape mirrors the other k3s services here: external shared Postgres, private
# exposure over the Tailscale ingress, authentication delegated to Authelia OIDC.
# Pin the app image; the chart default matches but drifts on every chart bump.
image:
tag: v4.6.5
# --- Local break-glass superuser -------------------------------------------
# Deliberately kept, exactly like Grafana's local admin: NetBox's SSO group->
# superuser mapping does not exist (that feature is LDAP-only, see README), so
# the first SSO login lands as an ordinary user and needs promoting BY someone.
# Password is generated by the chart and preserved in the netbox-superuser Secret.
superuser:
name: admin
email: [email protected]
# NetBox refuses writes via any hostname not listed here. The pod IP is appended
# automatically (allowedHostsIncludesPodIP) for the k8s probes.
allowedHosts:
- netbox.ad.ddupan.top
- netbox.netbox.svc.cluster.local
- localhost
# Envoy terminates TLS and forwards plain HTTP, so Django sees an http:// request
# carrying an https:// Origin header. Without this, every POST (including the login
# form) fails CSRF verification — it presents as "login is broken", not "config is
# missing". Same requirement behind any TLS-terminating proxy.
csrf:
trustedOrigins:
- https://netbox.ad.ddupan.top
# It is an infrastructure source of truth on a private tailnet, but "on the
# tailnet" is not authentication — require a login for read access too.
#
# This logs a benign FutureWarning at startup ("LOGIN_REQUIRED is deprecated ...
# can be removed from your configuration file"): NetBox v5.0 drops the setting and
# makes login mandatory, i.e. true is the direction of travel. The warning is
# unavoidable via this chart — the ConfigMap always emits the key, and NetBox warns
# on false as well ("unauthenticated access will no longer be supported").
loginRequired: true
# Everything else in this homelab is UTC (the node is Etc/UTC); stay consistent
# so timestamps line up with VictoriaLogs.
timeZone: UTC
# Version check phones home to api.github.com. The WAN flaps (CONTEXT.md §6) and
# a blocking outbound call on page render is exactly what we don't want.
releaseCheck:
url: ""
# --- Authentication: Authelia forward-auth (trusted headers) --------------
# Authelia authenticates + enforces 2FA at the GATEWAY (securitypolicy.yaml); by the
# time a request arrives here it is already authenticated, and Envoy has attached
# Remote-* headers describing who the user is.
#
# WHY NOT OIDC (which this used to be): NetBox has NO SSO group -> role mapping.
# REMOTE_AUTH_SUPERUSER_GROUPS and AUTH_LDAP_USER_FLAGS_BY_GROUP are LDAP-only; the
# social-auth pipeline only runs user_default_groups_handler, so an OIDC user landed
# as an ordinary member of one static group and had to be promoted BY HAND, and AD
# group changes never propagated. With header auth, REMOTE_AUTH_GROUP_SYNC_ENABLED
# re-evaluates group membership on EVERY request — the same declarative AD-group
# pattern already used by Grafana and Proxmox.
#
# ⚠⚠ THIS SETTING IS ONLY SAFE BEHIND THE GATEWAY. RemoteUserBackend trusts the
# header unconditionally — NetBox has no trusted-proxy allowlist. Two things keep
# that honest, and BOTH must stay true:
# 1. Envoy sets these headers from Authelia's response, and Envoy Gateway's
# headersToBackend OVERRIDES any client-supplied value ("coexisting headers
# will be overridden"), so a spoofed Remote-User cannot survive the hop.
# 2. networkpolicy.yaml restricts pod ingress to the gateway namespace, so nothing
# in-cluster can bypass Envoy and talk to :8080 directly.
# Removing either one turns `Remote-User: admin` into an instant superuser.
remoteAuth:
enabled: true
backends:
- netbox.authentication.RemoteUserBackend
# Authelia's header names. NetBox's defaults assume HTTP_REMOTE_USER_GROUP, but
# Authelia emits Remote-Groups -> HTTP_REMOTE_GROUPS, and joins values with a
# COMMA where NetBox defaults to "|". Both must be overridden or group sync
# silently yields one group literally named "a,b,c".
header: HTTP_REMOTE_USER
groupHeader: HTTP_REMOTE_GROUPS
groupSeparator: ","
# Authelia sends Remote-Email and Remote-Name (a single display name); it has no
# split given/family name, so the first/last-name headers are left unmapped.
userEmail: HTTP_REMOTE_EMAIL
autoCreateUser: true
autoCreateGroups: true # mirror AD groups into NetBox groups as they appear
groupSyncEnabled: true # re-evaluate membership on every request
# AD group -> NetBox role. Managed in ../../infrastructure/samba-ad (samba_ad_groups).
superuserGroups:
- netbox-admins
staffGroups:
- netbox-admins # is_staff => access to the Django admin site
# The OIDC client config that used to live here (SOCIAL_AUTH_OIDC_*) is gone; the
# netbox-secrets Secret now only carries the database password.
extraConfig: []
# --- Postgres: the shared CNPG cluster ------------------------------------
# Dedicated role + database, no shared superuser — same as Authelia and Gitea.
postgresql:
enabled: false
externalDatabase:
host: shared-postgresql-rw.shared-db.svc.cluster.local
port: 5432
database: netbox
username: netbox
existingSecretName: netbox-secrets
existingSecretKey: postgresql-password
# --- Valkey (Redis) for the RQ task queue + caching -----------------------
# Bundled subchart rather than a shared instance: nothing else in the cluster
# runs Redis, and NetBox wants two logical databases of its own.
#
# ⚠ Bitnami's public catalog no longer serves versioned tags (only `latest`,
# versioned images moved to the `bitnamilegacy` repo), which is why the chart
# ships `tag: latest`. `latest` is not reproducible, so pin the DIGEST instead —
# resolved 2026-07-25 for bitnami/valkey:latest.
valkey:
image:
digest: sha256:5d43ca8bb57aa263ef78d1684dbf1e4b4f63844727eafdd5ea2b0e102a25b141
# Single node: the chart default is primary+replica, which buys nothing here
# (one k8s node, no HA anywhere in this cluster — CONTEXT.md §6).
architecture: standalone
primary:
persistence:
enabled: true
storageClass: localpv-zfs-ceph
size: 1Gi
resourcesPreset: micro
# --- Storage --------------------------------------------------------------
# Media = uploaded images/attachments only; the real data is in Postgres.
#
# ⚠ NOT localpv-zfs-ceph, unlike every other PVC here. The chart mounts this ONE
# RWO claim into BOTH Deployments (web and worker — the worker writes files the web
# serves), and the OpenEBS ZFS class provisions a **zvol**: a block device with xfs
# on it, which cannot be mounted twice. Whichever pod won the race mounted it and
# the other stuck in Init forever with
# verifyMount: device already mounted at [...]
# from the CSI node plugin. local-path is hostPath-based, so kubelet can bind-mount
# the same directory into both pods. (localpv-zfs-ceph stays correct for the valkey
# PVC below — single StatefulSet pod, no sharing.)
persistence:
enabled: true
storageClass: local-path
size: 2Gi
# --- LAN exposure via the Contour gateway ---------------------------------
# https://netbox.ad.ddupan.top — reachable from any LAN host, no Tailscale client
# needed, and it does not traverse the WAN. Deliberately NOT on the cloudflared
# tunnel: a full inventory of the network is not something to publish.
#
# Gateway API, not Ingress. The shared gateway lives in ../../platform/envoy-gateway and already
# terminates TLS with the *.ad.ddupan.top wildcard from ../../platform/cert-manager — so this
# service needs no cert of its own, just this route plus a DNS A record on the DC
# (../../infrastructure/samba-ad). Authentication is enforced at the gateway by the SecurityPolicy in
# securitypolicy.yaml, before a request ever reaches this pod.
ingress:
enabled: false
httpRoute:
enabled: true
parentRefs:
- name: eg
namespace: envoy-gateway-system
sectionName: https # the :443 listener; :80 is for ACME/redirects only
hostnames:
- netbox.ad.ddupan.top
# --- Resource budget ------------------------------------------------------
# The laptop is the single k3s node and already runs everything (CONTEXT.md §6);
# at survey time it had ~4 GiB RAM headroom. Keep this deployment modest.
resourcesPreset: medium # 500m/1Gi requests, 750m/1.5Gi limits
worker:
resourcesPreset: small # 500m/512Mi requests — one worker, no bulk jobs yet
# Nightly changelog/job pruning. Retentions are the chart defaults (90 days).
housekeeping:
enabled: true
schedule: "17 4 * * *"
+7
View File
@@ -0,0 +1,7 @@
# OpenViking container ports
OPENVIKING_HTTP_PORT=1933
OPENVIKING_UI_PORT=8020
JINA_HTTP_PORT=8081
# Optional: disable the bundled VikingBot UI/agent helper if you do not need it.
OPENVIKING_WITH_BOT=1
+65
View File
@@ -0,0 +1,65 @@
# OpenViking (Docker)
This folder contains a Docker Compose setup for OpenViking using the official image.
## What this setup assumes
- VLM provider: `openai-codex`
- Embedding model family: `jina-embeddings-v5-text-small-clustering`
- Embedding server: local OpenAI-compatible endpoint powered by `llama.cpp` CUDA12 image
- Persistent OpenViking data lives in `./data`
- Persistent embedding model cache lives in `./models` (mounted to Hugging Face cache)
- HTTP API is exposed on `1933`
- Console/UI is exposed on `8020`
- Local embedding endpoint is exposed on `127.0.0.1:8081`
## Files
- `docker-compose.yml` — the container definition
- `.env.example` — optional port/bot defaults
- `ov.conf.example` — configuration example using local Jina embeddings + Codex VLM
- `ovcli.conf.example` — CLI/client config example
## Setup
1. Copy the env template:
```bash
cp .env.example .env
```
2. Create the persistent directories and copy the config examples:
```bash
mkdir -p data models
cp ov.conf.example data/ov.conf
cp ovcli.conf.example data/ovcli.conf
mkdir -p data/workspace
```
3. Start the containers:
```bash
docker compose up -d
```
4. If Codex auth is not yet available, complete the login/import step inside the OpenViking container:
```bash
docker compose exec -it openviking openviking-server init
```
5. Verify the server:
```bash
docker compose exec -it openviking openviking-server doctor
curl http://localhost:1933/health
```
## Notes
- The embedding service uses the Jina v5 omni small text-matching GGUF model and serves an OpenAI-compatible `/v1` API locally.
- `provider: "openai"` is used in `ov.conf` because the embedding endpoint is OpenAI-compatible, even though the underlying model is Jina.
- `provider: "openai-codex"` does not require `vlm.api_key` once Codex OAuth is available through `openviking-server init`.
- If you want the bundled VikingBot disabled, set `OPENVIKING_WITH_BOT=0` in `.env`.
- The first startup can take a while because the Jina model must be downloaded into `./models`.
View File
+26
View File
@@ -0,0 +1,26 @@
{
"storage": {
"workspace": "/app/.openviking/workspace",
"vectordb": {
"name": "context",
"backend": "local"
},
"agfs": {
"backend": "local"
}
},
"embedding": {
"dense": {
"api_base": "http://jina-embeddings:8080/v1",
"api_key": "local-jina",
"provider": "openai",
"dimension": 1024,
"model": "jinaai/jina-embeddings-v5-omni-small-text-matching-GGUF"
}
},
"vlm": {
"api_base": "https://chatgpt.com/backend-api/codex",
"provider": "openai-codex",
"model": "gpt-5.3-codex"
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"url": "http://localhost:1933",
"timeout": 60.0
}
+40
View File
@@ -0,0 +1,40 @@
services:
jina-embeddings:
image: ghcr.io/ggml-org/llama.cpp:server-cuda12-b8914
container_name: jina-embeddings
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
ports:
- "127.0.0.1:${JINA_HTTP_PORT:-8081}:8080"
volumes:
- ./models:/root/.cache/huggingface
command:
- -hf
- jinaai/jina-embeddings-v5-omni-small-clustering-GGUF:Q2_M
- --embedding
- --host
- "0.0.0.0"
- --port
- "8080"
restart: unless-stopped
openviking:
image: ghcr.io/volcengine/openviking:latest
container_name: openviking
depends_on:
- jina-embeddings
ports:
- "${OPENVIKING_HTTP_PORT:-1933}:1933"
- "${OPENVIKING_UI_PORT:-8020}:8020"
volumes:
- ./data:/app/.openviking
environment:
- OPENVIKING_CONFIG_FILE=/app/.openviking/ov.conf
- OPENVIKING_CLI_CONFIG_FILE=/app/.openviking/ovcli.conf
- OPENVIKING_WITH_BOT=${OPENVIKING_WITH_BOT:-1}
restart: unless-stopped
+26
View File
@@ -0,0 +1,26 @@
{
"storage": {
"workspace": "/app/.openviking/workspace",
"vectordb": {
"name": "context",
"backend": "local"
},
"agfs": {
"backend": "local"
}
},
"embedding": {
"dense": {
"api_base": "http://jina-embeddings:8080/v1",
"api_key": "local-jina",
"provider": "openai",
"dimension": 1024,
"model": "jinaai/jina-embeddings-v5-omni-small-text-matching-GGUF"
}
},
"vlm": {
"api_base": "https://chatgpt.com/backend-api/codex",
"provider": "openai-codex",
"model": "gpt-5.3-codex"
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"url": "http://localhost:1933",
"timeout": 60.0
}
+14
View File
@@ -0,0 +1,14 @@
version: "3.9"
services:
ps3netsrv:
image: shawly/ps3netsrv:latest
container_name: ps3netsrv
restart: unless-stopped
environment:
TZ: Asia/Tokyo
USER_ID: "1000"
GROUP_ID: "1000"
ports:
- "38008:38008"
volumes:
- "/mnt/pool/games/ps3:/games:rw"
+34
View File
@@ -0,0 +1,34 @@
# SeaweedFS (S3 storage)
**Purpose**
- Deploy SeaweedFS as the S3-compatible object store with the official Helm chart.
- Use chart-managed persistent storage and expose the admin UI through Tailscale.
**Files**
| File | Description |
| --- | --- |
| `values.yaml` | Helm values for the official SeaweedFS chart. |
| `helm.sh` | Installs or upgrades the SeaweedFS release. |
**Install**
1. Set real S3 access and secret keys in `values.yaml`.
2. Apply the manifests:
```bash
bash ~/services/apps/seaweedfs/helm.sh
```
**Access**
- Inside the cluster, the S3 endpoint is `http://seaweedfs-s3.seaweedfs.svc.cluster.local:8333`.
- The filer UI is available at `http://seaweedfs-filer.seaweedfs.svc.cluster.local:8888`.
- Public access is routed through Cloudflare Tunnel at `https://obj.ddupan.top`.
- The admin UI is exposed through Tailscale on the `seaweedfs-admin` Ingress.
- For local testing, use port-forward:
```bash
kubectl -n seaweedfs port-forward svc/seaweedfs-s3 8333:8333 \
svc/seaweedfs-filer 8888:8888 \
svc/seaweedfs-admin 23646:23646
```
**Notes**
- The chart manages master, volume, filer, S3, and admin components.
- The chart-managed S3 secret uses the current AK/SK pair for the admin user.
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
helm repo add seaweedfs https://seaweedfs.github.io/seaweedfs/helm >/dev/null 2>&1 || true
helm repo update >/dev/null
helm upgrade --install seaweedfs seaweedfs/seaweedfs \
--namespace seaweedfs \
--create-namespace \
-f "$(dirname "$0")/values.yaml" \
--wait
+39
View File
@@ -0,0 +1,39 @@
# LAN route to the SeaweedFS S3 endpoint.
#
# WHY, when obj.ddupan.top already works: that hostname resolves to Cloudflare and
# hairpins the request out through the WAN and back down the tunnel. It is fine for
# a browser. It is a bad dependency for **Terraform state**, which is exactly what
# you need during an incident — and on 2026-07-28 that path was blackholed for
# hours by a dead `openvpn-client@naist` tunnel whose 58 split-tunnel routes
# swallowed Cloudflare's ranges. State operations must not leave the LAN.
#
# The public obj.ddupan.top route is unchanged and still served via cloudflared;
# both terminate at the same Service.
#
# No cert work: s3.ad.ddupan.top is covered by the existing *.ad.ddupan.top
# wildcard on the gateway's `https` listener, so this is an HTTPRoute plus one DNS
# A record in ../../infrastructure/samba-ad — the documented way to add a LAN service.
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: seaweedfs-s3
namespace: seaweedfs
spec:
parentRefs:
- name: eg
namespace: envoy-gateway-system
# Pin to the wildcard listener; the https-auth listener only matches
# auth.ddupan.top and would report a needless "no matching listener".
sectionName: https
hostnames:
- s3.ad.ddupan.top
rules:
- backendRefs:
- name: seaweedfs-s3
port: 8333
# S3 PUTs of Terraform state can be large (smtp-relay's is already ~1.1MB
# because the azuread app registration carries a lot). Envoy's default
# per-try timeout is comfortably above that, but the retry policy matters
# more: a half-written state file is far worse than a failed apply, so do
# not add retries here. Terraform handles its own locking and retry.
+76
View File
@@ -0,0 +1,76 @@
global:
seaweedfs:
image:
repository: ""
name: chrislusf/seaweedfs
tag: latest
master:
enabled: true
replicas: 1
data:
type: persistentVolumeClaim
size: 10Gi
storageClass: localpv-zfs-ceph
logs:
type: emptyDir
volume:
enabled: true
replicas: 1
dataDirs:
- name: data1
type: persistentVolumeClaim
size: 100Gi
storageClass: localpv-zfs-ceph
maxVolumes: 0
idx:
type: emptyDir
logs:
type: emptyDir
filer:
enabled: true
replicas: 1
data:
type: persistentVolumeClaim
size: 10Gi
storageClass: localpv-zfs-ceph
logs:
type: emptyDir
s3:
enabled: true
enableAuth: true
# Identities come from a Secret synced out of OpenBao by External Secrets
# (../../platform/external-secrets/externalsecrets.yaml -> kv/k8s/seaweedfs-s3), NOT from
# the chart's own s3.credentials.
#
# WHY: those keys used to be INLINE in this file and were committed in the
# initial commit. They also slipped past a content scan, because the regex
# looked for `secret[:=]` and the key is written `secretKey` — the word is
# followed by "Key", not a colon. See CLAUDE.md on scanning by content.
# The leaked anvAdmin key is in git history and still needs ROTATING.
existingConfigSecret: seaweedfs-s3-config
s3:
enableAuth: true
# No credentials block on purpose — populating it makes the chart render a
# Secret from values, which is what put credentials in git in the first place.
admin:
enabled: true
replicas: 1
data:
type: emptyDir
logs:
type: emptyDir
ingress:
enabled: true
className: tailscale
host: seaweedfs-admin
path: /
pathType: Prefix
annotations: {}
tls:
- hosts:
- seaweedfs-admin
@@ -0,0 +1,27 @@
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: shared-postgresql
namespace: shared-db
spec:
serviceAccountName: shared-postgresql-sa
instances: 1
enableSuperuserAccess: true
superuserSecret:
name: shared-postgresql-superuser-secret
storage:
storageClass: localpv-zfs-ceph
size: 10Gi
resources:
requests:
memory: 512Mi
cpu: 250m
limits:
memory: 1Gi
cpu: 500m
bootstrap:
initdb:
database: e5renew
owner: postgres
secret:
name: shared-postgresql-superuser-secret
+144
View File
@@ -0,0 +1,144 @@
# Shared PostgreSQL Migration Runbook
## Target
- CloudNativePG cluster: `shared-postgresql`
- Namespace: `shared-db`
- Compatibility service: `shared-postgresql.shared-db.svc.cluster.local:5432`
- CNPG rw service: `shared-postgresql-rw.shared-db.svc.cluster.local:5432`
- Tailscale service: `shared-postgresql-tailscale.shared-db.svc.cluster.local:5432`
## Apply order
1. Create the CNPG superuser secret
2. Apply `shared-postgresql/cloudnativepg-cluster.yaml`
3. Wait for the cluster to become ready
4. Restore the databases and validate against the CNPG `rw` Service
5. Remove the legacy Helm release so the old Service can be removed cleanly
6. Apply `shared-postgresql/shared-postgresql-service.yaml`
7. Validate app connectivity through the compatibility and Tailscale Services
## Storage preflight
```bash
kubectl get storageclass localpv-zfs-ceph
kubectl get pods -n openebs
```
Make sure the OpenEBS ZFS storage class exists and the OpenEBS components are healthy before starting the cutover.
## Reconcile source passwords
The following passwords are the current source of truth for the migration:
- `SOURCE_POSTGRES_PASSWORD` for the source dump and CNPG superuser secret
- `OLD_GITEA_PASSWORD` and `OLD_CASDOOR_PASSWORD` for the restored app logins
## Create the CNPG secret
```bash
kubectl create secret generic shared-postgresql-superuser-secret \
-n shared-db \
--type=kubernetes.io/basic-auth \
--from-literal=username=postgres \
--from-literal=password="$SOURCE_POSTGRES_PASSWORD" \
--dry-run=client -o yaml | kubectl apply -f -
```
## Dump from the old instance
Pause writes from dependent apps first.
```bash
kubectl get pod -n shared-db -l app.kubernetes.io/component=primary,app.kubernetes.io/instance=shared-postgresql,app.kubernetes.io/name=postgresql --show-labels
SOURCE_POD="$(kubectl get pod -n shared-db -l app.kubernetes.io/component=primary,app.kubernetes.io/instance=shared-postgresql,app.kubernetes.io/name=postgresql -o jsonpath='{.items[0].metadata.name}')"
kubectl exec -n shared-db "$SOURCE_POD" -- sh -lc "PGPASSWORD='$SOURCE_POSTGRES_PASSWORD' pg_dumpall -h 127.0.0.1 -U postgres --globals-only" > globals.sql
kubectl exec -n shared-db "$SOURCE_POD" -- sh -lc "PGPASSWORD='$SOURCE_POSTGRES_PASSWORD' pg_dump -h 127.0.0.1 -U postgres -Fc -d e5renew" > e5renew.dump
kubectl exec -n shared-db "$SOURCE_POD" -- sh -lc "PGPASSWORD='$SOURCE_POSTGRES_PASSWORD' pg_dump -h 127.0.0.1 -U postgres -Fc -d gitea" > gitea.dump
kubectl exec -n shared-db "$SOURCE_POD" -- sh -lc "PGPASSWORD='$SOURCE_POSTGRES_PASSWORD' pg_dump -h 127.0.0.1 -U postgres -Fc -d casdoor" > casdoor.dump
```
## Restore into CNPG
`globals.sql` restores the roles and passwords from the source cluster. The init SQL only creates the application databases and grants.
```bash
cat globals.sql > globals.with-postgres-reset.sql
printf "ALTER ROLE postgres PASSWORD :'source_postgres_password';\n" >> globals.with-postgres-reset.sql
PGPASSWORD="$SOURCE_POSTGRES_PASSWORD" psql -v source_postgres_password="$SOURCE_POSTGRES_PASSWORD" -h shared-postgresql-rw.shared-db.svc.cluster.local -U postgres -d postgres -f globals.with-postgres-reset.sql
PGPASSWORD="$SOURCE_POSTGRES_PASSWORD" psql -h shared-postgresql-rw.shared-db.svc.cluster.local -U postgres -d postgres -f shared-postgresql/shared-postgresql-init.sql
PGPASSWORD="$SOURCE_POSTGRES_PASSWORD" pg_restore -h shared-postgresql-rw.shared-db.svc.cluster.local -U postgres -d e5renew e5renew.dump
PGPASSWORD="$SOURCE_POSTGRES_PASSWORD" pg_restore -h shared-postgresql-rw.shared-db.svc.cluster.local -U postgres -d gitea gitea.dump
PGPASSWORD="$SOURCE_POSTGRES_PASSWORD" pg_restore -h shared-postgresql-rw.shared-db.svc.cluster.local -U postgres -d casdoor casdoor.dump
```
## Pre-cutover validation
```bash
PGPASSWORD="$SOURCE_POSTGRES_PASSWORD" psql -h shared-postgresql-rw.shared-db.svc.cluster.local -U postgres -d e5renew -c 'select 1;'
PGPASSWORD="$SOURCE_POSTGRES_PASSWORD" psql -h shared-postgresql-rw.shared-db.svc.cluster.local -U postgres -d e5renew -c 'create table if not exists migration_check(id int); insert into migration_check values (1); delete from migration_check; drop table migration_check;'
PGPASSWORD="$OLD_GITEA_PASSWORD" psql -h shared-postgresql-rw.shared-db.svc.cluster.local -U gitea -d gitea -c 'create table if not exists migration_check(id int); insert into migration_check values (1); delete from migration_check; drop table migration_check;'
PGPASSWORD="$OLD_CASDOOR_PASSWORD" psql -h shared-postgresql-rw.shared-db.svc.cluster.local -U casdoor -d casdoor -c 'create table if not exists migration_check(id int); insert into migration_check values (1); delete from migration_check; drop table migration_check;'
```
## Post-cutover validation
```bash
PGPASSWORD="$SOURCE_POSTGRES_PASSWORD" psql -h shared-postgresql.shared-db.svc.cluster.local -U postgres -d e5renew -c 'select 1;'
PGPASSWORD="$SOURCE_POSTGRES_PASSWORD" psql -h shared-postgresql.shared-db.svc.cluster.local -U postgres -d e5renew -c 'create table if not exists migration_check(id int); insert into migration_check values (1); delete from migration_check; drop table migration_check;'
PGPASSWORD="$OLD_GITEA_PASSWORD" psql -h shared-postgresql.shared-db.svc.cluster.local -U gitea -d gitea -c 'create table if not exists migration_check(id int); insert into migration_check values (1); delete from migration_check; drop table migration_check;'
PGPASSWORD="$OLD_CASDOOR_PASSWORD" psql -h shared-postgresql.shared-db.svc.cluster.local -U casdoor -d casdoor -c 'create table if not exists migration_check(id int); insert into migration_check values (1); delete from migration_check; drop table migration_check;'
PGPASSWORD="$SOURCE_POSTGRES_PASSWORD" psql -h shared-postgresql-tailscale.shared-db.svc.cluster.local -U postgres -d e5renew -c 'select 1;'
```
## Rollback
- Keep the old Helm-based PostgreSQL deployment until validation passes.
- If restore fails, delete the CNPG cluster.
- Delete the CNPG compatibility Service with `kubectl delete -f shared-postgresql/shared-postgresql-service.yaml` so the `shared-postgresql` hostname is free for the legacy release again.
- Reapply the legacy Helm release.
- After the old Helm release is back, regenerate the Tailscale Service from the live pod labels and apply it:
```bash
python - <<'PY' | kubectl apply -f -
import json
import subprocess
import yaml
pod = subprocess.check_output([
'kubectl', 'get', 'pod', '-n', 'shared-db',
'-l', 'app.kubernetes.io/component=primary,app.kubernetes.io/instance=shared-postgresql,app.kubernetes.io/name=postgresql',
'-o', 'json'
], text=True)
labels = json.loads(pod)['items'][0]['metadata']['labels']
selector = {
'app.kubernetes.io/component': labels['app.kubernetes.io/component'],
'app.kubernetes.io/instance': labels['app.kubernetes.io/instance'],
'app.kubernetes.io/name': labels['app.kubernetes.io/name'],
}
service = {
'apiVersion': 'v1',
'kind': 'Service',
'metadata': {
'name': 'shared-postgresql-tailscale',
'namespace': 'shared-db',
},
'spec': {
'type': 'LoadBalancer',
'loadBalancerClass': 'tailscale',
'ports': [
{
'name': 'tcp-postgresql',
'port': 5432,
'protocol': 'TCP',
'targetPort': 5432,
}
],
'selector': selector,
},
}
print(yaml.safe_dump(service, sort_keys=False))
PY
```
- Do not delete the old PVC until the new cluster is verified.
@@ -0,0 +1,6 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: shared-postgresql-sa
namespace: shared-db
automountServiceAccountToken: true
@@ -0,0 +1,18 @@
-- Roles come from globals.sql; this script only creates databases and grants.
SELECT 'CREATE DATABASE e5renew'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'e5renew')\gexec
GRANT ALL PRIVILEGES ON DATABASE e5renew TO postgres;
SELECT 'CREATE DATABASE casdoor'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'casdoor')\gexec
GRANT ALL PRIVILEGES ON DATABASE casdoor TO casdoor;
SELECT 'CREATE DATABASE gitea'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'gitea')\gexec
GRANT ALL PRIVILEGES ON DATABASE gitea TO gitea;
-- authelia: role created out-of-band (see authelia/README.md); DB owned by it.
SELECT 'CREATE DATABASE authelia OWNER authelia'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'authelia')\gexec
GRANT ALL PRIVILEGES ON DATABASE authelia TO authelia;
@@ -0,0 +1,31 @@
apiVersion: v1
kind: Service
metadata:
name: shared-postgresql
namespace: shared-db
spec:
type: ClusterIP
selector:
cnpg.io/cluster: shared-postgresql
cnpg.io/instanceRole: primary
ports:
- name: postgres
port: 5432
targetPort: 5432
---
apiVersion: v1
kind: Service
metadata:
name: shared-postgresql-tailscale
namespace: shared-db
spec:
type: LoadBalancer
loadBalancerClass: tailscale
selector:
cnpg.io/cluster: shared-postgresql
cnpg.io/instanceRole: primary
ports:
- name: tcp-postgresql
port: 5432
protocol: TCP
targetPort: 5432
+4
View File
@@ -0,0 +1,4 @@
# Never commit the real credentials or minted tokens
secret.yaml
tokens/
.noreply-password
+136
View File
@@ -0,0 +1,136 @@
# SMTP relay (Postfix + sasl-xoauth2 → Microsoft 365)
One internal SMTP endpoint that in-cluster apps use for outbound mail. It authenticates
to Exchange Online with **OAuth2 (XOAUTH2)** via [`mauroreggio/postfix-365`], which bundles
[`sasl-xoauth2`] — tokens are refreshed **inside the SASL layer**, no sidecar/cron. Basic-auth
SMTP (app passwords) is being retired by Microsoft; this is the modern replacement.
```
Authelia / Gitea / … ──plain SMTP :25 (in-cluster, no auth)──▶ smtp-relay ──587 STARTTLS + XOAUTH2──▶ smtp.office365.com
```
Reach it at: `smtp-relay.smtp-relay.svc.cluster.local:25`. Sends **as** `[email protected]`.
[`mauroreggio/postfix-365`]: https://github.com/mauroreggio/postfix-365
[`sasl-xoauth2`]: https://github.com/tarickb/sasl-xoauth2
---
## Prerequisites (Microsoft 365 / Entra — mostly interactive, one-time)
1. **`ddupan.top` is a verified domain** in the tenant (Admin center → Settings → Domains,
Status = Healthy). If not, add it and complete the TXT verification (DNS is Cloudflare —
can be done in `../../../infrastructure/cloudflared/terraform/`).
2. **Mailbox `[email protected]`** exists with an **Exchange Online license**.
3. **Authenticated SMTP enabled** on it: Admin center → Users → that user → Mail →
*Manage email apps* → tick **Authenticated SMTP**. (OAuth won't work without this.)
## Step 1 — Entra app registration (as code)
```bash
cd terraform
az login # account that can create app regs AND grant admin consent (Global Admin)
terraform init
terraform apply # creates the app, delegated Graph SMTP.Send + admin consent, a client secret
```
Grab the three values for the k8s secret:
```bash
terraform output -raw client_id
terraform output -raw tenant_id
```
> This is a **PUBLIC** client (device-code flow) — there is **no client secret**. Leave
> `CLIENT_SECRET` empty in `secret.yaml`. Presenting a secret makes Entra reject the token
> refresh with `AADSTS700025 "Client is public..."`.
## Step 2 — Deploy the relay
```bash
cd ..
cp secret.example.yaml secret.yaml # paste CLIENT_ID / CLIENT_SECRET / TENANT_ID
kubectl apply -f namespace.yaml
kubectl apply -f secret.yaml -f pvc.yaml
kubectl apply -f deployment.yaml -f service.yaml
kubectl -n smtp-relay rollout status deploy/smtp-relay
```
At this point Postfix runs but has **no token yet**, so relaying fails until step 3.
## Step 3 — Bootstrap the token (one-time, interactive device-code)
The image's `sasl-xoauth2-tool` needs the `msal` Python module, which isn't bundled.
Install it ephemerally (only needed for this one mint; the C++ SASL plugin refreshes
without it):
```bash
POD=$(kubectl get pod -n smtp-relay -l app=smtp-relay -o name | head -1 | cut -d/ -f2)
kubectl exec -n smtp-relay $POD -- sh -c 'python3 -m ensurepip >/dev/null 2>&1; python3 -m pip install -q msal'
```
Mint the token (env vars come from the pod's secret; `CLIENT_SECRET` is empty → public flow):
```bash
kubectl exec -n smtp-relay -it deploy/smtp-relay -- sh -c \
'sasl-xoauth2-tool get-token outlook /etc/tokens/noreply@ddupan.top \
--client-id="$CLIENT_ID" --tenant="$TENANT_ID" --client-secret="$CLIENT_SECRET" --use-device-flow'
```
It prints a URL + code — open <https://microsoft.com/devicelogin> and **sign in as the SENDER
mailbox `[email protected]`** (NOT yourself/the admin — a token minted for the wrong user gives
`535 5.7.3`). If prompted for a client secret, press Enter. Then fix ownership so Postfix can
read/rewrite it:
```bash
kubectl exec -n smtp-relay $POD -- chown postfix:postfix /etc/tokens/noreply@ddupan.top
```
Verify identity if unsure: decode the token and check `upn` == `[email protected]`. The token
persists on the PVC; sasl-xoauth2 refreshes it automatically thereafter.
## Step 4 — Test
```bash
kubectl exec -n smtp-relay $POD -- sh -c \
'echo "Subject: relay test\n\nhello" | sendmail -f noreply@ddupan.top you@example.com'
kubectl exec -n smtp-relay $POD -- tail -n 40 /var/log/maillog # look for "status=sent"
```
## Step 5 — Point apps at it
- **Authelia** — replace the filesystem notifier with SMTP in `../authelia/values.yaml`:
address `smtp://smtp-relay.smtp-relay.svc.cluster.local:25`, sender `[email protected]`,
`disable_require_tls: true` (plain in-cluster hop). 2FA enrollment codes then go to real email.
- Any future app: same address, **From = `[email protected]`** (O365 rejects other senders
with `5.7.60` unless a send-as alias is configured in Exchange).
## Deliverability (keep mail out of Junk)
- **SPF / MX / DMARC** for `ddupan.top` already exist (M365 domain setup).
- **DKIM**: ✅ **enabled 2026-07-28** (`Enabled: True`, `Status: Valid`). CNAMEs are in
`../../infrastructure/cloudflared/terraform/` (`selector1/2._domainkey`); signing was turned on with
`scripts/enable-dkim.ps1` then `scripts/enable-dkim-finish.ps1`.
- ⚠️ **The CNAME target is NXDOMAIN until signing is enabled.** Microsoft creates the
tenant host (`<tenant>.d-v1.dkim.mail.microsoft`) only at enable time, so a correct
CNAME looks broken beforehand and `Get-DkimSigningConfig` reports `CnameMissing`.
**Do not go hunting for the "real" CNAME value** — run step 2 and re-check DNS.
- ⚠️ **Both scripts deadlock if run via `!` or with output redirected to a file** — the
device code never becomes visible. Run under a PTY:
`DOTNET_SYSTEM_NET_DISABLEIPV6=1 script -qfc "pwsh -NoProfile -File scripts/enable-dkim.ps1" /tmp/dkim.log`
The `DISABLEIPV6` is required on the laptop — see the IPv6 trap in the root `CLAUDE.md`;
without it `Connect-ExchangeOnline` hangs in `SYN-SENT` with no output at all.
- **Still soft**: SPF is `~all` and DMARC is `p=none`. Harden to `-all` / `p=quarantine`
once aggregate reports confirm DKIM passes — not before, or you quarantine your own mail.
## Notes / gotchas
- **PUBLIC client, no secret** — see Step 1. `CLIENT_SECRET` stays empty.
- **Sign in as `noreply@` (the sender), not the admin**, during the Step 3 device login — a
token minted for the wrong identity fails with `535 5.7.3`.
- **`msal` is ephemeral** — reinstall it in the pod (Step 3) before any re-mint; it's gone after
a restart but only the one-time mint needs it.
- **Token PVC is writable state, not in Git.** The refresh token rotates; it lives only on
the PVC. Back it up if you want to avoid re-running step 3.
- **Refresh-token longevity**: Azure AD refresh tokens renew on use but can expire under
Conditional Access / long idle — if relaying suddenly fails auth, re-run step 3.
+81
View File
@@ -0,0 +1,81 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: smtp-relay
namespace: smtp-relay
labels:
app: smtp-relay
spec:
replicas: 1
strategy:
type: Recreate # single writer on the token PVC (RWO)
selector:
matchLabels:
app: smtp-relay
template:
metadata:
labels:
app: smtp-relay
spec:
containers:
- name: postfix
# Postfix + sasl-xoauth2 (OAuth2/XOAUTH2 to M365). Refreshes tokens in the
# SASL layer — no sidecar. https://github.com/mauroreggio/postfix-365
image: ghcr.io/mauroreggio/postfix-365:1.0.0
envFrom:
- secretRef:
name: smtp-relay-secret # CLIENT_ID, CLIENT_SECRET, TENANT_ID
env:
- name: TIMEZONE
value: 'Asia/Shanghai'
- name: HOSTNAME
value: 'smtp-relay.ddupan.top' # HELO name
- name: DOMAIN_NAME
value: 'ddupan.top'
# Submitters trusted without SMTP AUTH. k3s pod + service CIDRs, plus the
# three Proxmox nodes by /32 so they can relay system mail (PVE alerts,
# smartd, cron) to M365 — they have no other way off a residential IP.
# Deliberately /32s, NOT 192.168.10.0/24: everything else on the LAN still
# hits `defer_unauth_destination`, so this stays a closed relay.
# Exposed to those nodes via service-lan.yaml (LoadBalancer :25).
- name: MY_NETWORK
value: '10.42.0.0/16, 10.43.0.0/16, 192.168.10.4/32, 192.168.10.7/32, 192.168.10.9/32'
- name: DISABLE_SMTP_AUTH_ON_PORT_25
value: 'true'
- name: MESSAGE_SIZE_LIMIT
value: '26214400' # 25 MiB
# The M365 mailbox we authenticate + send AS (device-code refresh token
# lives at /etc/tokens/<AUTH_USER> on the PVC).
- name: AUTH_USER
value: '[email protected]'
- name: RELAY_HOST
value: 'smtp.office365.com'
- name: RELAY_HOST_PORT
value: '587'
ports:
- name: smtp
containerPort: 25
volumeMounts:
# Writable + persistent: sasl-xoauth2 rewrites the token file on refresh.
- name: tokens
mountPath: /etc/tokens
readinessProbe:
tcpSocket:
port: 25
initialDelaySeconds: 10
periodSeconds: 15
livenessProbe:
tcpSocket:
port: 25
initialDelaySeconds: 30
periodSeconds: 30
resources:
requests:
cpu: 10m
memory: 64Mi
limits:
memory: 256Mi
volumes:
- name: tokens
persistentVolumeClaim:
claimName: smtp-relay-tokens

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