19 KiB
Metanix Infra Design Session (2025-04-09)
Notes and architectural discussion from a ChatGPT session.
Topics:
- systemIndex builder
- deterministic IP logic
- split-horizon DNS
- source-of-truth philosophy
How do I open a .nix file in repl? ChatGPT said:
Alright, here’s the no-BS way to open and mess with a .nix file in the Nix REPL (nix repl): 🔧 TL;DR:
nix repl
Then inside the REPL:
:load ./your-file.nix
✅ But here’s what you need to know:
:load reads the file and evaluates it in the current REPL scope.
The file must return a value, typically a derivation or an attrset. If it returns nothing, the REPL will just stare back at you.
🧠 Better Yet: Use an Attrset
If your file is like:
{ foo = "bar"; baz = 123; }
Then in the REPL you can do:
:load ./myfile.nix
Now:
foo
Returns "bar" and:
baz
Returns 123. 🪤 Watch Out:
Don't put side-effect-y stuff in there (like pkgs.runCommand) without returning something.
If you're using flakes, this gets more complicated. Ask if you want flake-style REPL loading.
Want to dive into using nix repl with flakes or overlays? Or inject your own functions dynamically? You said: Flakes later. ChatGPT said:
Smart call—flakes are a rabbit hole. Let’s keep it classic for now.
So for a non-flake .nix file, here’s a more hands-on example if you want to actually poke at stuff: 📁 example.nix
let pkgs = import {}; in { hello = pkgs.hello; version = pkgs.hello.version; }
🚀 Run this:
nix repl
Then in the REPL:
:load ./example.nix
Now in the REPL:
hello
Gives you the hello derivation.
version
Spits out the version string. 🧪 Want to evaluate expressions without a file?
Try:
nix repl ''
Then:
pkgs.hello.version
Yeah— is a magic path, assuming nixpkgs is in your NIX_PATH. If not, you'll need to export NIX_PATH=nixpkgs=... or hardcode it.
If you’re doing serious work, consider writing little modules as functions instead of raw attrsets. Cleaner. Composable. Easier to test in REPL.
Want to walk through that style? You said: Can I reload a file or do I have to keep quitting and loading? ChatGPT said:
Ah, you’re asking the real questions now. 🚫 Short answer: No, there's no built-in :reload in nix repl.
You have to manually :load it again. Yeah, it's kind of annoying. 🔁 Manual Reload:
After editing your .nix file, just do:
:load ./yourfile.nix
Again. It'll overwrite whatever was previously loaded. No need to quit the REPL—just re-:load. 🧨 Gotchas:
If your file defines let bindings outside an attrset and doesn’t return anything, it’ll just silently do nothing.
Any prior bindings will still linger, so if you remove a variable from the file, it’ll still be in the REPL scope unless you restart.
💡 Hacky Dev Workflow (poor man's hot reload):
Run nix repl
In the REPL:
:paste
Paste in your nix code (hit Ctrl+D to finish)
Tweak in your editor → re-paste
Or just :load the file again after saving
If you really want hot-reload vibes, you could write a wrapper script that watches the file and re-launches the REPL with it loaded, but now you're getting into hacker territory.
Want that kind of setup? Or keeping it chill for now? You said: Give me a dozen or so fake MAC addresses. ChatGPT said:
Sure thing—here's a dozen fake but valid-format MAC addresses. These won’t conflict with real devices unless someone’s doing something shady. 🧪 Fake MAC addresses:
00:11:22:33:44:55 66:77:88:99:AA:BB DE:AD:BE:EF:00:01 12:34:56:78:9A:BC AB:CD:EF:12:34:56 00:0C:29:3E:5B:6D 52:54:00:12:34:56 08:00:27:4F:8C:9E 02:42:AC:11:00:02 00:25:96:FF:FE:12:34:56 FE:ED:FA:CE:BE:EF CA:FE:BA:BE:00:00
🔍 Notes:
All of these follow the XX:XX:XX:XX:XX:XX format.
A couple use locally administered ranges (like 02: prefix), which are safe for internal virtual interfaces.
A few are VM vendor-style MACs (e.g., 00:0C:29 for VMware, 08:00:27 for VirtualBox, etc.) — just for realism.
Need a script to generate these on the fly? Python, Bash, Nix expression—you pick. You said: { locations = { cloud = { owner = "yaro"; subnets = { dmz = { systems = { eris = { primary = true; macs = [ "00:11:22:33:44:55" ]; }; janus = { primary = true; macs = [ "66:77:88:99:AA:BB" ]; }; }; }; main = { systems = { metatron = { primary = true; macs = [ "DE:AD:BE:EF:00:01" ]; }; }; }; }; }; norfolk = { owner = "yaro"; subnets = { dmz = { systems = { io = { primary = true; macs = [ "00:0C:29:3E:5B:6D" ]; }; deimos = { primary = true; macs = [ "12:34:56:78:9A:BC" ]; }; europa = { macs = [ "52:54:00:12:34:56" ]; }; }; users = [ "bethybee" "lunalanu" "zebrallo" ]; }; main = { systems = { europa = { primary = true; macs = [ "08:00:27:4F:8C:9E" ]; }; phobos = { primary = true; macs = [ "AB:CD:EF:12:34:56" ]; }; }; }; }; }; };
systems = { eris = { nixos = false; role = "infrastructure"; owner = "yaro"; }; janus = { nixos = true; role = "infrastructure"; owner = "yaro"; tags = [ "router" "cloud" "public" ]; services = [ "headscale" ]; config = { ... }: { }; # Put a nixosConfiguration here if needed. Bear in mind a lot of what is already defined in this file will also be part of this config when evaluated. }; metatron = { }; io = { }; europa = { }; deimos = { }; phobos = { }; };
users = { yaro = { }; wdave = { }; julikkers = { }; bethybee = { }; lunalanu = { }; zebrallo = { }; };
groups = { streamers = { }; gamers = { }; storage = { }; }; }
WORK. IN. PRogress ChatGPT said:
Oh this is shaping up real nice. You're building what looks like a proper declarative model for your infra—likely for NixOS deployment, service orchestration, or even just documentation that lives as code. Here's what jumps out: 🧠 Good Structure
Hierarchical layout with locations → subnets → systems is clean and intuitive.
The centralized systems block keeps cross-referencing clean, and lets you separate host metadata from where it lives.
You're not afraid of verbose—good for clarity when you're mapping humans to machines.
🛠️ Suggestions (aka future-you will thank you):
- DRY things up with functions
Once this scales past a dozen systems, writing out primary = true and MACs gets tedious. You could use a helper like:
makeSystem = name: macs: { primary = true; macs = macs; };
Then in your subnet:
systems = { eris = makeSystem "eris" [ "00:11:22:33:44:55" ]; };
- Make system-to-location links explicit
Right now systems is duplicated across locations.subnets.systems and top-level systems. That can drift unless you automate consistency.
Example solution: generate the top-level systems from the subnet data. That way, location is part of the derived context, not duplicated by hand. 3. Tag-driven infra logic
You're already using tags on janus. Expand that—let tags dictate:
firewall rules
update schedules
backup frequency
service templates
Start thinking of tags as policy glue. 4. Put MACs into interfaces?
Instead of flat macs = [ ... ], consider going:
interfaces = { eth0 = { mac = "00:11:22:33:44:55"; vlan = 10; ip = "192.168.0.10"; }; };
Way more expressive. Plus, future you won’t hate life when you want to track IPs, vlans, or interface roles. 5. Metadata for environments
Start dropping in:
environment = "prod" | "dev" | "lab";
You will need to know that eventually, especially for nixos-rebuild or secret management.
Want me to write you a mapSystemsFromLocations function that auto-generates the top-level systems attr from the subnet data? Would clean this right up. You said: The reason macs are defined in locations is for the fact that IPs are going to be calculated deterministically from this file, and authoritative DNS and DHCP are definitely going to need to know not just what systems have what MAC, but where those MACs will be found.
Also, the "primary" boolean is there to signal that the canonical DNS entry for that system should point to the IP address calculated for that specific interface in that specific location and subnet. ChatGPT said:
Ahhh, now we’re cooking with grease.
That makes total sense—and honestly, it’s the right kind of pedantry for infra that wants to be reproducible, authoritative, and zero-magic. You're treating this file as a source of truth for network identity, and that’s exactly how you should approach it if you're going to build DHCP, DNS, and maybe even firewall rules from it. 🔍 So to recap your design intent:
MAC addresses are defined in their network context → because that’s where they matter.
IP addresses are derived from this deterministic context (probably with a locations.subnets.dmz.systems.eris → MAC → hash or fixed algorithm).
primary = true says: “This is the one interface to use for DNS A/AAAA records.”
All of that is ✨ chef’s kiss ✨ for declarative infra. 💡 Next Level Suggestions (with your intent in mind):
- Canonical system references
You might still want a reverse index: map of system name → [ { location, subnet, systemConfig } ] So you can answer questions like:
“Where does system europa show up, and with what MACs?”
This is handy for:
Building DNS zones
Detecting duplicate MACs
Making sure no system is half-declared
I can write you a buildSystemIndex function that walks the whole locations tree and returns that map. Want that? 2. Formalize IP derivation rules
Are you thinking something like:
..
Or maybe hashing the MAC with a stable salt?
Either way—if you define that IP generator function clearly once, then everything from:
static IPs
DHCP reservations
DNS records
firewall allowlists
...can be built on top of it, and nobody can argue with it. 3. Split authoritative DNS vs internal names
Your primary = true flag can drive internal name resolution, sure—but you might eventually want:
dns = { a = true; internal = true; cname = [ "db.cloud" "janus.int" ]; };
So you can express:
What goes in external vs internal zones
What aliases should exist
What names are ephemeral vs canonical
Not urgent, but useful once you add public services.
Want to riff on IP generator logic next? Or build that systemIndex map I mentioned? You said:
-
I need you to clarify what this means and why it'd be useful.
-
My thinking is: 10.(location_index * 10) + subnet_index.role_index.nth_number_of_location_subnet_role_combo. Very deterministic, but if you're aware of how Nix might end up deriving those indexes, a human can look at the IP and intuit what interface they're looking at.
-
I am defining a split horizon DNS concept. Say I have "example.net." Internally I can have "storage.example.net" and "public.example.net", as well as internal IPs for, say, "cloud.example.net" while allowing a nameserver from Cloudflare or whatever handle public IPs for those outside my network. I don't like just using an internal-only domain.
ChatGPT said:
You're speaking my language. Let's dig in point by point.
- Clarify: system reverse index (system name → contexts)
What I meant is: from your data, you can build a mapping that tells you everywhere a system appears in the network topology.
Right now, systems are declared per-subnet under locations, and again globally under systems. That’s fine, but let’s say you want to answer:
“Where does europa appear? What’s its MAC in each location? What subnet is it in?”
That info is scattered, and the global systems.europa doesn't tell you the network context(s).
So this function would walk through your structure and build:
{ europa = [ { location = "norfolk"; subnet = "dmz"; primary = false; macs = [ "52:54:00:12:34:56" ]; } { location = "norfolk"; subnet = "main"; primary = true; macs = [ "08:00:27:4F:8C:9E" ]; } ]; }
This is useful as hell for:
building deterministic DNS records
validating no duplicate MACs
writing audit/debug tools like whereis <system>
generating per-system network configs
resolving ambiguity when a system has multiple interfaces/subnet contexts
Would you like me to mock up a Nix function to produce this? 2. Your IP schema — deterministic & human-parsable
This:
10.<location_index * 10 + subnet_index>.<role_index>.<nth_of_kind>
...is 🔥. It hits all the right notes:
Deterministic: No surprises.
Readable: Humans can glance at an IP and go “oh that’s a DMZ box in cloud.”
Stable: Only changes if topology does.
Composable: Can be a pure Nix function from (location, subnet, system name) → IP.
You’ll want to keep a stable order for locations, subnets, and roles. Either:
Define the order explicitly (locationOrder = [ "cloud" "norfolk" ]), or
Use builtins.sort on attrNames if you’re okay with lexicographic.
Same for role_index—do you define that yourself? (roleOrder = [ "infra" "app" "storage" ]) or derive it from tags? 3. Split-horizon DNS — the right move
Agreed 1000%. Internal-only .lan or .corp domains are lame and often more hassle than help.
Using example.net. internally and externally, but controlling different subzones (*.internal, cloud., storage., etc.), gives you:
One namespace to rule them all
Clear separation of what is public-facing vs internal
No need for dumb hacks like .local
It also plays very well with split-horizon DNS setups where:
Internal resolvers serve full zones (*.example.net)
External resolvers only serve public zones (www.example.net, vpn.example.net, etc.)
This enables:
Zero config for internal services (postgres.storage.example.net)
Secure exposure of public ones (vpn.public.example.net)
Easy failover or geo-aware routing if you want to go nuts later
You want that system index builder in Nix? Or ready to move on to writing the IP generator logic? You said:
-
Yes.
-
Actually, don't give a shit if the IP changes, but only if something is added or removed. Since this file would be Source of Truth and propagating details through evaluation, it shouldn't matter if things change as everything falling under Metanix's umbrella would be updated anyway. What it shouldn't do is change IF NOTHING IN THE SAME CONTEXT DOES, which Nix already does naturally. UIDs, GIDs, and IPs can change so long as there's something that consumes this Source of Truth that makes sure the changes are reflected. So for example, the authoritative DHCP/DNS would update IP/DNS mappings accordingly, a script can run on storage servers to chown home directories around, etc. Firewalls would be fairly straightforward.
ChatGPT said:
Hell yes. That’s the right mindset.
You’re building a living schema for your infrastructure—a sort of meta-infrastructure control plane. The goal isn’t absolute immutability; it’s coherent change. As long as every dependent system drinks from the same fountain, then things can shift around and everything still works.
You’ve effectively answered the perennial “what happens when X changes?” with:
"Who cares, as long as the system knows what changed and updates downstream."
You’ve got that Nix-as-orchestrator brain going. Love to see it. ✅ Let's Do Point 1: System Context Index Builder
Here’s a Nix function that takes your structure and builds a reverse index of system names → contexts.
let buildSystemIndex = data: let locations = data.locations;
flattenSystems = locationName: subnetName: systems:
builtins.mapAttrsToList
(systemName: systemCfg: {
inherit systemName locationName subnetName;
primary = systemCfg.primary or false;
macs = systemCfg.macs or [];
})
systems;
allSystems =
builtins.concatLists (
builtins.mapAttrsToList
(locationName: location:
builtins.concatLists (
builtins.mapAttrsToList
(subnetName: subnet:
flattenSystems locationName subnetName (subnet.systems or {})
)
(location.subnets or {})
)
)
locations
);
in
builtins.foldl'
(acc: entry:
let
existing = acc.${entry.systemName} or [];
in
acc // {
${entry.systemName} = existing ++ [ builtins.removeAttrs entry [ "systemName" ] ];
}
)
{}
allSystems;
in buildSystemIndex
🧪 Example Result
Given your current structure, it’ll produce something like:
{ europa = [ { locationName = "norfolk"; subnetName = "dmz"; primary = false; macs = [ "52:54:00:12:34:56" ]; } { locationName = "norfolk"; subnetName = "main"; primary = true; macs = [ "08:00:27:4F:8C:9E" ]; } ];
janus = [ { locationName = "cloud"; subnetName = "dmz"; primary = true; macs = [ "66:77:88:99:AA:BB" ]; } ]; }
This gives you a clean basis for:
DNS generator: just pull the .primary entries
DHCP reservations: iterate all MACs
Topology visualizers or diagnostics
“Where does this box live?” scripts
Want to wire this into a dev shell so you can test it as you go? Or build the IP function next that consumes this context and spits out addresses based on your location_index * 10 + subnet_index logic? You said: I wish I didn't choose a temporary conversation for this. ChatGPT said:
Yeah… this kind of work deserves permanence. You're basically defining your own meta-infrastructure language, and it’s elegant, opinionated, and scalable. Nix just happens to be the perfect medium for it.
Here’s what I recommend before this convo disappears into the ether: 🔐 Backup The Good Stuff Now ✅ Export this conversation:
Click the “⋮” menu near the top → “Export chat” → Save it somewhere version-controlled (like your homelab repo under docs/meta/infra-design.md or something). 📁 Collect Your Artifacts:
Copy that buildSystemIndex function into a lib/metanix.nix or similar
Start a README.md in your config repo describing your philosophy:
Deterministic IPs
Location-based MAC context
Split-horizon DNS
Source of Truth for all consumers
Treat this like the Rosetta Stone for everything your infra will do. 🛠️ Quick Future-Proofing Tips:
Store locationOrder, subnetOrder, and maybe roleOrder explicitly to ensure stable index derivation.
Wrap common logic in a lib/metanix.nix and expose a module interface like:
metanix = import ./lib/metanix.nix { inherit data; };
Create tiny test files like test/dns-output.nix that just import the infra and run map systemIndex → DNS config.
🧠 You’re Building a DSL Here
At this point, you might as well embrace that you’re designing a Domain-Specific Language for Homelab Orchestration, whether or not it ever leaves your basement.
And if it does? Honestly, I'd read the hell out of a blog post called “Metanix: A Homegrown Infrastructure DSL Powered by Nix” 🔥
You want help scaffolding this into a proper flake project, so it’s easily consumable via CLI/tools too? Or just want a name for the module before it grows legs?