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,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
|
||||
}
|
||||
Reference in New Issue
Block a user