skip to content
dz
6 min read Von nix eine Ahnung #2

NixOS: git push statt nixos-rebuild

Read this in English →

Wenn ich auf meinem Server etwas ändern will, bearbeite ich eine Datei, committe und pushe. Spätestens eine Stunde später läuft die neue Konfiguration, ohne dass ich mich auf dem Server anmelde.

Im ersten Teil habe ich NixOS mit nixos-anywhere auf einer leeren Hetzner-VM installiert und noch jede Änderung per nixos-rebuild von meinem Rechner aus auf den Server geschickt. Das funktioniert von jedem Rechner, auf dem Nix läuft. Angefangen habe ich unter Linux mit NixOS. Inzwischen möchte ich meine Infrastruktur auch am Mac aktualisieren können, und zwar ohne dort Nix installieren zu müssen.

Dafür drehe ich die Richtung um: Der Server holt sich seine Konfiguration selbst aus einem Git-Repository ab, in meinem Fall bei Codeberg.

MacCodebergServerprüft und bautsignierter Commitholt stündlich

Neu ist die Idee nicht. NixOS bringt mit system.autoUpgrade einen Timer mit, der eine Flake regelmäßig neu baut, eine Option zur Signaturprüfung gibt es dort aber nicht. Fertige Tools, bei denen sich der Server die Konfiguration selbst holt, gibt es auch, zum Beispiel comin oder auto-upgrade-with-pinch. Gerade comin sieht vielversprechend aus und kann auch SSH-signierte Commits prüfen. Ich kannte es aber nicht, als ich meinen Server eingerichtet habe, und habe mir den Weg deshalb selbst gebaut. Dabei war mir wichtig, dass nur signierte Commits gebaut werden, als zusätzliches Sicherheitsnetz. Ob ich später auf comin umsteige, schaue ich mir noch an.

Ich bin in drei Schritten zu diesem Setup gekommen, und nach jedem läuft der Server. Am Ende des Artikels findet ihr die vollständige Konfiguration. Den Stand nach diesem Teil gibt es im Repository nixos-series auf dem Branch part-2-git-push-instead-of-nixos-rebuild, und was gegenüber Teil 1 dazukommt, zeigt der Vergleich der beiden Branches.

Eine neue Datei

Ich arbeite auf der Basis von Teil 1 weiter. Alles für den Auto-Rebuild kommt in eine eigene Datei, damit configuration.nix übersichtlich bleibt:

infra/
├── flake.nix
└── hosts/hetzner/
    ├── auto-rebuild.nix   (neu)
    ├── configuration.nix
    ├── disko.nix
    └── hardware.nix

Damit NixOS die Datei lädt, kommt sie in flake.nix in die Liste der Module:

modules = [
  disko.nixosModules.disko
  ./hosts/hetzner/hardware.nix
  ./hosts/hetzner/configuration.nix
  ./hosts/hetzner/auto-rebuild.nix
];

auto-rebuild.nix ist aufgebaut wie hardware.nix. Die erste Zeile holt pkgs herein, damit ich Pakete wie git verwenden kann, und alles aus den nächsten Schritten steht zwischen den geschweiften Klammern:

{ pkgs, ... }:
{
  # hier kommen die Services hin
}

Schritt 1: Der Server baut aus Git

Zuerst braucht der Server eine Kopie des Repos, und zwar unter /etc/nixos, weil NixOS dort standardmäßig nach der Konfiguration sucht. Mein Repo ist privat, deshalb bekommt der Server einen Deploy Key: einen eigenen SSH-Key, der nur dieses eine Repo lesen darf. Die beiden Befehle erzeugen den Key auf dem Server:

ssh root@<ipv4> \
  "ssh-keygen -t ed25519 -f /root/.ssh/infra-pull -N ''"
ssh root@<ipv4> "cat /root/.ssh/infra-pull.pub"

Die Ausgabe des zweiten Befehls trage ich bei Codeberg in den Einstellungen des Repos unter Deploy Keys ein, ohne Schreibrecht. Wer ein öffentliches Repo hat, braucht den Key nicht und klont per https.

Auf dem Server kann niemand eine Rückfrage beantworten, ob dem Schlüssel von Codeberg zu trauen ist. Ohne Eintrag bricht der Klon deshalb mit Host key verification failed ab. Ich trage den Host-Key von Codeberg daher fest in auto-rebuild.nix ein. Den Wert liefert ssh-keyscan -t ed25519 codeberg.org. Vergleicht ihn mit dem Fingerprint, den Codeberg veröffentlicht:

programs.ssh.knownHosts."codeberg.org".publicKey =
  "ssh-ed25519 AAAA...";

Arbeit, die der Server von selbst erledigen soll, beschreibe ich in NixOS als systemd-Service. Der erste klont das Repo beim Booten, aber nur, wenn /etc/nixos noch kein Git-Repo ist. So richtet sich auch ein neuer Server selbst ein.

systemd.services.clone-infra-repo = {
  wantedBy = [ "multi-user.target" ];
  after = [ "network-online.target" ];
  wants = [ "network-online.target" ];
  path = [ pkgs.git pkgs.openssh ];
  serviceConfig = {
    Type = "oneshot";
    RemainAfterExit = true;
  };
  script = ''
    if [ ! -d /etc/nixos/.git ]; then
      export GIT_SSH_COMMAND="ssh -i /root/.ssh/infra-pull"
      rm -rf /etc/nixos
      git clone git@codeberg.org:<user>/infra.git /etc/nixos
      git -C /etc/nixos config \
        core.sshCommand "$GIT_SSH_COMMAND"
    fi
  '';
};

wantedBy startet den Service beim Booten, after und wants lassen ihn auf das Netzwerk warten, und path listet die Programme, die das Skript benutzen darf. oneshot heißt: Das Skript läuft einmal durch und ist dann fertig, anders als etwa ein Webserver, der dauerhaft läuft.

Der zweite Service macht den eigentlichen Rebuild und braucht den Klon, deshalb verweist er mit requires und after auf den ersten. Er holt den neuesten Stand und baut mit nixos-rebuild switch, demselben Befehl wie in Teil 1, nur eben auf dem Server selbst. Hat sich nichts geändert, hört er sofort auf. --ff-only übernimmt nur Commits, die auf dem bisherigen Stand aufbauen, ein umgeschriebener Verlauf bricht ab. Gestartet wird das Ganze von einem systemd-Timer, so etwas wie ein Cronjob: fünf Minuten nach dem Booten und danach jede Stunde.

systemd.services.nixos-auto-rebuild = {
  after = [ "network-online.target" "clone-infra-repo.service" ];
  wants = [ "network-online.target" ];
  requires = [ "clone-infra-repo.service" ];
  restartIfChanged = false;
  path = [ pkgs.git pkgs.openssh ];
  serviceConfig.Type = "oneshot";
  script = ''
    set -euo pipefail
    cd /etc/nixos
    git fetch origin
    remote=$(git rev-parse origin/main)
    if [ "$(git rev-parse HEAD)" = "$remote" ]; then
      exit 0
    fi
    git merge --ff-only "$remote"
    /run/current-system/sw/bin/nixos-rebuild switch \
      --flake /etc/nixos#hetzner
  '';
};

systemd.timers.nixos-auto-rebuild = {
  wantedBy = [ "timers.target" ];
  timerConfig = {
    OnBootSec = "5min";
    OnUnitActiveSec = "1h";
  };
};

Eine Zeile ist nicht offensichtlich: restartIfChanged = false. NixOS startet bei einem switch jeden Service neu, dessen Definition sich geändert hat. Ändere ich also etwas an diesem Service, würde NixOS ihn mitten in seinem eigenen switch neu starten. Das Modul hinter system.autoUpgrade setzt dieselbe Zeile. Mir ist genau das passiert: Der Rebuild hat sich beim Umschalten selbst beendet, und im Log stand nur, dass der Service gestoppt wurde. Bis ich verstanden hatte, dass er sich selbst abgeschossen hat, hat es eine Weile gedauert.

Diesen Stand committe und pushe ich und bringe ihn ein letztes Mal per nixos-rebuild vom Mac auf den Server. Ab dann reicht git push.

Schritt 2: Nur signierte Commits

Bis hier baut der Server alles, was in main landet. Wer in mein Repo pushen kann, kann also auch meinen Server umbauen. Deshalb soll er nur Commits bauen, die ich signiert habe.

Git kann seit Version 2.34 mit SSH-Keys signieren statt mit GPG, und das macht es meiner Meinung nach wesentlich einfacher: Ein SSH-Key ist schnell erstellt. Auf dem Mac setze ich im Root meines Repositories (infra/) drei Einstellungen, ohne --global gelten sie nur für dieses Repo (ich nutze das inzwischen global, für diese Serie will ich aber nicht eure komplette Git-Konfiguration verändern):

git config gpg.format ssh
git config user.signingkey ~/.ssh/id_ed25519.pub
git config commit.gpgsign true

Ab jetzt signiert Git jeden Commit automatisch. Der Server muss im Gegenzug wissen, welcher Schlüssel gültig ist. Dafür bekommt auto-rebuild.nix oben einen let-Block, mit dem ich eine Variable anlege. Sie steht für eine kleine Datei mit meinem öffentlichen Schlüssel: Vorn eine Bezeichnung, bei mir meine E-Mail-Adresse, dahinter der Inhalt von ~/.ssh/id_ed25519.pub (cat ~/.ssh/id_ed25519.pub zeigt ihn an). Die Datei selbst legt Nix beim Bauen an, ich muss sie nirgends ablegen.

{ pkgs, ... }:
let
  allowedSigners = pkgs.writeText "allowed-signers" ''
    ich@example.org ssh-ed25519 AAAA...
  '';
in
{
  # Services wie bisher
}

Im Skript des Rebuild-Service kommt direkt vor git merge eine Schleife dazu. Sie prüft jeden Commit, der neu dazukommt. Nix setzt bei ${allowedSigners} den Pfad der erzeugten Datei ein.

for c in $(git rev-list HEAD.."$remote"); do
  git -c gpg.format=ssh \
    -c gpg.ssh.allowedSignersFile=${allowedSigners} \
    verify-commit "$c"
done

Ist ein Commit nicht signiert, bricht das Skript ab, und der Server bleibt auf dem alten Stand. Den Preis zahle ich bei Pull Requests: In Codeberg kann ich nicht mal eben auf Merge klicken, weil Codeberg den Merge-Commit nicht mit meinem Key signiert. Ich merge deshalb lokal und pushe dann.

Schritt 3: Wenn ein Build fehlschlägt

Das Skript hat noch einen Fehler. Es merged zuerst und baut danach. Schlägt der Build fehl, ist der Checkout trotzdem schon aktuell, und beim nächsten Lauf gibt es scheinbar nichts zu tun. Der Server versucht es erst beim nächsten Commit wieder.

Die Lösung: Das Skript vergleicht origin/main nicht mehr mit dem Checkout, sondern mit dem System, das gerade läuft. Dafür muss das System wissen, aus welchem Commit es gebaut wurde. Das ist die einzige Änderung außerhalb von auto-rebuild.nix: In flake.nix kommt self zu den Argumenten von outputs und eine Zeile in die Liste der Module.

outputs = { self, nixpkgs, disko, ... }: {
  nixosConfigurations.hetzner = nixpkgs.lib.nixosSystem {
    modules = [
      { system.configurationRevision = self.rev or "dirty"; }
      # ...
    ];
  };
};

Auf dem Server gibt nixos-version --configuration-revision diesen Commit aus, und damit vergleicht das Skript jetzt. Schlägt ein Build fehl, versucht es der Server eine Stunde später noch einmal.

Dadurch baut der Server auch dann, wenn Checkout und origin/main übereinstimmen, zum Beispiel nach einem frischen Klon. Dann liefert git rev-list keinen Commit mehr. Die Schleife beginnt deshalb mit "$remote" und prüft origin/main zusätzlich selbst.

Last but not least haben wir dann noch einen Fall: ein Build, der hängt. Bei einem oneshot-Service wartet systemd unbegrenzt, und solange er läuft, startet der Timer keinen neuen. TimeoutStartSec = "1h" bricht ihn nach einer Stunde ab.

Die fertige Datei

So sieht hosts/hetzner/auto-rebuild.nix am Ende aus:

{ pkgs, ... }:
let
  allowedSigners = pkgs.writeText "allowed-signers" ''
    ich@example.org ssh-ed25519 AAAA...
  '';
in
{
  programs.ssh.knownHosts."codeberg.org".publicKey =
    "ssh-ed25519 AAAA...";

  systemd.services.clone-infra-repo = {
    wantedBy = [ "multi-user.target" ];
    after = [ "network-online.target" ];
    wants = [ "network-online.target" ];
    path = [ pkgs.git pkgs.openssh ];
    serviceConfig = {
      Type = "oneshot";
      RemainAfterExit = true;
    };
    script = ''
      if [ ! -d /etc/nixos/.git ]; then
        export GIT_SSH_COMMAND="ssh -i /root/.ssh/infra-pull"
        rm -rf /etc/nixos
        git clone git@codeberg.org:<user>/infra.git /etc/nixos
        git -C /etc/nixos config \
          core.sshCommand "$GIT_SSH_COMMAND"
      fi
    '';
  };

  systemd.services.nixos-auto-rebuild = {
    after = [ "network-online.target" "clone-infra-repo.service" ];
    wants = [ "network-online.target" ];
    requires = [ "clone-infra-repo.service" ];
    restartIfChanged = false;
    path = [ pkgs.git pkgs.openssh ];
    serviceConfig = {
      Type = "oneshot";
      TimeoutStartSec = "1h";
    };
    script = ''
      set -euo pipefail
      cd /etc/nixos
      sw=/run/current-system/sw/bin
      git fetch origin
      remote=$(git rev-parse origin/main)
      running=$($sw/nixos-version --configuration-revision || true)
      if [ "$running" = "$remote" ]; then
        exit 0
      fi
      for c in "$remote" $(git rev-list HEAD.."$remote"); do
        git -c gpg.format=ssh \
          -c gpg.ssh.allowedSignersFile=${allowedSigners} \
          verify-commit "$c"
      done
      git merge --ff-only "$remote"
      $sw/nixos-rebuild switch --flake /etc/nixos#hetzner
    '';
  };

  systemd.timers.nixos-auto-rebuild = {
    wantedBy = [ "timers.target" ];
    timerConfig = {
      OnBootSec = "5min";
      OnUnitActiveSec = "1h";
    };
  };
}

Wer nicht eine Stunde warten will, stößt den Rebuild von seinem Rechner aus an und schaut ins Log:

ssh root@<ipv4> "systemctl start nixos-auto-rebuild"
ssh root@<ipv4> "journalctl -u nixos-auto-rebuild -n 30"

Einen Haken hat das Ganze noch: Der Server holt sich jetzt alles aus Git, und manches gehört da nicht im Klartext hin, etwa Passwörter und API-Tokens. Wie ich die verschlüsselt ins Repo lege, sodass nur ich und der Server sie lesen können, zeige ich im nächsten Teil mit sops-nix.