Immae's blog

egrep -ri TODO /etc

Migrate from RAID1 disk to ZFS on NixOS

As of 2020-06-06 I only made those tests inside a VM (See below if you want to play with it too). I’m not fully at peace with the process yet to actually apply it on my server. Use at your own risk!

Edit 2020-08-13: Adjustments to the process after some advices by Linus Heckemann (sphalerite on freenode): the /boot partition stays outside of zfs and some flags are set by default on the zpool.

Edit 2020-08-25: The configuration was successfully applied. An issue occured with two ZFS pools having the same name, which prevented the system from booting. Thanks to sphalerite, BiBi and Raito_Bezarius for their supports during the hours of debugging it took to figure out the issue.

Context

I’m the happy owner of a server which holds my whole infrastructure for more than one year now, powered by NixOS for declarative deployments. When I installed it the first time, I didn’t know about ZFS and all its features (see there if you want some examples)

Since I cannot afford to reinstall everything from scratch, I had to find a way to deploy ZFS safely (i.e. without losing redundancy). This article explains step by step the choices I made.

Setup

The server has a single relevant partition mounted on / (the other partitions are BIOS boot and swap, non-relevant here). This partition is a RAID1 array, backed by two disks. The partition holding / on the underlying disks is the third one, that is /dev/md0 containing /dev/sda3 and /dev/sdb3.

The distribution of my server is NixOS, installed remotely via nixops. Some commands will rely on that fact below, but might be adapted depending on your distribution (or if you don’t use nixops)

I ordrered an additional disk to my server provider (/dev/sdc). The sole purpose of this disk is to ensure redundancy in case of failure during the process. The process itself could be adapted to not need it if you’re confident enough. It can be thrown away at the end of the process.

Play with libvirtd

Since I didn’t want to break my server, I created a libvirtd rough equivalent of my setup: three disk images, two of them mounted as RAID array. Since it is a quite specific setup, I couldn’t make a fully declarative VM handled by nixops, but I still made use of some of nixpkgs helpers.

The derivation below will produce an output with three disks image as described above:

# base_image.nix
{ system ? builtins.currentSystem, size ? "10" }:
let
  pkgs = import <nixpkgs> {};
  config = (import <nixpkgs/nixos/lib/eval-config.nix> {
    inherit system;
    modules = [ {
      fileSystems."/".device = "/dev/disk/by-label/root";

      boot.loader.grub.version = 2;
      boot.loader.grub.devices = [ "/dev/vda" "/dev/vdb" ];
      boot.loader.timeout = 0;
      boot.kernelParams = ["console=ttyS0,115200"];

      services.openssh.enable = true;
      services.openssh.startWhenNeeded = false;
      services.openssh.extraConfig = "UseDNS no";
    } ];
  }).config;
  the_key = builtins.getEnv "NIXOPS_LIBVIRTD_PUBKEY";
in pkgs.vmTools.runInLinuxVM (
  pkgs.runCommand "libvirtd-image"
    { memSize = 768;
      preVM =
        ''
          mkdir $out
          diskImage1=$out/image
          diskImage2=$out/image2
          diskImage3=$out/image3
          ${pkgs.vmTools.qemu}/bin/qemu-img create -f qcow2 $diskImage1 "${size}G"
          ${pkgs.vmTools.qemu}/bin/qemu-img create -f qcow2 $diskImage2 "${size}G"
          ${pkgs.vmTools.qemu}/bin/qemu-img create -f qcow2 $diskImage3 "${size}G"
          mv closure xchg/
        '';
      postVM =
        ''
          mv $diskImage1 $out/disk.qcow2
          mv $diskImage2 $out/disk2.qcow2
          mv $diskImage3 $out/disk3.qcow2
        '';
      QEMU_OPTS = builtins.concatStringsSep " " [
        "-drive file=$diskImage1,if=virtio,cache=unsafe,werror=report"
        "-drive file=$diskImage2,if=virtio,cache=unsafe,werror=report"
        "-drive file=$diskImage3,if=virtio,cache=unsafe,werror=report"
      ];
      buildInputs = [ pkgs.utillinux pkgs.perl pkgs.kmod ];
      exportReferencesGraph =
        [ "closure" config.system.build.toplevel ];
    }
    ''
      ${pkgs.parted}/bin/parted --script /dev/vda -- \
        mklabel gpt \
        mkpart ESP fat32 8MiB 256MiB \
        set 1 boot on \
        set 1 bios_grub on \
        mkpart sap1 linux-swap 256MiB 512MiB \
        mkpart primary ext4 512MiB -1
      ${pkgs.parted}/bin/parted --script /dev/vdb -- \
        mklabel gpt \
        mkpart ESP fat32 8MiB 256MiB \
        set 1 boot on \
        set 1 bios_grub on \
        mkpart sap1 linux-swap 256MiB 512MiB \
        mkpart primary ext4 512MiB -1
      ${pkgs.mdadm}/bin/mdadm --create /dev/md0 --metadata=0.90 --level=1 --raid-devices=2 /dev/vda3 /dev/vdb3

      # Create an empty filesystem and mount it.
      ${pkgs.e2fsprogs}/sbin/mkfs.ext4 -L root /dev/md0
      ${pkgs.e2fsprogs}/sbin/tune2fs -c 0 -i 0 /dev/md0
      mkdir /mnt
      mount /dev/md0 /mnt

      export HOME=$TMPDIR
      export NIX_STATE_DIR=$TMPDIR/state

      mkdir -p /mnt/etc/nixos

      # The initrd expects these directories to exist.
      mkdir /mnt/dev /mnt/proc /mnt/sys
      mount --bind /proc /mnt/proc
      mount --bind /dev /mnt/dev
      mount --bind /sys /mnt/sys

      # Copy all paths in the closure to the filesystem.
      storePaths=$(perl ${pkgs.pathsFromGraph} /tmp/xchg/closure)

      echo "filling Nix store..."
      mkdir -p /mnt/nix/store
      set -f
      cp -prd $storePaths /mnt/nix/store/

      mkdir -p /mnt/etc/nix
      echo 'build-users-group = ' > /mnt/etc/nix/nix.conf
      export USER=root

      ## Register the paths in the Nix database.
      printRegistration=1 perl ${pkgs.pathsFromGraph} /tmp/xchg/closure | \
          chroot /mnt ${config.nix.package.out}/bin/nix-store --load-db

      mkdir -p /mnt/nix/var/nix/profiles
      # Create the system profile to allow nixos-rebuild to work.
      chroot /mnt ${config.nix.package.out}/bin/nix-env \
          -p /nix/var/nix/profiles/system --set ${config.system.build.toplevel}

      # `nixos-rebuild' requires an /etc/NIXOS.
      mkdir -p /mnt/etc/nixos
      touch /mnt/etc/NIXOS

      # `switch-to-configuration' requires a /bin/sh
      mkdir -p /mnt/bin
      ln -s ${config.system.build.binsh}/bin/sh /mnt/bin/sh

      # Generate the GRUB menu.
      chroot /mnt ${config.system.build.toplevel}/bin/switch-to-configuration boot

      mkdir -p /mnt/etc/ssh/authorized_keys.d
      echo '${the_key}' > /mnt/etc/ssh/authorized_keys.d/root
      umount /mnt/proc /mnt/dev /mnt/sys
      umount /mnt
    ''
)

When deploying with nixops (via the libvirtd backend), you will need to make each image available. However, nixops only handles one and only one image, so we will need a bit of manual tasks. This is the nixops configuration I’m using:

# libvirtd.nix
{
  testzfs = { pkgs, lib, ... }:
  {
    fileSystems."/".device = lib.mkForce "/dev/disk/by-label/root";

    # Serial access via virsh console (quite handy for debugging)
    boot.kernelParams = ["console=ttyS0,115200"];
    boot.loader.grub.extraConfig = ''
      serial --unit=0 --speed=115200 --word=8 --parity=no --stop=1
      terminal_output serial
      terminal_input serial
    '';
    boot.loader.timeout = lib.mkForce 2;

    # You need to explicitely specify the additional disk here
    boot.loader.grub.devices = [ "/dev/sdb" ];

    deployment = {
      targetEnv = "libvirtd";
      libvirtd.baseImage = pkgs.callPackage ./base_image.nix {};
      # Additional images need to be specified explicitely here (only the sda one will be picked by nixops)
      libvirtd.extraDevicesXML = ''
        <disk type="file" device="disk" snapshot="external">
          <driver name="qemu" type="qcow2"/>
          <source file="/path/to/disk2.qcow2"/>
          <target dev="hdb"/>
        </disk>
        <disk type="file" device="disk" snapshot="external">
          <driver name="qemu" type="qcow2"/>
          <source file="/path/to/disk3.qcow2"/>
          <target dev="hdc"/>
        </disk>
      '';
    };

    # Some dummy service that writes to disk regularly
    systemd.services.nag-var = {
      description = "Some service reading and writing to /var";
      after = [ "network.target" ];
      wantedBy = [ "multi-user.target" ];
      script = ''
        #!${pkgs.stdenv.shell}
        mkdir -p /var/nagvar
        while true; do
          ${pkgs.coreutils}/bin/date > /var/nagvar/last
          ${pkgs.coreutils}/bin/sleep 10
        done
      '';
    };
  };
}

Now prepare the VM. Beware, this will rapidly fill-in your /nix/store with big images. (nix-store --delete /nix/store/*libvirtd-image* to clean them selectively if you’re doing tests)

# This command will fail due to missing images
nixops deploy --create-only
# Find the path to images at the beginning of the output. It will be
# slightly different from what you would get with nix-build due to
# some parameters given by nixops
P=/nix/store/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-libvirtd-image

# Stop VM.
virsh destroy nixops-...-testzfs

# Copy additional disks to places written in libvirtd.nix
# For some reason, sometimes I had to replace the first disk too in
# libvirtd folder.
cp $P/disk2.qcow2 /path/to/disk2.qcow2
cp $P/disk3.qcow2 /path/to/disk3.qcow2
chmod gu+w /path/to/disk2.qcow2 /path/to/disk3.qcow2

# Same action done by nixops on the first disk
qemu-img rebase -f qcow2 -b "" /path/to/disk2.qcow2
qemu-img rebase -f qcow2 -b "" /path/to/disk3.qcow2

# Edit libvirtd and add console configuration (in the <devices> section)
virsh edit nixops-...-testzfs
# <serial type='pty'><target port='0'/></serial>
# <console type='pty'><target type='serial' port='0'/></console>

nixops deploy --force-reboot

Now you should have a running VM containing two drives in a RAID1 array plus one unused drive, that mimics your production server, and that I used as a base for the migration process below.

In case of problem, you should be able to use virsh console to get an actual console of what’s happening on your VM (as early as grub stage). Also think of doing snapshots if you want to repeat some steps.

Migration process

Add the new disk to the RAID array

# Copy partitionning without boot partition
sfdisk -d /dev/sda | grep -v ^sector-size: | sed -e "s/21686148-6449-6E6F-744E-656564454649/0657FD6D-A4AB-43C4-84E5-0933C84B4F4F/" | sfdisk /dev/sdc

# Add the new partition to RAID array
mdadm --grow /dev/md0 --level=1 --raid-devices=3 --add /dev/sdc3

# Wait for synchronisation to finish
cat /proc/mdstat
(...)

Remove sda from the array

Beware in this step, depending on your grub configuration it could very well end up using sda for the next boot if you don’t wipe it correctly (last command of the step)

mdadm /dev/md0 --fail /dev/sda3 --remove /dev/sda3
mdadm --grow /dev/md0 --raid-devices=2

# delete old partition (so that grub doesn’t find it by error)
wipefs -a /dev/sda3

Add ZFS-specific configuration to nix

Add this to nix configuration:

boot.supportedFilesystems = [ "zfs" ];
networking.hostId = "9e16a79b";

# Maintenance target for later
systemd.targets.maintenance = {
  description = "Maintenance target with only sshd";
  after = [ "network-online.target" "network-setup.service" "sshd.service" ];
  requires = [ "network-online.target" "network-setup.service" "sshd.service" ];
  unitConfig = {
    AllowIsolate = "yes";
  };
};

And deploy:

nixops deploy
# nixos-rebuild switch

Convert sda3 to a ZFS filesystem

I wanted to use this migration to encrypt my filesystem at the same time. But doing it correctly requires specific configuration (in initrd) which I didn’t want to risk doing concurrently with the migration. So for now the password will be in cleartext (I’m aware it makes the encryption useless, but since encryption cannot be switched on later I need to activate it now. If someone obtains root access to your system during that time your encryption is screwed - he can obtain the ZFS master encryption key -, otherwise it can just be activated with a proper process later)

# Repartition your disk, it’s not recommended to have /boot in ZFS for now
# remove sda3, create a 2GB partition for /boot and create a new root partition
sfdisk --delete /dev/sda 3
fdisk /dev/sda
mdadm --create /dev/md1 --metadata=0.90 --level=1 --force --raid-devices=1 /dev/sda3
mkfs.ext4 /dev/md1
mkdir /mnt && mount /dev/md1 /mnt && echo -n "12345678" > /mnt/pass.key && chmod go-rwx /mnt/pass.key
echo -n "12345678" > /boot/pass.key && chmod go-rwx /boot/pass.key
zpool create -O xattr=sa -O acltype=posixacl -O atime=off -o ashift=12 -O mountpoint=legacy -f zpool sda4
zfs create -o encryption=on -o keyformat=passphrase -o keylocation=file:///boot/pass.key zpool/root
zfs create zpool/root/nix
zfs create -o atime=on zpool/root/var
zfs create -o sync=disabled zpool/root/tmp
zfs create zpool/root/etc
umount /mnt
mount -t zfs zpool/root /mnt
mkdir /mnt/nix && mount -t zfs zpool/root/nix /mnt/nix
mkdir /mnt/var && mount -t zfs zpool/root/var /mnt/var
mkdir /mnt/tmp && mount -t zfs zpool/root/tmp /mnt/tmp
mkdir /mnt/etc && mount -t zfs zpool/root/etc /mnt/etc
mkdir /mnt/boot && mount /dev/md1 /mnt/boot
rsync -aHAXS --one-file-system / /mnt/

Let NixOS know about new filesystem

Until there, you could do everything while keeping your system running, rebooting etc. From now on, everything must be done in one go (no reboot inbetween) or you might not be able to properly boot

Obtain your /boot uuid and replace below:

fileSystems."/"     = { fsType = "zfs"; device = "zpool/root"; };
fileSystems."/boot" = { fsType = "ext4"; device = "/dev/disk/by-uuid/5b27af91-f515-44f4-9a65-1516326d9297"; };
fileSystems."/etc"  = { fsType = "zfs"; device = "zpool/root/etc"; };
fileSystems."/nix"  = { fsType = "zfs"; device = "zpool/root/nix"; };
fileSystems."/tmp"  = { fsType = "zfs"; device = "zpool/root/tmp"; };
fileSystems."/var"  = { fsType = "zfs"; device = "zpool/root/var"; };
boot.initrd.secrets = {
  "/boot/pass.key" = "/boot/pass.key";
}

Deploy partially:

nixops deploy --dry-activate
# nixos-rebuild dry-activate

Go in maintenance mode, resynchronize and prepare next boot

systemctl isolate maintenance.target
systemctl stop systemd-journald systemd-journald.socket systemd-journald-dev-log.socket systemd-journald-audit.socket
rsync -aHAXS --delete --one-file-system / /mnt/

# Prepare next boot in zfs filesystem
NIXOS_INSTALL_BOOTLOADER=1 nixos-enter --root /mnt/ -- /nix/var/nix/profiles/system/bin/switch-to-configuration boot
# Prepare next boot in raid array - for grub
/nix/var/nix/profiles/system/bin/switch-to-configuration boot

# Unmount everything and prepare the filesystem
umount -R /mnt

# Remove sdb3 from raid array and attach it to ZFS. We still have
# the data both in raid and zfs, and no file is modified due to
# maintenance mode so they’re synchronized
mdadm /dev/md0 --fail /dev/sdb3 --remove /dev/sdb3

# Repartition /dev/sdb similarly to /dev/sda
fdisk /dev/sdb
# Add sdb3 to the /boot array
mdadm --grow /dev/md1 --level=1 --raid-devices=2 --add /dev/sdb3
zpool attach -f zpool sda4 sdb4

# Wait until it’s fully synchronized (or feel lucky and don’t wait)
zpool status

# Restart
shutdown -r now

Cleanup old system

Now that the installation is finished, you may cleanup the additional disk and profit

mdadm --stop /dev/md0
wipefs -a /dev/sdc3
shred -v /dev/sdc

Shell invocation

Langues : anglais

Bash et Zsh (pour ceux que je connais) ont des façons très différentes de démarrer, ce qui peut créer des soucis lorsqu'on passe de l'un à l'autre.

Pour commencer, on distingue deux états du shell :

et les shells font des actions différentes en fonction de ces deux états.

Shell de login

Pour indiquer à un shell que c'est un shell de login, le programme qui l'appelle met simplement un - devant son nom (ainsi le shell s'appellera -bash ou -zsh). Certains shells (bash) permettent de donner un argument à la ligne de commande pour faire comme si c'était un shell de login

Dans un script, on peut savoir qu'on est dans un login de la façon suivante :

Shell interactif

Un shell interactif est tout simplement un shell invoqué sans argument (du moins aucun qui n'implique une commande)

Dans un script, on peut savoir qu'on est en mode interactif de la façon suivante :

[[ $- == *i* ]]

(Oui c'est possible d'avoir un shell interactif avec un script, par exemple lorsqu'un fichier est sourcé)

Comportement de zsh à l'invocation

  1. Dans tous les cas, /etc/zshenv et ~/.zshenv sont sourcés (dans cet ordre).
  2. Si c'est un shell de login, /etc/zprofile et ~/.zprofile sont sourcés (dans cet ordre)

    Note

    /etc/zprofile est choisi à la compilation et peut varier selon les systèmes (Sur Archlinux c'est /etc/zsh/zprofile par exemple).

  3. Si le shell est interactif, /etc/zshrc et ~/.zshrc sont sourcés (dans cet ordre).
  4. Enfin, si c'est un shell de login (encore !) /etc/zlogin et ~/.zlogin sont sourcés (dans cet ordre toujours).

Comportement de bash à l'invocation

  1. Si c'est un shell de login et interactif, /etc/profile est sourcé, puis le premier existant parmi ~/.bash_profile, ~/.bash_login, ~/.profile.
  2. Si c'est un shell interactif non login, ~/.bashrc est sourcé.
  3. Enfin, si c'est un shell non-interactif, $BASH_ENV est sourcé.

Bash invoqué comme sh

Dans beaucoup de distributions, sh est simplement un lien symbolique vers bash. Dans ce cas, bash se comportera différemment à l'invocation :

  1. Si c'est un shell de login et interactif, /etc/profile et ~/.profile sont sourcés
  2. Si $ENV est un fichier, il est sourcé également

Gérer sa session avec systemd

Langues : anglais

Introduction

De plus en plus de distributions passent à "systemd" comme alternative à l'init de System V.

L'un des avantages de systemd est d'avoir un système de services pour l'utilisateur, et c'est ce système qu'on va utiliser pour gérer notre session. systemd est encore en développement actif, notamment pour la partie utilisateur. Ce guide tient compte du grand changement qu'il y a eu suite à la version 206 et sur laquelle sont basés la plupart des guides existant actuellement, et fonctionne au moins à la version 212.

Lorsqu'une session utilisateur démarre (que ce soit à distance via ssh ou en local), une instance de systemd --user démarre pour cet utilisateur. Cette instance a pour but de démarrer des services pour l'utilisateur, de façon similaire au processus 1 mais pour l'utilisateur. À noter que par défaut ces services se terminent quand la session de l'utilisateur disparaît.

Démarrage de systemd --user

Avant la version 206, sauf erreur, c'était l'utilisateur qui pouvait choisir de démarrer (ou non) cette instance. Maintenant elle ne peut plus être démarrée par l'utilisateur et c'est le gestionnaire qui doit la lancer.

Elle est démarrée automatiquement à la connexion à condition que le module pam_systemd soit actif dans pam pour le type de session demandé. Pour cela, on ajoute une ligne de la forme

-session   optional   pam_systemd.so

(Le "-" indiquant que ce n'est pas essentiel à la session. Dit autrement : si ça plante c'est pas grave on continue quand même). Sous Archlinux, le fichier /etc/pam.d/system-login contient déjà cette ligne et concerne tous les types de connexion, locale ou distante.

Agencement de systemd

Systemd sépare les différents services en slice/scope/service, via l'utilisation de cgroups (une façon de regrouper un ensemble de processus et leurs éventuels descendants sans échappatoire possible). Un système typique ressemble à ceci (obtenu avec systemd-cgls) :

On remarque que les services de l'utilisateur sont démarrés dans un contexte distinct de la session. Il faudra donc faire attention à la propagation des variables d'environnement.

Dès que toutes les session-c*.scope d'un utilisateur sont quittées, le service user@.service correspondant s'arrête également, et avec tous les sous-services. C'est un point important à noter ! Par exemple, en l'état, utiliser les "timers" de systemd comme remplacement à cron ne marchera pas !
Heureusement, on peut passer outre et s'en sortir quand même. On verra plus bas comment faire

Les services utilisateurs

Comparaison

Les services utilisateurs sont gérés exactement de la même façon que les services système, à ceci près que les unités (unit) sont recherchées dans des dossiers différents (cf. man systemd.unit). Au lancement de systemd --user, c'est l'unité default.target qui est lancée et qui lancera à son tour les services nécessaires.

systemctl --user et dbus

Les services gérés par systemd (qu'ils soient système ou utilisateur) se gèrent essentiellement via la commande systemctl (--user), qui dépend fortement de dbus. La première chose à faire est donc de s'assurer que dbus tourne, aussi bien au niveau système (system bus pour dbus) qu'au niveau utilisateur (session bus). Pour la partie système, on va se contenter d'ajouter une dépendance à user@.service. On va également normaliser le chemin du socket pour le session bus en l'indiquant dans la variable d'environnement appropriée DBUS_SESSION_BUS_ADDRESS. Comme user@.service est parent de tous les services de l'utilisateur, on va l'utiliser pour transmettre cette variable aussi :

# /etc/systemd/system/user@.service.d/dbus_env.conf
[Unit]
Wants=dbus.service

[Service]
Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/%I/bus

Note

La ligne 2 du fichier indique qu'on souhaite que le service dbus système soit démarré, alors que la ligne 5 concerne la variable d'environnement pour l'utilisateur. Ce sont donc deux bus différents qu'on configure ici.

Dans ce qui suit, on va définir des différents services dont on aura besoin. Ils peuvent être définis ou activés globalement ou à la discrétion de l'utilisateur. Cela se fait soit selon l'endroit où ils sont placés (/etc/systemd/user/ vs $HOME/.config/systemd/user) soit selon la façon dont ils sont activés (avec --global vs --user). Ici je vais définir les services et sockets associés à dbus globalement, mais ils seront ensuite activés ou non selon le choix de l'utilisateur :

# /etc/systemd/user/dbus.service
[Unit]
Description=D-Bus Message Bus
Requires=dbus.socket

[Service]
ExecStart=/usr/bin/dbus-daemon --session --address=systemd: --nofork --nopidfile --systemd-activation
ExecReload=/usr/bin/dbus-send --print-reply --session --type=method_call --dest=org.freedesktop.DBus / org.freedesktop.DBus.ReloadConfig
Restart=always
RestartSec=1

(on notera que la principale différence avec le service système de même nom est dans l'utilisation de --session au lieu de --system)

# /etc/systemd/user/dbus.socket
[Unit]
Description=D-Bus Message Bus Socket
Before=sockets.target

[Socket]
ListenStream=/run/user/%U/bus

[Install]
WantedBy=default.target

(Pour rappel, default.target est la cible activée par défaut par systemd).

À partir de là, l'essentiel est fait. On crée un fichier mes_services.target alias de default.target

# $HOME/.config/systemd/user/mes_services.target
[Unit]
Description=Mes services
Wants=dbus.service
AllowIsolate=true

[Install]
Alias=default.target

puis on crée des fichiers service ou autres cibles dans le même dossier. Ensuite, on les active avec la commande systemctl, fait habituellement pour gérer les services systèmes, sauf qu'ici on ajoute l'argument --user. La suite du billet consistera essentiellement en des astuces et des cas spécifiques intéressants.

Remarque importante

La directive After n'a pas le même sens pour un service et pour un target. Un service ne sera démarré qu'une fois que tous les services cités dans After sont prêts (pour la définition de être prêt, cf le man systemd.service, bloc Type=), alors que pour un target il considère que les services cités font partie de lui (et peuvent donc être démarrés en même temps que d'autres services du target). Dans certains des cas présentés dans la suite cette distinction prend sens.

Des services utilisateur qui durent

Par défaut, les services associés à un utilisateur (tout ce qui est sous user@1001.service dans l'arborescence ci-dessus) sont arrêtés lorsque l'utilisateur se déconnecte de toutes ses sessions, notamment les timers qu'on aimerait utiliser comme remplacement à la crontab.

Passer outre se fait avec la commande suivante :

loginctl enable-linger user

Dans ce cas, user@.service sera démarré dès le boot pour l'utilisateur correspondant, même lorsque celui-ci n'est pas connecté.

Je n'entre pas dans les détails sur comment définir l'équivalent des crontabs avec systemd, c'est très bien expliqué dans les pages de manuel et sur les sites dédiés à systemd (ou man systemd.timer). L'astuce ci-dessus règle le problème d'avoir des crontab qui durent pour un utilisateur même lorsque celui-ci n'est pas connecté.

Remarque importante

Pour les mails envoyés par les crontabs, c'est une autre histoire. À ma connaissance les services de systemd loggent tout dans le journal, et aucun mail ne peut être programmé directement avec les services de systemd

Les variables d'environnement

Les variables d'environnement peuvent devenir un vrai casse-tête lorsqu'on utilise les services : comment transmettre les variables d'environnement nécessaires aux services, par exemple GPG_AGENT_INFO ou SSH_AUTH_SOCK ou même DISPLAY. Une des façons de faire est de spécifier dans le fichier du service une directive de la forme

Environment=DISPLAY=:0

ou

EnvironmentFile=/fichier/a/charger

Le problème est que cette configuration est statique et doit être définie pour chaque service ou globalement dans un fichier /etc/systemd/system/user@.service.d/environment.conf. On perd le dynamisme habituel des scripts de configuration.

Ici, on va faire usage de la commande systemctl --user import-environment pour pallier ce problème. Cette commande permet d'importer des variables d'environnement, qui seront incluses dans les services démarrés par la suite. On commence par créer un service setenv.service qui va s'occuper de démarrer un script pour définir l'environnement. En ce qui me concerne je définis un environnement différent lorsque je suis en ligne de commande ou en graphique, du coup mon service dépend également de cela :

# $HOME/.config/systemd/user/setenv@.service
[Unit]
Description=Set environment
Wants=dbus.service gpg-agent.service ssh-agent.service
After=dbus.service gpg-agent.service ssh-agent.service

[Service]
Type=oneshot
Environment=SSH_AUTH_SOCK=%t/ssh_auth_sock
ExecStart=%h/bin/systemd_setenv %i

et j'active les services setenv@type.service selon les besoins. Ici par exemple, j'ai aussi défini une variable d'environnement pour le service. La raison est que la partie "%t" (/run/user/1000/) est plus facile à trouver dans ce fichier que dans un script... Il ne faudra juste pas oublier de l'appliquer dans le script ensuite.

Voici un exemple de script pour définir l'environnement :

# $HOME/bin/systemd_setenv
#!/bin/zsh

if [ "x$1" = "xcommun" ]; then
        . /etc/zsh/zprofile
        source $HOME/.gpg-agent-info
        export GPG_AGENT_INFO
        systemctl --user import-environment
        systemctl --user unset-environment PWD OLDPWD SHLVL _ MANAGERPID
elif [ "x$1" = "xgraphique" ]; then
        export XDG_CONFIG_HOME="$HOME/.config/"
        export SAL_USE_VCLPLUGIN=gtk
        export XDG_MENU_PREFIX="lxde-"
        export DISPLAY=:0
        systemctl --user import-environment XDG_CONFIG_HOME SAL_USE_VCLPLUGIN XDG_MENU_PREFIX DISPLAY
fi

Ici j'ai deux environnements différents selon que j'ai démarré une session graphique ou juste une invite en ligne de commande. À noter que même si je quitte l'environnement graphique, les variables définies par ce moyen restent définies pour les services démarrés par la suite... On pourrait utiliser la commande

systemctl --user unset-environment VARIABLE1 VARIABLE2

de la même façon pour détruire les variables après utilisation (auquel cas il faut adapter le service setenv pour qu'il appelle un autre script — ou le même avec des arguments différents — lorsqu'on quitte l'interface graphique).

Les *-agents

Des services qui vallent le coup d'être démarrés, peu importe le type de session (graphique ou console ou distant) sont notamment les services ssh-agent et gpg-agent. Il faut cependant penser à transmettre les variables d'environnement aux processus intéressés (cf. La section correspondante pour plus d'informations).

Le service envoy peut être utilisé pour gérer ces agents. Personnellement je préfère séparer les deux agents, ce qui n'est pas possible avec envoy.

Pour ssh-agent, on peut indiquer directement dans la commande d'exécution à quel endroit on souhaite mettre le socket, ce qui facilite les choses :

# $HOME/.config/systemd/user/ssh-agent.service
[Unit]
Description=ssh-agent
ConditionFileIsExecutable=/usr/bin/ssh-agent

[Service]
ExecStart=/usr/bin/ssh-agent -d -a %t/ssh_auth_sock
Restart=always

[Install]
WantedBy=mes_services.target

Pour gpg-agent, on ne peut que lui indiquer qu'on souhaite enregistrer les informations (les variables d'environnement) dans un fichier, et on ne peut pas forcer le socket à être placée à un endroit prévisible.

# $HOME/.config/systemd/user/gpg-agent.service
[Unit]
Description=gpg-agent
ConditionFileIsExecutable=/usr/bin/gpg-agent

[Service]
ExecStart=/usr/bin/gpg-agent --daemon --write-env-file %h/.gpg-agent-info
Type=forking
Restart=always

[Install]
WantedBy=mes_services.target

On peut ensuite charger le fichier $HOME/.gpg-agent-info pour avoir accès à l'agent.

source $HOME/.gpg-agent-info

Note

Le fait de ne pas avoir un emplacement standard peut poser problème si par exemple l'agent redémarre : selon toute probabilité, le socket ne sera pas au même endroit par la suite et les programmes déjà lancés ne sauront pas où contacter l'agent.

Utiliser systemd pour gérer sa session graphique

Il peut être tentant d'utiliser systemd pour gérer également sa session graphique : définir son gestionnaire de fenêtre préféré comme un service et pouvoir l'arrêter et switcher vers un autre gestionnaire de fenêtre à volonté sans avoir à redémarrer sa session. On verra plus tard que cela peut poser de nouveaux problèmes.

Session graphique comme service

Le plus simple dans ce cas est de définir une unité graphical.target (ou graphical@.target si on veut définir plusieurs gestionnaires de fenêtre) et de l'activer dans le fichier .xsession (ou autre selon ce qui est exécuté pour la session).
Il peut être nécessaire d'avoir les variables d'environnement correctement définies à ce moment là, auquel cas chaque unité devra avoir le service setenv@graphical.service activé (comme indiqué précédemment, il ne suffit pas de le mettre en After dans le fichier graphical.target pour qu'il soit activé avant les autres).

Définir une session graphique comme service peut sembler appréciable. Deux problèmes se posent cependant :
Le premier est que systemctl retourne immédiatement. Ainsi, un fichier .xsession contenant uniquement

# $HOME/.xsession
export DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus
systemctl --user start graphical.target

ne sera pas suffisante, sous peine de voir sa session graphique disparaître à peine commencée (note: le export DBUS_SESSION_BUS_ADDRESS est nécessaire pour le dialogue avec systemd). Il existe des programmes tels que systemd-wait qui vont attendre la fin d'un service donné et peut être utilisé (selon le type de session).

Le deuxième problème est plus ennuyeux : il vient de la distinction entre la session utilisateur elle-même et les services. Si on ne fait pas attention, on risque de se retrouver avec quelque chose comme ça :

Pourquoi ça peut être gênant ? Parce que essentiellement le seul programme ici qui est considéré comme actif est le programme systemd-wait qu'on a mis dans le .xsession. Un peu plus en détails sur ce que ça implique dans la section suivante sur polkit.

Note à propos de slim

Comme précisé sur le site d'Archlinux, slim n'est pas compatible avec systemd --user (du moins il ne l'était pas au moment de l'écriture de cet article). Qu'est-ce que cela signifie concrètement, et que peut-on y faire ?

Premièrement, un petit zoom sur le fonctionnement de slim. slim est un gestionnaire de connexion, c'est-à-dire de façon simplifiée un équivalent du login dans les tty mais en graphique. Cela a plusieurs implications : il doit s'occuper de démarrer un serveur X, et de démarrer la session de l'utilisateur après la connexion. Mais il doit aussi reprendre la main quand l'utilisateur s'en va.

Au moment de la connexion de l'utilisateur (via la configuration dans pam), slim démarre une instance de systemd --user, ainsi qu'un script de l'utilisateur. Il ne démarre pas de serveur X puisqu'il est déjà démarré.
Au moment de la déconnexion cependant, il ne redémarre pas tout et réutilise l'état dans lequel il était. Du point de vue de systemd, slim passe donc dans le "scope" de l'utilisateur précédent (même s'il est toujours lancé par root):

Avant déconnexion :

  • system.slice
    • slimd.service
      • slim
      • X
  • user.slice
    • user-1000.slice
      • session-c1.scope
        • ...
      • user@1000.service

Après déconnexion :

  • system.slice
    • slimd.service
  • user.slice
    • user-1000.slice
      • session-c1.scope
        • slim
        • X
      • user@1000.service


Ce comportement là est ennuyeux pour les sessions suivantes. Sous Archlinux, slim a été patché pendant l'intervalle d'écriture de ce billet (depuis slim 1.3.6-4, 2014-04-21) pour éviter cela, en quittant systématiquement slim à la fin d'une session (avec l'argument -nodaemon) et en ajoutant une directive Restart à l'unité systemd de slim.

Note

La directive Restart est on-failure, mais le patch fait quitter slim avec une erreur.

Une autre solution si on n'est pas sous Archlinux et qu'on ne souhaite pas patcher slim est de mettre une ligne dans /etc/slim.conf de la forme

# /etc/slim.conf
sessionstop_cmd        systemctl restart slim.service

qui aura pour effet de redémarrer slim à la fin de chaque session (ce qui revient essentiellement au même que le patch).

Polkit et les droits d'administration

Systemd distingue deux types d'utilisateur, à savoir les utilisateurs "actifs" et "inactifs". Par exemple si deux utilisateurs sont connectés physiquement sur le même pc, un seul est réellement "actif". Il fait également une distinction selon que l'utilisateur est local ou distant. L'information peut être obtenue avec

loginctl show-session "session"

"session" est l'identifiant de la session (on peut lister les sessions avec loginctl list-sessions).
En particulier, et c'est là que ça va devenir important, un service est toujours inactif.

Polkit est un service qui permet de donner, temporairement ou non, plus de droits à un utilisateur. Ces droits sont classés selon l'état de l'utilisateur, actif ou inactif ou autre.

Attention

Polkit et systemd n'ont pas la même notion d'utilisateur actif : pour polkit, un utilisateur actif/inactif est local et actif/inactif au sens de systemd. Tous les autres sont any (en particulier les utilisateurs distants). Cette règle est hardcodée dans la fonction check_authorization_sync du fichier src/polkitbackend/polkitbackendinteractiveauthority.c du code source de polkit :

if (session_is_local)
  {
    if (session_is_active)
      implicit_authorization = polkit_action_description_get_implicit_active (action_desc);
    else
      implicit_authorization = polkit_action_description_get_implicit_inactive (action_desc);
  }
else
  {
    implicit_authorization = polkit_action_description_get_implicit_any (action_desc);
  }

Je n'entrerai pas plus dans les détails sur polkit dans ce billet, mais on remarque déjà qu'un xterm démarré en tant que service et un démarré directement (par exemple via le fichier .xsession) n'auront pas les mêmes droits du point de vue de polkit ! Il faut donc faire attention à cela lorsqu'on souhaite utiliser systemd --user comme gestionnaire de session, car par exemple il sera incapable d'éteindre le pc sans sudo.

« Page précédente