NixOS base module

This module includes configuration that is automatically enabled for any NixOS system I use.

Unlike most of my modules, there's no "mjm.foo" option to enable the behavior.

{
  config,
  pkgs,
  lib,
  inputs,
  ...
}:
{
  imports = [
    "${inputs.home-manager}/nixos"

    ../../common/base

    <<nixos-submodules>>
  ];

  config = {
    <<nixos-misc>>
  };

  _class = "nixos";
}

This module extends the common base modules for all machines (not just NixOS):

=> common/base: Defaults for all machines

Users

./users.nix
{ pkgs, ... }: {
  <<nixos-users-config>>

  _class = "nixos";
}

This module contains configuration related to Unix users and permissions.

users.mutableUsers = false;

Disabling mutable users is a more natural fit. In my opinion, this should be the default, but I suppose it's a compromise to those more used to other Unix systems. With this setting, only users declared in the NixOS config will exist: add users at runtime is not possible.

users.manageLingering = false;

I don't use lingering for any users, so I'd rather not have the mechanism present for managing it.

security.sudo.enable = false;
security.run0.enable = true;
security.run0.wheelNeedsPassword = false;
security.run0.enableSudoAlias = true;

I use run0 instead of sudo on all of my systems. This avoids another setuid binary, closing off a class of vulnerabilities exposed by sudo.

users.users.mjm = {
  isNormalUser = true;
  description = "MJ";
  extraGroups = [ "wheel" ];
  shell = pkgs.fish;
  hashedPassword = "$y$j9T$tM/RKSjlb5ljgtpGT/Y8N1$3oXxWQh/q.KKCcJKoyVeIUVqjjt76EWX.uNEJRASt04";
};
nix.settings.trusted-users = [ "mjm" ];

I have one normal user defined on my machines. I use the fish shell, and I'm in the wheel group so that I can easily administer the system. For a similar reason, I add my user to the list of trusted users for nix. In particular, this avoids issues around trusting artifacts that I copy to servers via SSH.

networkd

./networkd.nix
{ config, lib, ... }:
let
  inherit (lib)
    mkIf
    mkOption
    types
    ;
  cfg = config.mjm.networkd;
in
{
  options.mjm.networkd = {
    enable = mkOption {
      type = types.bool;
      default = !config.networking.networkmanager.enable;
    };

    primaryLinkName = mkOption {
      type = types.str;
      default = "lan0";
    };

    secondaryLinkName = mkOption {
      type = types.nullOr types.str;
      default = null;
    };

    macvlan.enable = mkOption {
      type = types.bool;
      default = false;
    };

    primaryIface = mkOption {
      type = types.str;
      internal = true;
      readOnly = true;
    };

    bridgeParentName = mkOption {
      type = types.str;
      default = if cfg.secondaryLinkName == null then cfg.primaryLinkName else cfg.secondaryLinkName;
      readOnly = true;
    };
  };

  config = mkIf cfg.enable {
    <<networkd-config>>
  };

  _class = "nixos";
}

I use networkd to set up networking on any NixOS systems that don't have WiFi (in which case I use NetworkManager to be able to freely change networks more easily). I prefer to set my networkd configuration up explicitly, without using NixOS's networking.useNetworkd option that translates the various networking.* options to be backed by networkd.

My networkd configuration is a bit complex, primarily because it needs to optionally support using MACVLAN to provide networking for microVMs. I think the end result is pretty nice though.

There are three different network config structures I'm trying to support:

The complexity of this module is to be able to support all three of these.

systemd.network.enable = true;
networking.useDHCP = false;

To use networkd without using NixOS's networking.useNetworkd option, you have to disable the default DHCP option. There's an assertion for it, so evaluation will fail.

mjm.networkd.primaryIface =
  if cfg.secondaryLinkName == null && cfg.macvlan.enable then "mac0" else cfg.primaryLinkName;

The first thing to do is determine which interface is going to be the primary one: the one that this machine actually has an IP address on. The rest of the module will use this, as do some other unrelated modules. While there's three possible network shapes to consider, there's really only two cases here. If MACVLAN is enabled but there's only a single NIC for it, then we will have to create a "mac0" interface and use that as the primary. Otherwise, it's just the primary (or only) NIC, which defaults to "lan0".

systemd.network.networks."10-primary-lan" = {
  name = cfg.primaryIface;
  networkConfig = {
    DHCP = "ipv4";
    IPv6AcceptRA = true;
    IPv6PrivacyExtensions = true;
    MulticastDNS = "resolve";
  };
  dhcpV4Config = {
    UseDomains = true;
  };
  dhcpV6Config = {
    UseDNS = false;
  };
};

The primary interface needs to be configured to use DHCP to get its IPv4 address and SLAAC to get IPv6 addresses. I assign IPv4 addresses in my router's configuration, so even machines with static IPs get them through DHCP. The MulticastDNS option here is set to allow Avahi to be used without conflicting with systemd-resolved. Avahi is necessary to discover printers or other home devices.

UseDomains is enabled to make accessing my servers more convenient. The UseDNS setting for IPv6 is old enough that I don't actually remember its purpose. It might not be needed for my setup.

systemd.network.netdevs.mac0 = mkIf (cfg.primaryIface != cfg.primaryLinkName) {
  netdevConfig.Name = cfg.primaryIface;
  netdevConfig.Kind = "macvlan";
  macvlanConfig.Mode = "bridge";
};

If we're doing MACVLAN without a second NIC, then we'll need to create the MACVLAN interface that will be used as the primary interface for the host.

systemd.network.networks."10-bridge-lan" = mkIf cfg.macvlan.enable {
  name = cfg.bridgeParentName;
  networkConfig = {
    MACVLAN = mkIf (cfg.primaryIface != cfg.primaryLinkName) cfg.primaryIface;
    IPv6AcceptRA = false;
    LinkLocalAddressing = false;
  };
  linkConfig.RequiredForOnline = "carrier";
};

If MACVLAN is being used then the bridge interface for that needs to be configured. Router advertisements and link-local addressing are disabled, to avoid the device getting IP addresses that it isn't meant to use. Because it won't have an IP, "carrier" is the most online this interface is going to get, so letting networkd know that means its online status

is more correct.

The MACVLAN option is set if needed to connect this MACVLAN bridge to the mac0 primary interface, if it was created. The microVMs will handle creating their MACVTAP devices on this link when they start up, so that doesn't need to be configured here.

systemd.network.links = {
  "10-virtio" = {
    matchConfig.Driver = "virtio_net";
    linkConfig.Name = "lan0";
  };
  "10-rpi" = {
    matchConfig.Driver = "bcmgenet";
    linkConfig.Name = "lan0";
  };
  "10-vm-bridge" = {
    matchConfig.Property = "ID_VENDOR_ID=0x10ec ID_MODEL_ID=0x8125";
    linkConfig.Name = "lan1";
  };
  "15-other-ethernet" = {
    matchConfig.Driver = "e1000e r8169";
    linkConfig.Name = "lan0";
  };
};

Finally, I add a little configuration to rename my network interfaces to follow a consistent scheme. It pleases a weird and obsessive part of me, and it also makes configuration across machines easier. The main NIC will always be "lan0", and a second NIC if present will always be "lan1". I got the same PCIe NIC for all three VM hosts, so I can match on the exact vendor and model to match that device. Otherwise, I match by driver to fix the NICs for VMs, Raspberry Pis, and otherwise.

Note that the last rule has a higher number prefix, so that the "lan1" rule matches first. The secondary NICs use the same Realtek driver that is included in that last rule. This rule is only meant to match the built-in Ethernet port on the motherboard, which for my machines always uses either the Intel driver or the Realtek one.

kmscon

./kmscon.nix
{
  pkgs,
  config,
  lib,
  ...
}:
{
  <<kmscon-config>>

  _class = "nixos";
}

I've been using kmscon on my machines where possible to get a nicer console experience.

services.kmscon = {
  enable = lib.mkDefault true;
  config = {
    font-name = "Maple Mono";
    font-size = lib.mkDefault 16;
    mouse = true;
    hwaccel = config.hardware.graphics.enable;
  };
};

I default to enabling kmscon for a nicer console experience.

fonts.packages = lib.mkIf config.services.kmscon.enable [
  pkgs.maple-mono.variable
];

Above I set the font to use for kmscon, but that only works if the font is actually installed. That will be true already for systems that use my desktop module, but for servers that don't use that, they need it added here.

Chrony

./chrony.nix
{ lib, ... }: {
  services.timesyncd.enable = lib.mkDefault false;
  services.chrony.enable = lib.mkDefault true;
  services.chrony.extraFlags = [ "-s" ];

  _class = "nixos";
}

I'm using chrony as an NTP client rather than systemd-timesyncd. The latter only supports Simple NTP, which is missing important features of ordinary NTP.

The -s flag is important for the Raspberry Pis, since they don't have a hardware clock. The flag causes chrony to remember the time from the last time it was running, which brings it closer to correct between boots.

=> chrony FAQ: Should I prefer chrony over timesyncd if I do not need to run a server?

Apple Silicon (Asahi)

./asahi.nix
{
  inputs,
  lib,
  config,
  ...
}:
{
  imports = [
    "${inputs.nixos-apple-silicon}/apple-silicon-support"
  ];

  config = lib.mkMerge [
    { hardware.asahi.enable = lib.mkDefault false; }
    (lib.mkIf config.hardware.asahi.enable {
      <<asahi-config>>
    })
  ];

  _class = "nixos";
}

The NixOS module from the nixos-apple-silicon project is gated behind the hardware.asahi.enable option. It currently defaults to true, but that will change in the future to support this situation, where it can be imported unconditionally and then enabled as desired.

hardware.asahi.extractPeripheralFirmware =
  config.hardware.asahi.peripheralFirmwareDirectory != null;

Asahi Linux makes use of some firmware blobs that it extracts on the macOS side when installing and stores on the EFI partition. These then need to be gathered up and extracted and exposed to Linux. These blobs are proprietary, so legally they needed to be loaded off the actual system they were pulled from: they can't be redistributed from somewhere else.

This poses a small hiccup when building the system in CI to check for build errors. That firmware directory on the ESP is not available, so extracting the firmware is not possible. To workaround that, I just disable the extraction when the directory can't be found. When building and applying locally, the extraction will happen as it needs to, but otherwise, it can still build the rest of the system without issue.

services.chrony.enableRTCTrimming = false;
services.chrony.extraConfig = ''
  rtcsync
'';

With the default config for chrony, you'll see this error on an Asahi system:

Could not enable RTC interrupt : Invalid argument

This will prevent chrony from being able to update the RTC, so it may drift. Even worse, if the machine's battery dies, it will get reset to the Unix epoch, and never get fixed (except perhaps if you boot into macOS). Disabling RTC trimming and instead using the "rtcsync" directive does not require RTC interrupts, so chrony is able to update the RTC without issue.

Catppuccin

./catppuccin.nix
{ inputs, pkgs, ... }:
{
  imports = [
    "${inputs.catppuccin}/modules/nixos"
  ];

  catppuccin.enable = true;
  catppuccin.autoEnable = false;
  catppuccin.flavor = "macchiato";

  <<catppuccin-sources>>

  _class = "nixos";
}

I use catppuccin-nix to set up color schemes for many different things. I enable the module globally, but set autoEnable to false, so no catppuccin theming should be enabled that isn't explicitly mentioned in my configs. As for flavor, I prefer macchiato: dark, but not too dark.

catppuccin.sources = (import inputs.catppuccin { inherit pkgs; }).packages.overrideScope (
  _: _: {
    whiskers = pkgs.catppuccin-whiskers;
  }
);

Unfortunately, catppuccin-nix makes some use of IFD (import-from-derivation) to run the whiskers tool at evaluation time to produce some of the color themes. whiskers is a Rust tool, and Rust is not known for its fast compilation times. Needing to build whiskers can really slow things down, especially when building a system for another architecture using qemu-user. To make things worse, it doesn't end up getting cached in my personal attic cache because it doesn't end up in the closure of the final system.

The workaround for this is to override the catppuccin-nix module to pull whiskers from Nixpkgs. That version should be cached already.

Miscellaneous settings

time.timeZone = lib.mkDefault "Etc/UTC";

I want most of my machines to use UTC as the time zone. It makes more sense for servers to not have that kind of ambiguity around time. So I set it by default. Desktop machines will override this to my actual time zone.

programs.command-not-found.enable = false;

The common base module sets up nix-index, which provides its own command-not-found functionality. The default one provided by NixOS only works if the programs sqlite DB is present in the pkgs path, which is not the case if using a Git archive. So I disable it across the board in favor of nix-index.

documentation.enable = lib.mkDefault false;

I disable building documentation for servers and VMs, as it can sometimes be slow or broken. I can usually look up a man page on the workstation I'm using instead, as the desktop module will re-enable this.

networking.nftables.enable = lib.mkDefault true;

nftables is easier to work with than iptables, so I enable that by default. In some cases, it needs to be disabled due to a NixOS service module only supporting the iptables backend for NixOS's firewall, in which case I'll reluctantly re-enable it for just that machine.

nix.channel.enable = false;

I don't use nix-channel nor nixos-rebuild, so the paths that this manages are not needed for me.

system.disableInstallerTools = lib.mkDefault true;

This option is marked internal, with a description claiming to use at your own risk. That said, I have my own tooling for setting up my NixOS systems, so I don't actually use any of the things this installs, so I might as well leave it off and avoid any churn, especially in VMs, that comes from having them.

boot.loader.timeout = 0;

Removing the boot loader timeout of course makes booting faster, but it means the menu doesn't show at all. Thankfully, you can just hold down Space while booting to deactivate the timeout and use the menu.

environment.systemPackages = lib.mkIf (!config.microvm.guest.enable) (
  lib.attrValues {
    inherit (pkgs) file pciutils usbutils;
  }
);

Install some useful utilities on machines that aren't microVMs.

zramSwap.enable = lib.mkDefault (!config.microvm.guest.enable && !config.boot.zswap.enable);

I avoid doing fancy swap things on microVMs. I used to enable zram swap on all other machines, but I've come to learn that's maybe not a good idea a lot of the time. My impression now is that in most cases, a swap partition with zswap enabled gives more desirable performance characteristics when under memory pressure. So I've been moving to that configuration where I can (i.e. on systems where I have a swap partition already or the ability to create one).

So now I'm left with a configuration where zram swap is enabled by default unless a machine has opted in to zswap.

Proxy Information
Original URL
gemini://midna.dev/homelab/modules/nixos/base/
Status Code
Success (20)
Meta
text/gemini;lang=en-US
Capsule Response Time
26.008284 milliseconds
Gemini-to-HTML Time
1.159883 milliseconds

This content has been proxied by September (UNKNO).