Building a project (#2) - Preparing a server

This is a part of series Building a project, where I try to make something using OpenStack.

  1. Learning about OpenStack
  2. Preparing a server

Introduction

After a long, long hiatus, I decided give this project a push. Although I stopped after introducing myself to a very basic overview of OpenStack, I still heard about it from time to time.

Furthermore, I came across a blog post comparing Kubernetes and OpenStack, where I read a paragraph that intrigued me:

How to stack the stack?

Wait a second. So is the desired setup Kubernetes on OpenStack on Kubernetes?

You guessed right! Even though it sounds awkward, this architecture setup has the most advantages, effectively enabling you to combine the best of both worlds on one platform. [...]

It became less about which one to use, and more about how to use both.

The environment

I happened to be in possession of an old Dell XPS 13, so I decided to use it for hosting all kinds of services. It has a decent performance despite its age, which is an obvious advantage over my Raspberry Pi.

The choice of the operating system was, obviously, NixOS. Setting my bias aside, all I need to enable Kubernetes is apparently just a few lines of code:

services.kubernetes = {
  roles = [
    "master"
    "node"
  ];
};

While I have not tried it myself yet, it looked like the easiest way for me to set up Kubernetes among the guides I have read online.

On top of server-related stuff, I also wanted the machine to have a desktop environment in case I happen to do casual web browsing there. My choice here was, just like what it has been since the past decade, GNOME. I might try COSMIC one day, however.

Writing NixOS configuration

Like when I have set up instances for Ente, I wrote some configuration for the new device. As always, the starting point was flake.nix, where I created a new NixOS host.

nixosConfigurations = {
  # existing hosts

  # Dell XPS 13 9350
  xps13 = nixpkgs.lib.nixosSystem {
    modules = [ ./hosts/xps13 ];
    specialArgs = { inherit inputs outputs; };
  };
};

Other changes were mostly made within the host-specific directory; some notable ones are mentioned below.

Preparing secrets

Just like my other devices, the new one also required some secrets. As usual, sops-nix was my choice for storing them encrypted.

For some reason, I wanted to specify machine-id; a new one was created by running dbus-uuidgen.

[lyuk98@framework:~]$ dbus-uuidgen
7e7f3bdd40f715ee577580a868f47504

I wanted the device to connect to tailnet automatically. Like what I would do to servers, I created an OAuth credential.

A dialog for adding a new credential at Tailscale. Checkbox "Write" is checked for the "Auth Keys" scope.A dialog for adding a new credential at Tailscale. Checkbox "Write" is checked for the "Auth Keys" scope.

They were then written to secrets.yaml inside the host-specific directory. Please note that the values below are not the actual data that ended up in my repository.

machine-id: 7e7f3bdd40f715ee577580a868f47504
tailscale-auth-key: tskey-client-kiVq4NfjyH11CNTRL-CuSLnQJwGL7vj61JRFG3L7eMKZa25oyH

To encrypt the data, I created an age key for the host. It was saved to a temporary directory for now.

[lyuk98@framework:~]$ nix shell nixpkgs#age
[lyuk98@framework:~]$ tmp=$(mktemp --directory)
[lyuk98@framework:~]$ age-keygen --output $tmp/keys.txt
Public key: age1eqaqhyzdznd22j43j43e8qpra6z849hqljg9h3xruz0g0pmypassetfhdw

.sops.yaml at the root of the repository was edited to add the new public key:

keys:
  # Users
  - &users:
    - &lyuk98 270CB11B1189E79A17DCB7831BDAFDC5D60E735C
  # Hosts
  - &hosts:
    - &framework age14edzmqc4r07gp9lkj8z4gchccs373s8lcdrw69d6964tallpuuzqausgmk
    - &vault age1p0rc7s7r9krcqr8uy6dr8wlutyk9668a429y9k27xhfwtgwudgpq9e9ehq
    - &xps13 age1eqaqhyzdznd22j43j43e8qpra6z849hqljg9h3xruz0g0pmypassetfhdw

...and also to modify creation_rules:

creation_rules:
  # other hosts

  # Secrets specific to host "xps13"
  - path_regex: hosts/xps13/secrets.ya?ml$
    key_groups:
    - age:
      - *xps13
      pgp:
      - *lyuk98

  # Secrets for user "lyuk98" to be used across hosts
  - path_regex: hosts/common/users/lyuk98/secrets.ya?ml$
    key_groups:
    - age:
      - *framework
      - *xps13
      pgp:
      - *lyuk98

  # NetworkManager connections
  - path_regex: hosts/common/optional/system-connections/.+\.nmconnection$
    key_groups:
    - age:
      - *framework
      - *xps13
      pgp:
      - *lyuk98

secrets.yaml was then encrypted in place using sops.

[lyuk98@framework:~/nixos-config]$ sops encrypt --in-place hosts/xps13/secrets.yaml
[lyuk98@framework:~/nixos-config]$ sops updatekeys --yes hosts/common/users/lyuk98/secrets.yaml
[lyuk98@framework:~/nixos-config]$ for connection in hosts/common/optional/system-connections/*.nmconnection; do sops updatekeys --yes $connection; done

Creating a ZFS partition with disko

As an added challenge, I decided to use ZFS for storing data. It is a file system that took me a while to get used to; eventually, though, I ended up with a disko configuration I could get along with.

A separate EFI system partition was still required, so I could not use the whole disk for ZFS. Two partitions were therefore declared within disko.devices.disk.main.

{
  disko.devices = {
    disk = {

      # Primary disk
      main = {
        type = "disk";
        device = "/dev/nvme0n1";

        # GPT (partition table) as the disk's content
        content = {
          type = "gpt";

          # List of partitions
          partitions = {
            # EFI system partition
            esp = {
              priority = 100;
              end = "1G";
              type = "EF00";

              # FAT filesystem
              content = {
                type = "filesystem";
                format = "vfat";
                mountpoint = "/boot";
                mountOptions = [
                  "defaults"
                  "umask=0077"
                ];
              };
            };

            # ZFS partition
            zfs = {
              size = "100%";

              content = {
                type = "zfs";
                pool = "zroot";
              };
            };
          };
        };
      };
    };

  };
}

ZFS has a concept of storage pools, which can consist of multiple physical devices. Together with partitions, one was also defined within the same file. The new pool would employ transparent compression and native encryption using a passphrase.

{
  disko.devices = {

    zpool = {
      # The root zpool
      zroot = {
        type = "zpool";

        rootFsOptions = {
          # Do not mount root filesystem
          mountpoint = "none";

          # Enable transparent compression with zstd
          compression = "zstd";

          # Use ACL on datasets from this pool
          acltype = "posixacl";
          xattr = "sa";

          # Disable automatic snapshot
          "com.sun:auto-snapshot" = "false";
        };

        options = {
          # Force the pool to use 4,096 (2^12) byte sectors
          ashift = "12";
        };

        datasets = {
          # The root dataset
          "root" = {
            type = "zfs_fs";

            options = {
              # Use the default encryption method since OpenZFS 0.8.4
              encryption = "aes-256-gcm";

              # Use passphrase key format
              keyformat = "passphrase";
              keylocation = "prompt";

              # Do not mount this dataset
              mountpoint = "none";
            };
          };

          # Dataset for /nix
          "root/nix" = {
            type = "zfs_fs";

            options = {
              mountpoint = "/nix";
            };
            mountpoint = "/nix";
          };

          # Dataset for /persist
          "root/persist" = {
            type = "zfs_fs";

            options = {
              mountpoint = "/persist";
            };
            mountpoint = "/persist";
          };

          # Dataset for swap volume
          "root/swap" = {
            type = "zfs_volume";

            size = "16G";
            content = {
              type = "swap";
            };

            options = {
              # Set block size to system page size (4KiB)
              volblocksize = "4096";

              # Use zero-length encoding (ZLE) for compression
              compression = "zle";

              # Force data to be immediately flushed
              logbias = "throughput";
              sync = "always";

              # Prevent storing swap data in memory
              primarycache = "metadata";
              secondarycache = "none";

              # Disable automatic snapshot
              "com.sun:auto-snapshot" = "false";
            };
          };
        };
      };
    };

  };
}

Lastly, tmpfs was used for /:

{
  disko.devices = {

    nodev = {
      # Impermanent root with tmpfs
      "/" = {
        fsType = "tmpfs";
        mountOptions = [
          "defaults"
          "size=50%"
          "mode=0755"
        ];
      };
    };
  };
}

Installing NixOS

The installation process became somewhat different from what I have initially anticipated, which supposedly involves disko-install. During failed attempts, it soon became clear that not having enough memory prevented me from building the system locally. To save as much memory space as possible, I downloaded a "minimal installation ISO image".

It was then written to a portable storage using Fedora Media Writer.

A dialog for choosing "Write Options" at Fedora Media Writer. A file named "nixos-minimal-25.05.811874.daf6dc47aa4b-x86_64-linux.iso" is selected, and "USB SanDisk 3.2Gen1 (988.3 GB)" is selected as a USB Drive.A dialog for choosing "Write Options" at Fedora Media Writer. A file named "nixos-minimal-25.05.811874.daf6dc47aa4b-x86_64-linux.iso" is selected, and "USB SanDisk 3.2Gen1 (988.3 GB)" is selected as a USB Drive.

The soon-to-be-wiped laptop was then rebooted. Before booting into the installer, I disabled Secure Boot and put the firmware into Setup Mode by deleting all existing keys.

After booting, the device connected to the internet using wpa_supplicant.

[nixos@nixos:~]$ sudo systemctl start wpa_supplicant.service
[nixos@nixos:~]$ wpa_cli

The root user's password was then set, so that SSH connections to the device can be made.

[nixos@nixos:~]$ sudo passwd root

Meanwhile, on my (more powerful) machine, the age key that was saved to a temporary directory was copied to a new temporary directory; it was to prepare for the next step.

[lyuk98@framework:~]$ dir=$(mktemp --directory)
[lyuk98@framework:~]$ install -D --mode 0600 $tmp/keys.txt $dir/persist/var/lib/sops-nix/keys.txt

With nixos-anywhere, an actual installation was performed.

[lyuk98@framework:~/nixos-config]$ nix run github:nix-community/nixos-anywhere -- \
  --flake .#xps13 \
  --target-host root@<ip-address> \
  --extra-files $dir \
  --phases disko,install

Among phases kexec, disko, install, and reboot, I only specified phases disko and install. There was no need to kexec into an installer since the device has already booted into one, and I omitted reboot to perform some steps after installation. I did not actually do anything afterwards, though, and the only thing the absence of the reboot phase made me do was to reboot the machine in my own terms.

Enabling Secure Boot with Lanzaboote

Despite its criticisms, I wanted to continue using the system with Secure Boot enabled. I used Lanzaboote, as most guides for NixOS mentioned it.

Just like my previous self, Lanzaboote was chosen to enable Secure Boot on the new device.

Following the quickstart guide, the first thing to do was to create new Secure Boot keys.

[lyuk98@xps13:~]$ nix shell nixpkgs#sbctl
[lyuk98@xps13:~]$ sudo sbctl create-keys

I then remotely applied the change that disables systemd-boot and enables Lanzaboote.

[lyuk98@framework:~/nixos-config]$ nixos-rebuild switch \
  --target-host lyuk98@xps13 \
  --sudo \
  --ask-sudo-password \
  --flake .#xps13

After it was done, I realised that the Secure Boot keys were wiped because it was not set to be preserved across reboots. Because the aforementioned change now does so, all I had to do was to create another set of keys:

[lyuk98@xps13:~]$ sudo sbctl create-keys

...and to apply the same configuration, which would sign EFI binaries with the new keys:

[lyuk98@framework:~/nixos-config]$ nixos-rebuild switch \
  --target-host lyuk98@xps13 \
  --sudo \
  --ask-sudo-password \
  --flake .#xps13

I then made sure that enabling Secure Boot would not prevent me from booting into NixOS.

[lyuk98@xps13:~]$ sudo sbctl verify
Verifying file database and EFI images in /boot...
✓ /boot/EFI/BOOT/BOOTX64.EFI is signed
✓ /boot/EFI/Linux/nixos-generation-1-45wzdjf3iqke66uzyktrhh45qjb5zanyml2hxfk7bj5cnuxvzorq.efi is signed
✓ /boot/EFI/Linux/nixos-generation-2-tgt7vtvzoif4nstwjunxjnyqc7q7rqi3ctwimgntsihp5mtjzu3q.efi is signed
✓ /boot/EFI/Linux/nixos-generation-3-64edmqoob3gj6omtovcypgiew5r6ncvnyymlynoymcph5bhvnowa.efi is signed
✗ /boot/EFI/nixos/kernel-6.12.55-zxhzas57mq7ufqrlt4l4moejma4fin7avasznetmjbh3wtcwffoq.efi is not signed
✓ /boot/EFI/systemd/systemd-bootx64.efi is signed

With everything ready, the keys were enrolled to the firmware.

[lyuk98@xps13:~]$ sudo sbctl enroll-keys --firmware-builtin --microsoft

It is supposed to enable Secure Boot on its own, but I had to manually do so.

Automatic decryption with Clevis

With the presence of Trusted Platform Module (TPM), I can unlock the storage on boot without entering its passphrase. I have always used systemd-cryptenroll for this purpose, but it could apparently only be applied to LUKS-encrypted volumes.

Clevis was chosen as an alternative. Because the passphrase could be piped to the unlocking process:

[root@xps13:~]# systemd-ask-password | zfs load-key -n zroot/root
🔐 Password: (no echo)               
1 / 1 key(s) successfully verified

...I could let Clevis encrypt one and decrypt it in initrd to unlock the storage. In NixOS, this could easily be achieved by adding a few lines of code:

{
  boot.initrd.clevis = {
    # Enable Clevis in initrd
    enable = true;

    # Unlock the device at boot using secret
    devices."zroot/root" = {
      secretFile = "${./root.jwe}";
    };
  };
}

root.jwe was created from the new device, which is just the decryption passphrase encrypted with TPM. The PCR value of 7 makes the decryption (of the passphrase) dependent of the Secure Boot settings.

[lyuk98@xps13:~]$ nix shell nixpkgs#clevis
[lyuk98@xps13:~]$ systemd-ask-password | clevis encrypt tpm2 '{"pcr_ids": "7"}' > root.jwe

The encrypted key was then transferred to my repository.

[lyuk98@framework:~/nixos-config]$ scp lyuk98@xps13:~/root.jwe hosts/xps13/root.jwe

The new configuration was then applied to the target machine.

[lyuk98@framework:~/nixos-config]$ nixos-rebuild switch \
  --target-host lyuk98@xps13 \
  --sudo \
  --ask-sudo-password \
  --flake .#xps13

Disconnecting the battery

The laptop was now going to be plugged into a charger at all times, and I did not want it to be done with the battery still attached. As a result, I partially followed a guide from iFixit to disconnect the battery. As an owner of a Framework Laptop 13, I was glad to find out that I do not need any tool other than a Framework Screwdriver.

First, I removed eight screws from the bottom of the laptop.

The laptop flipped upside down

The flap in the middle was then opened, and a screw hidden underneath was removed.

A flap in the middle of the laptop's bottom cover is partially opened, with a screwdriver keeping it from closing. The screwdriver is placed on top of a screw underneath the flap.

Opening the cover took some force. After it was done, I could see the connector:

A battery connector inside the laptop, which is still connected

...which I disconnected using the spudger end of my screwdriver:

A disconnected battery connector inside the laptop

I did not remove the battery itself. The cover was reattached right away.

The next step

With the server ready, the next step would be to run Kubernetes. I have not figured out how to run OpenStack on top of it yet, but I probably will as I work on it.

More from 이영욱
All posts