diff --git a/flake/default.nix b/flake/default.nix index 231e602..e6788d1 100644 --- a/flake/default.nix +++ b/flake/default.nix @@ -39,7 +39,6 @@ let inherit inputs lib - self specialArgs registryAssembly util diff --git a/flake/guest.nix b/flake/guest.nix index 81b948d..f658d6d 100644 --- a/flake/guest.nix +++ b/flake/guest.nix @@ -1,158 +1,18 @@ { inputs, lib, - self, specialArgs, repositoryModules, registryAssembly, util, }: -let - mkGuestOptions = +{ + mkGuest = { - config, - lib, - 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. - ''; - }; - 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. - ''; - }; - }; - }; - - mkGuestConfig = - { - config, - interior, - lib, name, + interior ? { }, }: + { config, lib, ... }: let cfg = config.guests.${name}; @@ -162,131 +22,247 @@ let 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} + ${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"; - guestNet = - { lib, ... }: - { - networking.useNetworkd = true; - networking.useHostResolvConf = false; - - systemd.network.networks."20-eth0" = { - matchConfig.Name = "eth0"; - linkConfig.MACAddress = cfg.mac; - networkConfig.DHCP = "yes"; + route = lib.mkOption { + type = registryAssembly.definitions.route.type; + description = "Route registry entry published for this guest."; }; }; - in - lib.mkIf cfg.enable { - 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; - - 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); - mountsWithoutOneSource = lib.attrNames (lib.filterAttrs (_: mount: !hasOneSource mount) cfg.mounts); - fileMountsWithDirectorySettings = lib.attrNames ( - lib.filterAttrs (_: mount: mount.file != null && hasDirectorySettings mount) cfg.mounts - ); - incompleteHostDirectoryMounts = lib.attrNames ( - lib.filterAttrs (_: mount: mount.directory != null && hasDirectorySettings mount && !hasCompleteDirectorySettings mount) cfg.mounts - ); - in - [ - { - assertion = mountsWithoutOneSource == [ ]; - message = '' - guests.${name} mounts must set exactly one of directory or file for ${lib.concatStringsSep ", " mountsWithoutOneSource}. - ''; - } - { - assertion = fileMountsWithDirectorySettings == [ ]; - message = '' - guests.${name} file mounts must not set owner, group, or mode for ${lib.concatStringsSep ", " fileMountsWithDirectorySettings}. - ''; - } - { - assertion = incompleteHostDirectoryMounts == [ ]; - message = '' - guests.${name} has incomplete host directory settings for ${lib.concatStringsSep ", " incompleteHostDirectoryMounts}. 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. - ''; - } - ]; - - systemd.services."container@${name}" = lib.mkMerge [ - { - serviceConfig = lib.filterAttrs (_: v: v != null) { - MemoryMax = cfg.limits.memory; - CPUQuota = cfg.limits.cpu; - TasksMax = cfg.limits.tasksMax; - }; - preStart = lib.mkIf (directoryMounts != { }) directoryScript; - } - (let - bridgeDevice = "sys-subsystem-net-devices-${lib.replaceStrings [ "-" ] [ "\\x2d" ] (util.bridgeName cfg.vlan)}.device"; - in - { - after = [ bridgeDevice ]; - wants = [ bridgeDevice ]; - }) - ]; - - 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 = [ - repositoryModules - registryAssembly.module - inputs.sops-nix.nixosModules.sops - inputs.stylix.nixosModules.stylix - { - modules.guest.enable = true; + 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."; + }; + }; } - guestNet - interior - ]; + ); + 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. + ''; + }; + 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. + ''; + }; }; }; - }; -in -{ - mkGuest = - { - name, - interior ? { }, - }: - { config, lib, ... }: - { - options.guests.${name} = mkGuestOptions { inherit config lib name; }; - config = mkGuestConfig { - inherit - config - interior - lib - name - ; + + 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 + 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"; + }; + } + ]; + }; + }; }; }; }