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:
commit
42b2ce4d1d
19 changed files with 4745 additions and 0 deletions
87
nix/modules/agent.nix
Normal file
87
nix/modules/agent.nix
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
self: { config, lib, pkgs, ... }:
|
||||
let
|
||||
cfg = config.services.nix-ota-agent;
|
||||
inherit (lib) mkEnableOption mkOption mkIf types;
|
||||
in {
|
||||
options.services.nix-ota-agent = {
|
||||
enable = mkEnableOption "nix-ota device agent";
|
||||
package = mkOption {
|
||||
type = types.package;
|
||||
default = self.packages.${pkgs.system}.nix-ota-agent;
|
||||
};
|
||||
server = mkOption { type = types.str; example = "https://ota.example.com"; };
|
||||
channel = mkOption { type = types.str; default = "prod"; };
|
||||
deviceId = mkOption { type = types.str; example = "device-001"; };
|
||||
publicKey = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
description = "Base64-encoded ed25519 verifying key. The agent will reject manifests not signed by the matching private key. Mutually exclusive with publicKeyFile.";
|
||||
};
|
||||
publicKeyFile = mkOption {
|
||||
type = types.nullOr types.path;
|
||||
default = null;
|
||||
description = "Path to a file containing the base64-encoded verifying key. Use this if you need to write the key at runtime (e.g. from an orchestration system).";
|
||||
};
|
||||
interval = mkOption { type = types.int; default = 60; };
|
||||
healthCmd = mkOption { type = types.nullOr types.str; default = null; };
|
||||
cacheUrl = mkOption {
|
||||
type = types.str;
|
||||
description = "Substituter URL added to nix.settings.substituters so `nix copy` can fetch from it.";
|
||||
};
|
||||
cachePublicKey = mkOption {
|
||||
type = types.str;
|
||||
description = "Trusted public key of the binary cache (the one that signs store paths).";
|
||||
};
|
||||
};
|
||||
|
||||
config = mkIf cfg.enable {
|
||||
assertions = [{
|
||||
assertion = (cfg.publicKey != null) != (cfg.publicKeyFile != null);
|
||||
message = "services.nix-ota-agent: set exactly one of publicKey or publicKeyFile.";
|
||||
}];
|
||||
|
||||
nix.settings = {
|
||||
substituters = [ cfg.cacheUrl ];
|
||||
trusted-public-keys = [ cfg.cachePublicKey ];
|
||||
experimental-features = [ "nix-command" "flakes" ];
|
||||
};
|
||||
|
||||
environment.etc."nix-ota/public.key" = lib.mkIf (cfg.publicKey != null) {
|
||||
text = cfg.publicKey;
|
||||
};
|
||||
|
||||
systemd.services.nix-ota-agent = {
|
||||
description = "nix-ota device agent (oneshot)";
|
||||
after = [ "network-online.target" ];
|
||||
wants = [ "network-online.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
StateDirectory = "nix-ota";
|
||||
};
|
||||
environment = {
|
||||
NIX_OTA_SERVER = cfg.server;
|
||||
NIX_OTA_CHANNEL = cfg.channel;
|
||||
NIX_OTA_DEVICE_ID = cfg.deviceId;
|
||||
NIX_OTA_PUBLIC_KEY_FILE = if cfg.publicKeyFile != null
|
||||
then toString cfg.publicKeyFile
|
||||
else "/etc/nix-ota/public.key";
|
||||
NIX_OTA_STATE_DIR = "/var/lib/nix-ota";
|
||||
} // lib.optionalAttrs (cfg.healthCmd != null) {
|
||||
NIX_OTA_HEALTH_CMD = cfg.healthCmd;
|
||||
};
|
||||
script = ''
|
||||
export PATH=${lib.makeBinPath [ pkgs.nix pkgs.systemd pkgs.coreutils pkgs.bash ]}:$PATH
|
||||
exec ${cfg.package}/bin/nix-ota-agent --once
|
||||
'';
|
||||
};
|
||||
|
||||
systemd.timers.nix-ota-agent = {
|
||||
wantedBy = [ "timers.target" ];
|
||||
timerConfig = {
|
||||
OnBootSec = "1min";
|
||||
OnUnitActiveSec = "${toString cfg.interval}s";
|
||||
Unit = "nix-ota-agent.service";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue