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
144
crates/publisher/src/main.rs
Normal file
144
crates/publisher/src/main.rs
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue