18 files changed
--- 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"
1use std::{2 collections::HashSet,3 fs::File,4 io::{self, Write as _},5 os::unix::fs::OpenOptionsExt as _,6 sync::Arc,7};89use anyhow::{Context as _, Result, anyhow, bail};10use base64::Engine as _;11use base64::engine::general_purpose::STANDARD as BASE64;12use camino::{Utf8Path, Utf8PathBuf};13use clap::Parser;14use fleet_base::{fleetdata::SecretOwner, host::Config};15use fleet_usb::{16 DATA_DIR, GC_DIR, MANIFEST_VERSION,17 manifest::{Manifest, PathEntry, encrypt_manifest},18 manifest_done_name, manifest_name,19 names::{self, NAMING_SECRET_SIZE, NamingSecret},20 stream::EncryptWriter,21};22use nix_eval::{Store, eval_store, nix_go, nix_go_json};23use rand::Rng as _;24use tokio::task::spawn_blocking;25use tracing::{debug, info};26use zbus::zvariant::{self, OwnedObjectPath};2728use super::build_systems::build_task;2930const TARGET: &str = "usbd";3132const TMP_PREFIX: &str = "usbd-tmp-";33const MAX_CHUNK_SIZE: u64 = 1 << 30;34const MAX_CHUNKS: u32 = 1024;3536#[derive(Parser)]37pub enum Usbd {38 39 Write(UsbdWrite),40}4142impl Usbd {43 pub async fn run(self, config: &Config) -> Result<()> {44 match self {45 Usbd::Write(w) => w.run(config).await,46 }47 }48}4950#[derive(Parser)]51pub struct UsbdWrite {52 hostname: String,53 54 #[clap(long, default_value = "FLEETUSBD")]55 label: String,56 57 #[clap(long)]58 mount_point: Option<Utf8PathBuf>,59 60 61 #[clap(long, verbatim_doc_comment)]62 sign_key: Option<Utf8PathBuf>,63 #[clap(long, default_value = "toplevel-fleet")]64 build_attr: String,65}6667impl UsbdWrite {68 pub async fn run(self, config: &Config) -> Result<()> {69 let host = config.host(&self.hostname)?;70 let hostname = host.name.clone();71 let recipient = config.recipient(&SecretOwner::host(&hostname)).await?;72 let secret = naming_secret(config)?;7374 let built = build_task(config.clone(), hostname.clone(), &self.build_attr).await?;7576 let mut sign_keys: Vec<Utf8PathBuf> = self.sign_key.clone().into_iter().collect();77 if sign_keys.is_empty()78 && let Some(host_config) = &host.host_config79 {80 let usbd = nix_go!(host_config.usbd);81 let files: Vec<String> = nix_go_json!(usbd.signingKeyFiles);82 for file in files {83 let path = Utf8PathBuf::try_from(config.directory.join(&file))84 .context("fleet directory should be utf8")?;85 sign_keys.push(path);86 }87 }88 let mut sign_key_names = Vec::new();89 for key in sign_keys {90 info!("signing with {key}");91 sign_key_names.push(sign_key_name(&key)?);92 let store = eval_store();93 let path = built.clone();94 spawn_blocking(move || store.sign_closure(&path, &key)).await??;95 }9697 let (stick, dir) = match &self.mount_point {98 Some(dir) => (None, dir.clone()),99 None => {100 let stick = Stick::discover(&self.label).await?;101 let dir = stick.mount_point.clone();102 (Some(stick), dir)103 }104 };105 info!(target: TARGET, "writing update to {dir}");106107 108 109 let store = Arc::new(spawn_blocking(|| Store::open("auto")).await??);110 let manifest = {111 let secret = secret.clone();112 let hostname = hostname.clone();113 let built = built.clone();114 let dir = dir.clone();115 spawn_blocking(move || {116 write_closure(&store, &secret, &dir, &hostname, &built, &sign_key_names)117 })118 .await??119 };120121 let encrypted = encrypt_manifest(&manifest, std::iter::once(&*recipient))?;122 {123 let dir = dir.clone();124 let host_id = names::host_id(&secret, &hostname);125 spawn_blocking(move || write_manifest(&dir, &host_id, &encrypted)).await??;126 }127128 if let Some(stick) = stick {129 stick.unmount().await?;130 }131 info!(target: TARGET, "update for {hostname} written, the stick can be removed");132 Ok(())133 }134}135136fn sign_key_name(key_file: &Utf8Path) -> Result<String> {137 let key = std::fs::read_to_string(key_file)138 .with_context(|| format!("reading signing key {key_file}"))?;139 let (name, _) = key140 .trim()141 .split_once(':')142 .with_context(|| format!("signing key {key_file} should look like name:base64"))?;143 Ok(name.to_owned())144}145146fn naming_secret(config: &Config) -> Result<NamingSecret> {147 let config_field = &config.config_field;148 let file: String = nix_go_json!(config_field.usbd.namingSecretFile);149 let path = Utf8PathBuf::try_from(config.directory.join(&file))150 .context("fleet directory should be utf8")?;151 if path.exists() {152 let value = std::fs::read_to_string(&path)?;153 let bytes = BASE64154 .decode(value.trim())155 .with_context(|| format!("naming secret base64 in {path}"))?;156 let bytes: [u8; NAMING_SECRET_SIZE] = bytes157 .try_into()158 .map_err(|_| anyhow!("naming secret in {path} should be {NAMING_SECRET_SIZE} bytes"))?;159 Ok(NamingSecret(bytes))160 } else {161 let mut bytes = [0u8; NAMING_SECRET_SIZE];162 rand::rng().fill_bytes(&mut bytes);163 if let Some(parent) = path.parent() {164 std::fs::create_dir_all(parent)?;165 }166 let mut file = std::fs::OpenOptions::new()167 .write(true)168 .create_new(true)169 .mode(0o600)170 .open(&path)171 .with_context(|| format!("creating naming secret file {path}"))?;172 writeln!(file, "{}", BASE64.encode(bytes))?;173 info!(target: TARGET, "generated new usbd naming secret at {path}");174 Ok(NamingSecret(bytes))175 }176}177178fn write_closure(179 store: &Arc<Store>,180 secret: &NamingSecret,181 dir: &Utf8Path,182 hostname: &str,183 toplevel: &Utf8Path,184 sign_key_names: &[String],185) -> Result<Manifest> {186 let data_dir = dir.join(DATA_DIR);187 let gc_dir = dir.join(GC_DIR);188 std::fs::create_dir_all(&data_dir)?;189 std::fs::create_dir_all(&gc_dir)?;190191 let mut closure = store.compute_closure(toplevel)?;192 closure.sort();193 info!(target: TARGET, "update closure contains {} paths", closure.len());194195 let mut existing = HashSet::new();196 for shard in data_dir.read_dir_utf8().context("reading stick")? {197 let shard = shard?;198 if !shard.file_type()?.is_dir() {199 continue;200 }201 for entry in shard.path().read_dir_utf8()? {202 existing.insert(entry?.file_name().to_owned());203 }204 }205206 let total = closure.len();207 let mut entries = Vec::with_capacity(total);208 let mut reused = 0;209 for (i, path) in closure.iter().enumerate() {210 let (entry, written) = write_path(store, secret, &data_dir, &mut existing, path)211 .with_context(|| format!("writing {path}"))?;212 if written {213 info!(target: TARGET, "[{}/{total}] {path}", i + 1);214 } else {215 debug!(target: TARGET, "[{}/{total}] {path} (reused)", i + 1);216 reused += 1;217 }218 for name in sign_key_names {219 if !entry220 .sigs221 .iter()222 .any(|sig| sig.strip_prefix(name).is_some_and(|r| r.starts_with(':')))223 {224 bail!(225 "{path} has no signature by the configured key {name}, refusing to write an update the host would reject; sigs present: {:?}",226 entry.sigs227 );228 }229 }230 entries.push(entry);231 }232 info!(target: TARGET, "{} paths written, {reused} reused", total - reused);233234 {235 let tmp = gc_dir.join(format!("{TMP_PREFIX}gc"));236 let mut file = File::create(&tmp)?;237 for entry in &entries {238 for chunk in &entry.chunks {239 writeln!(file, "{chunk}")?;240 }241 }242 file.sync_all()?;243 drop(file);244 std::fs::rename(&tmp, gc_dir.join(names::host_id(secret, hostname)))?;245 }246247 let mut referenced = HashSet::new();248 for entry in gc_dir.read_dir_utf8()? {249 let entry = entry?;250 if entry.file_name().starts_with(TMP_PREFIX) {251 std::fs::remove_file(entry.path())?;252 continue;253 }254 let list = std::fs::read_to_string(entry.path())255 .with_context(|| format!("reading gc list {}", entry.file_name()))?;256 referenced.extend(list.lines().filter(|l| !l.is_empty()).map(str::to_owned));257 }258259 for shard in data_dir.read_dir_utf8()? {260 let shard = shard?;261 if !shard.file_type()?.is_dir() {262 if shard.file_name().starts_with(TMP_PREFIX) {263 std::fs::remove_file(shard.path())?;264 }265 continue;266 }267 for entry in shard.path().read_dir_utf8()? {268 let entry = entry?;269 let name = entry.file_name();270 let stale_data = names::is_data_name(name) && !referenced.contains(name);271 if stale_data || name.starts_with(TMP_PREFIX) {272 std::fs::remove_file(entry.path())273 .with_context(|| format!("deleting stale {name}"))?;274 }275 }276 }277 let _ = std::fs::remove_file(dir.join(manifest_done_name(&names::host_id(secret, hostname))));278279 Ok(Manifest {280 version: MANIFEST_VERSION,281 host: hostname.to_owned(),282 toplevel: toplevel.to_owned(),283 paths: entries,284 })285}286287fn write_path(288 store: &Arc<Store>,289 secret: &NamingSecret,290 dir: &Utf8Path,291 existing: &mut HashSet<String>,292 path: &Utf8Path,293) -> Result<(PathEntry, bool)> {294 let info = store.query_path_info(path)?;295 let key = names::file_key(secret, &info.nar_hash);296297 let mut chunks = None;298 for count in 1..=MAX_CHUNKS {299 if !existing.contains(&names::chunk_name(secret, &info.nar_hash, 0, count)) {300 continue;301 }302 let all = (0..count)303 .map(|i| names::chunk_name(secret, &info.nar_hash, i, count))304 .collect::<Vec<_>>();305 if all.iter().all(|c| existing.contains(c)) {306 chunks = Some(all);307 break;308 }309 }310311 let written = chunks.is_none();312 let chunks = if let Some(chunks) = chunks {313 chunks314 } else {315 let chunk_writer = ChunkWriter::new(dir.to_owned());316 let encrypt = EncryptWriter::new(&key, chunk_writer);317 let mut compress =318 zstd::stream::write::Encoder::new(encrypt, zstd::DEFAULT_COMPRESSION_LEVEL)?;319 store.nar_from_path(path, &mut compress)?;320 let tmp_files = compress.finish()?.finish()?.finish()?;321 let count = u32::try_from(tmp_files.len()).expect("chunk count");322 if count > MAX_CHUNKS {323 bail!("{path} does not fit into {MAX_CHUNKS} chunks");324 }325 let chunks = (0..count)326 .map(|i| names::chunk_name(secret, &info.nar_hash, i, count))327 .collect::<Vec<_>>();328 for (tmp, name) in tmp_files.iter().zip(&chunks) {329 let dest = dir.join(names::data_rel_path(name));330 std::fs::create_dir_all(dest.parent().expect("sharded path has a parent"))?;331 std::fs::rename(tmp, dest)?;332 }333 existing.extend(chunks.iter().cloned());334 chunks335 };336337 Ok((338 PathEntry {339 store_path: path.to_owned(),340 nar_hash: info.nar_hash,341 nar_size: info.nar_size,342 references: info.references,343 sigs: info.sigs,344 compression: "zstd".to_owned(),345 key: BASE64.encode(key),346 chunks,347 },348 written,349 ))350}351352fn write_manifest(dir: &Utf8Path, host_id: &str, encrypted: &[u8]) -> Result<()> {353 let tmp = dir.join(format!("{TMP_PREFIX}manifest"));354 let mut file = File::create(&tmp)?;355 file.write_all(encrypted)?;356 file.sync_all()?;357 drop(file);358 std::fs::rename(&tmp, dir.join(manifest_name(host_id)))?;359 nix::unistd::sync();360 Ok(())361}362363struct ChunkWriter {364 dir: Utf8PathBuf,365 files: Vec<(Utf8PathBuf, File)>,366 current_len: u64,367}368369impl ChunkWriter {370 fn new(dir: Utf8PathBuf) -> Self {371 Self {372 dir,373 files: Vec::new(),374 current_len: 0,375 }376 }377378 fn finish(self) -> io::Result<Vec<Utf8PathBuf>> {379 for (_, file) in &self.files {380 file.sync_all()?;381 }382 Ok(self.files.into_iter().map(|(path, _)| path).collect())383 }384}385386impl io::Write for ChunkWriter {387 fn write(&mut self, data: &[u8]) -> io::Result<usize> {388 if self.files.is_empty() || self.current_len == MAX_CHUNK_SIZE {389 let path = self.dir.join(format!("{TMP_PREFIX}{}", self.files.len()));390 let file = File::create(&path)?;391 self.files.push((path, file));392 self.current_len = 0;393 }394 let take = data.len().min((MAX_CHUNK_SIZE - self.current_len) as usize);395 let file = &mut self.files.last_mut().expect("just ensured").1;396 file.write_all(&data[..take])?;397 self.current_len += take as u64;398 Ok(take)399 }400 fn flush(&mut self) -> io::Result<()> {401 Ok(())402 }403}404405const UDISKS_DEST: &str = "org.freedesktop.UDisks2";406const IFACE_BLOCK: &str = "org.freedesktop.UDisks2.Block";407const IFACE_FILESYSTEM: &str = "org.freedesktop.UDisks2.Filesystem";408409struct Stick {410 conn: zbus::Connection,411 object: OwnedObjectPath,412 mount_point: Utf8PathBuf,413 mounted_by_us: bool,414}415416impl Stick {417 async fn discover(label: &str) -> Result<Self> {418 let conn = zbus::Connection::system()419 .await420 .context("connecting to system dbus for udisks")?;421 let om = zbus::fdo::ObjectManagerProxy::builder(&conn)422 .destination(UDISKS_DEST)?423 .path("/org/freedesktop/UDisks2")?424 .build()425 .await?;426 let objects = om427 .get_managed_objects()428 .await429 .context("is udisks2 running?")?;430431 let mut found = Vec::new();432 for (object, ifaces) in objects {433 let Some(block) = ifaces.iter().find(|(k, _)| k.as_str() == IFACE_BLOCK) else {434 continue;435 };436 if !ifaces.iter().any(|(k, _)| k.as_str() == IFACE_FILESYSTEM) {437 continue;438 }439 let Some(id_label) = block.1.get("IdLabel") else {440 continue;441 };442 let Ok(id_label) = String::try_from(id_label.clone()) else {443 continue;444 };445 if id_label == label {446 found.push(object);447 }448 }449 let object = match found.len() {450 0 => bail!("no inserted filesystem with label {label} found, is the stick inserted?"),451 1 => found.remove(0),452 _ => bail!("multiple filesystems with label {label} found: {found:?}"),453 };454455 let fs = zbus::Proxy::new(&conn, UDISKS_DEST, &object, IFACE_FILESYSTEM).await?;456 let mount_points: Vec<Vec<u8>> = fs.get_property("MountPoints").await?;457 if let Some(mp) = mount_points.first() {458 let mp = mp.strip_suffix(&[0]).unwrap_or(mp);459 let mount_point = Utf8PathBuf::from(String::from_utf8(mp.to_vec())?);460 info!(target: TARGET, "stick is already mounted at {mount_point}");461 return Ok(Self {462 conn,463 object,464 mount_point,465 mounted_by_us: false,466 });467 }468469 let options = std::collections::HashMap::<&str, zvariant::Value>::new();470 let mount_point: String = fs471 .call("Mount", &(options,))472 .await473 .context("mounting the stick via udisks")?;474 Ok(Self {475 conn,476 object,477 mount_point: Utf8PathBuf::from(mount_point),478 mounted_by_us: true,479 })480 }481482 async fn unmount(self) -> Result<()> {483 if !self.mounted_by_us {484 return Ok(());485 }486 let fs = zbus::Proxy::new(&self.conn, UDISKS_DEST, &self.object, IFACE_FILESYSTEM).await?;487 let options = std::collections::HashMap::<&str, zvariant::Value>::new();488 fs.call::<_, _, ()>("Unmount", &(options,))489 .await490 .context("unmounting the stick")?;491 Ok(())492 }493}
--- 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 <error>
#[clap(long, verbatim_doc_comment)]
hook: Option<Utf8PathBuf>,
+ /// 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<Utf8PathBuf>,
+ /// 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::<u16>(),
+ );
+ 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<Outcome> {
- 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::<Vec<_>>();
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<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);
--- 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<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))
}
--- 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<Vec<Utf8PathBuf>> {
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/<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.
--- 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
--- a/modules/module-list.nix
+++ b/modules/module-list.nix
@@ -6,4 +6,5 @@
./nixos.nix
./nixpkgs.nix
./secrets.nix
+ ./usbd.nix
]
--- 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
);
};
--- /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; }";
+ };
+ };
+ };
+}
--- 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