Reorganize the brownfield repository, remove retired and generated artifacts, harden ignore rules, and record the GitOps/IaC redesign.
25 KiB
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 — 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). It can generate the config that matters, verified against the live router — see Verdict.
- Chart:
netbox/netbox8.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.topwildcard 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
netboxrole/db on the shared CNPG cluster (shared-db) - Auth: Authelia forward-auth at the gateway (not OIDC — see
Authentication); local
adminretained 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:
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:
- Envoy overrides the headers.
headersToBackendreplaces any client-suppliedRemote-Userwith Authelia's verdict rather than merging it. networkpolicy.yamlblocks bypass. Without it, any pod could hitnetbox.netbox.svc:8080directly withRemote-User: adminand 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)
# 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_groupsin../../infrastructure/samba-ad), applied withansible-playbook provision-dc.yml --tags directory,accounts - DNS A record
netbox→192.168.10.127(samba_ad_extra_a_records), applied withansible-playbook provision-dc.yml --tags dns - the
ext-authzendpoint + access-control rule inauthelia/values.yaml, and theReferenceGrantinauthelia/referencegrant-extauth.yaml
Deploy
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.yamlis the only copy of Authelia's config. Before upgrading it, confirm it still matches the live release —helm get values authelia -n authelia -o yamland compare structurally (parse both to YAML and diff keys); a textual diff is useless becausehelm get valuessorts keys and strips comments.
Verify
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>.:
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_urlmust be the base URL without/api; passing.../apiyields a confusing go-openapi error (... is not supported by the TextConsumer). Provider v4.x fails against NetBox 4.6 regardless — pin~> 5.0. nb_inventoryneedspytzin 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.topis resolved by the DC and served on the LAN. Howeverauth.ddupan.topresolves 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 localadminaccount 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.urlis blanked so NetBox never blocks a page render onapi.github.com.- One k3s node, no HA:
valkey.architecture: standalone, one web pod, one worker. - Resource presets (
mediumweb /smallworker) are sized for the laptop's headroom at survey time (~4 GiB free), not for throughput.
Teardown
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.
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_urlmust 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_depthopposite to NetBox, and a VM'ssite_idis 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 NUC6i3SYB board with an i3-6100U
(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/bcmaclaim the PCI device but cannot drive BCM4360 (it needs proprietarybroadcom-sta/wlwith those modules blacklisted), so there is no netdev — an Interface would imply a capability the OS does not have. ap-buffalowas identified, not assumed: MAC OUId4:2c:46= BUFFALO.INC, modelWSR-1800AX4Sfrom its login page, and Buffalo's factory SSID suffix07B0matches the tail of that same MAC.
⚠ Two things the data now surfaces
ap-buffaloholds192.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.10or starting the pool at.11; NetBox shows the collision but cannot resolve it.- 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 theGenericmanufacturer are orphans needing one manual prune. Anything added since is managed — removing it fromtopology.ymlnow deletes it, which is exactly what the oldseed.pycould 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.pyintoproxmox/ansible/roles/vyos_routerso the template has one source, not two.