From 9366a90bcabb6aa1f122c25bd4c198a1c1f485db Mon Sep 17 00:00:00 2001 From: alexion Date: Fri, 28 Aug 2026 22:45:47 -0400 Subject: [PATCH] refactor(flake): split host guest and module assembly --- flake.nix | 9 +- flake/default.nix | 58 ++++++ flake/guest.nix | 282 +++++++++++++++++++++++++++ flake/host.nix | 49 +++++ flake/module.nix | 5 + {lib => flake}/registry.nix | 0 guests/actualbudget.nix | 21 +- guests/copyparty.nix | 51 +++-- guests/nesting-sample.nix | 11 -- guests/reverse-proxy.nix | 10 +- guests/sample.nix | 5 - hosts/neogaia/default.nix | 20 -- lib/default.nix | 101 +--------- lib/files.nix | 22 +++ lib/guests.nix | 370 ------------------------------------ lib/network.nix | 12 ++ modules/default.nix | 1 - modules/network/vlans.nix | 4 +- 18 files changed, 470 insertions(+), 561 deletions(-) create mode 100644 flake/default.nix create mode 100644 flake/guest.nix create mode 100644 flake/host.nix create mode 100644 flake/module.nix rename {lib => flake}/registry.nix (100%) delete mode 100644 guests/nesting-sample.nix delete mode 100644 guests/sample.nix create mode 100644 lib/files.nix delete mode 100644 lib/guests.nix create mode 100644 lib/network.nix diff --git a/flake.nix b/flake.nix index f5e8259..b31ddba 100644 --- a/flake.nix +++ b/flake.nix @@ -76,14 +76,13 @@ { self, nixpkgs, ... }@inputs: let inherit (nixpkgs) lib; - my = import ./lib { inherit lib inputs self; }; + flake = import ./flake { inherit lib inputs self; }; in { - # Helper functions for discovering and building hosts. - lib = my; - # Every host under hosts/ is discovered and built. - nixosConfigurations = my.mkHosts (self + "/hosts"); + nixosConfigurations = flake.mkHosts { + root = self + "/hosts"; + }; # A project shell for agent-local resources that should travel with this # checkout rather than the operator's global profile. diff --git a/flake/default.nix b/flake/default.nix new file mode 100644 index 0000000..58c9e1f --- /dev/null +++ b/flake/default.nix @@ -0,0 +1,58 @@ +{ + lib, + inputs, + self, +}: +let + # TODO: refactor the registry code + registryAssembly = import ./registry.nix { inherit lib self; }; + + util = import ../lib { inherit lib; }; + mkModules = import ./module.nix { inherit util; }; + + modules = mkModules { + root = self + "/modules"; + }; + + guestModules = guests.mkGuests { + root = self + "/guests"; + }; + + #---------------------------------------------------------- + + collectedRegistry = registryAssembly.collect { hosts = self.nixosConfigurations; }; + + specialArgs = { + inherit + inputs + self + util + ; + registry = collectedRegistry; + }; + + guests = import ./guest.nix { + inherit + inputs + lib + specialArgs + registryAssembly + util + ; + repositoryModules = modules; + }; + + hosts = import ./host.nix { + inherit + inputs + lib + specialArgs + guestModules + registryAssembly + ; + repositoryModules = modules; + }; +in +{ + inherit (hosts) mkHosts; +} diff --git a/flake/guest.nix b/flake/guest.nix new file mode 100644 index 0000000..9464f03 --- /dev/null +++ b/flake/guest.nix @@ -0,0 +1,282 @@ +{ + inputs, + lib, + specialArgs, + repositoryModules, + registryAssembly, + util, +}: +let + mkGuest = + name: + { config, lib, ... }: + let + cfg = config.guests.${name}; + + directoryMounts = lib.filterAttrs (_: mount: mount.directory != null) cfg.mounts; + + directoryScript = lib.concatStringsSep "\n" ( + lib.mapAttrsToList ( + _: mount: + '' + ${config.system.path}/bin/install -d \ + ${lib.optionalString (mount.owner != null) "-o ${lib.escapeShellArg (toString mount.owner)}"} \ + ${lib.optionalString (mount.group != null) "-g ${lib.escapeShellArg mount.group}"} \ + ${lib.optionalString (mount.mode != null) "-m ${lib.escapeShellArg mount.mode}"} \ + ${lib.escapeShellArg mount.directory} + '' + ) directoryMounts + ); + in + { + options.guests.${name} = { + enable = lib.mkEnableOption "the ${name} guest, run in its own nested container"; + vlan = lib.mkOption { + type = lib.types.ints.between 1 4094; + example = 10; + description = '' + The tagged VLAN this guest lives on. The guest attaches to its host's + `br-vlan` bridge for that VLAN. The id must be one of the host's + `modules.network.vlans.ids`. + ''; + }; + mac = lib.mkOption { + type = lib.types.str; + default = util.deriveMac "${config.networking.hostName}.${name}"; + defaultText = lib.literalMD "a stable address derived from the host name and guest option path"; + example = "bc:24:11:00:00:01"; + description = '' + The guest's MAC address on its VLAN, pinned inside the guest by its + own networkd. Set it to reuse an existing address so a router's DHCP + reservation keeps working. Left unset, a stable address is derived + from the host name and guest option path in the locally-administered range. + ''; + }; + address = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "10.23.20.126"; + description = '' + Expected IP address assigned outside NixOS for this guest. + Left null, this guest contributes no address to the address registry. + ''; + }; + reverseProxy = { + enable = lib.mkEnableOption "a reverse-proxy route for this guest"; + + route = lib.mkOption { + type = registryAssembly.definitions.route.type; + description = "Route registry entry published for this guest."; + }; + }; + mounts = lib.mkOption { + type = lib.types.attrsOf ( + lib.types.submodule { + options = { + directory = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "/srv/media"; + description = "Host directory bind-mounted into the guest."; + }; + file = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "/run/secrets/service-password"; + description = "Host file bind-mounted into the guest."; + }; + readOnly = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Mount the host path read-only inside the guest."; + }; + owner = lib.mkOption { + type = lib.types.nullOr ( + lib.types.oneOf [ + lib.types.str + lib.types.ints.positive + ] + ); + default = null; + description = "Owner assigned to the host directory before the guest starts."; + }; + group = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Group assigned to the host directory before the guest starts."; + }; + mode = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "0770"; + description = "Mode assigned to the host directory before the guest starts."; + }; + }; + } + ); + default = { }; + example = lib.literalExpression '' + { + "/data/media" = { directory = "/srv/media"; }; + "/data/config" = { directory = "/srv/config/jellyfin"; }; + } + ''; + description = '' + Host paths bind-mounted into the guest, keyed by the path they appear + at inside the guest, so a guest sees exactly the data it should at any + granularity — a single folder, a whole pool, or a decrypted secret. + Mounts are read-write unless `readOnly` is set. + ''; + }; + interior = lib.mkOption { + type = lib.types.deferredModule; + default = { }; + description = "NixOS module imported into the guest interior."; + }; + limits = { + memory = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "2G"; + description = '' + Cap on the guest's memory, applied to its unit as `MemoryMax`. + Accepts systemd size suffixes such as `512M` or `2G`. Left null, + the guest's memory is uncapped. + ''; + }; + cpu = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "150%"; + description = '' + Cap on the guest's CPU, applied to its unit as `CPUQuota`, where + `100%` is one full core. Left null, the guest's CPU is uncapped. + ''; + }; + tasksMax = lib.mkOption { + type = lib.types.nullOr lib.types.ints.positive; + default = null; + example = 512; + description = '' + Cap on the number of processes and threads the guest may spawn, + applied to its unit as `TasksMax`. Left null, the task count is + uncapped. + ''; + }; + }; + }; + + config = lib.mkIf cfg.enable { + assertions = + let + hasDirectorySettings = mount: mount.owner != null || mount.group != null || mount.mode != null; + hasCompleteDirectorySettings = mount: mount.owner != null && mount.group != null && mount.mode != null; + hasOneSource = mount: (mount.directory != null) != (mount.file != null); + mountsWithInvalidSource = lib.attrNames (lib.filterAttrs (_: mount: !hasOneSource mount) cfg.mounts); + fileMountsWithHostSettings = lib.attrNames ( + lib.filterAttrs (_: mount: mount.file != null && hasDirectorySettings mount) cfg.mounts + ); + directoryMountsWithPartialHostSettings = lib.attrNames ( + lib.filterAttrs (_: mount: mount.directory != null && hasDirectorySettings mount && !hasCompleteDirectorySettings mount) cfg.mounts + ); + in + [ + { + assertion = mountsWithInvalidSource == [ ]; + message = '' + guests.${name} mounts ${lib.concatStringsSep ", " mountsWithInvalidSource} must set exactly one of `directory` or `file`. + ''; + } + { + assertion = fileMountsWithHostSettings == [ ]; + message = '' + guests.${name} file mounts ${lib.concatStringsSep ", " fileMountsWithHostSettings} must not set `owner`, `group`, or `mode`. + ''; + } + { + assertion = directoryMountsWithPartialHostSettings == [ ]; + message = '' + guests.${name} directory mounts ${lib.concatStringsSep ", " directoryMountsWithPartialHostSettings} must set `owner`, `group`, and `mode` together. + ''; + } + { + assertion = lib.elem cfg.vlan config.modules.network.vlans.ids; + message = '' + guests.${name}.vlan = ${toString cfg.vlan} is not among its host's modules.network.vlans.ids (${lib.concatMapStringsSep ", " toString config.modules.network.vlans.ids}). Declare the VLAN on the host or correct the guest's placement. + ''; + } + ]; + + registry.address.${config.networking.hostName}.${name} = + lib.mkIf (cfg.address != null) cfg.address; + + registry.route.${config.networking.hostName}.${name} = + lib.mkIf cfg.reverseProxy.enable cfg.reverseProxy.route; + + systemd.services."container@${name}" = + let + bridgeDevice = "sys-subsystem-net-devices-${lib.replaceStrings [ "-" ] [ "\\x2d" ] (util.bridgeName cfg.vlan)}.device"; + in + { + after = [ bridgeDevice ]; + wants = [ bridgeDevice ]; + + serviceConfig = lib.filterAttrs (_: v: v != null) { + MemoryMax = cfg.limits.memory; + CPUQuota = cfg.limits.cpu; + TasksMax = cfg.limits.tasksMax; + }; + preStart = lib.mkIf (directoryMounts != { }) directoryScript; + }; + + containers.${name} = { + autoStart = true; + privateNetwork = lib.mkDefault true; + hostBridge = util.bridgeName cfg.vlan; + privateUsers = lib.mkDefault "no"; + bindMounts = lib.mapAttrs (_: mount: { + hostPath = if mount.directory != null then mount.directory else mount.file; + isReadOnly = mount.readOnly; + }) cfg.mounts; + + inherit specialArgs; + + config = { + imports = [ + inputs.sops-nix.nixosModules.sops + inputs.stylix.nixosModules.stylix + + repositoryModules + registryAssembly.module + cfg.interior + + { + modules.guest.enable = true; + + networking.useNetworkd = true; + networking.useHostResolvConf = false; + + systemd.network.networks."20-eth0" = { + matchConfig.Name = "eth0"; + linkConfig.MACAddress = cfg.mac; + networkConfig.DHCP = "yes"; + }; + } + ]; + }; + }; + }; + }; +in +{ + mkGuests = + { root }: + let + files = util.collectNixFiles root; + guestFiles = lib.filterAttrs (file: type: type == "regular" && lib.hasSuffix ".nix" file) (builtins.readDir root); + names = map (file: lib.removeSuffix ".nix" file) (lib.attrNames guestFiles); + in + { + imports = files ++ map mkGuest names; + }; +} diff --git a/flake/host.nix b/flake/host.nix new file mode 100644 index 0000000..3345fd1 --- /dev/null +++ b/flake/host.nix @@ -0,0 +1,49 @@ +{ + inputs, + lib, + specialArgs, + repositoryModules, + + guestModules, + # TODO refactor registry + registryAssembly, +}: +let + mkHost = + { + root, + name, + }: + lib.nixosSystem { + inherit specialArgs; + modules = [ + inputs.chaotic.nixosModules.default + inputs.disko.nixosModules.disko + inputs.sops-nix.nixosModules.sops + inputs.stylix.nixosModules.stylix + + repositoryModules + guestModules + + # TODO: refactor + registryAssembly.module (root + "/${name}") + + { + networking.hostName = name; + modules.host.enable = true; + } + ]; + }; +in +{ + mkHosts = + { root }: + let + directories = lib.filterAttrs (_name: type: type == "directory") (builtins.readDir root); + names = lib.attrNames directories; + in + lib.genAttrs names ( + name: + mkHost { inherit root name; } + ); +} diff --git a/flake/module.nix b/flake/module.nix new file mode 100644 index 0000000..5c38e3e --- /dev/null +++ b/flake/module.nix @@ -0,0 +1,5 @@ +{ util }: +{ root }: +{ + imports = util.collectNixFiles root; +} diff --git a/lib/registry.nix b/flake/registry.nix similarity index 100% rename from lib/registry.nix rename to flake/registry.nix diff --git a/guests/actualbudget.nix b/guests/actualbudget.nix index f22edd2..4310651 100644 --- a/guests/actualbudget.nix +++ b/guests/actualbudget.nix @@ -1,7 +1,6 @@ { config, lib, - my, ... }: let @@ -9,18 +8,6 @@ let dataDir = "/data"; in { - imports = [ - (my.guest { - name = "actualbudget"; - interior = { - modules.services.actualbudget = { - enable = true; - inherit dataDir; - }; - }; - }) - ]; - options.guests.actualbudget = { dataPath = lib.mkOption { type = lib.types.nullOr lib.types.str; @@ -39,7 +26,13 @@ in ]; guests.actualbudget = { - mounts.${dataDir}.hostPath = cfg.dataPath; + interior = { + modules.services.actualbudget = { + enable = true; + inherit dataDir; + }; + }; + mounts.${dataDir}.directory = cfg.dataPath; limits = { memory = lib.mkDefault "2G"; cpu = lib.mkDefault "200%"; diff --git a/guests/copyparty.nix b/guests/copyparty.nix index ee2148f..4b79572 100644 --- a/guests/copyparty.nix +++ b/guests/copyparty.nix @@ -1,7 +1,6 @@ { config, lib, - my, ... }: let @@ -34,26 +33,6 @@ let }; in { - imports = [ - (my.guest { - name = "copyparty"; - interior = { - modules.services.copyparty = { - enable = true; - inherit (cfg) accountName stateDir; - passwordFile = "/run/secrets/${cfg.passwordSecret}"; - reverseProxy = { - enable = cfg.reverseProxy.enable; - inherit (cfg.reverseProxy) trustedSources; - }; - volumes = lib.mapAttrs (_urlPath: volume: { - inherit (volume) path access flags; - }) cfg.volumes; - }; - }; - }) - ]; - options.guests.copyparty = { accountName = lib.mkOption { type = lib.types.str; @@ -106,19 +85,33 @@ in ]; guests.copyparty = { - secrets = [ cfg.passwordSecret ]; + interior.modules.services.copyparty = { + enable = true; + inherit (cfg) accountName stateDir; + passwordFile = "/run/secrets/${cfg.passwordSecret}"; + reverseProxy = { + enable = cfg.reverseProxy.enable; + inherit (cfg.reverseProxy) trustedSources; + }; + volumes = lib.mapAttrs (_urlPath: volume: { + inherit (volume) path access flags; + }) cfg.volumes; + }; + mounts = { ${cfg.stateDir} = { - hostPath = cfg.statePath; - create = { - owner = config.registry."stable-uid".${config.networking.hostName}.copyparty; - group = "storage"; - mode = "0770"; - }; + directory = cfg.statePath; + owner = config.registry."stable-uid".${config.networking.hostName}.copyparty; + group = "storage"; + mode = "0770"; + }; + ${"/run/secrets/${cfg.passwordSecret}"} = { + file = config.sops.secrets.${cfg.passwordSecret}.path; + readOnly = true; }; } - // lib.mapAttrs' (_urlPath: volume: lib.nameValuePair volume.path { inherit (volume) hostPath; }) cfg.volumes; + // lib.mapAttrs' (_urlPath: volume: lib.nameValuePair volume.path { directory = volume.hostPath; }) cfg.volumes; limits = { memory = lib.mkDefault "2G"; cpu = lib.mkDefault "200%"; diff --git a/guests/nesting-sample.nix b/guests/nesting-sample.nix deleted file mode 100644 index 5941932..0000000 --- a/guests/nesting-sample.nix +++ /dev/null @@ -1,11 +0,0 @@ -args@{ my, ... }: -# A sample guest whose interior runs an OCI container on Podman. -# The image is pulled at runtime, so the guest builds with no build-time fetch. -my.guest { - name = "nesting-sample"; - interior = { - virtualisation.oci-containers.containers.hello = { - image = "docker.io/library/hello-world"; - }; - }; -} args diff --git a/guests/reverse-proxy.nix b/guests/reverse-proxy.nix index c8871aa..27eb287 100644 --- a/guests/reverse-proxy.nix +++ b/guests/reverse-proxy.nix @@ -1,16 +1,14 @@ { config, lib, - my, ... }: let giteaSshBackend = "10.23.20.45:2022"; in { - imports = [ - (my.guest { - name = "reverse-proxy"; + config = lib.mkIf config.guests.reverse-proxy.enable { + guests.reverse-proxy = { interior = { modules.services.reverse-proxy.enable = true; @@ -66,11 +64,7 @@ in } ''; }; - }) - ]; - config = lib.mkIf config.guests.reverse-proxy.enable { - guests.reverse-proxy = { limits = { memory = lib.mkDefault "1G"; cpu = lib.mkDefault "100%"; diff --git a/guests/sample.nix b/guests/sample.nix deleted file mode 100644 index 293621a..0000000 --- a/guests/sample.nix +++ /dev/null @@ -1,5 +0,0 @@ -args@{ my, ... }: -# The tracer-bullet guest: the thinnest complete path from discovery to a -# running nested container. Its interior is just the guest-base — the baseline -# toolset and SSH access — so it proves the concept without carrying a service. -my.guest { name = "sample"; } args diff --git a/hosts/neogaia/default.nix b/hosts/neogaia/default.nix index 41d0d9e..17093aa 100644 --- a/hosts/neogaia/default.nix +++ b/hosts/neogaia/default.nix @@ -40,26 +40,6 @@ modules.network.ssh.hostKeys.sopsFile = ../../secrets/neogaia.yaml; modules.network.ssh.userKey.sopsFile = ../../secrets/neogaia.yaml; - # The walking-skeleton guest, enabled like any module: proves the guest path - # end to end through this host's `nix flake check`. - # Modest caps keep the skeleton guest from starving the laptop. - guests.sample.enable = true; - guests.sample.limits = { - memory = "1G"; - cpu = "100%"; - tasksMax = 512; - }; - - # The nesting guest, run with `nesting` on: proves an interior OCI container - # on Podman builds end to end through this host's `nix flake check`. - guests.nesting-sample.enable = true; - guests.nesting-sample.nesting = true; - guests.nesting-sample.limits = { - memory = "1G"; - cpu = "100%"; - tasksMax = 512; - }; - modules.shell.herdr.enable = true; modules.agents.tools.gitea-axi.enable = true; modules.agents.pi.enable = true; diff --git a/lib/default.nix b/lib/default.nix index 7826de4..9c535ce 100644 --- a/lib/default.nix +++ b/lib/default.nix @@ -1,100 +1,9 @@ -{ - lib, - inputs, - self, -}: +{ lib }: let - inherit (lib) - attrNames - filterAttrs - genAttrs - flatten - hasSuffix - mapAttrsToList - ; - - registry = import ./registry.nix { inherit lib self; }; - - collectNixFiles = - dir: - flatten ( - mapAttrsToList ( - name: type: - let - path = dir + "/${name}"; - in - if type == "directory" then - collectNixFiles path - else if type == "regular" && hasSuffix ".nix" name then - [ path ] - else - [ ] - ) (builtins.readDir dir) - ); - - moduleFiles = collectNixFiles (self + "/modules"); - guestFiles = collectNixFiles (self + "/guests"); - - specialArgs = { - inherit inputs self; - my = self.lib; - registry = self.lib.registry.collect { hosts = self.nixosConfigurations; }; - }; - - bridgeName = id: "br-vlan${toString id}"; - - guests = import ./guests.nix { - inherit - inputs - lib - self - specialArgs - bridgeName - moduleFiles - registry - ; - }; - - mkHost = - { - hostName, - system ? "x86_64-linux", - }: - inputs.nixpkgs.lib.nixosSystem { - inherit system specialArgs; - modules = - moduleFiles - ++ guestFiles - ++ [ - inputs.chaotic.nixosModules.default - inputs.disko.nixosModules.disko - inputs.sops-nix.nixosModules.sops - inputs.stylix.nixosModules.stylix - registry.module - (self + "/hosts/${hostName}") - { - networking.hostName = hostName; - modules.host.enable = true; - } - ]; - }; - - mkHosts = - hostsDir: - let - hostNames = attrNames (filterAttrs (_name: type: type == "directory") (builtins.readDir hostsDir)); - in - genAttrs hostNames (hostName: mkHost { inherit hostName; }); + files = import ./files.nix { inherit lib; }; + network = import ./network.nix { inherit lib; }; in { - inherit - collectNixFiles - mkHost - mkHosts - bridgeName - registry - ; - - inherit (guests) deriveMac guest; - + inherit (files) collectNixFiles; + inherit (network) bridgeName deriveMac; } diff --git a/lib/files.nix b/lib/files.nix new file mode 100644 index 0000000..daecfdd --- /dev/null +++ b/lib/files.nix @@ -0,0 +1,22 @@ +{ lib }: +let + collectNixFiles = + dir: + lib.flatten ( + lib.mapAttrsToList ( + name: type: + let + path = dir + "/${name}"; + in + if type == "directory" then + collectNixFiles path + else if type == "regular" && lib.hasSuffix ".nix" name then + [ path ] + else + [ ] + ) (builtins.readDir dir) + ); +in +{ + inherit collectNixFiles; +} diff --git a/lib/guests.nix b/lib/guests.nix deleted file mode 100644 index 6822f3b..0000000 --- a/lib/guests.nix +++ /dev/null @@ -1,370 +0,0 @@ -{ - inputs, - lib, - self, - specialArgs, - bridgeName, - moduleFiles, - registry, -}: -let - inherit (lib) escapeShellArg mapAttrsToList; - - deriveMac = - name: - let - hash = builtins.hashString "sha256" name; - octet = i: builtins.substring (i * 2) 2 hash; - in - lib.concatStringsSep ":" ([ "02" ] ++ map octet [ 0 1 2 3 4 ]); - - guest = - { - name, - interior ? { }, - }: - { config, lib, ... }: - let - optionPath = [ "guests" ] ++ lib.splitString "." name; - cfg = lib.getAttrFromPath optionPath config; - machineName = lib.replaceStrings [ "." ] [ "-" ] name; - - networked = cfg.vlan != null; - - userMounts = lib.mapAttrs (_guestPath: m: { - inherit (m) hostPath; - isReadOnly = m.readOnly; - }) cfg.mounts; - - secretMounts = lib.listToAttrs ( - map ( - name: - let - path = config.sops.secrets.${name}.path; - in - lib.nameValuePair path { - hostPath = path; - isReadOnly = true; - } - ) cfg.secrets - ); - - mountCollisions = lib.attrNames (builtins.intersectAttrs userMounts secretMounts); - - limitConfig = lib.filterAttrs (_: v: v != null) { - MemoryMax = cfg.limits.memory; - CPUQuota = cfg.limits.cpu; - TasksMax = cfg.limits.tasksMax; - }; - - createdMounts = lib.filterAttrs (_guestPath: m: m.create != null) cfg.mounts; - mountCreateScript = lib.concatStringsSep "\n" ( - mapAttrsToList ( - _guestPath: m: - let - create = m.create; - in - '' - ${config.system.path}/bin/install -d -o ${escapeShellArg (toString create.owner)} -g ${escapeShellArg create.group} -m ${escapeShellArg create.mode} ${escapeShellArg m.hostPath} - '' - ) createdMounts - ); - - guestNet = - { lib, ... }: - { - config = lib.mkIf networked { - networking.useNetworkd = true; - networking.useHostResolvConf = false; - - systemd.network.networks."20-eth0" = { - matchConfig.Name = "eth0"; - linkConfig.MACAddress = cfg.mac; - networkConfig.DHCP = "yes"; - }; - }; - }; - in - { - options = lib.setAttrByPath optionPath { - enable = lib.mkEnableOption "the ${name} guest, run in its own nested container"; - backend = lib.mkOption { - type = lib.types.enum [ - "container" - "microvm" - ]; - default = "container"; - description = '' - How the guest is realized. `container` runs the guest as a - systemd-nspawn nested container. `microvm` is reserved for a future - hard-isolation backend and is not built yet. - ''; - }; - vlan = lib.mkOption { - type = lib.types.nullOr (lib.types.ints.between 1 4094); - default = null; - example = 10; - description = '' - The tagged VLAN this guest lives on. The guest attaches to its host's - `br-vlan` bridge for that VLAN. Left null, the guest keeps a - private network with no bridge attachment. The id must be one of the - host's `modules.network.vlans.ids`. - ''; - }; - mac = lib.mkOption { - type = lib.types.str; - default = deriveMac name; - defaultText = lib.literalMD "a stable address derived from the guest's namespace path"; - example = "bc:24:11:00:00:01"; - description = '' - The guest's MAC address on its VLAN, pinned inside the guest by its - own networkd. Set it to reuse an existing address so a router's DHCP - reservation keeps working. Left unset, a stable address is derived - from the guest's namespace path in the locally-administered range. - ''; - }; - address = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - example = "10.23.20.126"; - description = '' - Expected IP address assigned outside NixOS for this guest. - Left null, this guest contributes no address to the address registry. - ''; - }; - reverseProxy = { - enable = lib.mkEnableOption "a reverse-proxy route for this guest"; - - route = lib.mkOption { - type = self.lib.registry.definitions.route.type; - description = "Route registry entry published for this guest."; - }; - }; - mounts = lib.mkOption { - type = lib.types.attrsOf ( - lib.types.submodule { - options = { - hostPath = lib.mkOption { - type = lib.types.str; - example = "/srv/media"; - description = "The path on the host bind-mounted into the guest."; - }; - readOnly = lib.mkOption { - type = lib.types.bool; - default = false; - description = '' - Mount the path read-only. Read-write by default, since a - service must write to the pool data it owns. - ''; - }; - create = lib.mkOption { - type = lib.types.nullOr (lib.types.submodule { - options = { - type = lib.mkOption { - type = lib.types.enum [ "directory" ]; - default = "directory"; - description = "Host path type to create before the guest starts."; - }; - owner = lib.mkOption { - type = lib.types.oneOf [ - lib.types.str - lib.types.ints.positive - ]; - description = "Owner assigned to the created host path."; - }; - group = lib.mkOption { - type = lib.types.str; - description = "Group assigned to the created host path."; - }; - mode = lib.mkOption { - type = lib.types.str; - example = "0770"; - description = "Mode assigned to the created host path."; - }; - }; - }); - default = null; - description = '' - Opt-in host path creation before the guest starts. Existing - paths are left enabled unless this is set, so served storage - roots can still be required to exist explicitly. - ''; - }; - }; - } - ); - default = { }; - example = lib.literalExpression '' - { - "/data/media" = { hostPath = "/srv/media"; }; - "/data/config" = { - hostPath = "/srv/config/jellyfin"; - readOnly = true; - }; - } - ''; - description = '' - Host paths bind-mounted into the guest, keyed by the path they appear - at inside the guest, so a guest sees exactly the data it should at any - granularity — a single folder or a whole pool. Each mount is - read-write unless `readOnly` is set. - ''; - }; - secrets = lib.mkOption { - type = lib.types.listOf lib.types.str; - default = [ ]; - example = [ "jellyfin-api-key" ]; - description = '' - Names of the secrets this guest needs. The host is the sole - decryptor: it decrypts each named secret from its own sops files and - bind-mounts the plaintext file into the guest read-only at - `/run/secrets/`, the same path it would occupy on a host, so a - service reads its credentials at a predictable location. The guest - names the files it wants and receives exactly those. It holds no age - key and decrypts nothing itself. Ownership carries across unchanged, - since the container maps ids one to one, so a secret owned by a uid on - the host is owned by that same uid inside the guest. - ''; - }; - limits = { - memory = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - example = "2G"; - description = '' - Cap on the guest's memory, applied to its unit as `MemoryMax`. - Accepts systemd size suffixes such as `512M` or `2G`. Left null, - the guest's memory is uncapped. - ''; - }; - cpu = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - example = "150%"; - description = '' - Cap on the guest's CPU, applied to its unit as `CPUQuota`, where - `100%` is one full core. Left null, the guest's CPU is uncapped. - ''; - }; - tasksMax = lib.mkOption { - type = lib.types.nullOr lib.types.ints.positive; - default = null; - example = 512; - description = '' - Cap on the number of processes and threads the guest may spawn, - applied to its unit as `TasksMax`. Left null, the task count is - uncapped. - ''; - }; - }; - nesting = lib.mkOption { - type = lib.types.bool; - default = false; - description = '' - Grant the guest's interior the prerequisites to run Podman or other - OCI containers of its own. Off by default, so a guest cannot nest - containers. On, the guest's container gains the network-administration - capability its container runtime uses to build bridges and firewall - rules, along with the tun and fuse device nodes such a runtime reaches - for, so the interior's `virtualisation.oci-containers` works with - Podman as its default runtime. - ''; - }; - autoStart = lib.mkOption { - type = lib.types.bool; - default = true; - description = '' - Start the guest at boot. On by default. Disabled, the guest stays - defined and can be started on demand, but does not come up at boot. - ''; - }; - }; - - config = lib.mkIf cfg.enable { - sops.secrets = lib.genAttrs cfg.secrets (_: { }); - - registry.address.${config.networking.hostName}.${machineName} = lib.mkIf (cfg.address != null) cfg.address; - - registry.route.${config.networking.hostName}.${machineName} = lib.mkIf cfg.reverseProxy.enable cfg.reverseProxy.route; - - assertions = [ - { - assertion = mountCollisions == [ ]; - message = '' - guests.${name} maps a mount at ${lib.concatStringsSep ", " mountCollisions}, colliding with a secret bind-mounted at the same path. Rename the mount or the secret so each in-guest path is used once. - ''; - } - { - assertion = cfg.backend == "container"; - message = '' - guests.${name}.backend = "${cfg.backend}" is not implemented. Only the "container" backend is built; "microvm" is reserved for future work. - ''; - } - { - assertion = !networked || lib.elem cfg.vlan config.modules.network.vlans.ids; - message = '' - guests.${name}.vlan = ${toString cfg.vlan} is not among its host's modules.network.vlans.ids (${lib.concatMapStringsSep ", " toString config.modules.network.vlans.ids}). Declare the VLAN on the host or correct the guest's placement. - ''; - } - ]; - - systemd.services."container@${machineName}" = lib.mkIf (cfg.backend == "container") ( - lib.mkMerge [ - { - serviceConfig = limitConfig; - preStart = lib.mkIf (createdMounts != { }) mountCreateScript; - } - (lib.mkIf networked ( - let - bridgeDevice = "sys-subsystem-net-devices-${lib.replaceStrings [ "-" ] [ "\\x2d" ] (bridgeName cfg.vlan)}.device"; - in - { - after = [ bridgeDevice ]; - wants = [ bridgeDevice ]; - } - )) - ] - ); - - containers.${machineName} = lib.mkIf (cfg.backend == "container") { - autoStart = cfg.autoStart; - privateNetwork = lib.mkDefault true; - hostBridge = lib.mkIf networked (bridgeName cfg.vlan); - privateUsers = lib.mkDefault "no"; - additionalCapabilities = lib.optionals cfg.nesting [ "CAP_NET_ADMIN" ]; - allowedDevices = lib.optionals cfg.nesting [ - { - node = "/dev/net/tun"; - modifier = "rwm"; - } - { - node = "/dev/fuse"; - modifier = "rwm"; - } - ]; - - bindMounts = userMounts // secretMounts; - - inherit specialArgs; - - config = { - imports = - moduleFiles - ++ [ - registry.module - inputs.sops-nix.nixosModules.sops - inputs.stylix.nixosModules.stylix - { - modules.guest.enable = true; - } - guestNet - interior - ]; - }; - }; - }; - }; -in -{ - inherit deriveMac guest; -} diff --git a/lib/network.nix b/lib/network.nix new file mode 100644 index 0000000..f8524f1 --- /dev/null +++ b/lib/network.nix @@ -0,0 +1,12 @@ +{ lib }: +{ + bridgeName = id: "br-vlan${toString id}"; + + deriveMac = + name: + let + hash = builtins.hashString "sha256" name; + octet = i: builtins.substring (i * 2) 2 hash; + in + lib.concatStringsSep ":" ([ "02" ] ++ map octet [ 0 1 2 3 4 ]); +} diff --git a/modules/default.nix b/modules/default.nix index 1c1310a..43489f2 100644 --- a/modules/default.nix +++ b/modules/default.nix @@ -64,7 +64,6 @@ in useUserPackages = true; extraSpecialArgs = { inherit inputs; - my = inputs.self.lib; }; users.${user.name} = { home.username = user.name; diff --git a/modules/network/vlans.nix b/modules/network/vlans.nix index cc2c929..dc79b05 100644 --- a/modules/network/vlans.nix +++ b/modules/network/vlans.nix @@ -1,14 +1,14 @@ { config, lib, - my, + util, ... }: # The host networking foundation: per-VLAN bridges over a tagged trunk. let cfg = config.modules.network.vlans; - inherit (my) bridgeName; + inherit (util) bridgeName; vlanName = id: "${cfg.trunk}.${toString id}"; in