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