Establish clean homelab infrastructure baseline
lint / yaml (push) Has been cancelled
lint / ansible (push) Has been cancelled
lint / terraform (push) Has been cancelled

Reorganize the brownfield repository, remove retired and generated artifacts, harden ignore rules, and record the GitOps/IaC redesign.
This commit is contained in:
2026-09-09 16:47:20 +00:00
commit 88a02ababa
418 changed files with 50579 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
# Real secret material — keep secret.example.yaml as the committed template.
secret.yaml
+221
View File
@@ -0,0 +1,221 @@
# NetBox evaluation — handoff context
> **STATUS 2026-07-26 — this brief has been ACTED ON. Read [`README.md`](README.md) first
> for what actually exists.** NetBox is deployed at <https://netbox.ad.ddupan.top>, populated
> from `terraform/topology.yml`, and two of the §4 consumers are proven by generators that
> diff against live systems. This file is kept as the original brief and rationale; where
> it disagrees with README.md, README.md is right.
>
> Corrections found by measuring the hardware (`dmidecode`) rather than trusting §3:
> pve1's CPU is an **i3-6100U** (not i3-6100) on board **NUC6i3SYB**, with **15 GiB**
> usable; pve2/pve3 are Lenovo machine-type `10VGCTO1WW`. §3 also omitted **retrolab**
> (10.60.0.10), which had a live AD DNS record all along.
Written 2026-07-25 for a fresh agent. Everything below is **verified live**, not assumed.
Nothing NetBox-related existed at the time of writing: this document is the brief, not a
record of work done.
---
## 1. Why this is being considered
The homelab is already managed as code (Ansible + Terraform, see §5). The gap NetBox would
fill is a **single source of truth for network facts**, which are currently duplicated across
three places that must be edited together and have no cross-check:
| fact | lives in | and again in | and again in |
|---|---|---|---|
| VNet `labnet` = VLAN 100 | `proxmox/ansible/roles/pve_sdn/defaults/main.yml` | — | — |
| subnet `10.60.0.0/24` | VyOS role `vyos_sdn_interfaces` | VyOS OSPF `area 0 network` | (implied by PVE VNet) |
| gateway `10.60.0.1` | VyOS role | — | — |
| host addresses (§3) | `proxmox/ansible/inventory/hosts.yml` | `samba-ad` DNS A records | router DHCP reservations |
Adding one VNet today means editing the PVE SDN role, the VyOS interface list, **and** the OSPF
network list. Forgetting the third is silent — the subnet exists and has a gateway, but nothing
outside can route to it.
**The user has NOT committed to adopting NetBox.** The task is to evaluate and propose, then
implement only if it earns its place. Be honest if it does not — for ~10 hosts and 2 VLANs it may
be more machinery than the duplication costs.
---
## 2. Physical / logical topology
```
INTERNET (unstable — see §6)
│
GigaEthernet0.0 10.1.72.0/24
┌────────┴─────────┐
│ NEC IX router │ 192.168.10.1
│ (GigaEth2.0 = │ · OSPF area 0 · BGP (17 routes via .127)
│ LAN side) │ · proxy-dns, DHCP pool .10–.250
└────────┬─────────┘
│
┌────────┴──────────┐ UNMANAGED (dumb) switch
│ flat L2 segment │ · passes 802.1Q tags untouched (verified)
│ 192.168.10.0/24 │ · passes jumbo frames 9000 MTU (verified)
└─┬──────┬────────┬─┘
│ │ │
pve1/2/3 laptop (VMs)
```
- **Single flat 1G LAN, one dumb switch.** No managed switch, no second NIC per node.
- **MTU 9000** on the three PVE nodes' `vmbr0` + bridge port. Router/DC/laptop remain 1500;
safe because TCP negotiates MSS in the SYN. Only large **UDP** to a 1500 host would break.
- **PVE SDN uses a VLAN zone**, not VXLAN — the dumb switch forwards tags, so VLAN is native and
needs no encapsulation. VLANs here are **segmentation, not security**: nothing enforces them.
---
## 3. Address allocations (all verified reachable 2026-07-25)
### 192.168.10.0/24 — the LAN
| addr | host | notes |
|---|---|---|
| .1 | NEC IX router | gateway, OSPF, BGP, DNS proxy, DHCP server |
| .2 | `vyos-rtr` (VM 100) | VyOS 2025.11, SDN gateway + OSPF |
| .4 | `pve1` | Proxmox, LINSTOR **controller**, NUC6i3SYB, i3-6100U, 15 GiB (corrected — see status note) |
| .5 | `dc1` | Samba AD DC (libvirt VM on the laptop), authoritative for `ad.ddupan.top` |
| .6 | `winadmin` | Windows Server 2025 (libvirt VM on the laptop) |
| .7 | `pve2` | Proxmox, ThinkCentre 2400GE 8G |
| .8 | `bao1` | OpenBao (libvirt VM on the laptop) — internal CA + secrets |
| .9 | `pve3` | Proxmox, ThinkCentre 2400GE 8G |
| .127 | `laptop` | "core" node: ZFS+NFS, k3s, netboot.xyz, libvirt host, OSPF **DR**, BGP |
| .10–.250 | — | **router DHCP pool** — do not statically allocate inside this |
### Other prefixes in play
| prefix | what |
|---|---|
| `10.60.0.0/24` | SDN `labnet` (VLAN **100**), gateway `10.60.0.1` on vyos `eth1` |
| `10.61.0.0/24` | SDN `retronet` (VLAN **110**), gateway `10.61.0.1` on vyos `eth2` |
| `10.42.0.0/16` | k3s pod CIDR (laptop) |
| `10.43.0.0/16` | k3s service CIDR (laptop) |
| `10.1.72.0/24` | WAN side of the IX |
### Routing
OSPF **area 0** across the LAN. Speakers: NEC IX (.1), VyOS (.2), laptop (.127, currently **DR**).
VyOS advertises the SDN subnets as **intra-area** networks — deliberately *not*
`redistribute connected`, which would leak every future interface and inject topology-blind E2
routes. Verified on the IX: `O 10.60.0.0/24 [110/2] via 192.168.10.2`.
---
## 4. What NetBox would need to model
Minimum to remove the duplication in §1:
- **Prefixes** + roles for the LAN and both SDN subnets, with the DHCP pool marked as a pool so
static assignments can't collide with it.
- **VLANs** 100/110 and their VLAN group, linked to the prefixes.
- **IP addresses** for §3, each assigned to a device/VM interface.
- **Devices/VMs**: 3 PVE nodes, the laptop, the IX, and the VMs (`vyos-rtr`, `dc1`, `winadmin`, `bao1`).
- **Interfaces**: notably vyos `eth0/eth1/eth2` and which VNet each attaches to.
Then the consumers that should read from it rather than hold their own copy:
1. `proxmox/ansible/roles/pve_sdn` — VNet name/tag list
2. `proxmox/ansible/roles/vyos_router` — interface addresses **and** the OSPF network list
3. `samba-ad/ansible` — `samba_ad_extra_a_records` (currently hand-listed)
4. possibly `proxmox/ansible/inventory/hosts.yml` — via `netbox.netbox.nb_inventory`
**Design question to answer, not assume:** does NetBox become the *authoring* surface (edit in the
UI, generate YAML/config) or a *derived mirror* (YAML stays authoritative, NetBox is populated from
it)? The repo's whole pattern is git-authoritative, so a UI that must be clicked to change routing
would be a regression. Consider `netbox-as-code`-style sync where git remains the input.
---
## 5. Existing conventions — match these
Repo root: `/home/panxiao81/services` (git, **but nothing is committed yet** — large untracked tree).
- **Ansible** per service: `<svc>/ansible/{ansible.cfg,inventory/hosts.yml,group_vars,roles/,*.yml}`.
Style: `host_key_checking = False`, `callback_result_format = yaml`, heavy WHY-comments in roles,
`retries`/`until` on anything touching the internet.
- **Terraform** per service: `<svc>/terraform/{versions,main,variables,outputs}.tf`, local state,
`.gitignore` for `*.tfstate`. Auth from the ambient CLI session.
Example: `openbao/terraform/` manages OpenBao's API surface.
- **Tooling**: Python CLIs via `uv`. Ansible is installed as
`uv tool install ansible-core --with ansible --with paramiko`.
⚠️ `uv tool install ansible` alone only exposes `ansible-community`, not `ansible-playbook`.
- **Secrets**: plaintext but **gitignored** (`samba-ad/ansible/group_vars/all/vault.yml`,
`proxmox/vyos/credentials.yml`). OpenBao (`bao.ad.ddupan.top`) is the real secrets store and the
internal CA; it has a Terraform config already.
- **Kubernetes**: single-node k3s on the laptop. Manifests live per service
(`smtp-relay/*.yaml`, `k3s/coredns-custom.yaml`). Authelia is Helm + `values.yaml`.
Ownership boundary already established for OpenBao and worth copying: **Terraform owns API-level
configuration, Ansible owns the machine and anything Terraform must not own** (key material,
secrets it cannot read back).
---
## 6. Constraints that will bite you
- **The WAN fails at random.** Bad ISP, cannot be changed. Any download/pull needs retries. DNS has
flapped repeatedly. Do NOT go debugging the router for this — it has been checked
(utilization "calm", memory 30%, 0 NAPT failures, upstreams fine).
- **Internal DNS must never depend on the WAN.** k3s CoreDNS sends `ad.ddupan.top` straight to the
DC and NXDOMAINs the dead search suffixes (`k3s/coredns-custom.yaml`). PVE nodes use the DC first.
If a pod times out resolving *anything*, suspect this first.
- **k3s pods inherit `ndots:5` + the node's search list** — names with <5 dots try every suffix
first. This already caused a CrashLoopBackOff that looked like a service bug.
- **The cluster holds nothing critical** and has **no HA**. LINSTOR `place-count 2`.
Guests are disposable; do not design as if they are not.
- **Storage**: `pve-rg` (SSD, ~187 GiB) and `pve-rg-hdd` (HDD, ~931 GiB) are LINSTOR/DRBD;
`laptop` is NFS (~560 GiB free) for ISOs/templates/backups, **not** VM disks.
### Where to run NetBox
Most natural: the existing **k3s on the laptop** (`.127`), same pattern as Authelia/smtp-relay —
Helm or manifests in `netbox/`, Postgres available via the shared `shared-postgresql` cluster in
namespace `shared-db`. Alternative: a VM on the PVE cluster. Note the laptop is *not* a PVE member
and is the single point of failure for k3s, NFS, the AD DC, and OpenBao already.
**SSO is available and expected**: Authelia is the OIDC provider (`https://auth.ddupan.top`), backed
by Samba AD over verified LDAPS. Grafana/Gitea/OpenBao are already clients — wire NetBox the same
way rather than inventing local accounts. AD group → app-role mapping is the established pattern
(e.g. `pve-admins` → Proxmox `Administrator`).
---
## 7. Useful access
| target | how |
|---|---|
| PVE nodes | `ssh [email protected].{4,7,9}` (key auth from the laptop) |
| VyOS | `ssh [email protected]`; op-mode non-interactively needs `/opt/vyatta/bin/vyatta-op-cmd-wrapper <cmd>` |
| NEC IX | netmiko `nec_ix_telnet`, creds in `~/scripts/netrestart/web/net.py`. `show running-config`/`show config` do **not** work — use `show ip route`, `show utilization`. No `show ip ospf neighbor` either; check adjacencies from VyOS. |
| k3s | `kubectl` on the laptop |
| OpenBao | `bao login -method=oidc` (browser); `~/.vault-token` |
Agent memory for this project lives in
`~/.claude/projects/-home-panxiao81-services/memory/` — read `MEMORY.md` first; the entries on
`homelab-proxmox-cluster`, `vyos-router-sdn`, and `flaky-wan-isp` are directly relevant.
---
## 8. Suggested first steps
1. Read the memory files above; do not re-derive the topology.
2. Decide **authoring vs mirror** (§4) — this shapes everything else.
3. Stand up NetBox (k3s + shared Postgres + Authelia OIDC), no data yet.
4. Model §3 by hand for the LAN + the two SDN prefixes. Check whether it actually reads better
than the current YAML before going further.
5. Only then attempt generation: start with the **one** case that is genuinely error-prone —
the VyOS OSPF network list plus its interface addresses, which today must be kept in sync
with the PVE SDN VNets by hand.
6. Report back whether it earns its place. "It does not, here is why" is a valid outcome.
## 9. Outcome (2026-07-26)
Steps 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.
+469
View File
@@ -0,0 +1,469 @@
# NetBox — evaluation deployment
NetBox (IPAM + DCIM) as a candidate **single source of truth for network facts**.
The case for it, the duplication it would remove, and the topology it would model are
in [`CONTEXT.md`](CONTEXT.md) — read that first.
**Status: deployed and populated for evaluation 2026-07-25. Nothing reads from it yet.**
No Ansible role, Terraform config or playbook consumes NetBox data, so deleting this
service breaks nothing (see [Teardown](#teardown)). It *can* generate the config that
matters, verified against the live router — see
[Verdict](#verdict-it-can-generate-the-config-that-matters).
- Chart: `netbox/netbox` **8.3.38** (app **v4.6.5**) — <https://netbox-community.github.io/netbox-chart/>
- URL: **<https://netbox.ad.ddupan.top>** — LAN only, via the shared Envoy Gateway
(`../../platform/envoy-gateway`) with the `*.ad.ddupan.top` wildcard cert from `../../platform/cert-manager`.
Deliberately **not** on the cloudflared tunnel: a full inventory of the network is not
something to publish to the internet.
- Namespace: `netbox`
- Database: dedicated `netbox` role/db on the shared CNPG cluster (`shared-db`)
- Auth: **Authelia forward-auth** at the gateway (not OIDC — see
[Authentication](#authentication-authelia-forward-auth-not-oidc)); local `admin`
retained as break-glass
> Exposure moved off the Tailscale ingress on 2026-07-25: no Tailscale client needed, it
> works from any LAN host (including the Windows admin VM), and traffic never leaves the
> LAN. That required adding cert-manager and — to get AD-group→role mapping — replacing
> Contour with Envoy Gateway. See those directories.
## Layout
| file | what |
|---|---|
| `values.yaml` | Helm values — the whole config, heavily commented |
| `secret.yaml` | **gitignored** — the database password (that is all) |
| `securitypolicy.yaml` | Authelia forward-auth at the gateway |
| `networkpolicy.yaml` | blocks bypassing the gateway — part of the trust boundary |
| `secret.example.yaml` | committed template for the above |
| `namespace.yaml` | the `netbox` namespace |
| `terraform/topology.yml` | **the authoritative topology** — git is the source, NetBox the mirror |
| `terraform/{versions,main,variables,outputs}.tf` | Terraform root that applies it |
| `terraform/attach-wireless.py` | the one thing Terraform cannot express (see below) |
| `terraform/gen-imports.py` | one-shot: adopt pre-existing objects into TF state |
| `terraform/.env` | **gitignored** — API token for the helper scripts |
| `generate/vyos-ospf.py` | derives the VyOS OSPF config from NetBox; `--diff` vs the live router |
| `generate/samba-a-records.py` | derives `samba_ad_extra_a_records`; `--diff` vs the live vars |
| `CONTEXT.md` | the evaluation brief (why NetBox, what it would model) |
## Architecture notes worth knowing before you touch it
**Postgres is external, Valkey is bundled.** The shared CNPG cluster gets a dedicated
role+database, same as Authelia and Gitea. Redis is *not* shared because nothing else in
the cluster runs one, and NetBox wants two logical DBs (RQ task queue + caching) to
itself. `postgresql.enabled: false` / `valkey.enabled: true`.
**The Valkey image is pinned by digest, not tag.** Bitnami's public catalog stopped
serving versioned tags in 2025 — versioned images moved to the `bitnamilegacy` repo and
only `latest` remains public, which is why the chart itself ships `tag: latest`. A
floating tag is not acceptable here, so `values.yaml` pins
`valkey.image.digest`. Verified: `docker.io/bitnami/valkey:9.0.1` → *not found*,
`:latest` → pulls (app 9.1.1). **To bump it, resolve the new digest explicitly:**
```bash
sudo k3s ctr images pull docker.io/bitnami/valkey:latest # prints the manifest digest
```
**How configuration reaches Django.** The chart renders `netbox.yaml` into a ConfigMap and
its `configuration.py` deep-merges that file plus every `*.yaml` under
`/run/config/extra/*/` into the Django settings namespace. That is what `extraConfig[]`
feeds — arbitrary settings keys work, not just the ones the chart models. `extraConfig`
is currently empty; it held the OIDC client config before the move to forward-auth, and
is the hook to reach for if a future setting has no chart value.
**CSRF.** Envoy terminates TLS and forwards plain HTTP, so Django sees an `http://`
request carrying an `https://` Origin. Without `csrf.trustedOrigins` every POST —
including the login form itself — fails CSRF verification. This is the failure that looks
like "login is broken" rather than "config is missing", and it applies behind any
TLS-terminating proxy.
**Exposure is Gateway API, not Ingress.** The chart's native `httpRoute` block attaches to
the shared `eg` Gateway in `envoy-gateway-system`, `sectionName: https`. Because that
listener already serves the `*.ad.ddupan.top` wildcard, this service owns no certificate
of its own — adding the next LAN service is an `HTTPRoute` plus one A record in
`samba_ad_extra_a_records` (`../../infrastructure/samba-ad`), with no Gateway or cert work.
## Authentication: Authelia forward-auth (not OIDC)
Authelia authenticates and enforces 2FA **at the gateway**. By the time a request
reaches NetBox it is already authenticated, and Envoy has attached headers describing
the user. NetBox runs `netbox.authentication.RemoteUserBackend` and reads them.
### Why not OIDC — the reason this design exists
NetBox was originally wired to Authelia over OIDC. It worked, but **NetBox has no SSO
group → role mapping**: `REMOTE_AUTH_SUPERUSER_GROUPS` and
`AUTH_LDAP_USER_FLAGS_BY_GROUP` are **LDAP-only**, and the social-auth pipeline runs only
`user_default_groups_handler`, which assigns one static group and nothing else. So every
SSO user landed as an ordinary member of `sso-users` and had to be promoted **by hand**,
and later AD group changes never propagated.
Header auth fixes exactly that: `REMOTE_AUTH_GROUP_SYNC_ENABLED` re-evaluates group
membership from the `Remote-Groups` header on **every request**, giving the same
declarative AD-group→role pattern Grafana and Proxmox already use. Remove someone from
`netbox-admins` in AD and their NetBox admin is gone immediately.
Getting there required replacing Contour with Envoy Gateway — Contour speaks only gRPC
ext_authz, Authelia only HTTP. See `../../platform/envoy-gateway/README.md`.
### Header mapping (all three defaults are wrong for Authelia)
| NetBox setting | value | why |
|---|---|---|
| `header` | `HTTP_REMOTE_USER` | matches Authelia's `Remote-User` |
| `groupHeader` | `HTTP_REMOTE_GROUPS` | NetBox defaults to `HTTP_REMOTE_USER_GROUP`, which Authelia never sends |
| `groupSeparator` | `,` | Authelia joins groups with a comma; NetBox defaults to `\|`, which would yield **one** group literally named `a,b,c` |
| `userEmail` | `HTTP_REMOTE_EMAIL` | Authelia sends `Remote-Email` |
Authelia has no split given/family name — only `Remote-Name` — so the first/last-name
headers are deliberately left unmapped.
### ⚠ Trust boundary — read before changing anything here
`RemoteUserBackend` trusts the header **unconditionally**; NetBox has no trusted-proxy
allowlist. Two things keep that safe and **both** must stay true:
1. **Envoy overrides the headers.** `headersToBackend` replaces any client-supplied
`Remote-User` with Authelia's verdict rather than merging it.
2. **`networkpolicy.yaml` blocks bypass.** Without it, any pod could hit
`netbox.netbox.svc:8080` directly with `Remote-User: admin` and be superuser.
Verified by test: a pod in `default` sending that header gets **connection refused**;
the same request from `envoy-gateway-system` is served. `failOpen: false`, so if Authelia
is down traffic is refused rather than admitted unauthenticated.
Access is further restricted in `authelia/values.yaml` to `group:netbox-admins` —
otherwise any AD account that can pass 2FA would be auto-provisioned a NetBox user.
**Local login remains as break-glass.** `AUTHENTICATION_BACKENDS` appends
`ObjectPermissionBackend`, which subclasses Django's `ModelBackend`, so the `admin`
password still works — reachable via `kubectl port-forward`, since the gateway
intercepts everything else.
## Prerequisites (already done)
```bash
# Dedicated Postgres role + database on the shared CNPG cluster
POD=$(kubectl -n shared-db get pods -l cnpg.io/instanceRole=primary -o jsonpath='{.items[0].metadata.name}')
kubectl -n shared-db exec "$POD" -c postgres -- psql -U postgres -v ON_ERROR_STOP=1 \
-c "CREATE ROLE netbox LOGIN PASSWORD '<see secret.yaml>'" \
-c "CREATE DATABASE netbox OWNER netbox"
```
There is **no OIDC client** for NetBox — forward-auth needs no client credential. What
must exist instead:
- AD group **`netbox-admins`** (`samba_ad_groups` in `../../infrastructure/samba-ad`), applied with
`ansible-playbook provision-dc.yml --tags directory,accounts`
- DNS A record **`netbox`** → `192.168.10.127` (`samba_ad_extra_a_records`), applied with
`ansible-playbook provision-dc.yml --tags dns`
- the `ext-authz` endpoint + access-control rule in `authelia/values.yaml`, and the
`ReferenceGrant` in `authelia/referencegrant-extauth.yaml`
## Deploy
```bash
helm repo add netbox https://netbox-community.github.io/netbox-chart/ && helm repo update netbox
kubectl apply -f netbox/namespace.yaml -f netbox/secret.yaml
# Authelia: ext-authz endpoint + access-control rule + cross-namespace grant
kubectl apply -f authelia/referencegrant-extauth.yaml
helm upgrade authelia authelia/authelia --version 0.11.6 -n authelia -f authelia/values.yaml
kubectl apply -f netbox/networkpolicy.yaml
helm upgrade --install netbox netbox/netbox --version 8.3.38 -n netbox -f netbox/values.yaml
kubectl apply -f netbox/securitypolicy.yaml # after the HTTPRoute it targets exists
```
> `authelia/values.yaml` is the *only* copy of Authelia's config. Before upgrading it,
> confirm it still matches the live release — `helm get values authelia -n authelia -o yaml`
> and compare **structurally** (parse both to YAML and diff keys); a textual diff is
> useless because `helm get values` sorts keys and strips comments.
## Verify
```bash
kubectl -n netbox rollout status deploy/netbox deploy/netbox-worker
kubectl -n netbox get pods,pvc,httproute,securitypolicy
# Unauthenticated request must be intercepted: 302 -> auth.ddupan.top
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' https://netbox.ad.ddupan.top/
# Trust boundary: this MUST fail (connection refused)
kubectl run spoof -n default --image=busybox:1.38.0 --restart=Never --rm -i -- \
wget -q -T 8 -O- --header='Remote-User: admin' http://netbox.netbox.svc.cluster.local/
# Local admin password (break-glass)
kubectl -n netbox get secret netbox-superuser -o jsonpath='{.data.password}' | base64 -d
# API reachable + DB migrated
kubectl -n netbox exec deploy/netbox -- curl -sf localhost:8080/api/status/ | head -c 400
```
Then browse <https://netbox.ad.ddupan.top>. Authelia intercepts, you authenticate with
2FA, and NetBox provisions the user on arrival — **already a superuser**, because
`netbox-admins` is in `REMOTE_AUTH_SUPERUSER_GROUPS`. No manual promotion step.
## Two API gotchas that will bite integrations
**Forward-auth blocks the API unless you carve it out.** Authelia intercepts every
request, including API calls carrying a valid NetBox token — it has no idea what a NetBox
token is, sees no session cookie, and 302s the caller to the login portal. Fixed with a
`bypass` rule for `^/api/` and `^/graphql/` in `authelia/values.yaml`, which must come
**before** the `two_factor` rule (Authelia is first-match-wins). This is not
unauthenticated access: NetBox's own token auth still gates those paths and an anonymous
call returns 403. Verified: token → 200, no token → 403, browser → 302.
**`Prefix.site` is silently ignored on NetBox 4.2+.** It was replaced by a generic scope
(`scope_type`/`scope_id`). Posting `site` returns **200 with the field dropped** — no
error — so a seed script looks like it worked while every prefix ends up unscoped, and an
idempotency check then re-patches forever. `VLAN` still uses `site`, so the two are
inconsistent. This is what made the first `seed.py` run non-idempotent.
## API tokens: `superuser.apiToken` is a dead value on NetBox 4.6
The chart still has a `superuser.apiToken` value (defaulting to a random UUID) and stores
it in the `netbox-superuser` Secret, but **no token is created** — verified:
`Token.objects.count()` was 0 after a clean bootstrap, and using that value returns
`403 {"detail":"Invalid v1 token"}`.
NetBox 4.5 introduced v2 tokens, which are stored only as a salted HMAC digest keyed by
`API_TOKEN_PEPPERS` and are the default for new tokens. Legacy v1 tokens (plaintext,
`Token <value>` header) are deprecated and go away in v5.0. So the secret's `api_token`
key should be treated as **stale, not a credential**.
Mint a real one in the UI, or from the CLI — note the plaintext is available only at
creation, and the header prefix is `Bearer nbt_<key>.`:
```bash
kubectl -n netbox exec deploy/netbox -c netbox -- /opt/netbox/venv/bin/python \
/opt/netbox/netbox/manage.py shell -c "
from users.models import Token, User
t = Token(user=User.objects.get(username='admin'), description='automation')
t.save()
print(t.get_auth_header_prefix() + t.token)"
```
This matters for the evaluation: any generation/sync tooling (§4 of `CONTEXT.md`)
authenticates this way, and `API_TOKEN_PEPPERS` becomes state that must survive — the
chart auto-generates pepper `1` and preserves it across upgrades via a `lookup`, so
**never clear that Secret or every issued token dies.**
**v2 tokens do NOT break the ecosystem** — an earlier version of this file claimed they
did; that was wrong and is corrected here. NetBox dispatches on the token *value* (an
`nbt_` prefix means v2), **not** the header scheme, so the same v2 token is accepted as
both `Authorization: Bearer <tok>` and `Authorization: Token <tok>` — verified, both 200.
The `"Invalid v1 token"` 403 above happens because the chart's UUID is not a token at all,
not because of the scheme. Tested working against this instance with a v2 token:
| integration | version | result |
|---|---|---|
| `netbox.netbox` modules (write) | 3.23.0 | ✅ created an object |
| `netbox.netbox.nb_inventory` (read) | 3.23.0 | ✅ grouped devices by role (needs `pytz`) |
| `e-breuninger/netbox` Terraform provider | **5.7.0** | ✅ read live prefix data |
Two traps when wiring them up:
- The Terraform provider's `server_url` must be the **base URL without `/api`**; passing
`.../api` yields a confusing go-openapi error
(`... is not supported by the TextConsumer`). Provider **v4.x fails against NetBox 4.6**
regardless — pin `~> 5.0`.
- `nb_inventory` needs `pytz` in the Ansible environment
(`uv tool install ansible-core --with ansible --with paramiko --with pytz`), and the
plugin must be enabled (`enable_plugins = netbox.netbox.nb_inventory`).
## Known caveats
- **Reaching NetBox no longer needs the WAN, but logging in still does.**
`netbox.ad.ddupan.top` is resolved by the DC and served on the LAN. However
`auth.ddupan.top` resolves publicly and routes back in through the cloudflared tunnel,
so the OIDC round-trip still crosses the flaky ISP link (`CONTEXT.md` §6) — the same
exposure Grafana has. The local `admin` account is the fallback when the WAN is down.
Closing this means giving Authelia an internal HTTPS name, which changes the **issuer**
and therefore touches Gitea, Grafana and OpenBao as registered clients — a decision to
take once for all services, not per service.
- `releaseCheck.url` is blanked so NetBox never blocks a page render on `api.github.com`.
- One k3s node, no HA: `valkey.architecture: standalone`, one web pod, one worker.
- Resource presets (`medium` web / `small` worker) are sized for the laptop's headroom at
survey time (~4 GiB free), not for throughput.
## Teardown
```bash
helm uninstall netbox -n netbox
kubectl delete ns netbox # also drops the media + valkey PVCs
POD=$(kubectl -n shared-db get pods -l cnpg.io/instanceRole=primary -o jsonpath='{.items[0].metadata.name}')
kubectl -n shared-db exec "$POD" -c postgres -- psql -U postgres \
-c "DROP DATABASE netbox" -c "DROP ROLE netbox"
# then remove the netbox access_control rule from authelia/values.yaml, and drop
# `netbox` from the ReferenceGrant's `from` list in authelia/referencegrant-extauth.yaml
helm upgrade authelia authelia/authelia --version 0.11.6 -n authelia -f authelia/values.yaml
kubectl apply -f authelia/referencegrant-extauth.yaml
```
The `netbox-admins` AD group and the `netbox` A record stay in `../../infrastructure/samba-ad` unless you
remove them there too; both are harmless if left.
## Data model
`terraform/topology.yml` holds the §3 topology; the Terraform root reads it with
`yamldecode` and applies it. **Direction is git → NetBox**: the YAML is authoritative,
NetBox is a derived mirror. That settles `CONTEXT.md` §4 the way the rest of the repo
works — a UI you must click to change routing would be a regression.
Terraform (not a script) because of the repo's own boundary — *Terraform owns API-level
configuration* (`../../infrastructure/openbao/terraform` is the precedent) — and because it brings the one
thing the previous hand-rolled `seed.py` could never do: **deletion**. Remove an entry from
the YAML and `terraform apply` removes the object. `plan` doubles as a drift report.
```bash
cd netbox/terraform
export TF_VAR_netbox_token=... # or terraform.tfvars
terraform init
terraform plan -parallelism=2 # see below re: parallelism
terraform apply -parallelism=2
uv run --with requests --with pyyaml python attach-wireless.py
```
Acceptance test, same spirit as the Ansible here:
`No changes. Your infrastructure matches the configuration.`
### Adopting an already-populated NetBox
`gen-imports.py` writes `imports.tf` from the live API so Terraform **adopts** existing
objects instead of failing on uniqueness. It reads the same `topology.yml`, so resource
addresses and `for_each` keys line up by construction. One-shot — delete `imports.tf` after
the first successful apply.
`netbox_device_primary_ip` is deliberately **not** imported: the provider has no importable
object at the device ID. Letting Terraform "create" it just re-PATCHes `primary_ip4` to the
value it already holds.
### ⚠ Terraform cannot do the Wi-Fi wiring
`netbox_device_interface` has **no `rf_role` and no `wireless_lans`** attribute — checked
against the provider schema — and `netbox.netbox`'s module has the identical gap. So
neither official tool can attach an SSID to a radio. `attach-wireless.py` does that last
mile from the same `topology.yml`; it is idempotent and has a `--check` mode. If a future
provider release adds those attributes, delete it and fold the fields into `main.tf`.
### Four things that cost time here
- **Pin the provider `~> 5.0`.** v4.3.1 fails against NetBox 4.6 at configure time.
- **`server_url` must NOT include `/api`** — same misleading go-openapi error if it does.
- **`-parallelism=2`.** At the default 10 the single NetBox pod times out reads
(`context deadline exceeded`) during import.
- **Pin NetBox's own defaults or fight them forever.** The provider defaults
`vm_role`/`is_full_depth` opposite to NetBox, and a VM's `site_id` is *derived from its
cluster* — leave any of them unset and every plan shows phantom changes.
Loaded: 1 site · 6 prefixes (+roles) · 2 VLANs in group `lab` · the DHCP pool as an
IPRange · 3 wireless LANs · 7 devices · 4 VMs in 2 clusters · interfaces with addresses,
MACs and primary IPs.
Hardware fields (model, serial, CPU/RAM) are **read from `dmidecode`**, not guessed — that
corrected three earlier assumptions: pve1 is a NUC6i3**SYB** board with an i3-6100**U**
(and reports a *blank* system serial, because the OEM never programmed DMI), and pve2/pve3
are Lenovo machine-type `10VGCTO1WW`. NetBox has no native CPU/RAM field for Devices (only
VMs get `vcpus`/`memory`), so that detail lives in `comments`; custom fields would be the
right answer if it ever needs to be queryable.
Two modelling choices worth knowing:
- **The laptop's Broadcom BCM4360 is an `inventory_item`, not an interface.** `b43`/`bcma`
claim the PCI device but cannot drive BCM4360 (it needs proprietary `broadcom-sta`/`wl`
with those modules blacklisted), so there is no netdev — an Interface would imply a
capability the OS does not have.
- **`ap-buffalo` was identified, not assumed**: MAC OUI `d4:2c:46` = BUFFALO.INC, model
`WSR-1800AX4S` from its login page, and Buffalo's factory SSID suffix `07B0` matches the
tail of that same MAC.
### ⚠ Two things the data now surfaces
1. **`ap-buffalo` holds `192.168.10.10` — the first address of the DHCP pool** (`.10`–`.250`).
Either it is a lease that can move, or a static overlapping the pool. Fix by moving the
AP below `.10` or starting the pool at `.11`; NetBox shows the collision but cannot
resolve it.
2. **Objects created before the Terraform conversion are not in state**, so Terraform will
not reconcile them: the guessed device types (`nuc6i3syh`, `thinkcentre-mini`,
`Laptop`) and the `Generic` manufacturer are orphans needing one manual prune. Anything
added *since* is managed — removing it from `topology.yml` now deletes it, which is
exactly what the old `seed.py` could not do.
### Wi-Fi
Three SSIDs off one AP, all bridged **untagged onto the flat LAN** (no `vlan` set) — the
Wi-Fi is not a separate segment; clients get an IX DHCP lease like anything else.
| SSID | radio | auth |
|---|---|---|
| `Buffalo-A-07B0-WPA3` | 5 GHz | WPA3-SAE |
| `Buffalo-A-07B0` | 5 GHz | WPA2-PSK (compat) |
| `Buffalo-G-07B0` | 2.4 GHz | WPA2-PSK |
**NetBox cannot express WPA3.** `auth_type` offers only
`open`/`wep`/`wpa-personal`/`wpa-enterprise`, so WPA3-SAE and WPA2-PSK both store as
`wpa-personal` and the real difference survives only in the description.
`auth_psk` is deliberately empty — NetBox *can* hold the passphrase, but the house Wi-Fi
key does not belong in a system whose backup story is untested when OpenBao is right there.
## Verdict: it can generate the config that matters
`CONTEXT.md` §8 step 5 named the one genuinely error-prone duplication — the VyOS
`area 0 network` list plus interface addresses, where forgetting a line is *silent*
(the subnet exists, has a gateway, and is unreachable from anywhere else).
`generate/vyos-ospf.py` derives it from NetBox and diffs against the live router:
```
$ uv run --with requests python netbox/generate/vyos-ospf.py --diff
matched=8 generated_only=0 live_only=0
```
**Exact match, zero drift.** The derivation rules are the valuable part:
| output | rule |
|---|---|
| interface addresses | every IP assigned to a `vyos-rtr` interface |
| `area 0 network` | every prefix the router **has an interface in** |
| `passive` | interfaces whose prefix has role `sdn` |
The second rule was wrong on the first attempt and the diff caught it. "The SDN prefixes"
omits `192.168.10.0/24` — but the LAN must be in area 0 or VyOS forms no adjacency with
the IX or the laptop and advertises nothing at all. Deriving from *where the router
actually has an address* yields the LAN for free and cannot forget a future VNet, which
is precisely the failure mode NetBox is supposed to prevent.
### Second consumer: AD DNS records (CONTEXT.md §4 item 3)
`generate/samba-a-records.py` derives `samba_ad_extra_a_records` from NetBox and diffs the
hand-written list in `../../infrastructure/samba-ad`:
```
in both=5 netbox only=3 vars.yml only=1
```
**Which** hosts get a record is intent, not a derived fact — domain-joined machines
self-register, so "every IP in the LAN prefix" would be wrong. The selector is NetBox's
native `dns_name` field on the IP address; set it and the host gets a record.
The diff paid for itself immediately: it found **`retrolab` (10.60.0.10) had a live AD DNS
record but was missing from NetBox entirely** — verified up and now modelled. That is the
duplication-detection the whole exercise is meant to provide.
Out of scope by design: service/ingress names like `netbox.ad.ddupan.top`, which point at
the k3s gateway rather than a host. Several such names share one address and `dns_name` is
single-valued per IP, so they stay hand-managed. This tool owns **host** records only.
**So it earns its place on this one case.** Honest caveats before adopting further:
- **This is one generator against one router.** The other consumers in `CONTEXT.md` §4
(the PVE SDN VNet list, `samba_ad_extra_a_records`, `nb_inventory`) are unproven.
- **NetBox demands ceremony.** Recording "pve1 is 192.168.10.4" first requires a
manufacturer, a device type, a device role and a site. For ~10 hosts that is real
overhead the YAML does not have.
- **Nothing consumes it yet.** Until a role actually reads from NetBox, this is a second
copy of the truth — the very duplication it is meant to remove. The next real step is
wiring `generate/vyos-ospf.py` into `proxmox/ansible/roles/vyos_router` so the template
has one source, not two.
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""Generate samba_ad_extra_a_records from NetBox.
uv run --with requests --with pyyaml python netbox/generate/samba-a-records.py [--diff]
CONTEXT.md §4 item 3: the AD DNS A records for non-domain-joined hosts are hand-listed in
../../../infrastructure/samba-ad/ansible/group_vars/all/vars.yml, duplicating addresses that already live in
NetBox. This derives them instead.
SOURCE OF TRUTH FOR *WHICH* HOSTS: the `dns_name` field on the NetBox IP address. That is
intent, not a derived fact — domain-joined machines register themselves in AD DNS and must
NOT get a static record, so "every IP in the LAN prefix" would be wrong. An address gets a
record iff someone set dns_name on it.
Deliberately NOT handled: service/ingress names such as netbox.ad.ddupan.top, which point
at the k3s gateway rather than at a host. Several of those share one address, and NetBox's
dns_name is single-valued per IP, so they stay hand-managed in vars.yml. This tool only
owns HOST records.
Read-only: prints YAML and diffs. It never writes to the DC — ../../../infrastructure/samba-ad applies it
(`ansible-playbook provision-dc.yml --tags dns`).
"""
from __future__ import annotations
import ipaddress
import json
import os
import re
import sys
import urllib.request
from pathlib import Path
ZONE = "ad.ddupan.top"
VARS = Path(__file__).parents[3] / "infrastructure/samba-ad/ansible/group_vars/all/vars.yml"
envfile = Path(__file__).parent.parent / "terraform" / ".env"
if envfile.exists():
for line in envfile.read_text().splitlines():
if line.strip() and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip())
BASE = os.environ.get("NETBOX_URL", "https://netbox.ad.ddupan.top").rstrip("/") + "/api"
TOKEN = os.environ.get("NETBOX_TOKEN", "")
if not TOKEN:
sys.exit("NETBOX_TOKEN not set (expected in netbox/terraform/.env)")
def get(path: str):
req = urllib.request.Request(BASE + path, headers={"Authorization": f"Bearer {TOKEN}"})
with urllib.request.urlopen(req, timeout=30) as r:
return json.load(r)
def generate() -> list[dict]:
out = []
for a in get("/ipam/ip-addresses/?limit=500")["results"]:
dns = (a.get("dns_name") or "").strip().lower()
if not dns.endswith(f".{ZONE}"):
continue
name = dns[: -len(f".{ZONE}")]
ip = str(ipaddress.ip_interface(a["address"]).ip)
out.append({"name": name, "ip": ip})
# Stable order so the diff is meaningful rather than churn.
return sorted(out, key=lambda r: ipaddress.ip_address(r["ip"]))
def current() -> list[dict]:
"""Parse the existing hand-written list without pulling in the whole vars file."""
if not VARS.exists():
return []
txt = VARS.read_text()
m = re.search(r"^samba_ad_extra_a_records:\s*$(.*?)(?=^\S)", txt, re.S | re.M)
if not m:
return []
return [{"name": n, "ip": i}
for n, i in re.findall(r'name:\s*"([^"]+)".*?ip:\s*"([^"]+)"', m.group(1))]
if __name__ == "__main__":
gen = generate()
if "--diff" not in sys.argv:
print("samba_ad_extra_a_records:")
for r in gen:
print(f' - {{ name: "{r["name"]}", ip: "{r["ip"]}" }}')
sys.exit(0)
have = {(r["name"], r["ip"]) for r in current()}
want = {(r["name"], r["ip"]) for r in gen}
for n, i in sorted(want | have, key=lambda x: ipaddress.ip_address(x[1])):
mark = " " if (n, i) in want and (n, i) in have else ("+ " if (n, i) in want else "- ")
print(f"{mark}{n:<10} {i}")
print(f"\nin both={len(want & have)} netbox only={len(want - have)} vars.yml only={len(have - want)}")
print("\n+ = NetBox has it, vars.yml does not - = hand-listed, not derivable from NetBox")
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""Generate the VyOS interface + OSPF config from NetBox.
uv run --with requests python netbox/generate/vyos-ospf.py [--diff]
This is the ONE case CONTEXT.md §8 step 5 nominates as worth automating: today the VyOS
interface addresses, the `area 0 network` list and the PVE SDN VNets must be kept in sync
BY HAND, and forgetting the OSPF line is silent — the subnet exists, has a gateway, and
is simply unreachable from anywhere else.
DERIVATION RULES (these are the interesting part, not the code):
addresses every IP assigned to a vyos-rtr interface
area 0 every prefix that vyos-rtr HAS AN INTERFACE IN
passive interfaces whose prefix has role `sdn`
The second rule matters. The obvious rule — "the SDN prefixes" — is WRONG and was caught
by diffing against the live router: it omits 192.168.10.0/24, but the LAN must be in
area 0 or VyOS has no adjacency with the NEC IX or the laptop and nothing is advertised
at all. Deriving from "where does this router actually have an address" produces the LAN
for free and cannot forget a future VNet.
The third rule is why eth0 is NOT passive: it is the only interface that must form
adjacencies. eth1/eth2 face guests and are advertised without peering.
Read-only. It prints config; it does not touch the router.
"""
from __future__ import annotations
import ipaddress
import json
import os
import subprocess
import sys
import urllib.request
from pathlib import Path
ROUTER = "vyos-rtr"
envfile = Path(__file__).parent.parent / "terraform" / ".env"
if envfile.exists():
for line in envfile.read_text().splitlines():
if line.strip() and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
os.environ.setdefault(k, v)
BASE = os.environ.get("NETBOX_URL", "").rstrip("/") + "/api"
TOKEN = os.environ.get("NETBOX_TOKEN", "")
if not TOKEN:
sys.exit("NETBOX_TOKEN not set (expected in netbox/terraform/.env)")
def get(path: str):
req = urllib.request.Request(BASE + path, headers={"Authorization": f"Bearer {TOKEN}"})
with urllib.request.urlopen(req, timeout=30) as r:
return json.load(r)
def generate() -> list[str]:
vm = get(f"/virtualization/virtual-machines/?name={ROUTER}")["results"]
if not vm:
sys.exit(f"{ROUTER} not found in NetBox")
prefixes = get("/ipam/prefixes/?limit=200")["results"]
ifaces = sorted(get(f"/virtualization/interfaces/?virtual_machine_id={vm[0]['id']}")["results"],
key=lambda i: i["name"])
lines, areas, passive = [], [], []
for iface in ifaces:
for addr in get(f"/ipam/ip-addresses/?vminterface_id={iface['id']}")["results"]:
lines.append(f"set interfaces ethernet {iface['name']} address '{addr['address']}'")
ip = ipaddress.ip_interface(addr["address"]).ip
# The prefix this address sits in == a network this router participates in.
for p in prefixes:
if ip in ipaddress.ip_network(p["prefix"]):
if p["prefix"] not in areas:
areas.append(p["prefix"])
if (p.get("role") or {}).get("slug") == "sdn":
passive.append(iface["name"])
break
# Keep the LAN first: it is the transit network, and reading the config that way
# matches how the adjacency is reasoned about.
areas.sort(key=lambda p: (not p.startswith("192.168."), p))
lines += [f"set protocols ospf area 0 network '{p}'" for p in areas]
lines += [f"set protocols ospf interface {i} passive" for i in sorted(set(passive))]
return lines
def live() -> list[str]:
out = subprocess.run(
["ssh", "-o", "ConnectTimeout=8", "-o", "BatchMode=yes", "[email protected]",
"/opt/vyatta/bin/vyatta-op-cmd-wrapper show configuration commands"],
capture_output=True, text=True, timeout=60).stdout
return [l.strip() for l in out.splitlines()
if ("ospf area" in l or "ospf interface" in l or
("ethernet eth" in l and "address" in l))]
if __name__ == "__main__":
gen = generate()
if "--diff" not in sys.argv:
print("\n".join(gen))
sys.exit(0)
have = live()
only_live = [l for l in have if l not in gen]
only_gen = [l for l in gen if l not in have]
for l in gen:
print((" " if l in have else "+ ") + l)
for l in only_live:
print("- " + l)
print(f"\nmatched={len(gen) - len(only_gen)} generated_only={len(only_gen)} live_only={len(only_live)}")
sys.exit(1 if (only_gen or only_live) else 0)
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: netbox
+42
View File
@@ -0,0 +1,42 @@
# Half of the trust boundary for header-based auth (the other half is Envoy
# overriding client-supplied Remote-* headers — see securitypolicy.yaml).
#
# WHY THIS IS NOT OPTIONAL: NetBox's RemoteUserBackend trusts HTTP_REMOTE_USER
# unconditionally; there is no trusted-proxy allowlist in NetBox. Envoy sanitises the
# header, but Envoy only sees traffic that goes THROUGH it. Any pod in the cluster
# could otherwise open a connection straight to netbox.netbox.svc:8080, send
# `Remote-User: admin`, and be a superuser. This policy removes that path.
#
# k3s enforces NetworkPolicy (kube-router backend), so this is a real control, not
# decoration.
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: netbox-ingress-gateway-only
namespace: netbox
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: netbox
app.kubernetes.io/instance: netbox
policyTypes:
- Ingress
ingress:
# Only Envoy may reach the app port.
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: envoy-gateway-system
ports:
- protocol: TCP
port: 8080
# kubelet probes come from the node itself, outside any namespace, so they are not
# matched by a namespaceSelector. Without this the pod fails its readiness probe
# and is pulled from the Service.
- from:
- ipBlock:
cidr: 192.168.10.127/32
ports:
- protocol: TCP
port: 8080
+16
View File
@@ -0,0 +1,16 @@
# Template for netbox/secret.yaml (which is gitignored). Copy, fill in, apply.
#
# Only the database password lives here. NetBox holds no OIDC client secret: it
# authenticates via Authelia forward-auth at the gateway (securitypolicy.yaml), so
# there is no client credential for this app to keep.
apiVersion: v1
kind: Secret
metadata:
name: netbox-secrets
namespace: netbox
type: Opaque
stringData:
# Password for the dedicated `netbox` role on the shared CNPG cluster.
# Must match what was granted in shared-db (see README "Prerequisites").
# Consumed via externalDatabase.existingSecretName/Key in values.yaml.
postgresql-password: "REPLACE_WITH_DB_PASSWORD"
+68
View File
@@ -0,0 +1,68 @@
# Authelia forward-auth in front of NetBox, enforced at the gateway.
#
# This is what makes AD-group -> NetBox-role mapping possible at all: NetBox has no
# SSO group mapping, but it does have header-based group sync, and this supplies the
# headers from a source the app can trust (see netbox/values.yaml remoteAuth).
#
# Flow: browser -> Envoy -> (extAuth) Authelia -> 200 + Remote-* headers -> NetBox.
# On 401/403 Authelia redirects to https://auth.ddupan.top and back.
---
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: SecurityPolicy
metadata:
name: netbox-authelia
namespace: netbox
spec:
# Targets the HTTPRoute the chart generates, so the policy applies to exactly the
# traffic that reaches NetBox and nothing else on the shared gateway.
targetRefs:
- group: gateway.networking.k8s.io
kind: HTTPRoute
name: netbox
extAuth:
# HTTP, not gRPC. This is the whole reason the gateway is Envoy Gateway and not
# Contour: Contour supports only the gRPC ext_authz protocol, and Authelia
# implements the HTTP ExtAuthz filter.
http:
backendRefs:
- name: authelia
namespace: authelia # allowed by ../authelia/referencegrant-extauth.yaml
# The SERVICE port, not the container port. The Authelia chart publishes
# port 80 -> targetPort http (9091); referencing 9091 here is rejected with
# "TCP Port 9091 not found on service authelia/authelia".
port: 80
# Authelia's ExtAuthz endpoint. The ORIGINAL request path is appended to this
# prefix, which is how Authelia learns what was being requested.
path: /api/authz/ext-authz/
# Headers Envoy copies from Authelia's response ONTO the upstream request.
# NOTE this belongs to the `http` service block, not to `extAuth` — the API
# rejects it one level up (headersToBackend is a field of HTTPExtAuthService).
#
# SECURITY: "coexisting headers will be overridden" (Envoy Gateway API docs) —
# a client-supplied Remote-User is replaced by Authelia's verdict, not merged.
# This list is the entire trust boundary; do not add anything NetBox reads for
# authorization that Authelia does not itself vouch for.
headersToBackend:
- Remote-User
- Remote-Groups
- Remote-Email
- Remote-Name
# Headers Envoy forwards TO Authelia. Without cookie there is no session and every
# request bounces to the portal; without the X-Forwarded-* trio Authelia cannot
# reconstruct the original URL and answers 400.
headersToExtAuth:
- cookie
- authorization
- proxy-authorization
- accept
- x-forwarded-proto
- x-forwarded-host
- x-forwarded-uri
- x-forwarded-for
- x-original-method
# Fail CLOSED. If Authelia is down, refuse traffic rather than admit unauthenticated
# requests to a service whose entire auth model is "trust the header".
failOpen: false
+8
View File
@@ -0,0 +1,8 @@
# Local state and real credentials stay out of git (same as the other TF roots here).
*.tfstate
*.tfstate.*
.terraform/
.terraform.lock.hcl
terraform.tfvars
# Real API token — mint with the snippet in ../README.md
.env
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Attach SSIDs to radio interfaces and set rf_role — the part Terraform cannot express.
uv run --with requests --with pyyaml python netbox/terraform/attach-wireless.py [--check]
WHY THIS EXISTS. Terraform creates the WirelessLAN objects (netbox_wireless_lan) and the
radio interfaces, but `netbox_device_interface` has NO attribute for either:
* rf_role (ap / station)
* wireless_lans (which SSIDs this radio broadcasts)
Verified against the provider schema for e-breuninger/netbox 5.7.0 — and the
netbox.netbox Ansible collection 3.23.0 has the same gap in netbox_device_interface.
So neither official tool can do this; a few lines of API call is the honest fallback
rather than dropping the data.
Idempotent, and reads the same topology.yml Terraform does, so there is one source of
truth. Run it after `terraform apply`. If a future provider release grows these
attributes, delete this file and move the fields into main.tf.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
import requests
import yaml
HERE = Path(__file__).parent
CHECK = "--check" in sys.argv
for envfile in (HERE / ".env", HERE / "../seed/.env"):
if envfile.exists():
for line in envfile.read_text().splitlines():
if line.strip() and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip())
URL = os.environ.get("NETBOX_URL", "https://netbox.ad.ddupan.top").rstrip("/")
TOKEN = os.environ.get("NETBOX_TOKEN", "")
if not TOKEN:
sys.exit("NETBOX_TOKEN not set (expected in netbox/terraform/.env)")
S = requests.Session()
S.headers.update({"Authorization": f"Bearer {TOKEN}"})
def get(path: str, **params):
r = S.get(f"{URL}/api{path}", params=params, timeout=30)
r.raise_for_status()
return r.json()
def main() -> int:
topo = yaml.safe_load((HERE / "topology.yml").read_text())
changed = 0
# ssid -> id, resolved once
lans = {w["ssid"]: w["id"] for w in get("/wireless/wireless-lans/", limit=200)["results"]}
for dev in topo["devices"]:
for iface in dev.get("interfaces", []):
want_ssids = iface.get("wireless_lans")
want_role = iface.get("rf_role")
if not want_ssids and not want_role:
continue
found = get("/dcim/interfaces/", device=dev["name"], name=iface["name"])["results"]
if not found:
print(f" ! {dev['name']}:{iface['name']} not in NetBox — run terraform apply first")
continue
cur = found[0]
patch = {}
if want_role and (cur.get("rf_role") or {}).get("value") != want_role:
patch["rf_role"] = want_role
if want_ssids:
have = sorted(w["id"] for w in (cur.get("wireless_lans") or []))
missing = [s for s in want_ssids if s not in lans]
if missing:
print(f" ! SSID(s) not in NetBox: {missing} — run terraform apply first")
continue
want = sorted(lans[s] for s in want_ssids)
if have != want:
patch["wireless_lans"] = want
if not patch:
continue
changed += 1
if CHECK:
print(f" ~ would patch {dev['name']}:{iface['name']}: {list(patch)}")
else:
r = S.patch(f"{URL}/api/dcim/interfaces/{cur['id']}/", json=patch, timeout=30)
r.raise_for_status()
print(f" ~ patched {dev['name']}:{iface['name']}: {list(patch)}")
print(f"changed={changed}" + (" (check mode)" if CHECK else ""))
if changed == 0:
print("idempotent: nothing to do")
return 0
if __name__ == "__main__":
sys.exit(main())
+40
View File
@@ -0,0 +1,40 @@
# Remote state in SeaweedFS S3, on the LAN.
#
# WHY remote at all: local state means the only copy lives on this laptop, which is
# also the k3s node, the NFS server and the libvirt host — i.e. the single point of
# failure. It also cannot be locked, so two concurrent applies silently corrupt it.
#
# WHY s3.ad.ddupan.top and NOT obj.ddupan.top: the public name resolves to
# Cloudflare and hairpins through the WAN. On 2026-07-28 that path was blackholed
# for hours by a dead VPN tunnel. State must be reachable when the WAN is not —
# it is what you need DURING an incident. See ../../seaweedfs/httproute-s3.yaml.
#
# CREDENTIALS are not in this file. Export them before running terraform:
# export AWS_ACCESS_KEY_ID=$(bao kv get -field=... kv/k8s/seaweedfs-s3) # see README
# export AWS_SECRET_ACCESS_KEY=...
# The `terraform` S3 identity is scoped to this bucket only — it deliberately
# cannot create buckets or read anything else in the store.
terraform {
backend "s3" {
bucket = "tfstate"
key = "netbox/terraform.tfstate"
endpoints = {
s3 = "https://s3.ad.ddupan.top"
}
# SeaweedFS is not AWS: it has no regions, no IAM, no metadata service and no
# account IDs, so every AWS-specific validation has to be skipped or the
# provider fails before it ever talks to the endpoint.
region = "us-east-1"
use_path_style = true
skip_credentials_validation = true
skip_metadata_api_check = true
skip_region_validation = true
skip_requesting_account_id = true
# Native S3 locking (Terraform >= 1.10; this repo runs 1.15). Writes a
# .tflock object alongside the state — no DynamoDB table needed.
use_lockfile = true
}
}
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""Generate imports.tf so Terraform ADOPTS the objects already in NetBox.
uv run --with requests --with pyyaml python netbox/terraform/gen-imports.py
Run once, when converting an already-populated NetBox to Terraform management. Without it
the first `terraform apply` tries to CREATE objects that exist and fails on uniqueness.
This is a one-shot bootstrap, not part of the normal loop: once `terraform apply` has run,
state holds the IDs and imports.tf can be deleted. Same intent as
../../../infrastructure/openbao/terraform/imports.tf.
It reads the same topology.yml Terraform does, so the resource addresses and for_each keys
line up by construction rather than by hand-transcription.
"""
from __future__ import annotations
import os
import sys
import urllib.parse
from pathlib import Path
import requests
import yaml
HERE = Path(__file__).parent
for envfile in (HERE / "../seed/.env", HERE / ".env"):
if envfile.exists():
for line in envfile.read_text().splitlines():
if line.strip() and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip())
URL = os.environ.get("NETBOX_URL", "https://netbox.ad.ddupan.top").rstrip("/")
TOKEN = os.environ.get("NETBOX_TOKEN", "")
if not TOKEN:
sys.exit("NETBOX_TOKEN not set")
S = requests.Session()
S.headers.update({"Authorization": f"Bearer {TOKEN}"})
def one(path: str, **params) -> str | None:
r = S.get(f"{URL}/api{path}", params={**params, "limit": 1}, timeout=30)
r.raise_for_status()
res = r.json()["results"]
return str(res[0]["id"]) if res else None
def main() -> int:
t = yaml.safe_load((HERE / "topology.yml").read_text())
out: list[str] = [
"# GENERATED by gen-imports.py — one-shot bootstrap, safe to delete after the first",
"# successful `terraform apply`. Do not hand-edit.",
"",
]
missing: list[str] = []
def emit(addr: str, oid: str | None, what: str):
if oid:
out.append(f'import {{\n to = {addr}\n id = "{oid}"\n}}\n')
else:
missing.append(what)
emit("netbox_site.this", one("/dcim/sites/", slug=t["site"]["slug"]), "site")
emit("netbox_vlan_group.this", one("/ipam/vlan-groups/", slug=t["vlan_group"]["slug"]), "vlan group")
for r in t["prefix_roles"]:
emit(f'netbox_ipam_role.this["{r["slug"]}"]', one("/ipam/roles/", slug=r["slug"]), r["slug"])
for v in t["vlans"]:
emit(f'netbox_vlan.this["{v["vid"]}"]', one("/ipam/vlans/", vid=v["vid"]), f"vlan {v['vid']}")
for p in t["prefixes"]:
emit(f'netbox_prefix.this["{p["prefix"]}"]', one("/ipam/prefixes/", prefix=p["prefix"]), p["prefix"])
for r in t["ip_ranges"]:
emit(f'netbox_ip_range.this["{r["start"]}-{r["end"]}"]',
one("/ipam/ip-ranges/", start_address=r["start"], end_address=r["end"]), "ip range")
for w in t["wireless_lans"]:
emit(f'netbox_wireless_lan.this["{w["ssid"]}"]', one("/wireless/wireless-lans/", ssid=w["ssid"]), w["ssid"])
for m in t["manufacturers"]:
emit(f'netbox_manufacturer.this["{m["slug"]}"]', one("/dcim/manufacturers/", slug=m["slug"]), m["slug"])
for d in t["device_types"]:
emit(f'netbox_device_type.this["{d["slug"]}"]', one("/dcim/device-types/", slug=d["slug"]), d["slug"])
for r in t["device_roles"]:
emit(f'netbox_device_role.this["{r["slug"]}"]', one("/dcim/device-roles/", slug=r["slug"]), r["slug"])
for c in t["cluster_types"]:
emit(f'netbox_cluster_type.this["{c["slug"]}"]', one("/virtualization/cluster-types/", slug=c["slug"]), c["slug"])
for c in t["clusters"]:
emit(f'netbox_cluster.this["{c["name"]}"]', one("/virtualization/clusters/", name=c["name"]), c["name"])
for d in t["devices"]:
did = one("/dcim/devices/", name=d["name"])
emit(f'netbox_device.this["{d["name"]}"]', did, d["name"])
for i in d["interfaces"]:
key = f'{d["name"]}:{i["name"]}'
iid = one("/dcim/interfaces/", device_id=did, name=i["name"]) if did else None
emit(f'netbox_device_interface.this["{key}"]', iid, key)
if i.get("ip"):
emit(f'netbox_ip_address.device["{key}"]', one("/ipam/ip-addresses/", address=i["ip"]), i["ip"])
# netbox_device_primary_ip is deliberately NOT imported: the provider has no
# importable object at the device ID ("no object exists with the given id").
# Letting Terraform "create" it simply re-PATCHes primary_ip4 to the value it
# already holds, which NetBox treats as a no-op.
if i.get("mac"):
mac = i["mac"].upper()
mid = one("/dcim/mac-addresses/", mac_address=mac)
emit(f'netbox_mac_address.this["{key}"]', mid, mac)
if iid:
emit(f'netbox_device_interface_primary_mac_address.this["{key}"]', iid, f"{key} primary mac")
for it in d.get("inventory_items", []):
key = f'{d["name"]}:{it["name"]}'
emit(f'netbox_inventory_item.this["{key}"]',
one("/dcim/inventory-items/", device_id=did, name=it["name"]) if did else None, key)
for v in t["virtual_machines"]:
vid = one("/virtualization/virtual-machines/", name=v["name"])
emit(f'netbox_virtual_machine.this["{v["name"]}"]', vid, v["name"])
for i in v["interfaces"]:
key = f'{v["name"]}:{i["name"]}'
iid = one("/virtualization/interfaces/", virtual_machine_id=vid, name=i["name"]) if vid else None
emit(f'netbox_interface.this["{key}"]', iid, key)
if i.get("ip"):
emit(f'netbox_ip_address.vm["{key}"]', one("/ipam/ip-addresses/", address=i["ip"]), i["ip"])
(HERE / "imports.tf").write_text("\n".join(out))
n = sum(1 for line in out if line.startswith("import {"))
print(f"wrote imports.tf with {n} import blocks")
if missing:
print(f"NOT FOUND in NetBox (Terraform will create these): {', '.join(missing)}")
return 0
if __name__ == "__main__":
sys.exit(main())
+402
View File
@@ -0,0 +1,402 @@
# GENERATED by gen-imports.py — one-shot bootstrap, safe to delete after the first
# successful `terraform apply`. Do not hand-edit.
import {
to = netbox_site.this
id = "1"
}
import {
to = netbox_vlan_group.this
id = "1"
}
import {
to = netbox_ipam_role.this["lan"]
id = "1"
}
import {
to = netbox_ipam_role.this["sdn"]
id = "2"
}
import {
to = netbox_ipam_role.this["k3s"]
id = "3"
}
import {
to = netbox_ipam_role.this["wan"]
id = "4"
}
import {
to = netbox_vlan.this["100"]
id = "1"
}
import {
to = netbox_vlan.this["110"]
id = "2"
}
import {
to = netbox_prefix.this["192.168.10.0/24"]
id = "1"
}
import {
to = netbox_prefix.this["10.60.0.0/24"]
id = "2"
}
import {
to = netbox_prefix.this["10.61.0.0/24"]
id = "3"
}
import {
to = netbox_prefix.this["10.42.0.0/16"]
id = "4"
}
import {
to = netbox_prefix.this["10.43.0.0/16"]
id = "5"
}
import {
to = netbox_prefix.this["10.1.72.0/24"]
id = "6"
}
import {
to = netbox_ip_range.this["192.168.10.10/24-192.168.10.250/24"]
id = "1"
}
import {
to = netbox_wireless_lan.this["Buffalo-A-07B0-WPA3"]
id = "1"
}
import {
to = netbox_wireless_lan.this["Buffalo-A-07B0"]
id = "2"
}
import {
to = netbox_wireless_lan.this["Buffalo-G-07B0"]
id = "3"
}
import {
to = netbox_manufacturer.this["nec"]
id = "1"
}
import {
to = netbox_manufacturer.this["intel"]
id = "2"
}
import {
to = netbox_manufacturer.this["lenovo"]
id = "3"
}
import {
to = netbox_manufacturer.this["dell"]
id = "5"
}
import {
to = netbox_manufacturer.this["buffalo"]
id = "6"
}
import {
to = netbox_manufacturer.this["yamaha"]
id = "7"
}
import {
to = netbox_manufacturer.this["broadcom"]
id = "8"
}
import {
to = netbox_device_type.this["ix2215"]
id = "1"
}
import {
to = netbox_device_type.this["nuc6i3syb"]
id = "5"
}
import {
to = netbox_device_type.this["10vgcto1ww"]
id = "6"
}
import {
to = netbox_device_type.this["xps-15-9570"]
id = "7"
}
import {
to = netbox_device_type.this["wsr-1800ax4s"]
id = "8"
}
import {
to = netbox_device_type.this["rtx1200"]
id = "9"
}
import {
to = netbox_device_role.this["router"]
id = "1"
}
import {
to = netbox_device_role.this["hypervisor"]
id = "2"
}
import {
to = netbox_device_role.this["core-node"]
id = "3"
}
import {
to = netbox_device_role.this["wireless-ap"]
id = "4"
}
import {
to = netbox_cluster_type.this["proxmox"]
id = "1"
}
import {
to = netbox_cluster_type.this["libvirt"]
id = "2"
}
import {
to = netbox_cluster.this["homelab"]
id = "1"
}
import {
to = netbox_cluster.this["laptop-libvirt"]
id = "2"
}
import {
to = netbox_device.this["ix2215"]
id = "1"
}
import {
to = netbox_device_interface.this["ix2215:GigaEthernet2.0"]
id = "1"
}
import {
to = netbox_ip_address.device["ix2215:GigaEthernet2.0"]
id = "1"
}
import {
to = netbox_device_interface.this["ix2215:GigaEthernet0.0"]
id = "2"
}
import {
to = netbox_device.this["pve1"]
id = "2"
}
import {
to = netbox_device_interface.this["pve1:vmbr0"]
id = "3"
}
import {
to = netbox_ip_address.device["pve1:vmbr0"]
id = "2"
}
import {
to = netbox_device.this["pve2"]
id = "3"
}
import {
to = netbox_device_interface.this["pve2:vmbr0"]
id = "4"
}
import {
to = netbox_ip_address.device["pve2:vmbr0"]
id = "3"
}
import {
to = netbox_device.this["pve3"]
id = "4"
}
import {
to = netbox_device_interface.this["pve3:vmbr0"]
id = "5"
}
import {
to = netbox_ip_address.device["pve3:vmbr0"]
id = "4"
}
import {
to = netbox_device.this["laptop"]
id = "5"
}
import {
to = netbox_device_interface.this["laptop:br0"]
id = "6"
}
import {
to = netbox_ip_address.device["laptop:br0"]
id = "5"
}
import {
to = netbox_inventory_item.this["laptop:BCM4360 802.11ac"]
id = "1"
}
import {
to = netbox_device.this["ap-buffalo"]
id = "6"
}
import {
to = netbox_device_interface.this["ap-buffalo:lan1"]
id = "7"
}
import {
to = netbox_ip_address.device["ap-buffalo:lan1"]
id = "12"
}
import {
to = netbox_mac_address.this["ap-buffalo:lan1"]
id = "1"
}
import {
to = netbox_device_interface_primary_mac_address.this["ap-buffalo:lan1"]
id = "7"
}
import {
to = netbox_device_interface.this["ap-buffalo:wlan-2.4g"]
id = "8"
}
import {
to = netbox_device_interface.this["ap-buffalo:wlan-5g"]
id = "9"
}
import {
to = netbox_device.this["rtx1200"]
id = "7"
}
import {
to = netbox_virtual_machine.this["vyos-rtr"]
id = "1"
}
import {
to = netbox_interface.this["vyos-rtr:eth0"]
id = "1"
}
import {
to = netbox_ip_address.vm["vyos-rtr:eth0"]
id = "6"
}
import {
to = netbox_interface.this["vyos-rtr:eth1"]
id = "2"
}
import {
to = netbox_ip_address.vm["vyos-rtr:eth1"]
id = "7"
}
import {
to = netbox_interface.this["vyos-rtr:eth2"]
id = "3"
}
import {
to = netbox_ip_address.vm["vyos-rtr:eth2"]
id = "8"
}
import {
to = netbox_virtual_machine.this["dc1"]
id = "2"
}
import {
to = netbox_interface.this["dc1:lan"]
id = "4"
}
import {
to = netbox_ip_address.vm["dc1:lan"]
id = "9"
}
import {
to = netbox_virtual_machine.this["winadmin"]
id = "3"
}
import {
to = netbox_interface.this["winadmin:lan"]
id = "5"
}
import {
to = netbox_ip_address.vm["winadmin:lan"]
id = "10"
}
import {
to = netbox_virtual_machine.this["bao1"]
id = "4"
}
import {
to = netbox_interface.this["bao1:lan"]
id = "6"
}
import {
to = netbox_ip_address.vm["bao1:lan"]
id = "11"
}
+245
View File
@@ -0,0 +1,245 @@
# NetBox object graph, driven by topology.yml.
#
# WHY yamldecode rather than HCL resources per object: topology.yml stays the readable,
# authoritative artifact (git -> NetBox, see ../CONTEXT.md §4), and Terraform supplies what
# a plain script could not — state, `plan` as a drift report, and DELETION. Removing an
# entry from the YAML now removes the object from NetBox, which the previous seed script
# never did.
#
# Ownership boundary, matching ../../../infrastructure/openbao/terraform: Terraform owns API-level
# configuration. The k8s manifests that RUN NetBox live one level up in ../.
locals {
topo = yamldecode(file("${path.module}/topology.yml"))
# --- flattened lookup maps -------------------------------------------------
# Interfaces are nested under devices/VMs in the YAML; Terraform needs flat maps keyed
# by a stable string. "<parent>:<iface>" is that key everywhere below.
device_ifaces = merge([
for d in local.topo.devices : {
for i in d.interfaces : "${d.name}:${i.name}" => merge(i, { device = d.name })
}
]...)
vm_ifaces = merge([
for v in local.topo.virtual_machines : {
for i in v.interfaces : "${v.name}:${i.name}" => merge(i, { vm = v.name })
}
]...)
# Only interfaces that actually carry an address.
device_ips = { for k, i in local.device_ifaces : k => i if try(i.ip, null) != null }
vm_ips = { for k, i in local.vm_ifaces : k => i if try(i.ip, null) != null }
# The single address that becomes the parent's primary_ip4.
device_primary = { for k, i in local.device_ips : i.device => k if try(i.primary, false) }
vm_primary = { for k, i in local.vm_ips : i.vm => k if try(i.primary, false) }
device_macs = { for k, i in local.device_ifaces : k => i if try(i.mac, null) != null }
inventory_items = merge([
for d in local.topo.devices : {
for it in try(d.inventory_items, []) : "${d.name}:${it.name}" => merge(it, { device = d.name })
}
]...)
}
# --- site + IPAM ---------------------------------------------------------------
resource "netbox_site" "this" {
name = local.topo.site.name
slug = local.topo.site.slug
description = local.topo.site.description
status = "active"
}
resource "netbox_ipam_role" "this" {
for_each = { for r in local.topo.prefix_roles : r.slug => r }
name = each.value.name
slug = each.value.slug
}
resource "netbox_vlan_group" "this" {
name = local.topo.vlan_group.name
slug = local.topo.vlan_group.slug
description = local.topo.vlan_group.description
# Required by the provider. The SDN zone is a plain VLAN zone on vmbr0, which is
# bridge-vlan-aware for the full range, so do not narrow this without changing that.
vid_ranges = [[1, 4094]]
}
resource "netbox_vlan" "this" {
for_each = { for v in local.topo.vlans : tostring(v.vid) => v }
vid = each.value.vid
name = each.value.name
group_id = netbox_vlan_group.this.id
site_id = netbox_site.this.id
status = "active"
}
resource "netbox_prefix" "this" {
for_each = { for p in local.topo.prefixes : p.prefix => p }
prefix = each.value.prefix
status = "active"
# The provider exposes plain `site_id` and handles NetBox 4.2+'s generic
# scope_type/scope_id internally — which is exactly the trap that broke the hand-rolled
# script (posting `site` was silently dropped). Using the provider avoids it.
site_id = netbox_site.this.id
role_id = netbox_ipam_role.this[each.value.role].id
vlan_id = try(netbox_vlan.this[tostring(each.value.vlan)].id, null)
description = each.value.description
}
resource "netbox_ip_range" "this" {
for_each = { for r in local.topo.ip_ranges : "${r.start}-${r.end}" => r }
start_address = each.value.start
end_address = each.value.end
status = each.value.status
mark_utilized = try(each.value.mark_utilized, false)
description = each.value.description
}
# --- Wi-Fi ---------------------------------------------------------------------
# ⚠ PARTIAL: the provider can create the SSIDs but has NO attribute for attaching them to
# a radio interface, and none for `rf_role`. Neither does the netbox.netbox Ansible
# collection. That last mile is done by ./attach-wireless.py — see ../README.md.
resource "netbox_wireless_lan" "this" {
for_each = { for w in local.topo.wireless_lans : w.ssid => w }
ssid = each.value.ssid
auth_type = each.value.auth_type
auth_cipher = each.value.auth_cipher
description = each.value.description
# auth_psk deliberately unset: OpenBao is the secrets store, not NetBox.
}
# --- hardware ------------------------------------------------------------------
resource "netbox_manufacturer" "this" {
for_each = { for m in local.topo.manufacturers : m.slug => m }
name = each.value.name
slug = each.value.slug
}
resource "netbox_device_type" "this" {
for_each = { for d in local.topo.device_types : d.slug => d }
model = each.value.model
slug = each.value.slug
manufacturer_id = netbox_manufacturer.this[each.value.manufacturer].id
# Same reason as vm_role above: NetBox's default is true, so pin it or every plan wants
# to clear it. Meaningless for this hardware (nothing is rack-mounted) but stops churn.
is_full_depth = true
}
resource "netbox_device_role" "this" {
for_each = { for r in local.topo.device_roles : r.slug => r }
name = each.value.name
slug = each.value.slug
color_hex = each.value.color
# NetBox defaults this to true; the provider defaults it to false, so without pinning it
# every plan shows a spurious vm_role true -> false diff.
vm_role = true
}
resource "netbox_device" "this" {
for_each = { for d in local.topo.devices : d.name => d }
name = each.value.name
site_id = netbox_site.this.id
role_id = netbox_device_role.this[each.value.role].id
device_type_id = netbox_device_type.this[each.value.type].id
description = each.value.description
comments = try(each.value.comments, "")
serial = try(each.value.serial, "")
status = try(each.value.status, "active")
}
resource "netbox_device_interface" "this" {
for_each = local.device_ifaces
device_id = netbox_device.this[each.value.device].id
name = each.value.name
type = each.value.type
description = try(each.value.description, "")
mtu = try(each.value.mtu, null)
}
resource "netbox_inventory_item" "this" {
for_each = local.inventory_items
device_id = netbox_device.this[each.value.device].id
name = each.value.name
manufacturer_id = netbox_manufacturer.this[each.value.manufacturer].id
part_id = try(each.value.part_id, "")
serial = try(each.value.serial, "")
description = try(each.value.description, "")
}
# MACs are first-class objects in NetBox 4.2+; `mac_address` on the interface is read-only.
resource "netbox_mac_address" "this" {
for_each = local.device_macs
mac_address = upper(each.value.mac)
device_interface_id = netbox_device_interface.this[each.key].id
}
resource "netbox_device_interface_primary_mac_address" "this" {
for_each = local.device_macs
interface_id = netbox_device_interface.this[each.key].id
mac_address_id = netbox_mac_address.this[each.key].id
}
# --- virtualization ------------------------------------------------------------
resource "netbox_cluster_type" "this" {
for_each = { for c in local.topo.cluster_types : c.slug => c }
name = each.value.name
slug = each.value.slug
}
resource "netbox_cluster" "this" {
for_each = { for c in local.topo.clusters : c.name => c }
name = each.value.name
cluster_type_id = netbox_cluster_type.this[each.value.type].id
description = each.value.description
site_id = netbox_site.this.id
}
resource "netbox_virtual_machine" "this" {
for_each = { for v in local.topo.virtual_machines : v.name => v }
name = each.value.name
cluster_id = netbox_cluster.this[each.value.cluster].id
description = each.value.description
# NetBox DERIVES a VM's site from its cluster. Leaving this unset makes the provider
# try to clear it on every plan (site_id 1 -> None), so declare it to match.
site_id = netbox_site.this.id
}
resource "netbox_interface" "this" {
for_each = local.vm_ifaces
virtual_machine_id = netbox_virtual_machine.this[each.value.vm].id
name = each.value.name
description = try(each.value.description, "")
}
# --- addresses -----------------------------------------------------------------
resource "netbox_ip_address" "device" {
for_each = local.device_ips
ip_address = each.value.ip
status = "active"
# No `object_type` here: the provider pairs that with the GENERIC `interface_id`
# ("all of interface_id,object_type must be specified"). The dedicated
# *_interface_id attributes are standalone and imply the type.
device_interface_id = netbox_device_interface.this[each.key].id
# Native NetBox field. Setting it is INTENT: "this host needs a static A record in AD
# DNS". Domain-joined hosts self-register and are deliberately absent.
# ../generate/samba-a-records.py turns these into samba_ad_extra_a_records.
dns_name = try(each.value.dns_name, "")
}
resource "netbox_ip_address" "vm" {
for_each = local.vm_ips
ip_address = each.value.ip
status = "active"
virtual_machine_interface_id = netbox_interface.this[each.key].id
dns_name = try(each.value.dns_name, "")
}
# primary_ip4 lives on the parent, so the provider models it as its own resource.
resource "netbox_device_primary_ip" "this" {
for_each = local.device_primary
device_id = netbox_device.this[each.key].id
ip_address_id = netbox_ip_address.device[each.value].id
}
+14
View File
@@ -0,0 +1,14 @@
output "site_id" {
value = netbox_site.this.id
description = "NetBox ID of the Homelab site."
}
output "prefix_ids" {
value = { for k, p in netbox_prefix.this : k => p.id }
description = "prefix -> NetBox ID, for cross-referencing from other tooling."
}
output "device_ids" {
value = { for k, d in netbox_device.this : k => d.id }
description = "device name -> NetBox ID."
}
@@ -0,0 +1,2 @@
# Copy to terraform.tfvars (gitignored) and fill in, or export TF_VAR_netbox_token.
netbox_token = "nbt_xxxxxxxx.yyyyyyyy"
+262
View File
@@ -0,0 +1,262 @@
# Homelab topology — the INPUT to NetBox, not a dump of it.
#
# This file is deliberately the authoritative artifact: git stays the source of truth
# and NetBox is a derived mirror, populated by ./seed.py. That answers the design
# question in ../CONTEXT.md §4 the way the rest of this repo works — a UI you must
# click to change routing would be a regression against every other service here.
#
# Facts mirror CONTEXT.md §3 (verified live 2026-07-25). Interface names are verified,
# not guessed, EXCEPT where marked `# placeholder`.
site:
name: Homelab
slug: homelab
description: "Single flat 1G LAN on one unmanaged switch, 192.168.10.0/24"
# --- Layer 3 -----------------------------------------------------------------
prefixes:
- prefix: 192.168.10.0/24
role: lan
description: "LAN. Flat L2 across one dumb switch; OSPF area 0 runs here."
- prefix: 10.60.0.0/24
role: sdn
vlan: 100
description: "PVE SDN VNet labnet. Gateway 10.60.0.1 on vyos eth1."
- prefix: 10.61.0.0/24
role: sdn
vlan: 110
description: "PVE SDN VNet retronet. Gateway 10.61.0.1 on vyos eth2."
- prefix: 10.42.0.0/16
role: k3s
description: "k3s pod CIDR (laptop). Not routed off-node."
- prefix: 10.43.0.0/16
role: k3s
description: "k3s service CIDR (laptop). Not routed off-node."
- prefix: 10.1.72.0/24
role: wan
description: "WAN side of the NEC IX (GigaEthernet0.0)."
prefix_roles:
- { name: LAN, slug: lan }
- { name: SDN VNet, slug: sdn }
- { name: Kubernetes, slug: k3s }
- { name: WAN, slug: wan }
# The router's DHCP pool (CONTEXT.md §4 called this out specifically).
#
# There is no `dhcp` status — IPRange offers only active/reserved/deprecated. The pool
# concept is the `mark_utilized` boolean ("Report space as fully utilized"), which makes
# NetBox stop offering those addresses as available.
#
# Be precise about what this buys: it does NOT hard-block an allocation inside the
# range. It makes the collision VISIBLE — the range shows 100% utilised and the
# address never appears as a suggestion — where plain YAML shows nothing at all.
ip_ranges:
- start: 192.168.10.10/24
end: 192.168.10.250/24
status: active
mark_utilized: true
description: "NEC IX DHCP pool — do NOT statically allocate inside this."
vlan_group:
name: lab
slug: lab
description: "PVE SDN zone `lab` (type vlan, bridge vmbr0). Segmentation, NOT security."
vlans:
- { vid: 100, name: labnet, prefix: 10.60.0.0/24 }
- { vid: 110, name: retronet, prefix: 10.61.0.0/24 }
# --- Wi-Fi -------------------------------------------------------------------
# Broadcast by ap-buffalo (below). No `vlan:` on any of them: the AP bridges, so wireless
# clients land UNTAGGED on the flat LAN and pick up an address from the IX DHCP pool.
# They are on the same L2 as everything else — the Wi-Fi is not a separate segment.
#
# ⚠ NetBox has no WPA3 auth_type — the choices are open/wep/wpa-personal/wpa-enterprise,
# so WPA2-PSK and WPA3-SAE both land on `wpa-personal`. The real difference is recorded in
# the description because the model cannot express it.
#
# auth_psk is deliberately LEFT EMPTY. NetBox can store the passphrase, but that would put
# the house Wi-Fi key in a system whose own DB backup story is untested; OpenBao is the
# secrets store here (see ../../../infrastructure/openbao).
wireless_lans:
- ssid: Buffalo-A-07B0-WPA3
auth_type: wpa-personal
auth_cipher: aes
description: "5 GHz, WPA3-SAE. Preferred SSID for clients that support it."
- ssid: Buffalo-A-07B0
auth_type: wpa-personal
auth_cipher: aes
description: "5 GHz, WPA2-PSK. Compatibility SSID for clients that cannot do WPA3."
- ssid: Buffalo-G-07B0
auth_type: wpa-personal
auth_cipher: aes
description: "2.4 GHz, WPA2-PSK. Range/IoT band."
# --- Layer 2 / hardware ------------------------------------------------------
# Model/serial values below are READ FROM THE HARDWARE (`dmidecode -s ...`), not guessed.
manufacturers:
- { name: NEC, slug: nec }
- { name: Intel, slug: intel }
- { name: Lenovo, slug: lenovo }
- { name: Dell, slug: dell }
- { name: Buffalo, slug: buffalo }
- { name: Yamaha, slug: yamaha }
- { name: Broadcom, slug: broadcom }
device_types:
- { model: IX2215, slug: ix2215, manufacturer: nec }
# dmidecode: system-manufacturer/product/serial are all BLANK on this NUC (the OEM
# never programmed them). baseboard-product-name is the only real identifier, and it
# is SYB (the board), not the SYH chassis this was previously guessed to be.
- { model: NUC6i3SYB, slug: nuc6i3syb, manufacturer: intel }
# Lenovo's machine-type; this is the ThinkCentre M715q Tiny.
- { model: 10VGCTO1WW, slug: 10vgcto1ww, manufacturer: lenovo }
- { model: XPS 15 9570, slug: xps-15-9570, manufacturer: dell }
- { model: WSR-1800AX4S, slug: wsr-1800ax4s, manufacturer: buffalo }
- { model: RTX1200, slug: rtx1200, manufacturer: yamaha }
device_roles:
- { name: Router, slug: router, color: f44336 }
- { name: Hypervisor, slug: hypervisor, color: 2196f3 }
- { name: Core Node, slug: core-node, color: 4caf50 }
- { name: Wireless AP, slug: wireless-ap, color: ff9800 }
devices:
- name: ix2215
role: router
type: ix2215
description: "NEC IX. Gateway, OSPF area 0, BGP, DNS proxy, DHCP server."
interfaces:
- { name: GigaEthernet2.0, type: 1000base-t, ip: 192.168.10.1/24, primary: true, dns_name: gw.ad.ddupan.top }
- { name: GigaEthernet0.0, type: 1000base-t, ip: null, description: "WAN uplink, 10.1.72.0/24" }
- name: pve1
role: hypervisor
type: nuc6i3syb
description: "Proxmox VE 9.2. LINSTOR controller."
# No serial: this NUC reports blank system-serial-number (see device_types note).
comments: "Intel Core i3-6100U @ 2.30GHz, 4 threads, 15 GiB RAM. BIOS SYSKLi35.86A.0045.2016.0527.1055. Board NUC6i3SYB."
interfaces:
- { name: vmbr0, type: bridge, ip: 192.168.10.4/24, primary: true, mtu: 9000, dns_name: pve1.ad.ddupan.top }
- name: pve2
role: hypervisor
type: 10vgcto1ww
serial: PC1AGX1Q
description: "Proxmox VE 9.2. LINSTOR satellite. The node that randomly froze."
comments: "AMD Ryzen 5 PRO 2400GE w/ Vega, 8 threads, 7 GiB RAM. BIOS M1XKT45A. Raven Ridge idle bug fixed in BIOS: Power Supply Idle Control = Typical Current Idle."
interfaces:
- { name: vmbr0, type: bridge, ip: 192.168.10.7/24, primary: true, mtu: 9000, dns_name: pve2.ad.ddupan.top }
- name: pve3
role: hypervisor
type: 10vgcto1ww
serial: PC1AGX1P
description: "Proxmox VE 9.2. LINSTOR satellite."
comments: "AMD Ryzen 5 PRO 2400GE w/ Vega, 8 threads, 7 GiB RAM. BIOS M1XKT55A. Same silicon as pve2, so susceptible to the same idle bug in principle."
interfaces:
- { name: vmbr0, type: bridge, ip: 192.168.10.9/24, primary: true, mtu: 9000, dns_name: pve3.ad.ddupan.top }
- name: laptop
role: core-node
type: xps-15-9570
serial: 6R7CQQ2
description: "Core node, NOT a PVE cluster member. k3s, NFS, libvirt host, netboot.xyz, OSPF DR. Single point of failure for most of the lab."
comments: "Intel Core i7-8750H @ 2.20GHz, 12 threads, 30 GiB RAM. BIOS 1.20.0. Nvidia dGPU stays bare-metal for nvidia-container-toolkit. Built-in battery acts as a UPS."
interfaces:
- { name: br0, type: bridge, ip: 192.168.10.127/24, primary: true }
inventory_items:
# Present in hardware but NOT usable, so it is an inventory item rather than an
# interface — there is no netdev for it.
#
# `lspci -k` shows bcma-pci-bridge bound and b43 loaded, but b43 does NOT support
# BCM4360; that chip needs Broadcom's proprietary `wl` (broadcom-sta) driver with
# b43/bcma/ssb blacklisted. Until then the laptop cannot scan or join Wi-Fi.
- name: BCM4360 802.11ac
manufacturer: broadcom
part_id: "14e4:43a0"
description: "PCI 3b:00.0, Apple-subsystem card. No driver: b43 claims it but cannot drive BCM4360; needs broadcom-sta (wl)."
# Wi-Fi. Runs as an AP/bridge, not a router — the NEC IX is the gateway, so this box's
# routing, NAT and DHCP are not in play. Wireless clients land directly on the flat LAN.
#
# ⚠ Its address .10 is the FIRST ADDRESS OF THE DHCP POOL above. Either it holds a lease
# (so the address can move) or it is a static that overlaps the pool. NetBox surfaces
# the overlap; the underlying config still needs a decision. See ../README.md.
#
# Identified by MAC OUI d4:2c:46 = BUFFALO.INC plus the model string on its login page.
- name: ap-buffalo
role: wireless-ap
type: wsr-1800ax4s
description: "Buffalo AirStation, AP/bridge mode. Provides the house Wi-Fi."
comments: "Wi-Fi 6 (802.11ax) dual band. Web UI on http://192.168.10.10/. Model read from its login page; serial not recorded (needs the label or an authenticated session)."
interfaces:
- { name: lan1, type: 1000base-t, ip: 192.168.10.10/24, primary: true, mac: "d4:2c:46:09:07:b0", dns_name: ap.ad.ddupan.top, description: "Uplink to the dumb switch" }
# Buffalo's factory SSID scheme: A = 5 GHz, G = 2.4 GHz, and the suffix is the tail
# of this AP's own MAC (d4:2c:46:09:07:b0 -> 07B0), which independently corroborates
# that this device is the AP.
- name: wlan-2.4g
type: ieee802.11ax
rf_role: ap
description: "2.4 GHz radio"
wireless_lans: [Buffalo-G-07B0]
- name: wlan-5g
type: ieee802.11ax
rf_role: ap
description: "5 GHz radio"
wireless_lans: [Buffalo-A-07B0-WPA3, Buffalo-A-07B0]
# Spare/shelf kit. Recorded so it is not forgotten — knowing what you own and are NOT
# using is a legitimate reason to run a DCIM tool.
- name: rtx1200
role: router
type: rtx1200
status: inventory # NOT active: unplugged, no addresses, not cabled
description: "Yamaha RTX1200. Spare — not in use."
comments: "Gigabit VPN router. Kept as a spare / potential replacement for the NEC IX. Serial not recorded (would need the chassis label)."
interfaces: []
# --- Virtual machines --------------------------------------------------------
cluster_types:
- { name: Proxmox VE, slug: proxmox }
- { name: libvirt, slug: libvirt }
clusters:
- { name: homelab, type: proxmox, description: "3-node PVE cluster, no HA, LINSTOR place-count 2." }
- { name: laptop-libvirt, type: libvirt, description: "libvirt guests on the laptop." }
virtual_machines:
- name: vyos-rtr
cluster: homelab
description: "VyOS 2025.11. SDN gateway, OSPF area 0. VM 100. Routed, not NAT'd."
interfaces:
- { name: eth0, ip: 192.168.10.2/24, primary: true, dns_name: vyos-rtr.ad.ddupan.top, description: "LAN" }
- { name: eth1, ip: 10.60.0.1/24, description: "labnet gateway (VLAN 100), OSPF passive" }
- { name: eth2, ip: 10.61.0.1/24, description: "retronet gateway (VLAN 110), OSPF passive" }
# Found by diffing NetBox against samba_ad_extra_a_records — it had a live A record and
# was missing from NetBox entirely. Verified up at 10.60.0.10.
- name: retrolab
cluster: homelab
description: "AD-joined XFCE/xrdp host for running 86Box. Lives on labnet (VLAN 100)."
interfaces:
- { name: eth0, ip: 10.60.0.10/24, primary: true, dns_name: retrolab.ad.ddupan.top }
- name: dc1
cluster: laptop-libvirt
description: "Samba AD DC, authoritative for ad.ddupan.top."
interfaces:
- { name: lan, ip: 192.168.10.5/24, primary: true } # placeholder: NIC name not verified
- name: winadmin
cluster: laptop-libvirt
description: "Windows Server 2025 admin box."
interfaces:
- { name: lan, ip: 192.168.10.6/24, primary: true } # placeholder: NIC name not verified
- name: bao1
cluster: laptop-libvirt
description: "OpenBao — internal CA + secrets store."
interfaces:
- { name: lan, ip: 192.168.10.8/24, primary: true, dns_name: bao.ad.ddupan.top } # placeholder: NIC name not verified
+15
View File
@@ -0,0 +1,15 @@
variable "netbox_url" {
type = string
description = "Base URL of NetBox, WITHOUT the /api suffix."
default = "https://netbox.ad.ddupan.top"
}
variable "netbox_token" {
type = string
sensitive = true
description = <<-EOT
NetBox API token. A v2 token (nbt_<key>.<secret>) works fine — NetBox dispatches on the
token value, not the Authorization scheme. Mint one with the snippet in ../README.md.
Supply via TF_VAR_netbox_token or terraform.tfvars (gitignored).
EOT
}
+20
View File
@@ -0,0 +1,20 @@
terraform {
required_version = ">= 1.5"
required_providers {
netbox = {
source = "e-breuninger/netbox"
# ⚠ Pin to 5.x. Provider 4.3.1 FAILS against NetBox 4.6 at provider-configure time
# with a go-openapi error ("... is not supported by the TextConsumer"); 5.7.0 works.
version = "~> 5.0"
}
}
}
# Same pattern as ../../../infrastructure/openbao/terraform and ../../../infrastructure/cloudflared/terraform: credentials come
# from outside the repo, state is local and gitignored.
provider "netbox" {
# ⚠ BASE URL ONLY — no /api suffix. Passing ".../api" produces the same misleading
# TextConsumer error as the version mismatch above and costs an hour to diagnose.
server_url = var.netbox_url
api_token = var.netbox_token
}
+190
View File
@@ -0,0 +1,190 @@
# NetBox — IPAM + DCIM, candidate "source of truth for network facts".
# THIS IS AN EVALUATION DEPLOYMENT (see CONTEXT.md §1: adoption is not decided).
# Nothing else in the repo reads from NetBox yet; it is safe to delete outright.
#
# Chart: netbox/netbox 8.3.38 (app v4.6.5)
# helm repo add netbox https://netbox-community.github.io/netbox-chart/
#
# Shape mirrors the other k3s services here: external shared Postgres, private
# exposure over the Tailscale ingress, authentication delegated to Authelia OIDC.
# Pin the app image; the chart default matches but drifts on every chart bump.
image:
tag: v4.6.5
# --- Local break-glass superuser -------------------------------------------
# Deliberately kept, exactly like Grafana's local admin: NetBox's SSO group->
# superuser mapping does not exist (that feature is LDAP-only, see README), so
# the first SSO login lands as an ordinary user and needs promoting BY someone.
# Password is generated by the chart and preserved in the netbox-superuser Secret.
superuser:
name: admin
email: [email protected]
# NetBox refuses writes via any hostname not listed here. The pod IP is appended
# automatically (allowedHostsIncludesPodIP) for the k8s probes.
allowedHosts:
- netbox.ad.ddupan.top
- netbox.netbox.svc.cluster.local
- localhost
# Envoy terminates TLS and forwards plain HTTP, so Django sees an http:// request
# carrying an https:// Origin header. Without this, every POST (including the login
# form) fails CSRF verification — it presents as "login is broken", not "config is
# missing". Same requirement behind any TLS-terminating proxy.
csrf:
trustedOrigins:
- https://netbox.ad.ddupan.top
# It is an infrastructure source of truth on a private tailnet, but "on the
# tailnet" is not authentication — require a login for read access too.
#
# This logs a benign FutureWarning at startup ("LOGIN_REQUIRED is deprecated ...
# can be removed from your configuration file"): NetBox v5.0 drops the setting and
# makes login mandatory, i.e. true is the direction of travel. The warning is
# unavoidable via this chart — the ConfigMap always emits the key, and NetBox warns
# on false as well ("unauthenticated access will no longer be supported").
loginRequired: true
# Everything else in this homelab is UTC (the node is Etc/UTC); stay consistent
# so timestamps line up with VictoriaLogs.
timeZone: UTC
# Version check phones home to api.github.com. The WAN flaps (CONTEXT.md §6) and
# a blocking outbound call on page render is exactly what we don't want.
releaseCheck:
url: ""
# --- Authentication: Authelia forward-auth (trusted headers) --------------
# Authelia authenticates + enforces 2FA at the GATEWAY (securitypolicy.yaml); by the
# time a request arrives here it is already authenticated, and Envoy has attached
# Remote-* headers describing who the user is.
#
# WHY NOT OIDC (which this used to be): NetBox has NO SSO group -> role mapping.
# REMOTE_AUTH_SUPERUSER_GROUPS and AUTH_LDAP_USER_FLAGS_BY_GROUP are LDAP-only; the
# social-auth pipeline only runs user_default_groups_handler, so an OIDC user landed
# as an ordinary member of one static group and had to be promoted BY HAND, and AD
# group changes never propagated. With header auth, REMOTE_AUTH_GROUP_SYNC_ENABLED
# re-evaluates group membership on EVERY request — the same declarative AD-group
# pattern already used by Grafana and Proxmox.
#
# ⚠⚠ THIS SETTING IS ONLY SAFE BEHIND THE GATEWAY. RemoteUserBackend trusts the
# header unconditionally — NetBox has no trusted-proxy allowlist. Two things keep
# that honest, and BOTH must stay true:
# 1. Envoy sets these headers from Authelia's response, and Envoy Gateway's
# headersToBackend OVERRIDES any client-supplied value ("coexisting headers
# will be overridden"), so a spoofed Remote-User cannot survive the hop.
# 2. networkpolicy.yaml restricts pod ingress to the gateway namespace, so nothing
# in-cluster can bypass Envoy and talk to :8080 directly.
# Removing either one turns `Remote-User: admin` into an instant superuser.
remoteAuth:
enabled: true
backends:
- netbox.authentication.RemoteUserBackend
# Authelia's header names. NetBox's defaults assume HTTP_REMOTE_USER_GROUP, but
# Authelia emits Remote-Groups -> HTTP_REMOTE_GROUPS, and joins values with a
# COMMA where NetBox defaults to "|". Both must be overridden or group sync
# silently yields one group literally named "a,b,c".
header: HTTP_REMOTE_USER
groupHeader: HTTP_REMOTE_GROUPS
groupSeparator: ","
# Authelia sends Remote-Email and Remote-Name (a single display name); it has no
# split given/family name, so the first/last-name headers are left unmapped.
userEmail: HTTP_REMOTE_EMAIL
autoCreateUser: true
autoCreateGroups: true # mirror AD groups into NetBox groups as they appear
groupSyncEnabled: true # re-evaluate membership on every request
# AD group -> NetBox role. Managed in ../../infrastructure/samba-ad (samba_ad_groups).
superuserGroups:
- netbox-admins
staffGroups:
- netbox-admins # is_staff => access to the Django admin site
# The OIDC client config that used to live here (SOCIAL_AUTH_OIDC_*) is gone; the
# netbox-secrets Secret now only carries the database password.
extraConfig: []
# --- Postgres: the shared CNPG cluster ------------------------------------
# Dedicated role + database, no shared superuser — same as Authelia and Gitea.
postgresql:
enabled: false
externalDatabase:
host: shared-postgresql-rw.shared-db.svc.cluster.local
port: 5432
database: netbox
username: netbox
existingSecretName: netbox-secrets
existingSecretKey: postgresql-password
# --- Valkey (Redis) for the RQ task queue + caching -----------------------
# Bundled subchart rather than a shared instance: nothing else in the cluster
# runs Redis, and NetBox wants two logical databases of its own.
#
# ⚠ Bitnami's public catalog no longer serves versioned tags (only `latest`,
# versioned images moved to the `bitnamilegacy` repo), which is why the chart
# ships `tag: latest`. `latest` is not reproducible, so pin the DIGEST instead —
# resolved 2026-07-25 for bitnami/valkey:latest.
valkey:
image:
digest: sha256:5d43ca8bb57aa263ef78d1684dbf1e4b4f63844727eafdd5ea2b0e102a25b141
# Single node: the chart default is primary+replica, which buys nothing here
# (one k8s node, no HA anywhere in this cluster — CONTEXT.md §6).
architecture: standalone
primary:
persistence:
enabled: true
storageClass: localpv-zfs-ceph
size: 1Gi
resourcesPreset: micro
# --- Storage --------------------------------------------------------------
# Media = uploaded images/attachments only; the real data is in Postgres.
#
# ⚠ NOT localpv-zfs-ceph, unlike every other PVC here. The chart mounts this ONE
# RWO claim into BOTH Deployments (web and worker — the worker writes files the web
# serves), and the OpenEBS ZFS class provisions a **zvol**: a block device with xfs
# on it, which cannot be mounted twice. Whichever pod won the race mounted it and
# the other stuck in Init forever with
# verifyMount: device already mounted at [...]
# from the CSI node plugin. local-path is hostPath-based, so kubelet can bind-mount
# the same directory into both pods. (localpv-zfs-ceph stays correct for the valkey
# PVC below — single StatefulSet pod, no sharing.)
persistence:
enabled: true
storageClass: local-path
size: 2Gi
# --- LAN exposure via the Contour gateway ---------------------------------
# https://netbox.ad.ddupan.top — reachable from any LAN host, no Tailscale client
# needed, and it does not traverse the WAN. Deliberately NOT on the cloudflared
# tunnel: a full inventory of the network is not something to publish.
#
# Gateway API, not Ingress. The shared gateway lives in ../../platform/envoy-gateway and already
# terminates TLS with the *.ad.ddupan.top wildcard from ../../platform/cert-manager — so this
# service needs no cert of its own, just this route plus a DNS A record on the DC
# (../../infrastructure/samba-ad). Authentication is enforced at the gateway by the SecurityPolicy in
# securitypolicy.yaml, before a request ever reaches this pod.
ingress:
enabled: false
httpRoute:
enabled: true
parentRefs:
- name: eg
namespace: envoy-gateway-system
sectionName: https # the :443 listener; :80 is for ACME/redirects only
hostnames:
- netbox.ad.ddupan.top
# --- Resource budget ------------------------------------------------------
# The laptop is the single k3s node and already runs everything (CONTEXT.md §6);
# at survey time it had ~4 GiB RAM headroom. Keep this deployment modest.
resourcesPreset: medium # 500m/1Gi requests, 750m/1.5Gi limits
worker:
resourcesPreset: small # 500m/512Mi requests — one worker, no bulk jobs yet
# Nightly changelog/job pruning. Retentions are the chart defaults (90 days).
housekeeping:
enabled: true
schedule: "17 4 * * *"