--- a/Cargo.lock +++ b/Cargo.lock @@ -607,7 +607,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags", + "bitflags 2.13.0", "cexpr", "clang-sys", "itertools 0.13.0", @@ -623,6 +623,12 @@ [[package]] name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" @@ -1275,6 +1281,29 @@ ] [[package]] +name = "ctaphid" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa622743e1747e48d25170854b552b3ddc753125a39a6732e57c5c7a5ff7fe11" +dependencies = [ + "ctaphid-types", + "hex", + "hidapi", + "log", + "rand_core 0.6.4", + "tap", +] + +[[package]] +name = "ctaphid-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4714cdd86d5134532b9decaa6774db0a6851ecd07e96a2f239332ae1f3239350" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] name = "ctr" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1623,7 +1652,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags", + "bitflags 2.13.0", "block2", "libc", "objc2", @@ -2036,11 +2065,14 @@ "base64 0.22.1", "bytes", "camino", + "chrono", "clap", + "ctaphid", "fleet-usb", "futures", "goodlog-subscriber", "hex", + "hidapi", "hostname", "http-body-util", "hyper", @@ -2048,6 +2080,7 @@ "nix", "nix-eval", "rand 0.10.1", + "tempfile", "tokio", "tracing", ] @@ -2539,6 +2572,18 @@ ] [[package]] +name = "hidapi" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "798154e4b6570af74899d71155fb0072d5b17e6aa12f39c8ef22c60fb8ec99e7" +dependencies = [ + "cc", + "libc", + "pkg-config", + "winapi", +] + +[[package]] name = "hkdf" version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3431,7 +3476,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" dependencies = [ - "bitflags", + "bitflags 2.13.0", "libc", "plain", "redox_syscall 0.8.1", @@ -3731,7 +3776,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df9854ea6ad14e3f4698a7f03b65bce0833dd2d81d594a0e4a984170537146b6" dependencies = [ - "bitflags", + "bitflags 2.13.0", "libc", "log", "netlink-packet-core", @@ -3743,7 +3788,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2288fcb784eb3defd5fb16f4c4160d5f477de192eac730f43e1d11c24d9a007" dependencies = [ - "bitflags", + "bitflags 2.13.0", "libc", "log", "netlink-packet-core", @@ -3819,7 +3864,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags", + "bitflags 2.13.0", "cfg-if", "cfg_aliases", "libc", @@ -4063,7 +4108,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags", + "bitflags 2.13.0", "block2", "dispatch2", "libc", @@ -4076,7 +4121,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c71e34919aba0d701380d911702455038a8a3587467fe0141d6a71501e7ffe48" dependencies = [ - "bitflags", + "bitflags 2.13.0", "objc2", "objc2-core-foundation", "objc2-foundation", @@ -4096,7 +4141,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags", + "bitflags 2.13.0", "block2", "libc", "objc2", @@ -4109,7 +4154,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" dependencies = [ - "bitflags", + "bitflags 2.13.0", "objc2", "objc2-core-foundation", ] @@ -4130,7 +4175,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" dependencies = [ - "bitflags", + "bitflags 2.13.0", "dispatch2", "libc", "objc2", @@ -4840,7 +4885,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ - "bitflags", + "bitflags 2.13.0", "num-traits", "rand 0.9.4", "rand_chacha 0.9.0", @@ -5067,7 +5112,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.0", ] [[package]] @@ -5076,7 +5121,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7" dependencies = [ - "bitflags", + "bitflags 2.13.0", ] [[package]] @@ -5389,7 +5434,7 @@ checksum = "bbf893f64684e58da8a68d56a5e84d1cf0440226274c515770fe267707a7d0b0" dependencies = [ "aes 0.9.1", - "bitflags", + "bitflags 2.13.0", "block-padding 0.4.2", "byteorder", "bytes", @@ -5553,7 +5598,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -5566,7 +5611,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys 0.12.1", @@ -5769,7 +5814,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.13.0", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -6025,7 +6070,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a75cbde1bf934313596a004973e462f9a82caa814dcf1a5f507bdf51597eeb4" dependencies = [ - "bitflags", + "bitflags 2.13.0", ] [[package]] @@ -6253,7 +6298,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags", + "bitflags 2.13.0", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -6299,6 +6344,12 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" [[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] name = "tempfile" version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -6682,7 +6733,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags", + "bitflags 2.13.0", "bytes", "futures-util", "http", @@ -7036,7 +7087,7 @@ checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" dependencies = [ "arrayvec", - "bitflags", + "bitflags 2.13.0", "cursor-icon", "log", "memchr", --- a/Cargo.toml +++ b/Cargo.toml @@ -41,12 +41,14 @@ chrono = { version = "0.4.41", features = ["serde"] } clap = { version = "4.5", features = ["derive", "env", "unicode", "wrap_help"] } clap_complete = "4.5" +ctaphid = "0.3.1" console-subscriber = "0.5.0" cxx = "1.0.168" cxx-build = "1.0.168" ed25519-dalek = "3.0.0-rc.0" futures = "0.3.31" hex = "0.4.3" +hidapi = { version = "1.2.6", default-features = false, features = ["linux-static-hidraw"] } hmac = "0.13.0" hostname = "0.4.1" http-body-util = "0.1" --- a/cmds/fleet/src/cmds/usbd.rs +++ b/cmds/fleet/src/cmds/usbd.rs @@ -2,6 +2,7 @@ collections::HashSet, fs::File, io::{self, Write as _}, + os::unix::fs::OpenOptionsExt as _, sync::Arc, }; @@ -12,15 +13,16 @@ use clap::Parser; use fleet_base::{fleetdata::SecretOwner, host::Config}; use fleet_usb::{ - MANIFEST_DONE_NAME, MANIFEST_NAME, MANIFEST_VERSION, + DATA_DIR, GC_DIR, MANIFEST_VERSION, manifest::{Manifest, PathEntry, encrypt_manifest}, + manifest_done_name, manifest_name, names::{self, NAMING_SECRET_SIZE, NamingSecret}, stream::EncryptWriter, }; -use nix_eval::{Store, eval_store}; +use nix_eval::{Store, eval_store, nix_go, nix_go_json}; use rand::Rng as _; use tokio::task::spawn_blocking; -use tracing::{info, warn}; +use tracing::{debug, info}; use zbus::zvariant::{self, OwnedObjectPath}; use super::build_systems::build_task; @@ -54,8 +56,9 @@ /// Write into this directory instead of discovering and mounting the stick via udisks #[clap(long)] mount_point: Option, - /// Nix secret signing key; its public counterpart must be in trusted-public-keys on the host - #[clap(long)] + /// Nix secret signing key, overrides hosts..usbd.signingKeyFiles; + /// the public counterpart must be in trusted-public-keys on the host + #[clap(long, verbatim_doc_comment)] sign_key: Option, #[clap(long, default_value = "toplevel-fleet")] build_attr: String, @@ -66,16 +69,29 @@ let host = config.host(&self.hostname)?; let hostname = host.name.clone(); let recipient = config.recipient(&SecretOwner::host(&hostname)).await?; - let secret = naming_secret(config, &hostname)?; + let secret = naming_secret(config)?; let built = build_task(config.clone(), hostname.clone(), &self.build_attr).await?; - if let Some(key) = self.sign_key.clone() { + let mut sign_keys: Vec = self.sign_key.clone().into_iter().collect(); + if sign_keys.is_empty() + && let Some(host_config) = &host.host_config + { + let usbd = nix_go!(host_config.usbd); + let files: Vec = nix_go_json!(usbd.signingKeyFiles); + for file in files { + let path = Utf8PathBuf::try_from(config.directory.join(&file)) + .context("fleet directory should be utf8")?; + sign_keys.push(path); + } + } + let mut sign_key_names = Vec::new(); + for key in sign_keys { + info!("signing with {key}"); + sign_key_names.push(sign_key_name(&key)?); let store = eval_store(); let path = built.clone(); spawn_blocking(move || store.sign_closure(&path, &key)).await??; - } else { - warn!(target: TARGET, "closure is not signed (no --sign-key), the host will refuse it unless paths are signed by other means"); } let (stick, dir) = match &self.mount_point { @@ -88,20 +104,25 @@ }; info!(target: TARGET, "writing update to {dir}"); - let store = eval_store(); + // Reopened to avoid nix Store::pathInfoCache: infos cached during the build + // would otherwise hide the signatures added by sign_closure above. + let store = Arc::new(spawn_blocking(|| Store::open("auto")).await??); let manifest = { let secret = secret.clone(); let hostname = hostname.clone(); let built = built.clone(); let dir = dir.clone(); - spawn_blocking(move || write_closure(&store, &secret, &dir, &hostname, &built)) - .await?? + spawn_blocking(move || { + write_closure(&store, &secret, &dir, &hostname, &built, &sign_key_names) + }) + .await?? }; let encrypted = encrypt_manifest(&manifest, std::iter::once(&*recipient))?; { let dir = dir.clone(); - spawn_blocking(move || write_manifest(&dir, &encrypted)).await??; + let host_id = names::host_id(&secret, &hostname); + spawn_blocking(move || write_manifest(&dir, &host_id, &encrypted)).await??; } if let Some(stick) = stick { @@ -112,36 +133,44 @@ } } -fn naming_secret(config: &Config, host: &str) -> Result { - let mut extra = config.data.extra.write().expect("no poisoning"); - let usbd = extra - .entry("usbd".to_owned()) - .or_insert_with(|| serde_json::json!({})); - let usbd = usbd - .as_object_mut() - .context("fleet.nix extra.usbd should be an object")?; - let secrets = usbd - .entry("namingSecrets") - .or_insert_with(|| serde_json::json!({})); - let secrets = secrets - .as_object_mut() - .context("fleet.nix extra.usbd.namingSecrets should be an object")?; - if let Some(value) = secrets.get(host) { - let value = value - .as_str() - .context("naming secret should be a base64 string")?; - let bytes = BASE64.decode(value).context("naming secret base64")?; +fn sign_key_name(key_file: &Utf8Path) -> Result { + let key = std::fs::read_to_string(key_file) + .with_context(|| format!("reading signing key {key_file}"))?; + let (name, _) = key + .trim() + .split_once(':') + .with_context(|| format!("signing key {key_file} should look like name:base64"))?; + Ok(name.to_owned()) +} + +fn naming_secret(config: &Config) -> Result { + let config_field = &config.config_field; + let file: String = nix_go_json!(config_field.usbd.namingSecretFile); + let path = Utf8PathBuf::try_from(config.directory.join(&file)) + .context("fleet directory should be utf8")?; + if path.exists() { + let value = std::fs::read_to_string(&path)?; + let bytes = BASE64 + .decode(value.trim()) + .with_context(|| format!("naming secret base64 in {path}"))?; let bytes: [u8; NAMING_SECRET_SIZE] = bytes .try_into() - .map_err(|_| anyhow!("naming secret should be {NAMING_SECRET_SIZE} bytes"))?; + .map_err(|_| anyhow!("naming secret in {path} should be {NAMING_SECRET_SIZE} bytes"))?; Ok(NamingSecret(bytes)) } else { let mut bytes = [0u8; NAMING_SECRET_SIZE]; rand::rng().fill_bytes(&mut bytes); - secrets.insert( - host.to_owned(), - serde_json::Value::String(BASE64.encode(bytes)), - ); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&path) + .with_context(|| format!("creating naming secret file {path}"))?; + writeln!(file, "{}", BASE64.encode(bytes))?; + info!(target: TARGET, "generated new usbd naming secret at {path}"); Ok(NamingSecret(bytes)) } } @@ -152,38 +181,100 @@ dir: &Utf8Path, hostname: &str, toplevel: &Utf8Path, + sign_key_names: &[String], ) -> Result { + let data_dir = dir.join(DATA_DIR); + let gc_dir = dir.join(GC_DIR); + std::fs::create_dir_all(&data_dir)?; + std::fs::create_dir_all(&gc_dir)?; + let mut closure = store.compute_closure(toplevel)?; closure.sort(); info!(target: TARGET, "update closure contains {} paths", closure.len()); let mut existing = HashSet::new(); - for entry in dir.read_dir_utf8().context("reading stick")? { - existing.insert(entry?.file_name().to_owned()); + for shard in data_dir.read_dir_utf8().context("reading stick")? { + let shard = shard?; + if !shard.file_type()?.is_dir() { + continue; + } + for entry in shard.path().read_dir_utf8()? { + existing.insert(entry?.file_name().to_owned()); + } } let total = closure.len(); let mut entries = Vec::with_capacity(total); + let mut reused = 0; for (i, path) in closure.iter().enumerate() { - let entry = write_path(store, secret, dir, &mut existing, path) + let (entry, written) = write_path(store, secret, &data_dir, &mut existing, path) .with_context(|| format!("writing {path}"))?; - info!(target: TARGET, "[{}/{total}] {path}", i + 1); + if written { + info!(target: TARGET, "[{}/{total}] {path}", i + 1); + } else { + debug!(target: TARGET, "[{}/{total}] {path} (reused)", i + 1); + reused += 1; + } + for name in sign_key_names { + if !entry + .sigs + .iter() + .any(|sig| sig.strip_prefix(name).is_some_and(|r| r.starts_with(':'))) + { + bail!( + "{path} has no signature by the configured key {name}, refusing to write an update the host would reject; sigs present: {:?}", + entry.sigs + ); + } + } entries.push(entry); } + info!(target: TARGET, "{} paths written, {reused} reused", total - reused); - let referenced = entries - .iter() - .flat_map(|e| e.chunks.iter()) - .map(String::as_str) - .collect::>(); - for name in &existing { - let stale_data = names::is_data_name(name) && !referenced.contains(name.as_str()); - let stale_other = name.starts_with(TMP_PREFIX) || name == MANIFEST_DONE_NAME; - if stale_data || stale_other { - std::fs::remove_file(dir.join(name)) - .with_context(|| format!("deleting stale {name}"))?; + { + let tmp = gc_dir.join(format!("{TMP_PREFIX}gc")); + let mut file = File::create(&tmp)?; + for entry in &entries { + for chunk in &entry.chunks { + writeln!(file, "{chunk}")?; + } + } + file.sync_all()?; + drop(file); + std::fs::rename(&tmp, gc_dir.join(names::host_id(secret, hostname)))?; + } + + let mut referenced = HashSet::new(); + for entry in gc_dir.read_dir_utf8()? { + let entry = entry?; + if entry.file_name().starts_with(TMP_PREFIX) { + std::fs::remove_file(entry.path())?; + continue; + } + let list = std::fs::read_to_string(entry.path()) + .with_context(|| format!("reading gc list {}", entry.file_name()))?; + referenced.extend(list.lines().filter(|l| !l.is_empty()).map(str::to_owned)); + } + + for shard in data_dir.read_dir_utf8()? { + let shard = shard?; + if !shard.file_type()?.is_dir() { + if shard.file_name().starts_with(TMP_PREFIX) { + std::fs::remove_file(shard.path())?; + } + continue; } + for entry in shard.path().read_dir_utf8()? { + let entry = entry?; + let name = entry.file_name(); + let stale_data = names::is_data_name(name) && !referenced.contains(name); + if stale_data || name.starts_with(TMP_PREFIX) { + std::fs::remove_file(entry.path()) + .with_context(|| format!("deleting stale {name}"))?; + } + } } + let _ = std::fs::remove_file(dir.join(manifest_done_name(&names::host_id(secret, hostname)))); Ok(Manifest { version: MANIFEST_VERSION, @@ -199,7 +290,7 @@ dir: &Utf8Path, existing: &mut HashSet, path: &Utf8Path, -) -> Result { +) -> Result<(PathEntry, bool)> { let info = store.query_path_info(path)?; let key = names::file_key(secret, &info.nar_hash); @@ -217,6 +308,7 @@ } } + let written = chunks.is_none(); let chunks = if let Some(chunks) = chunks { chunks } else { @@ -234,31 +326,36 @@ .map(|i| names::chunk_name(secret, &info.nar_hash, i, count)) .collect::>(); for (tmp, name) in tmp_files.iter().zip(&chunks) { - std::fs::rename(tmp, dir.join(name))?; + let dest = dir.join(names::data_rel_path(name)); + std::fs::create_dir_all(dest.parent().expect("sharded path has a parent"))?; + std::fs::rename(tmp, dest)?; } existing.extend(chunks.iter().cloned()); chunks }; - Ok(PathEntry { - store_path: path.to_owned(), - nar_hash: info.nar_hash, - nar_size: info.nar_size, - references: info.references, - sigs: info.sigs, - compression: "zstd".to_owned(), - key: BASE64.encode(key), - chunks, - }) + Ok(( + PathEntry { + store_path: path.to_owned(), + nar_hash: info.nar_hash, + nar_size: info.nar_size, + references: info.references, + sigs: info.sigs, + compression: "zstd".to_owned(), + key: BASE64.encode(key), + chunks, + }, + written, + )) } -fn write_manifest(dir: &Utf8Path, encrypted: &[u8]) -> Result<()> { +fn write_manifest(dir: &Utf8Path, host_id: &str, encrypted: &[u8]) -> Result<()> { let tmp = dir.join(format!("{TMP_PREFIX}manifest")); let mut file = File::create(&tmp)?; file.write_all(encrypted)?; file.sync_all()?; drop(file); - std::fs::rename(&tmp, dir.join(MANIFEST_NAME))?; + std::fs::rename(&tmp, dir.join(manifest_name(host_id)))?; nix::unistd::sync(); Ok(()) } --- a/cmds/usbd/Cargo.toml +++ b/cmds/usbd/Cargo.toml @@ -11,10 +11,13 @@ base64.workspace = true bytes.workspace = true camino.workspace = true +chrono.workspace = true clap.workspace = true +ctaphid.workspace = true fleet-usb.workspace = true futures.workspace = true goodlog-subscriber.workspace = true +hidapi.workspace = true hostname.workspace = true hex.workspace = true http-body-util.workspace = true @@ -23,5 +26,6 @@ nix = { workspace = true, features = ["mount"] } nix-eval.workspace = true rand.workspace = true +tempfile.workspace = true tokio = { workspace = true, features = ["net", "process"] } tracing.workspace = true --- a/cmds/usbd/src/main.rs +++ b/cmds/usbd/src/main.rs @@ -5,14 +5,17 @@ use anyhow::{Context as _, Result, bail}; use camino::{Utf8Path, Utf8PathBuf}; use clap::Parser; -use fleet_usb::{MANIFEST_DONE_NAME, MANIFEST_NAME, manifest::decrypt_manifest}; +use fleet_usb::{ + DATA_DIR, LOGS_DIR, MANIFEST_DONE_SUFFIX, MANIFEST_PREFIX, manifest::decrypt_manifest, names, +}; use goodlog_subscriber::{LogOpts, setup_logging}; use nix::mount::MsFlags; use nix_eval::{ Store, gc_register_my_thread, gc_unregister_my_thread, init_libraries, init_tokio_for_nix, }; +use rand::RngExt as _; use tokio::task::spawn_blocking; -use tracing::{error, info, warn}; +use tracing::{debug, error, info, warn}; const MOUNT_TARGET: &str = "/run/fleet-usbd/mnt"; @@ -39,6 +42,17 @@ /// copying, switching, done, noop, failed #[clap(long, verbatim_doc_comment)] hook: Option, + /// Blink connected Nitrokey 3 devices while the daemon is working: + /// blinking stopped and the machine rebooted - update succeeded, + /// blinking stopped without a reboot - update failed + #[clap(long, verbatim_doc_comment)] + nk3_wink: bool, + /// After every run, dump the journal onto the stick, encrypted to this armored OpenPGP public key + #[clap(long)] + log_recipient_file: Option, + /// journalctl --since window of the log dump + #[clap(long, default_value = "-48h")] + log_since: String, #[clap(long, default_value = "/nix/var/nix/profiles/system")] profile: String, /// Do not reboot after the update is applied @@ -92,28 +106,110 @@ Ok(()) } -fn mark_done(dir: &Utf8Path) -> Result<()> { - std::fs::rename(dir.join(MANIFEST_NAME), dir.join(MANIFEST_DONE_NAME)) - .context("marking update as done")?; +fn mark_done(manifest_path: &Utf8Path) -> Result<()> { + std::fs::rename( + manifest_path, + format!("{manifest_path}{MANIFEST_DONE_SUFFIX}"), + ) + .context("marking update as done")?; nix::unistd::sync(); Ok(()) } +fn dump_logs(dir: &Utf8Path, recipient: &Utf8Path, since: &str) -> Result<()> { + use std::process::{Command, Stdio}; + let logs_dir = dir.join(LOGS_DIR); + std::fs::create_dir_all(&logs_dir)?; + let tmp = logs_dir.join("usbd-tmp-log"); + let _ = std::fs::remove_file(&tmp); + + let _ = Command::new("journalctl").arg("--sync").status(); + + let gpg_home = tempfile::tempdir().context("creating gpg home")?; + let mut journalctl = Command::new("journalctl") + .args(["-o", "export"]) + .arg(format!("--since={since}")) + .stdout(Stdio::piped()) + .spawn() + .context("spawning journalctl")?; + let gpg = Command::new("gpg") + .env("GNUPGHOME", gpg_home.path()) + .args([ + "--batch", + "--no-tty", + "--yes", + "--trust-model", + "always", + "--encrypt", + "--recipient-file", + ]) + .arg(recipient) + .arg("--output") + .arg(&tmp) + .stdin(journalctl.stdout.take().expect("stdout is piped")) + .status() + .context("running gpg")?; + let journalctl = journalctl.wait()?; + if !journalctl.success() { + bail!("journalctl failed: {journalctl}"); + } + if !gpg.success() { + bail!("gpg failed: {gpg}"); + } + + let file = std::fs::File::open(&tmp)?; + file.sync_all()?; + drop(file); + let name = format!( + "{}-{:04x}.export.gpg", + chrono::Local::now().format("%Y-%m-%d-%H-%M-%S"), + rand::rng().random::(), + ); + std::fs::rename(&tmp, logs_dir.join(&name))?; + info!("journal dumped to {LOGS_DIR}/{name}"); + Ok(()) +} + async fn apply(opts: &Opts, dir: &Utf8Path, hook: &Hook) -> Result { - let manifest_path = dir.join(MANIFEST_NAME); - if !manifest_path.exists() { - if dir.join(MANIFEST_DONE_NAME).exists() { - info!("update on this stick is already applied"); + let identity = host_identity(&opts.host_key)?; + + let mut manifests = Vec::new(); + let mut done_files = Vec::new(); + for entry in dir.read_dir_utf8().context("reading stick")? { + let entry = entry?; + let name = entry.file_name(); + if !name.starts_with(MANIFEST_PREFIX) { + continue; + } + if name.ends_with(MANIFEST_DONE_SUFFIX) { + done_files.push(entry.path().to_owned()); } else { - info!("no update manifest on the stick"); + manifests.push(entry.path().to_owned()); } - return Ok(Outcome::Nothing); } - let identity = host_identity(&opts.host_key)?; - let data = std::fs::read(&manifest_path).context("reading manifest")?; - let manifest = decrypt_manifest(&data, &identity) - .context("decrypting manifest, is this stick meant for this machine?")?; + let mut found = None; + for path in manifests { + let data = std::fs::read(&path).with_context(|| format!("reading manifest {path}"))?; + match decrypt_manifest(&data, &identity).with_context(|| format!("manifest {path}"))? { + Some(manifest) => { + found = Some((path, manifest)); + break; + } + None => debug!("manifest {path} is for a different host"), + } + } + let Some((manifest_path, manifest)) = found else { + for path in done_files { + let data = std::fs::read(&path)?; + if decrypt_manifest(&data, &identity)?.is_some() { + info!("update on this stick is already applied"); + return Ok(Outcome::Nothing); + } + } + info!("no update for this host on the stick"); + return Ok(Outcome::Nothing); + }; let host = match &opts.hostname { Some(host) => host.clone(), @@ -132,15 +228,16 @@ let current = std::fs::read_link("/run/current-system").ok(); if current.as_deref() == Some(manifest.toplevel.as_std_path()) { info!("system is already up to date"); - mark_done(dir)?; + mark_done(&manifest_path)?; return Ok(Outcome::UpToDate); } + let data_dir = dir.join(DATA_DIR); let missing = manifest .paths .iter() .flat_map(|e| e.chunks.iter()) - .filter(|c| !dir.join(c.as_str()).exists()) + .filter(|c| !data_dir.join(names::data_rel_path(c)).exists()) .count(); if missing > 0 { bail!("stick is missing {missing} update files, was it synchronized fully?"); @@ -152,13 +249,21 @@ manifest.paths.len() ); hook.call("copying", None).await; - let cache = server::CacheServer::start(dir.to_owned(), &manifest).await?; + let cache = server::CacheServer::start(dir, &manifest).await?; { let url = cache.url.clone(); let toplevel = manifest.toplevel.clone(); + let paths = manifest + .paths + .iter() + .map(|e| e.store_path.clone()) + .collect::>(); spawn_blocking(move || -> Result<()> { let src = Store::open(&url)?; let dst = Store::open("auto")?; + for path in &paths { + dst.add_temp_root(path)?; + } src.copy_to(&dst, &toplevel) }) .await? @@ -186,12 +291,44 @@ bail!("{switch} boot failed: {status}"); } - mark_done(dir)?; + mark_done(&manifest_path)?; Ok(Outcome::Applied) } +fn wink_all() -> Result<()> { + let hidapi = hidapi::HidApi::new().context("initializing hidapi")?; + for info in hidapi.device_list() { + let nk3 = info.vendor_id() == 0x20a0 && info.product_id() == 0x42b2; + let fido = info.usage_page() == 0xf1d0; + if !nk3 && !fido { + continue; + } + let winked = info + .open_device(&hidapi) + .map_err(anyhow::Error::from) + .and_then(|device| Ok(ctaphid::Device::new(device, info.clone())?)) + .and_then(|device| Ok(device.wink()?)); + if let Err(e) = winked { + debug!("winking {:?}: {e:#}", info.path()); + } + } + Ok(()) +} + async fn run(opts: &Opts) -> Result<()> { let hook = Hook(opts.hook.clone()); + let winker = opts.nk3_wink.then(|| { + tokio::spawn(async { + loop { + match spawn_blocking(wink_all).await { + Ok(Ok(())) => {} + Ok(Err(e)) => debug!("nk3 wink: {e:#}"), + Err(e) => debug!("nk3 wink task: {e}"), + } + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + } + }) + }); let mounted = match &opts.device { Some(device) => { @@ -207,10 +344,28 @@ let outcome = apply(opts, &dir, &hook).await; + if let Err(e) = &outcome { + error!("update failed: {e:#}"); + } + if let Some(recipient) = &opts.log_recipient_file { + let recipient = recipient.clone(); + let dir = dir.clone(); + let since = opts.log_since.clone(); + let dumped = spawn_blocking(move || dump_logs(&dir, &recipient, &since)) + .await + .expect("dump_logs should not panic"); + if let Err(e) = dumped { + warn!("failed to dump logs onto the stick: {e:#}"); + } + } + nix::unistd::sync(); if mounted && let Err(e) = nix::mount::umount(MOUNT_TARGET) { warn!("unmounting the stick: {e}"); } + if let Some(winker) = winker { + winker.abort(); + } match outcome { Ok(Outcome::Applied) => { --- a/cmds/usbd/src/server.rs +++ b/cmds/usbd/src/server.rs @@ -4,7 +4,7 @@ use base64::Engine as _; use base64::engine::general_purpose::STANDARD as BASE64; use bytes::Bytes; -use camino::Utf8PathBuf; +use camino::{Utf8Path, Utf8PathBuf}; use fleet_usb::{manifest::Manifest, manifest::PathEntry, stream::DecryptReader}; use futures::SinkExt as _; use http_body_util::{BodyExt as _, Full, StreamBody, combinators::BoxBody}; @@ -39,7 +39,8 @@ } impl CacheServer { - pub async fn start(dir: Utf8PathBuf, manifest: &Manifest) -> Result { + pub async fn start(dir: &Utf8Path, manifest: &Manifest) -> Result { + let dir = dir.join(fleet_usb::DATA_DIR); let mut token = [0u8; 16]; rand::rng().fill_bytes(&mut token); let token = hex::encode(token); @@ -167,7 +168,7 @@ let files = entry .chunks .iter() - .map(|c| state.dir.join(c)) + .map(|c| state.dir.join(fleet_usb::names::data_rel_path(c))) .collect::>(); let (mut tx, rx) = futures::channel::mpsc::channel::, std::io::Error>>(8); --- a/crates/fleet-usb/src/lib.rs +++ b/crates/fleet-usb/src/lib.rs @@ -2,6 +2,17 @@ pub mod names; pub mod stream; -pub const MANIFEST_NAME: &str = "usbd-update"; -pub const MANIFEST_DONE_NAME: &str = "usbd-update.done"; +pub const MANIFEST_PREFIX: &str = "usbd-update-"; +pub const MANIFEST_DONE_SUFFIX: &str = ".done"; pub const MANIFEST_VERSION: u32 = 1; +pub const DATA_DIR: &str = "update-data"; +pub const GC_DIR: &str = "gc"; +pub const LOGS_DIR: &str = "logs"; + +pub fn manifest_name(host_id: &str) -> String { + format!("{MANIFEST_PREFIX}{host_id}") +} + +pub fn manifest_done_name(host_id: &str) -> String { + format!("{MANIFEST_PREFIX}{host_id}{MANIFEST_DONE_SUFFIX}") +} --- a/crates/fleet-usb/src/manifest.rs +++ b/crates/fleet-usb/src/manifest.rs @@ -44,11 +44,14 @@ Ok(out) } -pub fn decrypt_manifest(data: &[u8], identity: &dyn Identity) -> Result { +/// Ok(None) means the manifest is valid but encrypted for a different host. +pub fn decrypt_manifest(data: &[u8], identity: &dyn Identity) -> Result> { let decryptor = age::Decryptor::new(data).context("age decrypt")?; - let mut reader = decryptor - .decrypt(std::iter::once(identity)) - .context("manifest is not encrypted for this identity")?; + let mut reader = match decryptor.decrypt(std::iter::once(identity)) { + Ok(reader) => reader, + Err(age::DecryptError::NoMatchingKeys) => return Ok(None), + Err(e) => return Err(e).context("decrypting manifest"), + }; let mut json = Vec::new(); reader.read_to_end(&mut json)?; let manifest: Manifest = serde_json::from_slice(&json).context("manifest json")?; @@ -58,5 +61,5 @@ manifest.version ); } - Ok(manifest) + Ok(Some(manifest)) } --- a/crates/fleet-usb/src/names.rs +++ b/crates/fleet-usb/src/names.rs @@ -18,14 +18,14 @@ } pub fn file_key(secret: &NamingSecret, nar_hash: &str) -> [u8; 32] { - derive(secret, &[b"fleet-usb.v1.key\0", nar_hash.as_bytes()]) + derive(secret, &[b"fleet-usb.v1.key", nar_hash.as_bytes()]) } pub fn chunk_name(secret: &NamingSecret, nar_hash: &str, index: u32, count: u32) -> String { let h = derive( secret, &[ - b"fleet-usb.v1.name\0", + b"fleet-usb.v1.name", nar_hash.as_bytes(), &index.to_le_bytes(), &count.to_le_bytes(), @@ -34,6 +34,15 @@ hex::encode(&h[..16]) } +pub fn host_id(secret: &NamingSecret, host: &str) -> String { + let h = derive(secret, &[b"fleet-usb.v1.host", host.as_bytes()]); + hex::encode(&h[..16]) +} + +pub fn data_rel_path(name: &str) -> String { + format!("{}/{name}", &name[..2]) +} + pub fn is_data_name(name: &str) -> bool { name.len() == 32 && name --- a/crates/nix-eval/src/lib.cc +++ b/crates/nix-eval/src/lib.cc @@ -204,6 +204,17 @@ return out; } +rust::String add_temp_root(Store *store, rust::Str path) { + try { + auto nixStore = store->ptr; + auto sp = nixStore->parseStorePath(std::string(path)); + nixStore->addTempRoot(sp); + return rust::String(); + } catch (const std::exception &e) { + return rust::String(e.what()); + } +} + CxxBuildResult compute_closure(Store *store, rust::Str path) { CxxBuildResult res{rust::String(), {}}; try { --- a/crates/nix-eval/src/lib.hh +++ b/crates/nix-eval/src/lib.hh @@ -34,6 +34,8 @@ struct CxxPathInfo; CxxPathInfo query_path_info(Store *store, rust::Str path); +rust::String add_temp_root(Store *store, rust::Str path); + CxxBuildResult compute_closure(Store *store, rust::Str path); rust::String nar_from_path(Store *store, rust::Str path, size_t sink_data, --- a/crates/nix-eval/src/lib.rs +++ b/crates/nix-eval/src/lib.rs @@ -141,6 +141,9 @@ unsafe fn query_path_info(store: *mut Store, path: &str) -> CxxPathInfo; #[allow(clippy::missing_safety_doc)] + unsafe fn add_temp_root(store: *mut Store, path: &str) -> String; + + #[allow(clippy::missing_safety_doc)] unsafe fn compute_closure(store: *mut Store, path: &str) -> CxxBuildResult; #[allow(clippy::missing_safety_doc)] @@ -665,6 +668,16 @@ }) } + /// The root is held for the lifetime of this process. + #[instrument(skip(self))] + pub fn add_temp_root(&self, path: &Utf8Path) -> Result<()> { + let msg = unsafe { nix_cxx::add_temp_root(self.as_ptr().cast(), path.as_str()) }; + if !msg.is_empty() { + bail!("failed to add temp root for {path}: {msg}"); + } + Ok(()) + } + #[instrument(skip(self))] pub fn compute_closure(&self, path: &Utf8Path) -> Result> { let res = unsafe { nix_cxx::compute_closure(self.as_ptr().cast(), path.as_str()) }; --- a/docs/features/usbd.adoc +++ b/docs/features/usbd.adoc @@ -2,24 +2,24 @@ For airgapped deployments/system updates for low-tech users with limited internet access, it is better to have something more manual than pusher. -fleet-usbd is a simple update daemon, which watches for a USB stick with the system update to be inserted into the machine, reads the manifest (usbd-update) from it, copies the closure from the USB stick, switches the current system generation, moves the manifest (usbd-update.done) and reboots. +fleet-usbd is a simple update daemon, which watches for a USB stick with the system update to be inserted into the machine, finds its manifest on it, copies the closure from the USB stick, switches the current system generation, renames the manifest to *.done and reboots. The stick is discovered by volume label; the label is only a discovery hint, not a trust anchor. Detection is a systemd device unit on /dev/disk/by-label/