Establish clean homelab infrastructure baseline
Reorganize the brownfield repository, remove retired and generated artifacts, harden ignore rules, and record the GitOps/IaC redesign.
This commit is contained in:
@@ -0,0 +1,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]
|
||||
@@ -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
@@ -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/
|
||||
@@ -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¸2Ÿ„ªd€¥<FžÜj@ΣjDJ×6 µ±¬˜—Ÿ¼ ÅË»°ªj�XPv'ò€æŸì™Ú` 옉x"MCJ† �ÌÜqÖ5¦<@`»�1žBĎ̵G¼a/ß_
|
||||
@@ -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
|
||||
@@ -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
@@ -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 45–75 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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-----
|
||||
@@ -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-----
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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: 
|
||||
- When sending or referencing a local image, always use an absolute filesystem path in the Markdown image tag (e.g., ); 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
global:
|
||||
scrape_interval: 15s
|
||||
evaluation_interval: 15s
|
||||
|
||||
scrape_configs:
|
||||
- job_name: "litellm"
|
||||
static_configs:
|
||||
- targets: ["litellm:4000"]
|
||||
@@ -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"]
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
@@ -0,0 +1,12 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: marker
|
||||
namespace: default
|
||||
spec:
|
||||
selector:
|
||||
app: marker
|
||||
ports:
|
||||
- name: http
|
||||
port: 8001
|
||||
targetPort: 8001
|
||||
@@ -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 MB–1 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.
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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 2008–2025)**. 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 2019–2025, 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.
|
||||
Executable
+81
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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).
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
3.0.2
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
# Real secret material — keep secret.example.yaml as the committed template.
|
||||
secret.yaml
|
||||
@@ -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 1–5 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.
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -0,0 +1,4 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: netbox
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 * * *"
|
||||
@@ -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
|
||||
@@ -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`.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"url": "http://localhost:1933",
|
||||
"timeout": 60.0
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"url": "http://localhost:1933",
|
||||
"timeout": 60.0
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
# Never commit the real credentials or minted tokens
|
||||
secret.yaml
|
||||
tokens/
|
||||
.noreply-password
|
||||
@@ -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.
|
||||
@@ -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
Reference in New Issue
Block a user