Initial nix-ota implementation

Self-hostable OTA update system for NixOS fleets: a control server,
device agent, publisher CLI, and NixOS modules that ship prebuilt
system closures from a binary cache to devices that don't have the
flake.

- crates/common: signed manifest types (ed25519), store-path validator
- crates/server: axum + sqlite + HTMX dashboard, channel/device API
- crates/agent: poll, verify signature + revision, nix copy, switch,
  health check, magic-rollback on failure
- crates/publisher: keygen + sign + publish CLI for operators/CI
- nix/modules: NixOS modules for server and agent
- nix/tests/ota.nix: end-to-end VM test exercising publish A -> B ->
  broken C -> rollback to B (passes)

The control server never holds the signing key; manifests are signed
offline and verified against a pinned public key on each device.
This commit is contained in:
0m.ax 2026-05-25 14:58:42 +02:00
commit 42b2ce4d1d
19 changed files with 4745 additions and 0 deletions

70
flake.nix Normal file
View file

@ -0,0 +1,70 @@
{
description = "nix-ota open-source OTA updates for NixOS fleets";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
};
outputs = { self, nixpkgs }:
let
systems = [ "x86_64-linux" "aarch64-linux" ];
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f system nixpkgs.legacyPackages.${system});
mkPkg = pkgs: pname:
let
subdir = {
"nix-ota-server" = "server";
"nix-ota-agent" = "agent";
"nix-ota-publisher" = "publisher";
}.${pname};
in pkgs.rustPlatform.buildRustPackage {
inherit pname;
version = "0.1.0";
src = ./.;
cargoLock.lockFile = ./Cargo.lock;
buildAndTestSubdir = "crates/${subdir}";
nativeBuildInputs = with pkgs; [ pkg-config ];
buildInputs = with pkgs; [ openssl ];
doCheck = false;
meta.license = pkgs.lib.licenses.mit;
};
in {
packages = forAllSystems (system: pkgs: {
nix-ota-server = mkPkg pkgs "nix-ota-server";
nix-ota-agent = mkPkg pkgs "nix-ota-agent";
nix-ota-publisher = mkPkg pkgs "nix-ota-publisher";
default = self.packages.${system}.nix-ota-server;
});
apps = forAllSystems (system: pkgs: {
server = {
type = "app";
program = "${self.packages.${system}.nix-ota-server}/bin/nix-ota-server";
};
agent = {
type = "app";
program = "${self.packages.${system}.nix-ota-agent}/bin/nix-ota-agent";
};
nix-ota = {
type = "app";
program = "${self.packages.${system}.nix-ota-publisher}/bin/nix-ota";
};
default = self.apps.${system}.server;
});
nixosModules = {
server = import ./nix/modules/server.nix self;
agent = import ./nix/modules/agent.nix self;
};
devShells = forAllSystems (system: pkgs: {
default = pkgs.mkShell {
packages = with pkgs; [ cargo rustc rustfmt clippy pkg-config openssl sqlite ];
};
});
checks = forAllSystems (system: pkgs: {
vm = import ./nix/tests/ota.nix { inherit pkgs self system; };
});
};
}