Creating a NixOS thin gaming client live USB

Comment on Mastodon

Introduction

This article will cover a use case I suppose very personal, but I love the way I solved it so let me share this story.

I'm a gamer, mostly on computer, but I have a big rig running Windows because many games still don't work well with Linux, but I also play video games on my Linux laptop. Unfortunately, my laptop only has an intel integrated graphic card, so many games won't run well enough to be played, so I'm using an external GPU for some games. But it's not ideal, the eGPU is big (think of it as a big shoes box), doesn't have mouse/keyboard/usb connectors, so I've put it into another room with a screen at a height to play while standing up, controller in hands. This doesn't solve everything, but I can play most games running on it and allowing a controller.

But if I install a game on both the big rig and the laptop, I have to manually sync the saves (I'm buying most of the games on GOG which doesn't have a Linux client to sync saves), it's highly boring and error-prone.

So, thanks to NixOS, I made a recipe to generate a USB live media to play on the big rig, using the data from the laptop, so it's acting as a thin client. The idea of a read only media to boot from is very nice, because USB memory sticks are terrible if you try to install Linux on them (I tried many times, it always ended with I/O errors quickly) and there is exactly what you need, generated from a declarative file.

What does it solve concretely? I can play some games on my laptop anywhere on the small screen, I can also play with my eGPU on the standing desk, but now I can also play all the installed games from the big rig with mouse/keyboard/144hz screen.

What's in the live image?

The generated ISO (USB capable) should come with a desktop environment like Xfce, Nvidia drivers, Steam, Lutris, Minigalaxy and some other programs I like to use, I keep the programs list minimal because I could still use nix-shell to run a program later.

For the system configuration, I declare the user "gaming" with the same uid as the user on my laptop, and use an NFS mount at boot time.

I'm not using Network Manager because I need the system to get an IP before connecting to a user account.

The code

I'll be using flakes for this, it makes pinning so much easier.

I have two files, "flake.nix" and "iso.nix" in the same directory.

flake.nix file:

{
  inputs = {
    nixpkgs.url = "nixpkgs/nixos-unstable";

  };

  outputs = { self, nixpkgs, ... }@inputs:
    let
      system = "x86_64-linux";

      pkgs = import nixpkgs { inherit system; config = { allowUnfree = true; }; };
      lib = nixpkgs.lib;

    in
    {

      nixosConfigurations.isoimage = nixpkgs.lib.nixosSystem {
        system = "x86_64-linux";
        modules = [
          ./iso.nix
          "${nixpkgs}/nixos/modules/installer/cd-dvd/installation-cd-base.nix"
        ];
      };

    };
}

And iso.nix file:

{ config, pkgs, ... }:
{

  # compress 6x faster than default
  # but iso is 15% bigger
  # tradeoff acceptable because we don't want to distribute
  # default is xz which is very slow
  isoImage.squashfsCompression = "zstd -Xcompression-level 6";
  
  # my azerty keyboard
  i18n.defaultLocale = "fr_FR.UTF-8";
  services.xserver.layout = "fr";
  console = {
    keyMap = "fr";
  };
  
  # xanmod kernel for better performance
  # see https://xanmod.org/
  boot.kernelPackages = pkgs.linuxPackages_xanmod;
  
  # prevent GPU to stay at 100% performance
  hardware.nvidia.powerManagement.enable = true;
  
  # sound support
  hardware.pulseaudio.enable = true;
 
  # getting IP from dhcp
  # no network manager
  networking.dhcpcd.enable = true;
  networking.hostName = "biggy"; # Define your hostname.
  networking.wireless.enable = false;

  # many programs I use are under a non-free licence
  nixpkgs.config.allowUnfree = true;

  # enable steam
  programs.steam.enable = true;

  # enable ACPI
  services.acpid.enable = true;

  # thermal CPU management
  services.thermald.enable = true;

  # enable XFCE, nvidia driver and autologin
  services.xserver.desktopManager.xfce.enable = true;
  services.xserver.displayManager.lightdm.autoLogin.timeout = 10;
  services.xserver.displayManager.lightdm.enable = true;
  services.xserver.enable = true;
  services.xserver.libinput.enable = true;
  services.xserver.videoDrivers = [ "nvidia" ];
  services.xserver.xkbOptions = "eurosign:e";

  time.timeZone = "Europe/Paris";

  # declare the gaming user and its fixed password
  users.mutableUsers = false;
  users.users.gaming.initialHashedPassword = "$6$bVayIA6aEVMCIGaX$FYkalbiet783049zEfpugGjZ167XxirQ19vk63t.GSRjzxw74rRi6IcpyEdeSuNTHSxi3q1xsaZkzy6clqBU4b0";
  users.users.gaming = {
    isNormalUser = true;
    shell = pkgs.fish;
    uid = 1001;
    extraGroups = [ "networkmanager" "video" ];
  };
  services.xserver.displayManager.autoLogin = {
    enable = true;
    user = "gaming";
  };

  # mount the NFS before login
  systemd.services.mount-gaming = {
    path = with pkgs; [ nfs-utils ];
    serviceConfig.Type = "oneshot";
    script = ''
      mount.nfs -o fsc,nfsvers=4.2,wsize=1048576,rsize=1048576,async,noatime t470-eth.local:/home/jeux/ /home/jeux/
    '';
    before = [ "display-manager.service" ];
    wantedBy = [ "display-manager.service" ];
    after = [ "network-online.target" ];
  };

  # useful packages
  environment.systemPackages = with pkgs; [
    bwm_ng
    chiaki
    dunst # for notify-send required in Dead Cells
    file
    fzf
    kakoune
    libstrangle
    lutris
    mangohud
    minigalaxy
    ncdu
    nfs-utils
    steam
    steam-run
    tmux
    unzip
    vlc
    xorg.libXcursor
    zip
  ];

}

Then I can update the sources using "nix flake lock --update-input nixpkgs", that will tell you the date of the nixpkgs repository image you are using, and you can compare the dates for updating. I recommend using a program like git to keep track of your files, if you see a failure with a more recent nixpkgs after the lock update, you can have fun pinpointing the issue and reporting it, or restoring the lock to the previous version and be able to continue building ISOs.

You can build the iso with the command "nix build .#nixosConfigurations.isoimage.config.system.build.isoImage", this will create a symlink "result" in the directory, containing the ISO that you can burn on a disk or copy to a memory stick using dd.

Server side

Of course, because I'm using NFS to share the data, I need to configure my laptop to serves the files over NFS, this is easy to achieve, just add the following code to your "configuration.nix" file and rebuild the system:

services.nfs.server.enable = true;
services.nfs.server.exports = ''
  /home/gaming 10.42.42.141(rw,nohide,insecure,no_subtree_check)
'';

If like me you are using the firewall, I'd recommend opening the NFS 4.2 port (TCP/2049) on the Ethernet interface only:

networking.firewall.enable = true;
networking.firewall.allowedTCPPorts = [ ];
networking.firewall.allowedUDPPorts = [ ];
networking.firewall.interfaces.enp0s31f6.allowedTCPPorts = [ 2049 ];

In this case, you can see my NFS client is 10.42.42.141, and previously the NFS server was referred to as laptop-ethernet.local which I declare in my LAN unbound DNS server.

You could make a specialisation for the NFS server part, so it would only be enabled when you choose this option at boot.

NFS performance improvement

If you have a few GB of spare memory on the gaming computer, you can enable cachefilesd, a service that will cache some NFS accesses to make the experience even smoother. You need memory because the cache will have to be stored in the tmpfs and it needs a few gigabytes to be useful.

If you want to enable it, just add the code to the iso.nix file, this will create a 10 MB * 300 cache disk. As tmpfs lacks user_xattr mount option, we need to create a raw disk on the tmpfs root partition and format it with ext4, then mount on the fscache directory used by cachefilesd.

services.cachefilesd.enable = true;
services.cachefilesd.extraConfig = ''
  brun 6%
  bcull 3%
  bstop 1%
  frun 6%
  fcull 3%
  fstop 1%
'';

# hints from http://www.indimon.co.uk/2016/cachefilesd-on-tmpfs/
systemd.services.tmpfs-cache = {
  path = with pkgs; [ e2fsprogs busybox ];
  serviceConfig.Type = "oneshot";
  script = '' 
    if [ ! -f /disk0 ]; then 
      dd if=/dev/zero of=/disk0 bs=10M count=600 
      echo 'y' | mkfs.ext4 /disk0 
    fi 
    mkdir -p /var/cache/fscache 
    mount | grep fscache || mount /disk0 /var/cache/fscache -t ext4 -o loop,user_xattr 
  '';
  before = [ "cachefilesd.service" ];
  wantedBy = [ "cachefilesd.service" ];
};

Security consideration

Opening an NFS server on the network must be done only in a safe LAN, however I don't consider my gaming account to contain any important secret, but it would be bad if someone on the LAN mount it and delete all the files.

However, there are two NFS alternatives that could be used:

Size optimization

The generated ISO can be reduced in size by removing some packages.

Gnome

for example Gnome comes with orca which will bring many dependencies for text-to-speech. You can easily exclude many Gnome packages.

environment.gnome.excludePackages = with pkgs.gnome; [
  pkgs.orca
  epiphany
  yelp
  totem
  gnome-weather
  gnome-calendar
  gnome-contacts
  gnome-logs
  gnome-maps
  gnome-music
  pkgs.gnome-photos
];

Wine

I found that Wine came with the Windows compiler as a dependency, but yet it doesn't seem useful for running games in Lutris.

NixOS discourse: Wine installing mingw32 compiler?

It's possible to rebuild Wine used by Lutris without support for the mingw compiler, replace the lutris line in the "systemPackages" list with the following code:

(lutris-free.override {
  lutris-unwrapped = lutris-unwrapped.override {
    wine = wineWowPackages.staging.override {
      mingwSupport = false;
    };
  };
})

Note that I'm using lutris-free which doesn't support Steam because it makes it a bit lighter and I don't need to manage my Steam games with Lutris.

Possible improvements

It could be possible to try getting a package from the nix-store on the NFS server before trying cache.nixos.org which would improve bandwidth usage, it's easy to achieve but yet I need to try it in this context.

Issue

I found Steam games running with Proton are slow to start. I made a bug report on the Steam Linux client github.

Github: Proton games takes around 5 minutes to start from a network share

This can be solved partially by mounting ~/.local/share/Steam/steamapps/common/SteamLinuxRuntime_soldier/var as tmpfs, it will uses less than 650MB.

Conclusion

I really love this setup, I can backup my games and saves from the laptop, play on the laptop, but now I can extend all this with a bigger and more comfortable setup. The USB live media doesn't take long to be copied to a USB memory stick, so in case one is defective, I can just recopy the image. The live media can be booted all in memory then be unplugged, this gives a crazy fast responsive desktop and can't be altered.

My previous attempts at installing Linux on an USB memory stick all gave bad results, it was extremely slow, i/o errors were common enough that the system became unusable after a few hours. I could add a small partition to one disk of the big rig or add a new disk, but this will increase the maintenance of a system that doesn't do much.