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

View file

@ -0,0 +1,22 @@
[package]
name = "nix-ota-publisher"
version.workspace = true
edition.workspace = true
license.workspace = true
[[bin]]
name = "nix-ota"
path = "src/main.rs"
[dependencies]
nix-ota-common = { path = "../common" }
anyhow.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
clap.workspace = true
reqwest = { version = "0.12", features = ["json", "rustls-tls", "blocking"], default-features = false }
ed25519-dalek.workspace = true
base64.workspace = true

View file

@ -0,0 +1,144 @@
//! `nix-ota` — operator/CI CLI.
//!
//! Subcommands:
//! keygen Generate an ed25519 keypair for manifest signing.
//! sign Sign a manifest body and print it to stdout.
//! publish Sign + POST a manifest to the control server.
//! show-pubkey Derive and print the verifying key from a signing key.
//!
//! The signing key never leaves the operator's machine (or the CI secret
//! store); the control server only sees signed manifests.
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use nix_ota_common as common;
use std::path::PathBuf;
#[derive(Parser, Debug)]
#[command(version, about = "nix-ota operator CLI")]
struct Cli {
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand, Debug)]
enum Cmd {
/// Generate a new ed25519 signing key. Writes private key to
/// `--out` and prints public key to stdout.
Keygen {
#[arg(long)]
out: PathBuf,
},
/// Print the public key derived from a signing key file.
ShowPubkey {
#[arg(long)]
key: PathBuf,
},
/// Build a signed manifest from arguments and print to stdout.
Sign {
#[arg(long)]
key: PathBuf,
#[arg(long)]
channel: String,
#[arg(long)]
store_path: String,
#[arg(long)]
substituter: String,
#[arg(long)]
revision: u64,
},
/// Sign and publish a manifest to a control server.
Publish {
#[arg(long, env = "NIX_OTA_SERVER")]
server: String,
#[arg(long, env = "NIX_OTA_PUBLISH_TOKEN")]
token: String,
#[arg(long, env = "NIX_OTA_SIGNING_KEY_FILE")]
key: PathBuf,
#[arg(long)]
channel: String,
#[arg(long)]
store_path: String,
#[arg(long)]
substituter: String,
/// If unset, the publisher fetches the current revision and uses N+1.
#[arg(long)]
revision: Option<u64>,
},
}
fn main() -> Result<()> {
tracing_subscriber::fmt().with_env_filter("info").init();
let cli = Cli::parse();
match cli.cmd {
Cmd::Keygen { out } => {
let sk = common::generate_keypair();
std::fs::write(&out, common::encode_signing_key(&sk))
.with_context(|| format!("writing {}", out.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&out, std::fs::Permissions::from_mode(0o600)).ok();
}
println!("{}", common::encode_verifying_key(&sk.verifying_key()));
}
Cmd::ShowPubkey { key } => {
let sk = load_key(&key)?;
println!("{}", common::encode_verifying_key(&sk.verifying_key()));
}
Cmd::Sign { key, channel, store_path, substituter, revision } => {
let sk = load_key(&key)?;
common::validate_store_path(&store_path)?;
let m = common::sign_manifest(&sk, common::ManifestBody {
channel, store_path, substituter,
timestamp: common::now(), revision, key_id: String::new(),
})?;
println!("{}", serde_json::to_string_pretty(&m)?);
}
Cmd::Publish { server, token, key, channel, store_path, substituter, revision } => {
let sk = load_key(&key)?;
common::validate_store_path(&store_path)?;
let client = reqwest::blocking::Client::new();
let rev = match revision {
Some(r) => r,
None => next_revision(&client, &server, &channel)?,
};
let m = common::sign_manifest(&sk, common::ManifestBody {
channel: channel.clone(),
store_path,
substituter,
timestamp: common::now(),
revision: rev,
key_id: String::new(),
})?;
let url = format!("{}/channels/{}/publish",
server.trim_end_matches('/'), channel);
let resp = client.post(&url)
.bearer_auth(&token)
.json(&m)
.send()?;
let status = resp.status();
let body = resp.text().unwrap_or_default();
if !status.is_success() {
anyhow::bail!("publish failed: {status} {body}");
}
println!("{body}");
}
}
Ok(())
}
fn load_key(p: &PathBuf) -> Result<ed25519_dalek::SigningKey> {
let s = std::fs::read_to_string(p)
.with_context(|| format!("reading key {}", p.display()))?;
Ok(common::decode_signing_key(s.trim())?)
}
fn next_revision(client: &reqwest::blocking::Client, server: &str, channel: &str) -> Result<u64> {
let url = format!("{}/channels/{}/current", server.trim_end_matches('/'), channel);
let r = client.get(&url).send()?;
if r.status() == reqwest::StatusCode::NOT_FOUND { return Ok(1); }
if !r.status().is_success() { anyhow::bail!("fetch current: {}", r.status()); }
let m: common::Manifest = r.json()?;
Ok(m.body.revision + 1)
}