git.delta.rocks / fleet / refs/commits / 14689aa0c750

difftreelog

fix temp root

urnmkxtlYaroslav Bolyukin2026-07-07parent: #615754c.patch.diff

18 files changed

modifiedCargo.lockdiffbeforeafterboth
--- 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",
modifiedCargo.tomldiffbeforeafterboth
--- 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"
modifiedcmds/fleet/src/cmds/usbd.rsdiffbeforeafterboth
--- 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<Utf8PathBuf>,
-	/// Nix secret signing key; its public counterpart must be in trusted-public-keys on the host
-	#[clap(long)]
+	/// Nix secret signing key, overrides hosts.<name>.usbd.signingKeyFiles;
+	/// the public counterpart must be in trusted-public-keys on the host
+	#[clap(long, verbatim_doc_comment)]
 	sign_key: Option<Utf8PathBuf>,
 	#[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<Utf8PathBuf> = 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<String> = 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<NamingSecret> {
-	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<String> {
+	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<NamingSecret> {
+	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<Manifest> {
+	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::<HashSet<_>>();
-	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<String>,
 	path: &Utf8Path,
-) -> Result<PathEntry> {
+) -> 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::<Vec<_>>();
 		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(())
 }
modifiedcmds/usbd/Cargo.tomldiffbeforeafterboth
--- 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
modifiedcmds/usbd/src/main.rsdiffbeforeafterboth
after · cmds/usbd/src/main.rs
1mod server;23use std::{process::ExitCode, sync::Arc};45use anyhow::{Context as _, Result, bail};6use camino::{Utf8Path, Utf8PathBuf};7use clap::Parser;8use fleet_usb::{9	DATA_DIR, LOGS_DIR, MANIFEST_DONE_SUFFIX, MANIFEST_PREFIX, manifest::decrypt_manifest, names,10};11use goodlog_subscriber::{LogOpts, setup_logging};12use nix::mount::MsFlags;13use nix_eval::{14	Store, gc_register_my_thread, gc_unregister_my_thread, init_libraries, init_tokio_for_nix,15};16use rand::RngExt as _;17use tokio::task::spawn_blocking;18use tracing::{debug, error, info, warn};1920const MOUNT_TARGET: &str = "/run/fleet-usbd/mnt";2122#[derive(Parser)]23#[clap(version, author)]24struct Opts {25	/// Block device with the update filesystem, e.g. /dev/disk/by-label/FLEETUSBD26	#[clap(27		long,28		required_unless_present = "mount_point",29		conflicts_with = "mount_point"30	)]31	device: Option<Utf8PathBuf>,32	/// Already mounted update directory, instead of --device33	#[clap(long)]34	mount_point: Option<Utf8PathBuf>,35	/// Host ssh key the manifest is encrypted to36	#[clap(long, default_value = "/etc/ssh/ssh_host_ed25519_key")]37	host_key: Utf8PathBuf,38	/// Expected manifest host name, defaults to the system hostname39	#[clap(long)]40	hostname: Option<String>,41	/// Executed on update lifecycle events with the event name as the first argument:42	/// copying, switching, done, noop, failed <error>43	#[clap(long, verbatim_doc_comment)]44	hook: Option<Utf8PathBuf>,45	/// Blink connected Nitrokey 3 devices while the daemon is working:46	/// blinking stopped and the machine rebooted - update succeeded,47	/// blinking stopped without a reboot - update failed48	#[clap(long, verbatim_doc_comment)]49	nk3_wink: bool,50	/// After every run, dump the journal onto the stick, encrypted to this armored OpenPGP public key51	#[clap(long)]52	log_recipient_file: Option<Utf8PathBuf>,53	/// journalctl --since window of the log dump54	#[clap(long, default_value = "-48h")]55	log_since: String,56	#[clap(long, default_value = "/nix/var/nix/profiles/system")]57	profile: String,58	/// Do not reboot after the update is applied59	#[clap(long)]60	no_reboot: bool,61	#[clap(flatten)]62	log: LogOpts,63}6465enum Outcome {66	Applied,67	UpToDate,68	Nothing,69}7071struct Hook(Option<Utf8PathBuf>);72impl Hook {73	async fn call(&self, event: &str, detail: Option<&str>) {74		let Some(script) = &self.0 else {75			return;76		};77		let mut cmd = tokio::process::Command::new(script);78		cmd.arg(event);79		if let Some(detail) = detail {80			cmd.arg(detail);81		}82		match cmd.status().await {83			Ok(status) if status.success() => {}84			Ok(status) => warn!("hook {event}: {status}"),85			Err(e) => warn!("hook {event}: {e}"),86		}87	}88}8990fn host_identity(path: &Utf8Path) -> Result<age::ssh::Identity> {91	let data = std::fs::read(path).with_context(|| format!("reading host key {path}"))?;92	age::ssh::Identity::from_buffer(std::io::Cursor::new(data), Some(path.to_string()))93		.with_context(|| format!("parsing host key {path}"))94}9596fn mount_stick(device: &Utf8Path) -> Result<()> {97	std::fs::create_dir_all(MOUNT_TARGET)?;98	nix::mount::mount(99		Some(device.as_std_path()),100		MOUNT_TARGET,101		Some("vfat"),102		MsFlags::MS_NOSUID | MsFlags::MS_NODEV | MsFlags::MS_NOEXEC,103		None::<&str>,104	)105	.with_context(|| format!("mounting {device} at {MOUNT_TARGET}"))?;106	Ok(())107}108109fn mark_done(manifest_path: &Utf8Path) -> Result<()> {110	std::fs::rename(111		manifest_path,112		format!("{manifest_path}{MANIFEST_DONE_SUFFIX}"),113	)114	.context("marking update as done")?;115	nix::unistd::sync();116	Ok(())117}118119fn dump_logs(dir: &Utf8Path, recipient: &Utf8Path, since: &str) -> Result<()> {120	use std::process::{Command, Stdio};121	let logs_dir = dir.join(LOGS_DIR);122	std::fs::create_dir_all(&logs_dir)?;123	let tmp = logs_dir.join("usbd-tmp-log");124	let _ = std::fs::remove_file(&tmp);125126	let _ = Command::new("journalctl").arg("--sync").status();127128	let gpg_home = tempfile::tempdir().context("creating gpg home")?;129	let mut journalctl = Command::new("journalctl")130		.args(["-o", "export"])131		.arg(format!("--since={since}"))132		.stdout(Stdio::piped())133		.spawn()134		.context("spawning journalctl")?;135	let gpg = Command::new("gpg")136		.env("GNUPGHOME", gpg_home.path())137		.args([138			"--batch",139			"--no-tty",140			"--yes",141			"--trust-model",142			"always",143			"--encrypt",144			"--recipient-file",145		])146		.arg(recipient)147		.arg("--output")148		.arg(&tmp)149		.stdin(journalctl.stdout.take().expect("stdout is piped"))150		.status()151		.context("running gpg")?;152	let journalctl = journalctl.wait()?;153	if !journalctl.success() {154		bail!("journalctl failed: {journalctl}");155	}156	if !gpg.success() {157		bail!("gpg failed: {gpg}");158	}159160	let file = std::fs::File::open(&tmp)?;161	file.sync_all()?;162	drop(file);163	let name = format!(164		"{}-{:04x}.export.gpg",165		chrono::Local::now().format("%Y-%m-%d-%H-%M-%S"),166		rand::rng().random::<u16>(),167	);168	std::fs::rename(&tmp, logs_dir.join(&name))?;169	info!("journal dumped to {LOGS_DIR}/{name}");170	Ok(())171}172173async fn apply(opts: &Opts, dir: &Utf8Path, hook: &Hook) -> Result<Outcome> {174	let identity = host_identity(&opts.host_key)?;175176	let mut manifests = Vec::new();177	let mut done_files = Vec::new();178	for entry in dir.read_dir_utf8().context("reading stick")? {179		let entry = entry?;180		let name = entry.file_name();181		if !name.starts_with(MANIFEST_PREFIX) {182			continue;183		}184		if name.ends_with(MANIFEST_DONE_SUFFIX) {185			done_files.push(entry.path().to_owned());186		} else {187			manifests.push(entry.path().to_owned());188		}189	}190191	let mut found = None;192	for path in manifests {193		let data = std::fs::read(&path).with_context(|| format!("reading manifest {path}"))?;194		match decrypt_manifest(&data, &identity).with_context(|| format!("manifest {path}"))? {195			Some(manifest) => {196				found = Some((path, manifest));197				break;198			}199			None => debug!("manifest {path} is for a different host"),200		}201	}202	let Some((manifest_path, manifest)) = found else {203		for path in done_files {204			let data = std::fs::read(&path)?;205			if decrypt_manifest(&data, &identity)?.is_some() {206				info!("update on this stick is already applied");207				return Ok(Outcome::Nothing);208			}209		}210		info!("no update for this host on the stick");211		return Ok(Outcome::Nothing);212	};213214	let host = match &opts.hostname {215		Some(host) => host.clone(),216		None => hostname::get()217			.context("querying hostname")?218			.to_string_lossy()219			.into_owned(),220	};221	if manifest.host != host {222		bail!(223			"update is for host {}, but this host is {host}",224			manifest.host225		);226	}227228	let current = std::fs::read_link("/run/current-system").ok();229	if current.as_deref() == Some(manifest.toplevel.as_std_path()) {230		info!("system is already up to date");231		mark_done(&manifest_path)?;232		return Ok(Outcome::UpToDate);233	}234235	let data_dir = dir.join(DATA_DIR);236	let missing = manifest237		.paths238		.iter()239		.flat_map(|e| e.chunks.iter())240		.filter(|c| !data_dir.join(names::data_rel_path(c)).exists())241		.count();242	if missing > 0 {243		bail!("stick is missing {missing} update files, was it synchronized fully?");244	}245246	info!(247		"applying update to {} ({} paths)",248		manifest.toplevel,249		manifest.paths.len()250	);251	hook.call("copying", None).await;252	let cache = server::CacheServer::start(dir, &manifest).await?;253	{254		let url = cache.url.clone();255		let toplevel = manifest.toplevel.clone();256		let paths = manifest257			.paths258			.iter()259			.map(|e| e.store_path.clone())260			.collect::<Vec<_>>();261		spawn_blocking(move || -> Result<()> {262			let src = Store::open(&url)?;263			let dst = Store::open("auto")?;264			for path in &paths {265				dst.add_temp_root(path)?;266			}267			src.copy_to(&dst, &toplevel)268		})269		.await?270		.context("copying closure from the stick")?;271	}272	drop(cache);273274	hook.call("switching", None).await;275	{276		let profile = opts.profile.clone();277		let toplevel = manifest.toplevel.clone();278		spawn_blocking(move || -> Result<()> {279			let store = Store::open("auto")?;280			store.switch_profile(&profile, &toplevel)281		})282		.await??;283	}284	let switch = manifest.toplevel.join("bin/switch-to-configuration");285	let status = tokio::process::Command::new(&switch)286		.arg("boot")287		.status()288		.await289		.with_context(|| format!("running {switch}"))?;290	if !status.success() {291		bail!("{switch} boot failed: {status}");292	}293294	mark_done(&manifest_path)?;295	Ok(Outcome::Applied)296}297298fn wink_all() -> Result<()> {299	let hidapi = hidapi::HidApi::new().context("initializing hidapi")?;300	for info in hidapi.device_list() {301		let nk3 = info.vendor_id() == 0x20a0 && info.product_id() == 0x42b2;302		let fido = info.usage_page() == 0xf1d0;303		if !nk3 && !fido {304			continue;305		}306		let winked = info307			.open_device(&hidapi)308			.map_err(anyhow::Error::from)309			.and_then(|device| Ok(ctaphid::Device::new(device, info.clone())?))310			.and_then(|device| Ok(device.wink()?));311		if let Err(e) = winked {312			debug!("winking {:?}: {e:#}", info.path());313		}314	}315	Ok(())316}317318async fn run(opts: &Opts) -> Result<()> {319	let hook = Hook(opts.hook.clone());320	let winker = opts.nk3_wink.then(|| {321		tokio::spawn(async {322			loop {323				match spawn_blocking(wink_all).await {324					Ok(Ok(())) => {}325					Ok(Err(e)) => debug!("nk3 wink: {e:#}"),326					Err(e) => debug!("nk3 wink task: {e}"),327				}328				tokio::time::sleep(std::time::Duration::from_secs(5)).await;329			}330		})331	});332333	let mounted = match &opts.device {334		Some(device) => {335			mount_stick(device)?;336			true337		}338		None => false,339	};340	let dir = opts341		.mount_point342		.clone()343		.unwrap_or_else(|| Utf8PathBuf::from(MOUNT_TARGET));344345	let outcome = apply(opts, &dir, &hook).await;346347	if let Err(e) = &outcome {348		error!("update failed: {e:#}");349	}350	if let Some(recipient) = &opts.log_recipient_file {351		let recipient = recipient.clone();352		let dir = dir.clone();353		let since = opts.log_since.clone();354		let dumped = spawn_blocking(move || dump_logs(&dir, &recipient, &since))355			.await356			.expect("dump_logs should not panic");357		if let Err(e) = dumped {358			warn!("failed to dump logs onto the stick: {e:#}");359		}360	}361362	nix::unistd::sync();363	if mounted && let Err(e) = nix::mount::umount(MOUNT_TARGET) {364		warn!("unmounting the stick: {e}");365	}366	if let Some(winker) = winker {367		winker.abort();368	}369370	match outcome {371		Ok(Outcome::Applied) => {372			hook.call("done", None).await;373			if opts.no_reboot {374				info!("update applied, reboot to activate");375			} else {376				info!("update applied, rebooting");377				let status = tokio::process::Command::new("systemctl")378					.arg("reboot")379					.status()380					.await381					.context("requesting reboot")?;382				if !status.success() {383					bail!("systemctl reboot failed: {status}");384				}385			}386			Ok(())387		}388		Ok(Outcome::UpToDate) => {389			hook.call("done", None).await;390			Ok(())391		}392		Ok(Outcome::Nothing) => {393			hook.call("noop", None).await;394			Ok(())395		}396		Err(e) => {397			hook.call("failed", Some(&format!("{e:#}"))).await;398			Err(e)399		}400	}401}402403fn main() -> ExitCode {404	let opts = Opts::parse();405	if let Err(e) = setup_logging(&opts.log) {406		eprintln!("{e:#}");407		return ExitCode::FAILURE;408	}409410	init_libraries();411412	let runtime = tokio::runtime::Builder::new_multi_thread()413		.enable_all()414		.on_thread_start(|| {415			gc_register_my_thread();416		})417		.on_thread_stop(|| {418			gc_unregister_my_thread();419		})420		.build()421		.expect("failed to build runtime");422	let runtime = Arc::new(runtime);423424	init_tokio_for_nix(runtime.clone());425426	runtime.block_on(async {427		if let Err(e) = run(&opts).await {428			error!("{e:#}");429			ExitCode::FAILURE430		} else {431			ExitCode::SUCCESS432		}433	})434}
modifiedcmds/usbd/src/server.rsdiffbeforeafterboth
--- 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<Self> {
+	pub async fn start(dir: &Utf8Path, manifest: &Manifest) -> Result<Self> {
+		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::<Vec<_>>();
 
 	let (mut tx, rx) = futures::channel::mpsc::channel::<Result<Frame<Bytes>, std::io::Error>>(8);
modifiedcrates/fleet-usb/src/lib.rsdiffbeforeafterboth
--- 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}")
+}
modifiedcrates/fleet-usb/src/manifest.rsdiffbeforeafterboth
--- 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<Manifest> {
+/// Ok(None) means the manifest is valid but encrypted for a different host.
+pub fn decrypt_manifest(data: &[u8], identity: &dyn Identity) -> Result<Option<Manifest>> {
 	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))
 }
modifiedcrates/fleet-usb/src/names.rsdiffbeforeafterboth
--- 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
modifiedcrates/nix-eval/src/lib.ccdiffbeforeafterboth
--- 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 {
modifiedcrates/nix-eval/src/lib.hhdiffbeforeafterboth
--- 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,
modifiedcrates/nix-eval/src/lib.rsdiffbeforeafterboth
--- 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<Vec<Utf8PathBuf>> {
 		let res = unsafe { nix_cxx::compute_closure(self.as_ptr().cast(), path.as_str()) };
modifieddocs/features/usbd.adocdiffbeforeafterboth
--- 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/<label> with the daemon service bound to it (WantedBy=), no udisks dependency.
 
 == Update flow
 
 1. Stick inserted, device unit starts the service, stick is mounted.
-2. Daemon decrypts the manifest, checks the system name matches this host.
+2. Daemon scans usbd-update-* files and tries to decrypt each; the one encrypted to this host's key is its manifest (the daemon has no naming secret, so it cannot derive its manifest name directly). The system name inside is checked as well.
 3. If the manifest's system is already the current generation - noop. Guards against reinsertion when the .done rename previously failed.
 4. Closure is copied from the stick. Hash verification is part of the copy; copying already-present paths is a noop.
 5. New generation is set as the boot default (boot, not switch), so the existing on-boot health-check rollback applies.
-6. Manifest is renamed to usbd-update.done.
+6. Manifest is renamed to *.done.
 7. Reboot.
 
 == Requirements
 
 One file per store path:: it should be possible to rewrite the system update on USB stick in incremental fashion: unchanged store paths keep their exact file name and bytes, only added/removed paths change on the stick.
-Deterministic, non-leaking names and ciphertext:: file names and file contents must be byte-stable across rewrites (otherwise incremental sync degrades to full rewrite), but must not leak store path names/contents. Mix a secret into the derivation: per-file key and file name are derived from (deployment secret, narHash); the deployment secret is an ordinary fleet shared secret owned by the target host. Derivation must use narHash, not the store path: input-addressed paths can be rebuilt nondeterministically, yielding the same store path with different contents, and a same-name-different-bytes file breaks both immutability and size-only synchronization. Note age cannot be used for the path files - its encryption is randomized, so ciphertext would churn on every rewrite.
+Deterministic, non-leaking names and ciphertext:: file names and file contents must be byte-stable across rewrites (otherwise incremental sync degrades to full rewrite), but must not leak store path names/contents. Mix a secret into the derivation: per-file key and file name are derived from (naming secret, narHash); the naming secret is project-wide, writer-side state in the file referenced by the `usbd.namingSecretFile` option (relative to the project directory, generated on first write). Hosts never need it - each manifest carries the keys for its paths - and because it is shared across hosts, a store path used by two hosts maps to the same file name and key, so shared sticks dedup naturally. Derivation must use narHash, not the store path: input-addressed paths can be rebuilt nondeterministically, yielding the same store path with different contents, and a same-name-different-bytes file breaks both immutability and size-only synchronization. Note age cannot be used for the path files - its encryption is randomized, so ciphertext would churn on every rewrite.
 Manifest encrypted to the host ssh key:: the manifest is encrypted the same way fleet already performs secret encryption (age, ssh-ed25519 host key recipient). It carries the system name and the per-path metadata listed in the copying section.
 Manifest should include system name:: update daemon should check if the system update is suitable for it.
 User feedback via callbacks:: how the user learns "update running / done / safe to remove" differs per client (screen, beep, LED). The daemon only exposes callback hooks for overriding; no built-in feedback.
@@ -33,7 +33,7 @@
 2. Daemon binds an ephemeral HTTP listener on 127.0.0.1 serving the binary cache protocol: /nix-cache-info, synthesized <hash>.narinfo (Sig: from build time, URL: nar/<derived-name>, Compression: zstd) and /nar/<derived-name>, which streaming-decrypts the stick file (chunked AEAD) into the .nar.zst; nix does the decompression and NAR hash check itself.
 3. The copy goes through the nix-eval FFI (Store::open on the localhost cache, copy_to into the local store), not a subprocess. A random token path prefix in the cache URL keeps other local processes from pulling decrypted NARs off the listener.
 4. Signatures are verified by nix against trusted-public-keys; the deployer signing key is pinned there by the fleet module.
-5. The copy holds a temporary GC root on the system path so a concurrent GC cannot race it. The listener is shut down after the copy, then the normal flow continues: set boot generation, rename manifest, reboot.
+5. The daemon holds a temporary GC root on every update path for the duration of the copy (temp roots protect not-yet-valid paths individually, so intermediate paths are covered before the toplevel becomes reachable), so a concurrent GC cannot race it. The listener is shut down after the copy, then the normal flow continues: set boot generation, rename manifest, reboot.
 
 Stick writing is the mirror image and also goes through the FFI: query path infos of the system closure, sign, dump each path to NAR, zstd-compress, encrypt with the derived per-file key, write under the derived name, write the age-encrypted manifest last.
 
@@ -41,6 +41,15 @@
 
 FAT32 (mkfs.vfat): universally supported and more robust against unclean ejects than exFAT. The 4 GiB file size limit is handled by splitting large encrypted files into fixed-size chunks; chunk names are derived from (deployment secret, narHash, chunk index) and the manifest lists the chunk sequence per path. This is plain size-splitting, unrelated to dedup chunking. Filesystem is case-insensitive; derived file names should be lowercase.
 
+Layout:
+
+/usbd-update-<derive(hostname)>:: age-encrypted manifest for one host, renamed to *.done after a successful update. One manifest per host, so several hosts can share a stick.
+/update-data/<xx>/<name>:: encrypted path chunks under derived names, sharded into 256 subdirectories by the first two name characters.
+/gc/<derive(hostname)>:: plaintext list of chunk names referenced by that host's update (bare names, the shard prefix is implied). The writer refreshes its own list, then deletes only data files referenced by no list, so several hosts' updates can share one stick without the writers deleting each other's files.
+/logs/<timestamp>-<random>.export.gpg:: journal dump in `journalctl -o export` format, written by the daemon after every run when `logRecipientFile` is set. Encrypted with OpenPGP to the operator key, so it can be decrypted with a smartcard. The random suffix avoids collisions between hosts sharing a stick (the daemon cannot derive its host id, and a plaintext hostname would leak).
+
+FAT32 allows at most 65536 directory entries per directory; a 32-character name costs 4 entries (3 LFN + 1 short), so a flat directory would cap out at ~16000 files. The 256-way shard gives each subdirectory its own budget (~4M files total), and as a side effect keeps the volume root nearly empty, so even a FAT16-formatted stick with its fixed 512-entry root directory works.
+
 == Synchronization
 
 Stick content should be synchronizable from http with a known tool that works on Windows. Flat content-addressed layout plus one manifest file satisfies this: rclone handles it (single portable exe, can be pre-installed on the stick itself with a .bat wrapper running `rclone sync --progress`; rclone has no native GUI and its experimental web GUI downloads assets from github on first run, so it is unusable here). Files are immutable under their derived names, so size-only comparison suffices. Sync should use --delete-before: rclone does not order transfers and by default deletes extraneous files last, which would require the stick to hold the old and new closure simultaneously. A partially synced stick is safe: the copy is hash-verified and the closure must be complete before the switch happens; the daemon should stat all manifest-referenced files upfront to report a partially synced stick cleanly.
@@ -57,6 +66,14 @@
     signPublicKeys = [ "client-1:..." ];
     # Optional user feedback script: receives copying/switching/done/noop/failed
     # hook = ./usbd-hook.sh;
+    # Optional builtin feedback: blink connected Nitrokey 3 devices while working.
+    # Blinking stopped and the machine rebooted - success; stopped without
+    # a reboot - failed, send the stick back (it carries the encrypted logs).
+    # The blink is the standard CTAPHID wink: white, firmware-fixed duration,
+    # no colors - the nk3 admin app exposes nothing else.
+    # nk3Wink = true;
+    # Optional journal back-channel, encrypted to this OpenPGP key
+    # logRecipientFile = ./operator.asc;
   };
 }
 ----
@@ -68,16 +85,33 @@
 [source,shell]
 ----
 mkfs.vfat -n FLEETUSBD /dev/sdX1
-fleet usbd write HOSTNAME --sign-key ./client-1.secret
+fleet usbd write HOSTNAME
 ----
 
-The signing keypair is generated once with `nix key generate-secret --key-name client-1`.
+The signing keypair is generated once with `nix key generate-secret --key-name client-1`; the secret key file is referenced from the fleet configuration (or overridden with `--sign-key`):
+
+[source,nix]
+----
+{
+  hosts.HOSTNAME.usbd.signingKeyFiles = [ "client-1.secret" ];
+}
+----
+
+Paths are resolved relative to the fleet project directory and deliberately are not nix paths, so the secret key is never imported into the store.
+
+Reading a journal dump from the stick:
 
+[source,shell]
+----
+gpg -d logs/2026-07-07-10-10-20-1a2b.export.gpg | systemd-journal-remote -o /tmp/metis.journal -
+journalctl --file /tmp/metis.journal -b
+----
+
 == Not a requirement
 
 Rollback prevention:: not a concern for any of the current clients.
 Update authenticity in the daemon:: handled by nix itself via store signatures during the copy; nothing to do on the usbd level.
 Chunking of large paths:: attic-style sub-path chunking would help large paths that change slightly, but conflicts with ease of synchronization; skipped.
-Status/log back-channel on the stick:: postponed, depends on the client.
+Encrypted journal on the host:: journald has no built-in journal encryption (FSS sealing is tamper-evidence only), which is why logs are exported and encrypted on the way to the stick instead.
 GC of old generations:: not a daemon problem.
-Multi-host sticks:: conflicts with incremental stick rewrite; one stick per host for now.
+Hiding cross-host linkage:: the naming secret is shared project-wide for dedup, so an observer of a shared stick can tell which files two hosts have in common (not what they are). Per-host secrets would hide this at the cost of duplicating shared paths; not a concern for the current clients.
modifiedflake.nixdiffbeforeafterboth
--- a/flake.nix
+++ b/flake.nix
@@ -175,6 +175,7 @@
 
                 pkg-config
                 openssl
+                udev
                 rustPlatform.bindgenHook
                 inputs'.nix.packages.nix-expr-c
                 inputs'.nix.packages.nix-flake-c
modifiedmodules/module-list.nixdiffbeforeafterboth
--- a/modules/module-list.nix
+++ b/modules/module-list.nix
@@ -6,4 +6,5 @@
   ./nixos.nix
   ./nixpkgs.nix
   ./secrets.nix
+  ./usbd.nix
 ]
modifiedmodules/nixos/usbd.nixdiffbeforeafterboth
--- a/modules/nixos/usbd.nix
+++ b/modules/nixos/usbd.nix
@@ -47,6 +47,24 @@
         failed <error>.
       '';
     };
+    nk3Wink = mkOption {
+      type = types.bool;
+      default = false;
+      description = ''
+        Blink connected Nitrokey 3 devices while an update is running.
+        Blinking stopped and the machine rebooted - update succeeded;
+        blinking stopped without a reboot - update failed.
+      '';
+    };
+    logRecipientFile = mkOption {
+      type = types.nullOr types.path;
+      default = null;
+      description = ''
+        Armored OpenPGP public key. When set, the daemon dumps the journal
+        of the last 48 hours onto the stick after every run, encrypted to
+        this key.
+      '';
+    };
     extraArgs = mkOption {
       type = types.listOf types.str;
       default = [ ];
@@ -64,6 +82,10 @@
         deviceUnit
         "local-fs.target"
       ];
+      path = [
+        pkgs.gnupg
+        config.systemd.package
+      ];
       serviceConfig = {
         Type = "oneshot";
         ExecStartPre = "-${pkgs.dosfstools}/bin/fsck.vfat -a ${devicePath}";
@@ -77,6 +99,11 @@
             "--hook"
             cfg.hook
           ]
+          ++ optionals cfg.nk3Wink [ "--nk3-wink" ]
+          ++ optionals (cfg.logRecipientFile != null) [
+            "--log-recipient-file"
+            cfg.logRecipientFile
+          ]
           ++ cfg.extraArgs
         );
       };
addedmodules/usbd.nixdiffbeforeafterboth
--- /dev/null
+++ b/modules/usbd.nix
@@ -0,0 +1,55 @@
+{
+  lib,
+  fleetLib,
+  config,
+  ...
+}:
+let
+  inherit (lib.options) mkOption literalExpression;
+  inherit (lib.types) listOf str submodule;
+  inherit (fleetLib.options) mkHostsOption;
+
+  _file = ./usbd.nix;
+
+  usbdOptions = {
+    signingKeyFiles = mkOption {
+      description = ''
+        Nix secret key files `fleet usbd write` signs the update closure with,
+        resolved relative to the fleet project directory.
+        Plain strings instead of paths, so the keys are not imported into the nix store.
+      '';
+      type = listOf str;
+      default = [ ];
+    };
+  };
+in
+{
+  options = {
+    usbd = usbdOptions // {
+      namingSecretFile = mkOption {
+        description = ''
+          File with the project-wide naming secret used to derive on-stick file
+          names and encryption keys, resolved relative to the fleet project
+          directory. Generated by `fleet usbd write` when missing.
+          A plain string instead of a path, so the secret is not imported into
+          the nix store.
+        '';
+        type = str;
+        default = "usbd/naming.secret";
+      };
+    };
+    hosts = mkHostsOption {
+      inherit _file;
+      options.usbd = mkOption {
+        description = ''
+          fleet-usbd stick writing configuration for this host.
+        '';
+        type = submodule { options = usbdOptions; };
+        default = {
+          inherit (config.usbd) signingKeyFiles;
+        };
+        defaultText = literalExpression "{ inherit (fleetConfiguration.usbd) signingKeyFiles; }";
+      };
+    };
+  };
+}
modifiedpkgs/fleet-usbd.nixdiffbeforeafterboth
--- a/pkgs/fleet-usbd.nix
+++ b/pkgs/fleet-usbd.nix
@@ -6,6 +6,7 @@
   stdenv,
   pkg-config,
   rustPlatform,
+  udev,
 }:
 let
   system = stdenv.hostPlatform.system;
@@ -28,6 +29,7 @@
     inputs.nix.packages.${system}.nix-expr-c
     inputs.nix.packages.${system}.nix-flake-c
     inputs.nix.packages.${system}.nix-fetchers-c
+    udev
   ];
   nativeBuildInputs = [
     pkg-config