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

Reorganize the brownfield repository, remove retired and generated artifacts, harden ignore rules, and record the GitOps/IaC redesign.
This commit is contained in:
2026-09-09 16:47:20 +00:00
commit 88a02ababa
418 changed files with 50579 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
# Local state and real credentials stay out of git (same as the other TF roots here).
*.tfstate
*.tfstate.*
.terraform/
.terraform.lock.hcl
terraform.tfvars
# Real API token — mint with the snippet in ../README.md
.env
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Attach SSIDs to radio interfaces and set rf_role — the part Terraform cannot express.
uv run --with requests --with pyyaml python netbox/terraform/attach-wireless.py [--check]
WHY THIS EXISTS. Terraform creates the WirelessLAN objects (netbox_wireless_lan) and the
radio interfaces, but `netbox_device_interface` has NO attribute for either:
* rf_role (ap / station)
* wireless_lans (which SSIDs this radio broadcasts)
Verified against the provider schema for e-breuninger/netbox 5.7.0 — and the
netbox.netbox Ansible collection 3.23.0 has the same gap in netbox_device_interface.
So neither official tool can do this; a few lines of API call is the honest fallback
rather than dropping the data.
Idempotent, and reads the same topology.yml Terraform does, so there is one source of
truth. Run it after `terraform apply`. If a future provider release grows these
attributes, delete this file and move the fields into main.tf.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
import requests
import yaml
HERE = Path(__file__).parent
CHECK = "--check" in sys.argv
for envfile in (HERE / ".env", HERE / "../seed/.env"):
if envfile.exists():
for line in envfile.read_text().splitlines():
if line.strip() and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip())
URL = os.environ.get("NETBOX_URL", "https://netbox.ad.ddupan.top").rstrip("/")
TOKEN = os.environ.get("NETBOX_TOKEN", "")
if not TOKEN:
sys.exit("NETBOX_TOKEN not set (expected in netbox/terraform/.env)")
S = requests.Session()
S.headers.update({"Authorization": f"Bearer {TOKEN}"})
def get(path: str, **params):
r = S.get(f"{URL}/api{path}", params=params, timeout=30)
r.raise_for_status()
return r.json()
def main() -> int:
topo = yaml.safe_load((HERE / "topology.yml").read_text())
changed = 0
# ssid -> id, resolved once
lans = {w["ssid"]: w["id"] for w in get("/wireless/wireless-lans/", limit=200)["results"]}
for dev in topo["devices"]:
for iface in dev.get("interfaces", []):
want_ssids = iface.get("wireless_lans")
want_role = iface.get("rf_role")
if not want_ssids and not want_role:
continue
found = get("/dcim/interfaces/", device=dev["name"], name=iface["name"])["results"]
if not found:
print(f" ! {dev['name']}:{iface['name']} not in NetBox — run terraform apply first")
continue
cur = found[0]
patch = {}
if want_role and (cur.get("rf_role") or {}).get("value") != want_role:
patch["rf_role"] = want_role
if want_ssids:
have = sorted(w["id"] for w in (cur.get("wireless_lans") or []))
missing = [s for s in want_ssids if s not in lans]
if missing:
print(f" ! SSID(s) not in NetBox: {missing} — run terraform apply first")
continue
want = sorted(lans[s] for s in want_ssids)
if have != want:
patch["wireless_lans"] = want
if not patch:
continue
changed += 1
if CHECK:
print(f" ~ would patch {dev['name']}:{iface['name']}: {list(patch)}")
else:
r = S.patch(f"{URL}/api/dcim/interfaces/{cur['id']}/", json=patch, timeout=30)
r.raise_for_status()
print(f" ~ patched {dev['name']}:{iface['name']}: {list(patch)}")
print(f"changed={changed}" + (" (check mode)" if CHECK else ""))
if changed == 0:
print("idempotent: nothing to do")
return 0
if __name__ == "__main__":
sys.exit(main())
+40
View File
@@ -0,0 +1,40 @@
# Remote state in SeaweedFS S3, on the LAN.
#
# WHY remote at all: local state means the only copy lives on this laptop, which is
# also the k3s node, the NFS server and the libvirt host — i.e. the single point of
# failure. It also cannot be locked, so two concurrent applies silently corrupt it.
#
# WHY s3.ad.ddupan.top and NOT obj.ddupan.top: the public name resolves to
# Cloudflare and hairpins through the WAN. On 2026-07-28 that path was blackholed
# for hours by a dead VPN tunnel. State must be reachable when the WAN is not —
# it is what you need DURING an incident. See ../../seaweedfs/httproute-s3.yaml.
#
# CREDENTIALS are not in this file. Export them before running terraform:
# export AWS_ACCESS_KEY_ID=$(bao kv get -field=... kv/k8s/seaweedfs-s3) # see README
# export AWS_SECRET_ACCESS_KEY=...
# The `terraform` S3 identity is scoped to this bucket only — it deliberately
# cannot create buckets or read anything else in the store.
terraform {
backend "s3" {
bucket = "tfstate"
key = "netbox/terraform.tfstate"
endpoints = {
s3 = "https://s3.ad.ddupan.top"
}
# SeaweedFS is not AWS: it has no regions, no IAM, no metadata service and no
# account IDs, so every AWS-specific validation has to be skipped or the
# provider fails before it ever talks to the endpoint.
region = "us-east-1"
use_path_style = true
skip_credentials_validation = true
skip_metadata_api_check = true
skip_region_validation = true
skip_requesting_account_id = true
# Native S3 locking (Terraform >= 1.10; this repo runs 1.15). Writes a
# .tflock object alongside the state — no DynamoDB table needed.
use_lockfile = true
}
}
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""Generate imports.tf so Terraform ADOPTS the objects already in NetBox.
uv run --with requests --with pyyaml python netbox/terraform/gen-imports.py
Run once, when converting an already-populated NetBox to Terraform management. Without it
the first `terraform apply` tries to CREATE objects that exist and fails on uniqueness.
This is a one-shot bootstrap, not part of the normal loop: once `terraform apply` has run,
state holds the IDs and imports.tf can be deleted. Same intent as
../../../infrastructure/openbao/terraform/imports.tf.
It reads the same topology.yml Terraform does, so the resource addresses and for_each keys
line up by construction rather than by hand-transcription.
"""
from __future__ import annotations
import os
import sys
import urllib.parse
from pathlib import Path
import requests
import yaml
HERE = Path(__file__).parent
for envfile in (HERE / "../seed/.env", HERE / ".env"):
if envfile.exists():
for line in envfile.read_text().splitlines():
if line.strip() and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip())
URL = os.environ.get("NETBOX_URL", "https://netbox.ad.ddupan.top").rstrip("/")
TOKEN = os.environ.get("NETBOX_TOKEN", "")
if not TOKEN:
sys.exit("NETBOX_TOKEN not set")
S = requests.Session()
S.headers.update({"Authorization": f"Bearer {TOKEN}"})
def one(path: str, **params) -> str | None:
r = S.get(f"{URL}/api{path}", params={**params, "limit": 1}, timeout=30)
r.raise_for_status()
res = r.json()["results"]
return str(res[0]["id"]) if res else None
def main() -> int:
t = yaml.safe_load((HERE / "topology.yml").read_text())
out: list[str] = [
"# GENERATED by gen-imports.py — one-shot bootstrap, safe to delete after the first",
"# successful `terraform apply`. Do not hand-edit.",
"",
]
missing: list[str] = []
def emit(addr: str, oid: str | None, what: str):
if oid:
out.append(f'import {{\n to = {addr}\n id = "{oid}"\n}}\n')
else:
missing.append(what)
emit("netbox_site.this", one("/dcim/sites/", slug=t["site"]["slug"]), "site")
emit("netbox_vlan_group.this", one("/ipam/vlan-groups/", slug=t["vlan_group"]["slug"]), "vlan group")
for r in t["prefix_roles"]:
emit(f'netbox_ipam_role.this["{r["slug"]}"]', one("/ipam/roles/", slug=r["slug"]), r["slug"])
for v in t["vlans"]:
emit(f'netbox_vlan.this["{v["vid"]}"]', one("/ipam/vlans/", vid=v["vid"]), f"vlan {v['vid']}")
for p in t["prefixes"]:
emit(f'netbox_prefix.this["{p["prefix"]}"]', one("/ipam/prefixes/", prefix=p["prefix"]), p["prefix"])
for r in t["ip_ranges"]:
emit(f'netbox_ip_range.this["{r["start"]}-{r["end"]}"]',
one("/ipam/ip-ranges/", start_address=r["start"], end_address=r["end"]), "ip range")
for w in t["wireless_lans"]:
emit(f'netbox_wireless_lan.this["{w["ssid"]}"]', one("/wireless/wireless-lans/", ssid=w["ssid"]), w["ssid"])
for m in t["manufacturers"]:
emit(f'netbox_manufacturer.this["{m["slug"]}"]', one("/dcim/manufacturers/", slug=m["slug"]), m["slug"])
for d in t["device_types"]:
emit(f'netbox_device_type.this["{d["slug"]}"]', one("/dcim/device-types/", slug=d["slug"]), d["slug"])
for r in t["device_roles"]:
emit(f'netbox_device_role.this["{r["slug"]}"]', one("/dcim/device-roles/", slug=r["slug"]), r["slug"])
for c in t["cluster_types"]:
emit(f'netbox_cluster_type.this["{c["slug"]}"]', one("/virtualization/cluster-types/", slug=c["slug"]), c["slug"])
for c in t["clusters"]:
emit(f'netbox_cluster.this["{c["name"]}"]', one("/virtualization/clusters/", name=c["name"]), c["name"])
for d in t["devices"]:
did = one("/dcim/devices/", name=d["name"])
emit(f'netbox_device.this["{d["name"]}"]', did, d["name"])
for i in d["interfaces"]:
key = f'{d["name"]}:{i["name"]}'
iid = one("/dcim/interfaces/", device_id=did, name=i["name"]) if did else None
emit(f'netbox_device_interface.this["{key}"]', iid, key)
if i.get("ip"):
emit(f'netbox_ip_address.device["{key}"]', one("/ipam/ip-addresses/", address=i["ip"]), i["ip"])
# netbox_device_primary_ip is deliberately NOT imported: the provider has no
# importable object at the device ID ("no object exists with the given id").
# Letting Terraform "create" it simply re-PATCHes primary_ip4 to the value it
# already holds, which NetBox treats as a no-op.
if i.get("mac"):
mac = i["mac"].upper()
mid = one("/dcim/mac-addresses/", mac_address=mac)
emit(f'netbox_mac_address.this["{key}"]', mid, mac)
if iid:
emit(f'netbox_device_interface_primary_mac_address.this["{key}"]', iid, f"{key} primary mac")
for it in d.get("inventory_items", []):
key = f'{d["name"]}:{it["name"]}'
emit(f'netbox_inventory_item.this["{key}"]',
one("/dcim/inventory-items/", device_id=did, name=it["name"]) if did else None, key)
for v in t["virtual_machines"]:
vid = one("/virtualization/virtual-machines/", name=v["name"])
emit(f'netbox_virtual_machine.this["{v["name"]}"]', vid, v["name"])
for i in v["interfaces"]:
key = f'{v["name"]}:{i["name"]}'
iid = one("/virtualization/interfaces/", virtual_machine_id=vid, name=i["name"]) if vid else None
emit(f'netbox_interface.this["{key}"]', iid, key)
if i.get("ip"):
emit(f'netbox_ip_address.vm["{key}"]', one("/ipam/ip-addresses/", address=i["ip"]), i["ip"])
(HERE / "imports.tf").write_text("\n".join(out))
n = sum(1 for line in out if line.startswith("import {"))
print(f"wrote imports.tf with {n} import blocks")
if missing:
print(f"NOT FOUND in NetBox (Terraform will create these): {', '.join(missing)}")
return 0
if __name__ == "__main__":
sys.exit(main())
+402
View File
@@ -0,0 +1,402 @@
# GENERATED by gen-imports.py — one-shot bootstrap, safe to delete after the first
# successful `terraform apply`. Do not hand-edit.
import {
to = netbox_site.this
id = "1"
}
import {
to = netbox_vlan_group.this
id = "1"
}
import {
to = netbox_ipam_role.this["lan"]
id = "1"
}
import {
to = netbox_ipam_role.this["sdn"]
id = "2"
}
import {
to = netbox_ipam_role.this["k3s"]
id = "3"
}
import {
to = netbox_ipam_role.this["wan"]
id = "4"
}
import {
to = netbox_vlan.this["100"]
id = "1"
}
import {
to = netbox_vlan.this["110"]
id = "2"
}
import {
to = netbox_prefix.this["192.168.10.0/24"]
id = "1"
}
import {
to = netbox_prefix.this["10.60.0.0/24"]
id = "2"
}
import {
to = netbox_prefix.this["10.61.0.0/24"]
id = "3"
}
import {
to = netbox_prefix.this["10.42.0.0/16"]
id = "4"
}
import {
to = netbox_prefix.this["10.43.0.0/16"]
id = "5"
}
import {
to = netbox_prefix.this["10.1.72.0/24"]
id = "6"
}
import {
to = netbox_ip_range.this["192.168.10.10/24-192.168.10.250/24"]
id = "1"
}
import {
to = netbox_wireless_lan.this["Buffalo-A-07B0-WPA3"]
id = "1"
}
import {
to = netbox_wireless_lan.this["Buffalo-A-07B0"]
id = "2"
}
import {
to = netbox_wireless_lan.this["Buffalo-G-07B0"]
id = "3"
}
import {
to = netbox_manufacturer.this["nec"]
id = "1"
}
import {
to = netbox_manufacturer.this["intel"]
id = "2"
}
import {
to = netbox_manufacturer.this["lenovo"]
id = "3"
}
import {
to = netbox_manufacturer.this["dell"]
id = "5"
}
import {
to = netbox_manufacturer.this["buffalo"]
id = "6"
}
import {
to = netbox_manufacturer.this["yamaha"]
id = "7"
}
import {
to = netbox_manufacturer.this["broadcom"]
id = "8"
}
import {
to = netbox_device_type.this["ix2215"]
id = "1"
}
import {
to = netbox_device_type.this["nuc6i3syb"]
id = "5"
}
import {
to = netbox_device_type.this["10vgcto1ww"]
id = "6"
}
import {
to = netbox_device_type.this["xps-15-9570"]
id = "7"
}
import {
to = netbox_device_type.this["wsr-1800ax4s"]
id = "8"
}
import {
to = netbox_device_type.this["rtx1200"]
id = "9"
}
import {
to = netbox_device_role.this["router"]
id = "1"
}
import {
to = netbox_device_role.this["hypervisor"]
id = "2"
}
import {
to = netbox_device_role.this["core-node"]
id = "3"
}
import {
to = netbox_device_role.this["wireless-ap"]
id = "4"
}
import {
to = netbox_cluster_type.this["proxmox"]
id = "1"
}
import {
to = netbox_cluster_type.this["libvirt"]
id = "2"
}
import {
to = netbox_cluster.this["homelab"]
id = "1"
}
import {
to = netbox_cluster.this["laptop-libvirt"]
id = "2"
}
import {
to = netbox_device.this["ix2215"]
id = "1"
}
import {
to = netbox_device_interface.this["ix2215:GigaEthernet2.0"]
id = "1"
}
import {
to = netbox_ip_address.device["ix2215:GigaEthernet2.0"]
id = "1"
}
import {
to = netbox_device_interface.this["ix2215:GigaEthernet0.0"]
id = "2"
}
import {
to = netbox_device.this["pve1"]
id = "2"
}
import {
to = netbox_device_interface.this["pve1:vmbr0"]
id = "3"
}
import {
to = netbox_ip_address.device["pve1:vmbr0"]
id = "2"
}
import {
to = netbox_device.this["pve2"]
id = "3"
}
import {
to = netbox_device_interface.this["pve2:vmbr0"]
id = "4"
}
import {
to = netbox_ip_address.device["pve2:vmbr0"]
id = "3"
}
import {
to = netbox_device.this["pve3"]
id = "4"
}
import {
to = netbox_device_interface.this["pve3:vmbr0"]
id = "5"
}
import {
to = netbox_ip_address.device["pve3:vmbr0"]
id = "4"
}
import {
to = netbox_device.this["laptop"]
id = "5"
}
import {
to = netbox_device_interface.this["laptop:br0"]
id = "6"
}
import {
to = netbox_ip_address.device["laptop:br0"]
id = "5"
}
import {
to = netbox_inventory_item.this["laptop:BCM4360 802.11ac"]
id = "1"
}
import {
to = netbox_device.this["ap-buffalo"]
id = "6"
}
import {
to = netbox_device_interface.this["ap-buffalo:lan1"]
id = "7"
}
import {
to = netbox_ip_address.device["ap-buffalo:lan1"]
id = "12"
}
import {
to = netbox_mac_address.this["ap-buffalo:lan1"]
id = "1"
}
import {
to = netbox_device_interface_primary_mac_address.this["ap-buffalo:lan1"]
id = "7"
}
import {
to = netbox_device_interface.this["ap-buffalo:wlan-2.4g"]
id = "8"
}
import {
to = netbox_device_interface.this["ap-buffalo:wlan-5g"]
id = "9"
}
import {
to = netbox_device.this["rtx1200"]
id = "7"
}
import {
to = netbox_virtual_machine.this["vyos-rtr"]
id = "1"
}
import {
to = netbox_interface.this["vyos-rtr:eth0"]
id = "1"
}
import {
to = netbox_ip_address.vm["vyos-rtr:eth0"]
id = "6"
}
import {
to = netbox_interface.this["vyos-rtr:eth1"]
id = "2"
}
import {
to = netbox_ip_address.vm["vyos-rtr:eth1"]
id = "7"
}
import {
to = netbox_interface.this["vyos-rtr:eth2"]
id = "3"
}
import {
to = netbox_ip_address.vm["vyos-rtr:eth2"]
id = "8"
}
import {
to = netbox_virtual_machine.this["dc1"]
id = "2"
}
import {
to = netbox_interface.this["dc1:lan"]
id = "4"
}
import {
to = netbox_ip_address.vm["dc1:lan"]
id = "9"
}
import {
to = netbox_virtual_machine.this["winadmin"]
id = "3"
}
import {
to = netbox_interface.this["winadmin:lan"]
id = "5"
}
import {
to = netbox_ip_address.vm["winadmin:lan"]
id = "10"
}
import {
to = netbox_virtual_machine.this["bao1"]
id = "4"
}
import {
to = netbox_interface.this["bao1:lan"]
id = "6"
}
import {
to = netbox_ip_address.vm["bao1:lan"]
id = "11"
}
+245
View File
@@ -0,0 +1,245 @@
# NetBox object graph, driven by topology.yml.
#
# WHY yamldecode rather than HCL resources per object: topology.yml stays the readable,
# authoritative artifact (git -> NetBox, see ../CONTEXT.md §4), and Terraform supplies what
# a plain script could not — state, `plan` as a drift report, and DELETION. Removing an
# entry from the YAML now removes the object from NetBox, which the previous seed script
# never did.
#
# Ownership boundary, matching ../../../infrastructure/openbao/terraform: Terraform owns API-level
# configuration. The k8s manifests that RUN NetBox live one level up in ../.
locals {
topo = yamldecode(file("${path.module}/topology.yml"))
# --- flattened lookup maps -------------------------------------------------
# Interfaces are nested under devices/VMs in the YAML; Terraform needs flat maps keyed
# by a stable string. "<parent>:<iface>" is that key everywhere below.
device_ifaces = merge([
for d in local.topo.devices : {
for i in d.interfaces : "${d.name}:${i.name}" => merge(i, { device = d.name })
}
]...)
vm_ifaces = merge([
for v in local.topo.virtual_machines : {
for i in v.interfaces : "${v.name}:${i.name}" => merge(i, { vm = v.name })
}
]...)
# Only interfaces that actually carry an address.
device_ips = { for k, i in local.device_ifaces : k => i if try(i.ip, null) != null }
vm_ips = { for k, i in local.vm_ifaces : k => i if try(i.ip, null) != null }
# The single address that becomes the parent's primary_ip4.
device_primary = { for k, i in local.device_ips : i.device => k if try(i.primary, false) }
vm_primary = { for k, i in local.vm_ips : i.vm => k if try(i.primary, false) }
device_macs = { for k, i in local.device_ifaces : k => i if try(i.mac, null) != null }
inventory_items = merge([
for d in local.topo.devices : {
for it in try(d.inventory_items, []) : "${d.name}:${it.name}" => merge(it, { device = d.name })
}
]...)
}
# --- site + IPAM ---------------------------------------------------------------
resource "netbox_site" "this" {
name = local.topo.site.name
slug = local.topo.site.slug
description = local.topo.site.description
status = "active"
}
resource "netbox_ipam_role" "this" {
for_each = { for r in local.topo.prefix_roles : r.slug => r }
name = each.value.name
slug = each.value.slug
}
resource "netbox_vlan_group" "this" {
name = local.topo.vlan_group.name
slug = local.topo.vlan_group.slug
description = local.topo.vlan_group.description
# Required by the provider. The SDN zone is a plain VLAN zone on vmbr0, which is
# bridge-vlan-aware for the full range, so do not narrow this without changing that.
vid_ranges = [[1, 4094]]
}
resource "netbox_vlan" "this" {
for_each = { for v in local.topo.vlans : tostring(v.vid) => v }
vid = each.value.vid
name = each.value.name
group_id = netbox_vlan_group.this.id
site_id = netbox_site.this.id
status = "active"
}
resource "netbox_prefix" "this" {
for_each = { for p in local.topo.prefixes : p.prefix => p }
prefix = each.value.prefix
status = "active"
# The provider exposes plain `site_id` and handles NetBox 4.2+'s generic
# scope_type/scope_id internally — which is exactly the trap that broke the hand-rolled
# script (posting `site` was silently dropped). Using the provider avoids it.
site_id = netbox_site.this.id
role_id = netbox_ipam_role.this[each.value.role].id
vlan_id = try(netbox_vlan.this[tostring(each.value.vlan)].id, null)
description = each.value.description
}
resource "netbox_ip_range" "this" {
for_each = { for r in local.topo.ip_ranges : "${r.start}-${r.end}" => r }
start_address = each.value.start
end_address = each.value.end
status = each.value.status
mark_utilized = try(each.value.mark_utilized, false)
description = each.value.description
}
# --- Wi-Fi ---------------------------------------------------------------------
# ⚠ PARTIAL: the provider can create the SSIDs but has NO attribute for attaching them to
# a radio interface, and none for `rf_role`. Neither does the netbox.netbox Ansible
# collection. That last mile is done by ./attach-wireless.py — see ../README.md.
resource "netbox_wireless_lan" "this" {
for_each = { for w in local.topo.wireless_lans : w.ssid => w }
ssid = each.value.ssid
auth_type = each.value.auth_type
auth_cipher = each.value.auth_cipher
description = each.value.description
# auth_psk deliberately unset: OpenBao is the secrets store, not NetBox.
}
# --- hardware ------------------------------------------------------------------
resource "netbox_manufacturer" "this" {
for_each = { for m in local.topo.manufacturers : m.slug => m }
name = each.value.name
slug = each.value.slug
}
resource "netbox_device_type" "this" {
for_each = { for d in local.topo.device_types : d.slug => d }
model = each.value.model
slug = each.value.slug
manufacturer_id = netbox_manufacturer.this[each.value.manufacturer].id
# Same reason as vm_role above: NetBox's default is true, so pin it or every plan wants
# to clear it. Meaningless for this hardware (nothing is rack-mounted) but stops churn.
is_full_depth = true
}
resource "netbox_device_role" "this" {
for_each = { for r in local.topo.device_roles : r.slug => r }
name = each.value.name
slug = each.value.slug
color_hex = each.value.color
# NetBox defaults this to true; the provider defaults it to false, so without pinning it
# every plan shows a spurious vm_role true -> false diff.
vm_role = true
}
resource "netbox_device" "this" {
for_each = { for d in local.topo.devices : d.name => d }
name = each.value.name
site_id = netbox_site.this.id
role_id = netbox_device_role.this[each.value.role].id
device_type_id = netbox_device_type.this[each.value.type].id
description = each.value.description
comments = try(each.value.comments, "")
serial = try(each.value.serial, "")
status = try(each.value.status, "active")
}
resource "netbox_device_interface" "this" {
for_each = local.device_ifaces
device_id = netbox_device.this[each.value.device].id
name = each.value.name
type = each.value.type
description = try(each.value.description, "")
mtu = try(each.value.mtu, null)
}
resource "netbox_inventory_item" "this" {
for_each = local.inventory_items
device_id = netbox_device.this[each.value.device].id
name = each.value.name
manufacturer_id = netbox_manufacturer.this[each.value.manufacturer].id
part_id = try(each.value.part_id, "")
serial = try(each.value.serial, "")
description = try(each.value.description, "")
}
# MACs are first-class objects in NetBox 4.2+; `mac_address` on the interface is read-only.
resource "netbox_mac_address" "this" {
for_each = local.device_macs
mac_address = upper(each.value.mac)
device_interface_id = netbox_device_interface.this[each.key].id
}
resource "netbox_device_interface_primary_mac_address" "this" {
for_each = local.device_macs
interface_id = netbox_device_interface.this[each.key].id
mac_address_id = netbox_mac_address.this[each.key].id
}
# --- virtualization ------------------------------------------------------------
resource "netbox_cluster_type" "this" {
for_each = { for c in local.topo.cluster_types : c.slug => c }
name = each.value.name
slug = each.value.slug
}
resource "netbox_cluster" "this" {
for_each = { for c in local.topo.clusters : c.name => c }
name = each.value.name
cluster_type_id = netbox_cluster_type.this[each.value.type].id
description = each.value.description
site_id = netbox_site.this.id
}
resource "netbox_virtual_machine" "this" {
for_each = { for v in local.topo.virtual_machines : v.name => v }
name = each.value.name
cluster_id = netbox_cluster.this[each.value.cluster].id
description = each.value.description
# NetBox DERIVES a VM's site from its cluster. Leaving this unset makes the provider
# try to clear it on every plan (site_id 1 -> None), so declare it to match.
site_id = netbox_site.this.id
}
resource "netbox_interface" "this" {
for_each = local.vm_ifaces
virtual_machine_id = netbox_virtual_machine.this[each.value.vm].id
name = each.value.name
description = try(each.value.description, "")
}
# --- addresses -----------------------------------------------------------------
resource "netbox_ip_address" "device" {
for_each = local.device_ips
ip_address = each.value.ip
status = "active"
# No `object_type` here: the provider pairs that with the GENERIC `interface_id`
# ("all of interface_id,object_type must be specified"). The dedicated
# *_interface_id attributes are standalone and imply the type.
device_interface_id = netbox_device_interface.this[each.key].id
# Native NetBox field. Setting it is INTENT: "this host needs a static A record in AD
# DNS". Domain-joined hosts self-register and are deliberately absent.
# ../generate/samba-a-records.py turns these into samba_ad_extra_a_records.
dns_name = try(each.value.dns_name, "")
}
resource "netbox_ip_address" "vm" {
for_each = local.vm_ips
ip_address = each.value.ip
status = "active"
virtual_machine_interface_id = netbox_interface.this[each.key].id
dns_name = try(each.value.dns_name, "")
}
# primary_ip4 lives on the parent, so the provider models it as its own resource.
resource "netbox_device_primary_ip" "this" {
for_each = local.device_primary
device_id = netbox_device.this[each.key].id
ip_address_id = netbox_ip_address.device[each.value].id
}
+14
View File
@@ -0,0 +1,14 @@
output "site_id" {
value = netbox_site.this.id
description = "NetBox ID of the Homelab site."
}
output "prefix_ids" {
value = { for k, p in netbox_prefix.this : k => p.id }
description = "prefix -> NetBox ID, for cross-referencing from other tooling."
}
output "device_ids" {
value = { for k, d in netbox_device.this : k => d.id }
description = "device name -> NetBox ID."
}
@@ -0,0 +1,2 @@
# Copy to terraform.tfvars (gitignored) and fill in, or export TF_VAR_netbox_token.
netbox_token = "nbt_xxxxxxxx.yyyyyyyy"
+262
View File
@@ -0,0 +1,262 @@
# Homelab topology — the INPUT to NetBox, not a dump of it.
#
# This file is deliberately the authoritative artifact: git stays the source of truth
# and NetBox is a derived mirror, populated by ./seed.py. That answers the design
# question in ../CONTEXT.md §4 the way the rest of this repo works — a UI you must
# click to change routing would be a regression against every other service here.
#
# Facts mirror CONTEXT.md §3 (verified live 2026-07-25). Interface names are verified,
# not guessed, EXCEPT where marked `# placeholder`.
site:
name: Homelab
slug: homelab
description: "Single flat 1G LAN on one unmanaged switch, 192.168.10.0/24"
# --- Layer 3 -----------------------------------------------------------------
prefixes:
- prefix: 192.168.10.0/24
role: lan
description: "LAN. Flat L2 across one dumb switch; OSPF area 0 runs here."
- prefix: 10.60.0.0/24
role: sdn
vlan: 100
description: "PVE SDN VNet labnet. Gateway 10.60.0.1 on vyos eth1."
- prefix: 10.61.0.0/24
role: sdn
vlan: 110
description: "PVE SDN VNet retronet. Gateway 10.61.0.1 on vyos eth2."
- prefix: 10.42.0.0/16
role: k3s
description: "k3s pod CIDR (laptop). Not routed off-node."
- prefix: 10.43.0.0/16
role: k3s
description: "k3s service CIDR (laptop). Not routed off-node."
- prefix: 10.1.72.0/24
role: wan
description: "WAN side of the NEC IX (GigaEthernet0.0)."
prefix_roles:
- { name: LAN, slug: lan }
- { name: SDN VNet, slug: sdn }
- { name: Kubernetes, slug: k3s }
- { name: WAN, slug: wan }
# The router's DHCP pool (CONTEXT.md §4 called this out specifically).
#
# There is no `dhcp` status — IPRange offers only active/reserved/deprecated. The pool
# concept is the `mark_utilized` boolean ("Report space as fully utilized"), which makes
# NetBox stop offering those addresses as available.
#
# Be precise about what this buys: it does NOT hard-block an allocation inside the
# range. It makes the collision VISIBLE — the range shows 100% utilised and the
# address never appears as a suggestion — where plain YAML shows nothing at all.
ip_ranges:
- start: 192.168.10.10/24
end: 192.168.10.250/24
status: active
mark_utilized: true
description: "NEC IX DHCP pool — do NOT statically allocate inside this."
vlan_group:
name: lab
slug: lab
description: "PVE SDN zone `lab` (type vlan, bridge vmbr0). Segmentation, NOT security."
vlans:
- { vid: 100, name: labnet, prefix: 10.60.0.0/24 }
- { vid: 110, name: retronet, prefix: 10.61.0.0/24 }
# --- Wi-Fi -------------------------------------------------------------------
# Broadcast by ap-buffalo (below). No `vlan:` on any of them: the AP bridges, so wireless
# clients land UNTAGGED on the flat LAN and pick up an address from the IX DHCP pool.
# They are on the same L2 as everything else — the Wi-Fi is not a separate segment.
#
# ⚠ NetBox has no WPA3 auth_type — the choices are open/wep/wpa-personal/wpa-enterprise,
# so WPA2-PSK and WPA3-SAE both land on `wpa-personal`. The real difference is recorded in
# the description because the model cannot express it.
#
# auth_psk is deliberately LEFT EMPTY. NetBox can store the passphrase, but that would put
# the house Wi-Fi key in a system whose own DB backup story is untested; OpenBao is the
# secrets store here (see ../../../infrastructure/openbao).
wireless_lans:
- ssid: Buffalo-A-07B0-WPA3
auth_type: wpa-personal
auth_cipher: aes
description: "5 GHz, WPA3-SAE. Preferred SSID for clients that support it."
- ssid: Buffalo-A-07B0
auth_type: wpa-personal
auth_cipher: aes
description: "5 GHz, WPA2-PSK. Compatibility SSID for clients that cannot do WPA3."
- ssid: Buffalo-G-07B0
auth_type: wpa-personal
auth_cipher: aes
description: "2.4 GHz, WPA2-PSK. Range/IoT band."
# --- Layer 2 / hardware ------------------------------------------------------
# Model/serial values below are READ FROM THE HARDWARE (`dmidecode -s ...`), not guessed.
manufacturers:
- { name: NEC, slug: nec }
- { name: Intel, slug: intel }
- { name: Lenovo, slug: lenovo }
- { name: Dell, slug: dell }
- { name: Buffalo, slug: buffalo }
- { name: Yamaha, slug: yamaha }
- { name: Broadcom, slug: broadcom }
device_types:
- { model: IX2215, slug: ix2215, manufacturer: nec }
# dmidecode: system-manufacturer/product/serial are all BLANK on this NUC (the OEM
# never programmed them). baseboard-product-name is the only real identifier, and it
# is SYB (the board), not the SYH chassis this was previously guessed to be.
- { model: NUC6i3SYB, slug: nuc6i3syb, manufacturer: intel }
# Lenovo's machine-type; this is the ThinkCentre M715q Tiny.
- { model: 10VGCTO1WW, slug: 10vgcto1ww, manufacturer: lenovo }
- { model: XPS 15 9570, slug: xps-15-9570, manufacturer: dell }
- { model: WSR-1800AX4S, slug: wsr-1800ax4s, manufacturer: buffalo }
- { model: RTX1200, slug: rtx1200, manufacturer: yamaha }
device_roles:
- { name: Router, slug: router, color: f44336 }
- { name: Hypervisor, slug: hypervisor, color: 2196f3 }
- { name: Core Node, slug: core-node, color: 4caf50 }
- { name: Wireless AP, slug: wireless-ap, color: ff9800 }
devices:
- name: ix2215
role: router
type: ix2215
description: "NEC IX. Gateway, OSPF area 0, BGP, DNS proxy, DHCP server."
interfaces:
- { name: GigaEthernet2.0, type: 1000base-t, ip: 192.168.10.1/24, primary: true, dns_name: gw.ad.ddupan.top }
- { name: GigaEthernet0.0, type: 1000base-t, ip: null, description: "WAN uplink, 10.1.72.0/24" }
- name: pve1
role: hypervisor
type: nuc6i3syb
description: "Proxmox VE 9.2. LINSTOR controller."
# No serial: this NUC reports blank system-serial-number (see device_types note).
comments: "Intel Core i3-6100U @ 2.30GHz, 4 threads, 15 GiB RAM. BIOS SYSKLi35.86A.0045.2016.0527.1055. Board NUC6i3SYB."
interfaces:
- { name: vmbr0, type: bridge, ip: 192.168.10.4/24, primary: true, mtu: 9000, dns_name: pve1.ad.ddupan.top }
- name: pve2
role: hypervisor
type: 10vgcto1ww
serial: PC1AGX1Q
description: "Proxmox VE 9.2. LINSTOR satellite. The node that randomly froze."
comments: "AMD Ryzen 5 PRO 2400GE w/ Vega, 8 threads, 7 GiB RAM. BIOS M1XKT45A. Raven Ridge idle bug fixed in BIOS: Power Supply Idle Control = Typical Current Idle."
interfaces:
- { name: vmbr0, type: bridge, ip: 192.168.10.7/24, primary: true, mtu: 9000, dns_name: pve2.ad.ddupan.top }
- name: pve3
role: hypervisor
type: 10vgcto1ww
serial: PC1AGX1P
description: "Proxmox VE 9.2. LINSTOR satellite."
comments: "AMD Ryzen 5 PRO 2400GE w/ Vega, 8 threads, 7 GiB RAM. BIOS M1XKT55A. Same silicon as pve2, so susceptible to the same idle bug in principle."
interfaces:
- { name: vmbr0, type: bridge, ip: 192.168.10.9/24, primary: true, mtu: 9000, dns_name: pve3.ad.ddupan.top }
- name: laptop
role: core-node
type: xps-15-9570
serial: 6R7CQQ2
description: "Core node, NOT a PVE cluster member. k3s, NFS, libvirt host, netboot.xyz, OSPF DR. Single point of failure for most of the lab."
comments: "Intel Core i7-8750H @ 2.20GHz, 12 threads, 30 GiB RAM. BIOS 1.20.0. Nvidia dGPU stays bare-metal for nvidia-container-toolkit. Built-in battery acts as a UPS."
interfaces:
- { name: br0, type: bridge, ip: 192.168.10.127/24, primary: true }
inventory_items:
# Present in hardware but NOT usable, so it is an inventory item rather than an
# interface — there is no netdev for it.
#
# `lspci -k` shows bcma-pci-bridge bound and b43 loaded, but b43 does NOT support
# BCM4360; that chip needs Broadcom's proprietary `wl` (broadcom-sta) driver with
# b43/bcma/ssb blacklisted. Until then the laptop cannot scan or join Wi-Fi.
- name: BCM4360 802.11ac
manufacturer: broadcom
part_id: "14e4:43a0"
description: "PCI 3b:00.0, Apple-subsystem card. No driver: b43 claims it but cannot drive BCM4360; needs broadcom-sta (wl)."
# Wi-Fi. Runs as an AP/bridge, not a router — the NEC IX is the gateway, so this box's
# routing, NAT and DHCP are not in play. Wireless clients land directly on the flat LAN.
#
# ⚠ Its address .10 is the FIRST ADDRESS OF THE DHCP POOL above. Either it holds a lease
# (so the address can move) or it is a static that overlaps the pool. NetBox surfaces
# the overlap; the underlying config still needs a decision. See ../README.md.
#
# Identified by MAC OUI d4:2c:46 = BUFFALO.INC plus the model string on its login page.
- name: ap-buffalo
role: wireless-ap
type: wsr-1800ax4s
description: "Buffalo AirStation, AP/bridge mode. Provides the house Wi-Fi."
comments: "Wi-Fi 6 (802.11ax) dual band. Web UI on http://192.168.10.10/. Model read from its login page; serial not recorded (needs the label or an authenticated session)."
interfaces:
- { name: lan1, type: 1000base-t, ip: 192.168.10.10/24, primary: true, mac: "d4:2c:46:09:07:b0", dns_name: ap.ad.ddupan.top, description: "Uplink to the dumb switch" }
# Buffalo's factory SSID scheme: A = 5 GHz, G = 2.4 GHz, and the suffix is the tail
# of this AP's own MAC (d4:2c:46:09:07:b0 -> 07B0), which independently corroborates
# that this device is the AP.
- name: wlan-2.4g
type: ieee802.11ax
rf_role: ap
description: "2.4 GHz radio"
wireless_lans: [Buffalo-G-07B0]
- name: wlan-5g
type: ieee802.11ax
rf_role: ap
description: "5 GHz radio"
wireless_lans: [Buffalo-A-07B0-WPA3, Buffalo-A-07B0]
# Spare/shelf kit. Recorded so it is not forgotten — knowing what you own and are NOT
# using is a legitimate reason to run a DCIM tool.
- name: rtx1200
role: router
type: rtx1200
status: inventory # NOT active: unplugged, no addresses, not cabled
description: "Yamaha RTX1200. Spare — not in use."
comments: "Gigabit VPN router. Kept as a spare / potential replacement for the NEC IX. Serial not recorded (would need the chassis label)."
interfaces: []
# --- Virtual machines --------------------------------------------------------
cluster_types:
- { name: Proxmox VE, slug: proxmox }
- { name: libvirt, slug: libvirt }
clusters:
- { name: homelab, type: proxmox, description: "3-node PVE cluster, no HA, LINSTOR place-count 2." }
- { name: laptop-libvirt, type: libvirt, description: "libvirt guests on the laptop." }
virtual_machines:
- name: vyos-rtr
cluster: homelab
description: "VyOS 2025.11. SDN gateway, OSPF area 0. VM 100. Routed, not NAT'd."
interfaces:
- { name: eth0, ip: 192.168.10.2/24, primary: true, dns_name: vyos-rtr.ad.ddupan.top, description: "LAN" }
- { name: eth1, ip: 10.60.0.1/24, description: "labnet gateway (VLAN 100), OSPF passive" }
- { name: eth2, ip: 10.61.0.1/24, description: "retronet gateway (VLAN 110), OSPF passive" }
# Found by diffing NetBox against samba_ad_extra_a_records — it had a live A record and
# was missing from NetBox entirely. Verified up at 10.60.0.10.
- name: retrolab
cluster: homelab
description: "AD-joined XFCE/xrdp host for running 86Box. Lives on labnet (VLAN 100)."
interfaces:
- { name: eth0, ip: 10.60.0.10/24, primary: true, dns_name: retrolab.ad.ddupan.top }
- name: dc1
cluster: laptop-libvirt
description: "Samba AD DC, authoritative for ad.ddupan.top."
interfaces:
- { name: lan, ip: 192.168.10.5/24, primary: true } # placeholder: NIC name not verified
- name: winadmin
cluster: laptop-libvirt
description: "Windows Server 2025 admin box."
interfaces:
- { name: lan, ip: 192.168.10.6/24, primary: true } # placeholder: NIC name not verified
- name: bao1
cluster: laptop-libvirt
description: "OpenBao — internal CA + secrets store."
interfaces:
- { name: lan, ip: 192.168.10.8/24, primary: true, dns_name: bao.ad.ddupan.top } # placeholder: NIC name not verified
+15
View File
@@ -0,0 +1,15 @@
variable "netbox_url" {
type = string
description = "Base URL of NetBox, WITHOUT the /api suffix."
default = "https://netbox.ad.ddupan.top"
}
variable "netbox_token" {
type = string
sensitive = true
description = <<-EOT
NetBox API token. A v2 token (nbt_<key>.<secret>) works fine — NetBox dispatches on the
token value, not the Authorization scheme. Mint one with the snippet in ../README.md.
Supply via TF_VAR_netbox_token or terraform.tfvars (gitignored).
EOT
}
+20
View File
@@ -0,0 +1,20 @@
terraform {
required_version = ">= 1.5"
required_providers {
netbox = {
source = "e-breuninger/netbox"
# ⚠ Pin to 5.x. Provider 4.3.1 FAILS against NetBox 4.6 at provider-configure time
# with a go-openapi error ("... is not supported by the TextConsumer"); 5.7.0 works.
version = "~> 5.0"
}
}
}
# Same pattern as ../../../infrastructure/openbao/terraform and ../../../infrastructure/cloudflared/terraform: credentials come
# from outside the repo, state is local and gitignored.
provider "netbox" {
# ⚠ BASE URL ONLY — no /api suffix. Passing ".../api" produces the same misleading
# TextConsumer error as the version mismatch above and costs an hour to diagnose.
server_url = var.netbox_url
api_token = var.netbox_token
}