difftreelog
fix temp root
18 files changed
Cargo.lockdiffbeforeafterboth607source = "registry+https://github.com/rust-lang/crates.io-index"607source = "registry+https://github.com/rust-lang/crates.io-index"608checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"608checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"609dependencies = [609dependencies = [610 "bitflags",610 "bitflags 2.13.0",611 "cexpr",611 "cexpr",612 "clang-sys",612 "clang-sys",613 "itertools 0.13.0",613 "itertools 0.13.0",621 "syn 2.0.118",621 "syn 2.0.118",622]622]623624[[package]]625name = "bitflags"626version = "1.3.2"627source = "registry+https://github.com/rust-lang/crates.io-index"628checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"623629624[[package]]630[[package]]625name = "bitflags"631name = "bitflags"1274 "rand_core 0.10.1",1280 "rand_core 0.10.1",1275]1281]12821283[[package]]1284name = "ctaphid"1285version = "0.3.1"1286source = "registry+https://github.com/rust-lang/crates.io-index"1287checksum = "aa622743e1747e48d25170854b552b3ddc753125a39a6732e57c5c7a5ff7fe11"1288dependencies = [1289 "ctaphid-types",1290 "hex",1291 "hidapi",1292 "log",1293 "rand_core 0.6.4",1294 "tap",1295]12961297[[package]]1298name = "ctaphid-types"1299version = "0.2.0"1300source = "registry+https://github.com/rust-lang/crates.io-index"1301checksum = "4714cdd86d5134532b9decaa6774db0a6851ecd07e96a2f239332ae1f3239350"1302dependencies = [1303 "bitflags 1.3.2",1304]127613051277[[package]]1306[[package]]1278name = "ctr"1307name = "ctr"1623source = "registry+https://github.com/rust-lang/crates.io-index"1652source = "registry+https://github.com/rust-lang/crates.io-index"1624checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"1653checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"1625dependencies = [1654dependencies = [1626 "bitflags",1655 "bitflags 2.13.0",1627 "block2",1656 "block2",1628 "libc",1657 "libc",1629 "objc2",1658 "objc2",2036 "base64 0.22.1",2065 "base64 0.22.1",2037 "bytes",2066 "bytes",2038 "camino",2067 "camino",2068 "chrono",2039 "clap",2069 "clap",2070 "ctaphid",2040 "fleet-usb",2071 "fleet-usb",2041 "futures",2072 "futures",2042 "goodlog-subscriber",2073 "goodlog-subscriber",2043 "hex",2074 "hex",2075 "hidapi",2044 "hostname",2076 "hostname",2045 "http-body-util",2077 "http-body-util",2046 "hyper",2078 "hyper",2047 "hyper-util",2079 "hyper-util",2048 "nix",2080 "nix",2049 "nix-eval",2081 "nix-eval",2050 "rand 0.10.1",2082 "rand 0.10.1",2083 "tempfile",2051 "tokio",2084 "tokio",2052 "tracing",2085 "tracing",2053]2086]2538 "tracing",2571 "tracing",2539]2572]25732574[[package]]2575name = "hidapi"2576version = "1.5.0"2577source = "registry+https://github.com/rust-lang/crates.io-index"2578checksum = "798154e4b6570af74899d71155fb0072d5b17e6aa12f39c8ef22c60fb8ec99e7"2579dependencies = [2580 "cc",2581 "libc",2582 "pkg-config",2583 "winapi",2584]254025852541[[package]]2586[[package]]2542name = "hkdf"2587name = "hkdf"3431source = "registry+https://github.com/rust-lang/crates.io-index"3476source = "registry+https://github.com/rust-lang/crates.io-index"3432checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"3477checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"3433dependencies = [3478dependencies = [3434 "bitflags",3479 "bitflags 2.13.0",3435 "libc",3480 "libc",3436 "plain",3481 "plain",3437 "redox_syscall 0.8.1",3482 "redox_syscall 0.8.1",3731source = "registry+https://github.com/rust-lang/crates.io-index"3776source = "registry+https://github.com/rust-lang/crates.io-index"3732checksum = "df9854ea6ad14e3f4698a7f03b65bce0833dd2d81d594a0e4a984170537146b6"3777checksum = "df9854ea6ad14e3f4698a7f03b65bce0833dd2d81d594a0e4a984170537146b6"3733dependencies = [3778dependencies = [3734 "bitflags",3779 "bitflags 2.13.0",3735 "libc",3780 "libc",3736 "log",3781 "log",3737 "netlink-packet-core",3782 "netlink-packet-core",3743source = "registry+https://github.com/rust-lang/crates.io-index"3788source = "registry+https://github.com/rust-lang/crates.io-index"3744checksum = "e2288fcb784eb3defd5fb16f4c4160d5f477de192eac730f43e1d11c24d9a007"3789checksum = "e2288fcb784eb3defd5fb16f4c4160d5f477de192eac730f43e1d11c24d9a007"3745dependencies = [3790dependencies = [3746 "bitflags",3791 "bitflags 2.13.0",3747 "libc",3792 "libc",3748 "log",3793 "log",3749 "netlink-packet-core",3794 "netlink-packet-core",3819source = "registry+https://github.com/rust-lang/crates.io-index"3864source = "registry+https://github.com/rust-lang/crates.io-index"3820checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"3865checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"3821dependencies = [3866dependencies = [3822 "bitflags",3867 "bitflags 2.13.0",3823 "cfg-if",3868 "cfg-if",3824 "cfg_aliases",3869 "cfg_aliases",3825 "libc",3870 "libc",4063source = "registry+https://github.com/rust-lang/crates.io-index"4108source = "registry+https://github.com/rust-lang/crates.io-index"4064checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"4109checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"4065dependencies = [4110dependencies = [4066 "bitflags",4111 "bitflags 2.13.0",4067 "block2",4112 "block2",4068 "dispatch2",4113 "dispatch2",4069 "libc",4114 "libc",4076source = "registry+https://github.com/rust-lang/crates.io-index"4121source = "registry+https://github.com/rust-lang/crates.io-index"4077checksum = "c71e34919aba0d701380d911702455038a8a3587467fe0141d6a71501e7ffe48"4122checksum = "c71e34919aba0d701380d911702455038a8a3587467fe0141d6a71501e7ffe48"4078dependencies = [4123dependencies = [4079 "bitflags",4124 "bitflags 2.13.0",4080 "objc2",4125 "objc2",4081 "objc2-core-foundation",4126 "objc2-core-foundation",4082 "objc2-foundation",4127 "objc2-foundation",4096source = "registry+https://github.com/rust-lang/crates.io-index"4141source = "registry+https://github.com/rust-lang/crates.io-index"4097checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"4142checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"4098dependencies = [4143dependencies = [4099 "bitflags",4144 "bitflags 2.13.0",4100 "block2",4145 "block2",4101 "libc",4146 "libc",4102 "objc2",4147 "objc2",4109source = "registry+https://github.com/rust-lang/crates.io-index"4154source = "registry+https://github.com/rust-lang/crates.io-index"4110checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a"4155checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a"4111dependencies = [4156dependencies = [4112 "bitflags",4157 "bitflags 2.13.0",4113 "objc2",4158 "objc2",4114 "objc2-core-foundation",4159 "objc2-core-foundation",4115]4160]4130source = "registry+https://github.com/rust-lang/crates.io-index"4175source = "registry+https://github.com/rust-lang/crates.io-index"4131checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396"4176checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396"4132dependencies = [4177dependencies = [4133 "bitflags",4178 "bitflags 2.13.0",4134 "dispatch2",4179 "dispatch2",4135 "libc",4180 "libc",4136 "objc2",4181 "objc2",4840source = "registry+https://github.com/rust-lang/crates.io-index"4885source = "registry+https://github.com/rust-lang/crates.io-index"4841checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"4886checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"4842dependencies = [4887dependencies = [4843 "bitflags",4888 "bitflags 2.13.0",4844 "num-traits",4889 "num-traits",4845 "rand 0.9.4",4890 "rand 0.9.4",4846 "rand_chacha 0.9.0",4891 "rand_chacha 0.9.0",5067source = "registry+https://github.com/rust-lang/crates.io-index"5112source = "registry+https://github.com/rust-lang/crates.io-index"5068checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"5113checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"5069dependencies = [5114dependencies = [5070 "bitflags",5115 "bitflags 2.13.0",5071]5116]507251175073[[package]]5118[[package]]5076source = "registry+https://github.com/rust-lang/crates.io-index"5121source = "registry+https://github.com/rust-lang/crates.io-index"5077checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7"5122checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7"5078dependencies = [5123dependencies = [5079 "bitflags",5124 "bitflags 2.13.0",5080]5125]508151265082[[package]]5127[[package]]5389checksum = "bbf893f64684e58da8a68d56a5e84d1cf0440226274c515770fe267707a7d0b0"5434checksum = "bbf893f64684e58da8a68d56a5e84d1cf0440226274c515770fe267707a7d0b0"5390dependencies = [5435dependencies = [5391 "aes 0.9.1",5436 "aes 0.9.1",5392 "bitflags",5437 "bitflags 2.13.0",5393 "block-padding 0.4.2",5438 "block-padding 0.4.2",5394 "byteorder",5439 "byteorder",5395 "bytes",5440 "bytes",5553source = "registry+https://github.com/rust-lang/crates.io-index"5598source = "registry+https://github.com/rust-lang/crates.io-index"5554checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"5599checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"5555dependencies = [5600dependencies = [5556 "bitflags",5601 "bitflags 2.13.0",5557 "errno",5602 "errno",5558 "libc",5603 "libc",5559 "linux-raw-sys 0.4.15",5604 "linux-raw-sys 0.4.15",5566source = "registry+https://github.com/rust-lang/crates.io-index"5611source = "registry+https://github.com/rust-lang/crates.io-index"5567checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"5612checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"5568dependencies = [5613dependencies = [5569 "bitflags",5614 "bitflags 2.13.0",5570 "errno",5615 "errno",5571 "libc",5616 "libc",5572 "linux-raw-sys 0.12.1",5617 "linux-raw-sys 0.12.1",5769source = "registry+https://github.com/rust-lang/crates.io-index"5814source = "registry+https://github.com/rust-lang/crates.io-index"5770checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"5815checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"5771dependencies = [5816dependencies = [5772 "bitflags",5817 "bitflags 2.13.0",5773 "core-foundation 0.10.1",5818 "core-foundation 0.10.1",5774 "core-foundation-sys",5819 "core-foundation-sys",5775 "libc",5820 "libc",6025source = "registry+https://github.com/rust-lang/crates.io-index"6070source = "registry+https://github.com/rust-lang/crates.io-index"6026checksum = "7a75cbde1bf934313596a004973e462f9a82caa814dcf1a5f507bdf51597eeb4"6071checksum = "7a75cbde1bf934313596a004973e462f9a82caa814dcf1a5f507bdf51597eeb4"6027dependencies = [6072dependencies = [6028 "bitflags",6073 "bitflags 2.13.0",6029]6074]603060756031[[package]]6076[[package]]6253source = "registry+https://github.com/rust-lang/crates.io-index"6298source = "registry+https://github.com/rust-lang/crates.io-index"6254checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"6299checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"6255dependencies = [6300dependencies = [6256 "bitflags",6301 "bitflags 2.13.0",6257 "core-foundation 0.9.4",6302 "core-foundation 0.9.4",6258 "system-configuration-sys",6303 "system-configuration-sys",6259]6304]6298source = "registry+https://github.com/rust-lang/crates.io-index"6343source = "registry+https://github.com/rust-lang/crates.io-index"6299checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"6344checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"63456346[[package]]6347name = "tap"6348version = "1.0.1"6349source = "registry+https://github.com/rust-lang/crates.io-index"6350checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"630063516301[[package]]6352[[package]]6302name = "tempfile"6353name = "tempfile"6682source = "registry+https://github.com/rust-lang/crates.io-index"6733source = "registry+https://github.com/rust-lang/crates.io-index"6683checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"6734checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"6684dependencies = [6735dependencies = [6685 "bitflags",6736 "bitflags 2.13.0",6686 "bytes",6737 "bytes",6687 "futures-util",6738 "futures-util",6688 "http",6739 "http",7036checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd"7087checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd"7037dependencies = [7088dependencies = [7038 "arrayvec",7089 "arrayvec",7039 "bitflags",7090 "bitflags 2.13.0",7040 "cursor-icon",7091 "cursor-icon",7041 "log",7092 "log",7042 "memchr",7093 "memchr",Cargo.tomldiffbeforeafterboth41chrono = { version = "0.4.41", features = ["serde"] }41chrono = { version = "0.4.41", features = ["serde"] }42clap = { version = "4.5", features = ["derive", "env", "unicode", "wrap_help"] }42clap = { version = "4.5", features = ["derive", "env", "unicode", "wrap_help"] }43clap_complete = "4.5"43clap_complete = "4.5"44ctaphid = "0.3.1"44console-subscriber = "0.5.0"45console-subscriber = "0.5.0"45cxx = "1.0.168"46cxx = "1.0.168"46cxx-build = "1.0.168"47cxx-build = "1.0.168"47ed25519-dalek = "3.0.0-rc.0"48ed25519-dalek = "3.0.0-rc.0"48futures = "0.3.31"49futures = "0.3.31"49hex = "0.4.3"50hex = "0.4.3"51hidapi = { version = "1.2.6", default-features = false, features = ["linux-static-hidraw"] }50hmac = "0.13.0"52hmac = "0.13.0"51hostname = "0.4.1"53hostname = "0.4.1"52http-body-util = "0.1"54http-body-util = "0.1"cmds/fleet/src/cmds/usbd.rsdiffbeforeafterboth2 collections::HashSet,2 collections::HashSet,3 fs::File,3 fs::File,4 io::{self, Write as _},4 io::{self, Write as _},5 os::unix::fs::OpenOptionsExt as _,5 sync::Arc,6 sync::Arc,6};7};7812use clap::Parser;13use clap::Parser;13use fleet_base::{fleetdata::SecretOwner, host::Config};14use fleet_base::{fleetdata::SecretOwner, host::Config};14use fleet_usb::{15use fleet_usb::{15 MANIFEST_DONE_NAME, MANIFEST_NAME, MANIFEST_VERSION,16 DATA_DIR, GC_DIR, MANIFEST_VERSION,16 manifest::{Manifest, PathEntry, encrypt_manifest},17 manifest::{Manifest, PathEntry, encrypt_manifest},18 manifest_done_name, manifest_name,17 names::{self, NAMING_SECRET_SIZE, NamingSecret},19 names::{self, NAMING_SECRET_SIZE, NamingSecret},18 stream::EncryptWriter,20 stream::EncryptWriter,19};21};20use nix_eval::{Store, eval_store};22use nix_eval::{Store, eval_store, nix_go, nix_go_json};21use rand::Rng as _;23use rand::Rng as _;22use tokio::task::spawn_blocking;24use tokio::task::spawn_blocking;23use tracing::{info, warn};25use tracing::{debug, info};24use zbus::zvariant::{self, OwnedObjectPath};26use zbus::zvariant::{self, OwnedObjectPath};252726use super::build_systems::build_task;28use super::build_systems::build_task;54 /// Write into this directory instead of discovering and mounting the stick via udisks56 /// Write into this directory instead of discovering and mounting the stick via udisks55 #[clap(long)]57 #[clap(long)]56 mount_point: Option<Utf8PathBuf>,58 mount_point: Option<Utf8PathBuf>,59 /// Nix secret signing key, overrides hosts.<name>.usbd.signingKeyFiles;57 /// Nix secret signing key; its public counterpart must be in trusted-public-keys on the host60 /// the public counterpart must be in trusted-public-keys on the host58 #[clap(long)]61 #[clap(long, verbatim_doc_comment)]59 sign_key: Option<Utf8PathBuf>,62 sign_key: Option<Utf8PathBuf>,60 #[clap(long, default_value = "toplevel-fleet")]63 #[clap(long, default_value = "toplevel-fleet")]61 build_attr: String,64 build_attr: String,66 let host = config.host(&self.hostname)?;69 let host = config.host(&self.hostname)?;67 let hostname = host.name.clone();70 let hostname = host.name.clone();68 let recipient = config.recipient(&SecretOwner::host(&hostname)).await?;71 let recipient = config.recipient(&SecretOwner::host(&hostname)).await?;69 let secret = naming_secret(config, &hostname)?;72 let secret = naming_secret(config)?;707371 let built = build_task(config.clone(), hostname.clone(), &self.build_attr).await?;74 let built = build_task(config.clone(), hostname.clone(), &self.build_attr).await?;727573 if let Some(key) = self.sign_key.clone() {76 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)?);74 let store = eval_store();92 let store = eval_store();75 let path = built.clone();93 let path = built.clone();76 spawn_blocking(move || store.sign_closure(&path, &key)).await??;94 spawn_blocking(move || store.sign_closure(&path, &key)).await??;77 } else {95 }78 warn!(target: TARGET, "closure is not signed (no --sign-key), the host will refuse it unless paths are signed by other means");79 }809681 let (stick, dir) = match &self.mount_point {97 let (stick, dir) = match &self.mount_point {82 Some(dir) => (None, dir.clone()),98 Some(dir) => (None, dir.clone()),88 };104 };89 info!(target: TARGET, "writing update to {dir}");105 info!(target: TARGET, "writing update to {dir}");90106107 // Reopened to avoid nix Store::pathInfoCache: infos cached during the build108 // would otherwise hide the signatures added by sign_closure above.91 let store = eval_store();109 let store = Arc::new(spawn_blocking(|| Store::open("auto")).await??);92 let manifest = {110 let manifest = {93 let secret = secret.clone();111 let secret = secret.clone();94 let hostname = hostname.clone();112 let hostname = hostname.clone();95 let built = built.clone();113 let built = built.clone();96 let dir = dir.clone();114 let dir = dir.clone();97 spawn_blocking(move || write_closure(&store, &secret, &dir, &hostname, &built))115 spawn_blocking(move || {116 write_closure(&store, &secret, &dir, &hostname, &built, &sign_key_names)117 })98 .await??118 .await??99 };119 };100120101 let encrypted = encrypt_manifest(&manifest, std::iter::once(&*recipient))?;121 let encrypted = encrypt_manifest(&manifest, std::iter::once(&*recipient))?;102 {122 {103 let dir = dir.clone();123 let dir = dir.clone();124 let host_id = names::host_id(&secret, &hostname);104 spawn_blocking(move || write_manifest(&dir, &encrypted)).await??;125 spawn_blocking(move || write_manifest(&dir, &host_id, &encrypted)).await??;105 }126 }106127107 if let Some(stick) = stick {128 if let Some(stick) = stick {112 }133 }113}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}114145115fn naming_secret(config: &Config, host: &str) -> Result<NamingSecret> {146fn naming_secret(config: &Config) -> Result<NamingSecret> {116 let mut extra = config.data.extra.write().expect("no poisoning");147 let config_field = &config.config_field;117 let usbd = extra148 let file: String = nix_go_json!(config_field.usbd.namingSecretFile);118 .entry("usbd".to_owned())119 .or_insert_with(|| serde_json::json!({}));120 let usbd = usbd149 let path = Utf8PathBuf::try_from(config.directory.join(&file))121 .as_object_mut()122 .context("fleet.nix extra.usbd should be an object")?;150 .context("fleet directory should be utf8")?;123 let secrets = usbd124 .entry("namingSecrets")125 .or_insert_with(|| serde_json::json!({}));126 let secrets = secrets127 .as_object_mut()128 .context("fleet.nix extra.usbd.namingSecrets should be an object")?;129 if let Some(value) = secrets.get(host) {151 if path.exists() {130 let value = value152 let value = std::fs::read_to_string(&path)?;131 .as_str()132 .context("naming secret should be a base64 string")?;133 let bytes = BASE64.decode(value).context("naming secret base64")?;153 let bytes = BASE64154 .decode(value.trim())155 .with_context(|| format!("naming secret base64 in {path}"))?;134 let bytes: [u8; NAMING_SECRET_SIZE] = bytes156 let bytes: [u8; NAMING_SECRET_SIZE] = bytes135 .try_into()157 .try_into()136 .map_err(|_| anyhow!("naming secret should be {NAMING_SECRET_SIZE} bytes"))?;158 .map_err(|_| anyhow!("naming secret in {path} should be {NAMING_SECRET_SIZE} bytes"))?;137 Ok(NamingSecret(bytes))159 Ok(NamingSecret(bytes))138 } else {160 } else {139 let mut bytes = [0u8; NAMING_SECRET_SIZE];161 let mut bytes = [0u8; NAMING_SECRET_SIZE];140 rand::rng().fill_bytes(&mut bytes);162 rand::rng().fill_bytes(&mut bytes);141 secrets.insert(163 if let Some(parent) = path.parent() {142 host.to_owned(),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}"))?;143 serde_json::Value::String(BASE64.encode(bytes)),172 writeln!(file, "{}", BASE64.encode(bytes))?;144 );173 info!(target: TARGET, "generated new usbd naming secret at {path}");145 Ok(NamingSecret(bytes))174 Ok(NamingSecret(bytes))146 }175 }147}176}152 dir: &Utf8Path,181 dir: &Utf8Path,153 hostname: &str,182 hostname: &str,154 toplevel: &Utf8Path,183 toplevel: &Utf8Path,184 sign_key_names: &[String],155) -> Result<Manifest> {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)?;190156 let mut closure = store.compute_closure(toplevel)?;191 let mut closure = store.compute_closure(toplevel)?;157 closure.sort();192 closure.sort();158 info!(target: TARGET, "update closure contains {} paths", closure.len());193 info!(target: TARGET, "update closure contains {} paths", closure.len());159194160 let mut existing = HashSet::new();195 let mut existing = HashSet::new();161 for entry in dir.read_dir_utf8().context("reading stick")? {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()? {162 existing.insert(entry?.file_name().to_owned());202 existing.insert(entry?.file_name().to_owned());163 }203 }204 }164205165 let total = closure.len();206 let total = closure.len();166 let mut entries = Vec::with_capacity(total);207 let mut entries = Vec::with_capacity(total);208 let mut reused = 0;167 for (i, path) in closure.iter().enumerate() {209 for (i, path) in closure.iter().enumerate() {168 let entry = write_path(store, secret, dir, &mut existing, path)210 let (entry, written) = write_path(store, secret, &data_dir, &mut existing, path)169 .with_context(|| format!("writing {path}"))?;211 .with_context(|| format!("writing {path}"))?;212 if written {170 info!(target: TARGET, "[{}/{total}] {path}", i + 1);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 }171 entries.push(entry);230 entries.push(entry);172 }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 }173246174 let referenced = entries247 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()))?;175 .iter()256 referenced.extend(list.lines().filter(|l| !l.is_empty()).map(str::to_owned));176 .flat_map(|e| e.chunks.iter())257 }177 .map(String::as_str)258178 .collect::<HashSet<_>>();259 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 }179 for name in &existing {267 for entry in shard.path().read_dir_utf8()? {268 let entry = entry?;269 let name = entry.file_name();180 let stale_data = names::is_data_name(name) && !referenced.contains(name.as_str());270 let stale_data = names::is_data_name(name) && !referenced.contains(name);181 let stale_other = name.starts_with(TMP_PREFIX) || name == MANIFEST_DONE_NAME;271 if stale_data || name.starts_with(TMP_PREFIX) {182 if stale_data || stale_other {183 std::fs::remove_file(dir.join(name))272 std::fs::remove_file(entry.path())184 .with_context(|| format!("deleting stale {name}"))?;273 .with_context(|| format!("deleting stale {name}"))?;185 }274 }186 }275 }276 }277 let _ = std::fs::remove_file(dir.join(manifest_done_name(&names::host_id(secret, hostname))));187278188 Ok(Manifest {279 Ok(Manifest {189 version: MANIFEST_VERSION,280 version: MANIFEST_VERSION,199 dir: &Utf8Path,290 dir: &Utf8Path,200 existing: &mut HashSet<String>,291 existing: &mut HashSet<String>,201 path: &Utf8Path,292 path: &Utf8Path,202) -> Result<PathEntry> {293) -> Result<(PathEntry, bool)> {203 let info = store.query_path_info(path)?;294 let info = store.query_path_info(path)?;204 let key = names::file_key(secret, &info.nar_hash);295 let key = names::file_key(secret, &info.nar_hash);205296217 }308 }218 }309 }219310311 let written = chunks.is_none();220 let chunks = if let Some(chunks) = chunks {312 let chunks = if let Some(chunks) = chunks {221 chunks313 chunks222 } else {314 } else {234 .map(|i| names::chunk_name(secret, &info.nar_hash, i, count))326 .map(|i| names::chunk_name(secret, &info.nar_hash, i, count))235 .collect::<Vec<_>>();327 .collect::<Vec<_>>();236 for (tmp, name) in tmp_files.iter().zip(&chunks) {328 for (tmp, name) in tmp_files.iter().zip(&chunks) {329 let dest = dir.join(names::data_rel_path(name));237 std::fs::rename(tmp, dir.join(name))?;330 std::fs::create_dir_all(dest.parent().expect("sharded path has a parent"))?;331 std::fs::rename(tmp, dest)?;238 }332 }239 existing.extend(chunks.iter().cloned());333 existing.extend(chunks.iter().cloned());240 chunks334 chunks241 };335 };242336243 Ok(PathEntry {337 Ok((338 PathEntry {244 store_path: path.to_owned(),339 store_path: path.to_owned(),245 nar_hash: info.nar_hash,340 nar_hash: info.nar_hash,249 compression: "zstd".to_owned(),344 compression: "zstd".to_owned(),250 key: BASE64.encode(key),345 key: BASE64.encode(key),251 chunks,346 chunks,252 })347 },348 written,349 ))253}350}254351255fn write_manifest(dir: &Utf8Path, encrypted: &[u8]) -> Result<()> {352fn write_manifest(dir: &Utf8Path, host_id: &str, encrypted: &[u8]) -> Result<()> {256 let tmp = dir.join(format!("{TMP_PREFIX}manifest"));353 let tmp = dir.join(format!("{TMP_PREFIX}manifest"));257 let mut file = File::create(&tmp)?;354 let mut file = File::create(&tmp)?;258 file.write_all(encrypted)?;355 file.write_all(encrypted)?;259 file.sync_all()?;356 file.sync_all()?;260 drop(file);357 drop(file);261 std::fs::rename(&tmp, dir.join(MANIFEST_NAME))?;358 std::fs::rename(&tmp, dir.join(manifest_name(host_id)))?;262 nix::unistd::sync();359 nix::unistd::sync();263 Ok(())360 Ok(())264}361}cmds/usbd/Cargo.tomldiffbeforeafterboth11base64.workspace = true11base64.workspace = true12bytes.workspace = true12bytes.workspace = true13camino.workspace = true13camino.workspace = true14chrono.workspace = true14clap.workspace = true15clap.workspace = true16ctaphid.workspace = true15fleet-usb.workspace = true17fleet-usb.workspace = true16futures.workspace = true18futures.workspace = true17goodlog-subscriber.workspace = true19goodlog-subscriber.workspace = true20hidapi.workspace = true18hostname.workspace = true21hostname.workspace = true19hex.workspace = true22hex.workspace = true20http-body-util.workspace = true23http-body-util.workspace = true23nix = { workspace = true, features = ["mount"] }26nix = { workspace = true, features = ["mount"] }24nix-eval.workspace = true27nix-eval.workspace = true25rand.workspace = true28rand.workspace = true29tempfile.workspace = true26tokio = { workspace = true, features = ["net", "process"] }30tokio = { workspace = true, features = ["net", "process"] }27tracing.workspace = true31tracing.workspace = true2832cmds/usbd/src/main.rsdiffbeforeafterboth5use anyhow::{Context as _, Result, bail};5use anyhow::{Context as _, Result, bail};6use camino::{Utf8Path, Utf8PathBuf};6use camino::{Utf8Path, Utf8PathBuf};7use clap::Parser;7use clap::Parser;8use fleet_usb::{MANIFEST_DONE_NAME, MANIFEST_NAME, manifest::decrypt_manifest};8use fleet_usb::{9 DATA_DIR, LOGS_DIR, MANIFEST_DONE_SUFFIX, MANIFEST_PREFIX, manifest::decrypt_manifest, names,10};9use goodlog_subscriber::{LogOpts, setup_logging};11use goodlog_subscriber::{LogOpts, setup_logging};10use nix::mount::MsFlags;12use nix::mount::MsFlags;11use nix_eval::{13use nix_eval::{12 Store, gc_register_my_thread, gc_unregister_my_thread, init_libraries, init_tokio_for_nix,14 Store, gc_register_my_thread, gc_unregister_my_thread, init_libraries, init_tokio_for_nix,13};15};16use rand::RngExt as _;14use tokio::task::spawn_blocking;17use tokio::task::spawn_blocking;15use tracing::{error, info, warn};18use tracing::{debug, error, info, warn};161917const MOUNT_TARGET: &str = "/run/fleet-usbd/mnt";20const MOUNT_TARGET: &str = "/run/fleet-usbd/mnt";182139 /// copying, switching, done, noop, failed <error>42 /// copying, switching, done, noop, failed <error>40 #[clap(long, verbatim_doc_comment)]43 #[clap(long, verbatim_doc_comment)]41 hook: Option<Utf8PathBuf>,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,42 #[clap(long, default_value = "/nix/var/nix/profiles/system")]56 #[clap(long, default_value = "/nix/var/nix/profiles/system")]43 profile: String,57 profile: String,44 /// Do not reboot after the update is applied58 /// Do not reboot after the update is applied92 Ok(())106 Ok(())93}107}9410895fn mark_done(dir: &Utf8Path) -> Result<()> {109fn mark_done(manifest_path: &Utf8Path) -> Result<()> {96 std::fs::rename(dir.join(MANIFEST_NAME), dir.join(MANIFEST_DONE_NAME))110 std::fs::rename(111 manifest_path,112 format!("{manifest_path}{MANIFEST_DONE_SUFFIX}"),113 )97 .context("marking update as done")?;114 .context("marking update as done")?;98 nix::unistd::sync();115 nix::unistd::sync();99 Ok(())116 Ok(())100}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}101172102async fn apply(opts: &Opts, dir: &Utf8Path, hook: &Hook) -> Result<Outcome> {173async fn apply(opts: &Opts, dir: &Utf8Path, hook: &Hook) -> Result<Outcome> {174 let identity = host_identity(&opts.host_key)?;175103 let manifest_path = dir.join(MANIFEST_NAME);176 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();104 if !manifest_path.exists() {181 if !name.starts_with(MANIFEST_PREFIX) {182 continue;183 }105 if dir.join(MANIFEST_DONE_NAME).exists() {184 if name.ends_with(MANIFEST_DONE_SUFFIX) {106 info!("update on this stick is already applied");185 done_files.push(entry.path().to_owned());107 } else {186 } else {108 info!("no update manifest on the stick");187 manifests.push(entry.path().to_owned());109 }188 }110 return Ok(Outcome::Nothing);189 }111 }112190113 let identity = host_identity(&opts.host_key)?;191 let mut found = None;192 for path in manifests {114 let data = std::fs::read(&manifest_path).context("reading manifest")?;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 }115 let manifest = decrypt_manifest(&data, &identity)202 let Some((manifest_path, manifest)) = found else {203 for path in done_files {204 let data = std::fs::read(&path)?;116 .context("decrypting manifest, is this stick meant for this machine?")?;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 };117213118 let host = match &opts.hostname {214 let host = match &opts.hostname {119 Some(host) => host.clone(),215 Some(host) => host.clone(),132 let current = std::fs::read_link("/run/current-system").ok();228 let current = std::fs::read_link("/run/current-system").ok();133 if current.as_deref() == Some(manifest.toplevel.as_std_path()) {229 if current.as_deref() == Some(manifest.toplevel.as_std_path()) {134 info!("system is already up to date");230 info!("system is already up to date");135 mark_done(dir)?;231 mark_done(&manifest_path)?;136 return Ok(Outcome::UpToDate);232 return Ok(Outcome::UpToDate);137 }233 }138234235 let data_dir = dir.join(DATA_DIR);139 let missing = manifest236 let missing = manifest140 .paths237 .paths141 .iter()238 .iter()142 .flat_map(|e| e.chunks.iter())239 .flat_map(|e| e.chunks.iter())143 .filter(|c| !dir.join(c.as_str()).exists())240 .filter(|c| !data_dir.join(names::data_rel_path(c)).exists())144 .count();241 .count();145 if missing > 0 {242 if missing > 0 {146 bail!("stick is missing {missing} update files, was it synchronized fully?");243 bail!("stick is missing {missing} update files, was it synchronized fully?");152 manifest.paths.len()249 manifest.paths.len()153 );250 );154 hook.call("copying", None).await;251 hook.call("copying", None).await;155 let cache = server::CacheServer::start(dir.to_owned(), &manifest).await?;252 let cache = server::CacheServer::start(dir, &manifest).await?;156 {253 {157 let url = cache.url.clone();254 let url = cache.url.clone();158 let toplevel = manifest.toplevel.clone();255 let toplevel = manifest.toplevel.clone();256 let paths = manifest257 .paths258 .iter()259 .map(|e| e.store_path.clone())260 .collect::<Vec<_>>();159 spawn_blocking(move || -> Result<()> {261 spawn_blocking(move || -> Result<()> {160 let src = Store::open(&url)?;262 let src = Store::open(&url)?;161 let dst = Store::open("auto")?;263 let dst = Store::open("auto")?;264 for path in &paths {265 dst.add_temp_root(path)?;266 }162 src.copy_to(&dst, &toplevel)267 src.copy_to(&dst, &toplevel)163 })268 })164 .await?269 .await?186 bail!("{switch} boot failed: {status}");291 bail!("{switch} boot failed: {status}");187 }292 }188293189 mark_done(dir)?;294 mark_done(&manifest_path)?;190 Ok(Outcome::Applied)295 Ok(Outcome::Applied)191}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}192317193async fn run(opts: &Opts) -> Result<()> {318async fn run(opts: &Opts) -> Result<()> {194 let hook = Hook(opts.hook.clone());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 });195332196 let mounted = match &opts.device {333 let mounted = match &opts.device {197 Some(device) => {334 Some(device) => {207344208 let outcome = apply(opts, &dir, &hook).await;345 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 }209361210 nix::unistd::sync();362 nix::unistd::sync();211 if mounted && let Err(e) = nix::mount::umount(MOUNT_TARGET) {363 if mounted && let Err(e) = nix::mount::umount(MOUNT_TARGET) {212 warn!("unmounting the stick: {e}");364 warn!("unmounting the stick: {e}");213 }365 }366 if let Some(winker) = winker {367 winker.abort();368 }214369215 match outcome {370 match outcome {216 Ok(Outcome::Applied) => {371 Ok(Outcome::Applied) => {cmds/usbd/src/server.rsdiffbeforeafterboth4use base64::Engine as _;4use base64::Engine as _;5use base64::engine::general_purpose::STANDARD as BASE64;5use base64::engine::general_purpose::STANDARD as BASE64;6use bytes::Bytes;6use bytes::Bytes;7use camino::Utf8PathBuf;7use camino::{Utf8Path, Utf8PathBuf};8use fleet_usb::{manifest::Manifest, manifest::PathEntry, stream::DecryptReader};8use fleet_usb::{manifest::Manifest, manifest::PathEntry, stream::DecryptReader};9use futures::SinkExt as _;9use futures::SinkExt as _;10use http_body_util::{BodyExt as _, Full, StreamBody, combinators::BoxBody};10use http_body_util::{BodyExt as _, Full, StreamBody, combinators::BoxBody};39}39}404041impl CacheServer {41impl CacheServer {42 pub async fn start(dir: Utf8PathBuf, manifest: &Manifest) -> Result<Self> {42 pub async fn start(dir: &Utf8Path, manifest: &Manifest) -> Result<Self> {43 let dir = dir.join(fleet_usb::DATA_DIR);43 let mut token = [0u8; 16];44 let mut token = [0u8; 16];44 rand::rng().fill_bytes(&mut token);45 rand::rng().fill_bytes(&mut token);45 let token = hex::encode(token);46 let token = hex::encode(token);167 let files = entry168 let files = entry168 .chunks169 .chunks169 .iter()170 .iter()170 .map(|c| state.dir.join(c))171 .map(|c| state.dir.join(fleet_usb::names::data_rel_path(c)))171 .collect::<Vec<_>>();172 .collect::<Vec<_>>();172173173 let (mut tx, rx) = futures::channel::mpsc::channel::<Result<Frame<Bytes>, std::io::Error>>(8);174 let (mut tx, rx) = futures::channel::mpsc::channel::<Result<Frame<Bytes>, std::io::Error>>(8);crates/fleet-usb/src/lib.rsdiffbeforeafterboth2pub mod names;2pub mod names;3pub mod stream;3pub mod stream;445pub const MANIFEST_NAME: &str = "usbd-update";5pub const MANIFEST_PREFIX: &str = "usbd-update-";6pub const MANIFEST_DONE_NAME: &str = "usbd-update.done";6pub const MANIFEST_DONE_SUFFIX: &str = ".done";7pub const MANIFEST_VERSION: u32 = 1;7pub const MANIFEST_VERSION: u32 = 1;8pub const DATA_DIR: &str = "update-data";9pub const GC_DIR: &str = "gc";10pub const LOGS_DIR: &str = "logs";1112pub fn manifest_name(host_id: &str) -> String {13 format!("{MANIFEST_PREFIX}{host_id}")14}1516pub fn manifest_done_name(host_id: &str) -> String {17 format!("{MANIFEST_PREFIX}{host_id}{MANIFEST_DONE_SUFFIX}")18}819crates/fleet-usb/src/manifest.rsdiffbeforeafterboth44 Ok(out)44 Ok(out)45}45}464647/// Ok(None) means the manifest is valid but encrypted for a different host.47pub fn decrypt_manifest(data: &[u8], identity: &dyn Identity) -> Result<Manifest> {48pub fn decrypt_manifest(data: &[u8], identity: &dyn Identity) -> Result<Option<Manifest>> {48 let decryptor = age::Decryptor::new(data).context("age decrypt")?;49 let decryptor = age::Decryptor::new(data).context("age decrypt")?;49 let mut reader = decryptor50 let mut reader = match decryptor.decrypt(std::iter::once(identity)) {50 .decrypt(std::iter::once(identity))51 Ok(reader) => reader,52 Err(age::DecryptError::NoMatchingKeys) => return Ok(None),51 .context("manifest is not encrypted for this identity")?;53 Err(e) => return Err(e).context("decrypting manifest"),54 };52 let mut json = Vec::new();55 let mut json = Vec::new();53 reader.read_to_end(&mut json)?;56 reader.read_to_end(&mut json)?;54 let manifest: Manifest = serde_json::from_slice(&json).context("manifest json")?;57 let manifest: Manifest = serde_json::from_slice(&json).context("manifest json")?;58 manifest.version61 manifest.version59 );62 );60 }63 }61 Ok(manifest)64 Ok(Some(manifest))62}65}6366crates/fleet-usb/src/names.rsdiffbeforeafterboth18}18}191920pub fn file_key(secret: &NamingSecret, nar_hash: &str) -> [u8; 32] {20pub fn file_key(secret: &NamingSecret, nar_hash: &str) -> [u8; 32] {21 derive(secret, &[b"fleet-usb.v1.key\0", nar_hash.as_bytes()])21 derive(secret, &[b"fleet-usb.v1.key", nar_hash.as_bytes()])22}22}232324pub fn chunk_name(secret: &NamingSecret, nar_hash: &str, index: u32, count: u32) -> String {24pub fn chunk_name(secret: &NamingSecret, nar_hash: &str, index: u32, count: u32) -> String {25 let h = derive(25 let h = derive(26 secret,26 secret,27 &[27 &[28 b"fleet-usb.v1.name\0",28 b"fleet-usb.v1.name",29 nar_hash.as_bytes(),29 nar_hash.as_bytes(),30 &index.to_le_bytes(),30 &index.to_le_bytes(),31 &count.to_le_bytes(),31 &count.to_le_bytes(),34 hex::encode(&h[..16])34 hex::encode(&h[..16])35}35}3637pub fn host_id(secret: &NamingSecret, host: &str) -> String {38 let h = derive(secret, &[b"fleet-usb.v1.host", host.as_bytes()]);39 hex::encode(&h[..16])40}4142pub fn data_rel_path(name: &str) -> String {43 format!("{}/{name}", &name[..2])44}364537pub fn is_data_name(name: &str) -> bool {46pub fn is_data_name(name: &str) -> bool {38 name.len() == 3247 name.len() == 32crates/nix-eval/src/lib.ccdiffbeforeafterboth204 return out;204 return out;205}205}206207rust::String add_temp_root(Store *store, rust::Str path) {208 try {209 auto nixStore = store->ptr;210 auto sp = nixStore->parseStorePath(std::string(path));211 nixStore->addTempRoot(sp);212 return rust::String();213 } catch (const std::exception &e) {214 return rust::String(e.what());215 }216}206217207CxxBuildResult compute_closure(Store *store, rust::Str path) {218CxxBuildResult compute_closure(Store *store, rust::Str path) {208 CxxBuildResult res{rust::String(), {}};219 CxxBuildResult res{rust::String(), {}};crates/nix-eval/src/lib.hhdiffbeforeafterboth34struct CxxPathInfo;34struct CxxPathInfo;35CxxPathInfo query_path_info(Store *store, rust::Str path);35CxxPathInfo query_path_info(Store *store, rust::Str path);3637rust::String add_temp_root(Store *store, rust::Str path);363837CxxBuildResult compute_closure(Store *store, rust::Str path);39CxxBuildResult compute_closure(Store *store, rust::Str path);3840crates/nix-eval/src/lib.rsdiffbeforeafterboth140 #[allow(clippy::missing_safety_doc)]140 #[allow(clippy::missing_safety_doc)]141 unsafe fn query_path_info(store: *mut Store, path: &str) -> CxxPathInfo;141 unsafe fn query_path_info(store: *mut Store, path: &str) -> CxxPathInfo;142143 #[allow(clippy::missing_safety_doc)]144 unsafe fn add_temp_root(store: *mut Store, path: &str) -> String;142145143 #[allow(clippy::missing_safety_doc)]146 #[allow(clippy::missing_safety_doc)]144 unsafe fn compute_closure(store: *mut Store, path: &str) -> CxxBuildResult;147 unsafe fn compute_closure(store: *mut Store, path: &str) -> CxxBuildResult;665 })668 })666 }669 }670671 /// The root is held for the lifetime of this process.672 #[instrument(skip(self))]673 pub fn add_temp_root(&self, path: &Utf8Path) -> Result<()> {674 let msg = unsafe { nix_cxx::add_temp_root(self.as_ptr().cast(), path.as_str()) };675 if !msg.is_empty() {676 bail!("failed to add temp root for {path}: {msg}");677 }678 Ok(())679 }667680668 #[instrument(skip(self))]681 #[instrument(skip(self))]669 pub fn compute_closure(&self, path: &Utf8Path) -> Result<Vec<Utf8PathBuf>> {682 pub fn compute_closure(&self, path: &Utf8Path) -> Result<Vec<Utf8PathBuf>> {docs/features/usbd.adocdiffbeforeafterboth223For airgapped deployments/system updates for low-tech users with limited internet access, it is better to have something more manual than pusher.3For airgapped deployments/system updates for low-tech users with limited internet access, it is better to have something more manual than pusher.445fleet-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.5fleet-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.667The 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.7The 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.889== Update flow9== Update flow1010111. Stick inserted, device unit starts the service, stick is mounted.111. Stick inserted, device unit starts the service, stick is mounted.122. Daemon decrypts the manifest, checks the system name matches this host.122. 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.133. If the manifest's system is already the current generation - noop. Guards against reinsertion when the .done rename previously failed.133. If the manifest's system is already the current generation - noop. Guards against reinsertion when the .done rename previously failed.144. Closure is copied from the stick. Hash verification is part of the copy; copying already-present paths is a noop.144. Closure is copied from the stick. Hash verification is part of the copy; copying already-present paths is a noop.155. New generation is set as the boot default (boot, not switch), so the existing on-boot health-check rollback applies.155. New generation is set as the boot default (boot, not switch), so the existing on-boot health-check rollback applies.166. Manifest is renamed to usbd-update.done.166. Manifest is renamed to *.done.177. Reboot.177. Reboot.181819== Requirements19== Requirements202021One 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.21One 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.22Deterministic, 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.22Deterministic, 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.23Manifest 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.23Manifest 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.24Manifest should include system name:: update daemon should check if the system update is suitable for it.24Manifest should include system name:: update daemon should check if the system update is suitable for it.25User 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.25User 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.332. 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.332. 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.343. 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.343. 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.354. Signatures are verified by nix against trusted-public-keys; the deployer signing key is pinned there by the fleet module.354. Signatures are verified by nix against trusted-public-keys; the deployer signing key is pinned there by the fleet module.365. 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.365. 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.373738Stick 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.38Stick 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.393940== Stick format40== Stick format414142FAT32 (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.42FAT32 (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.434344Layout:4546/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.47/update-data/<xx>/<name>:: encrypted path chunks under derived names, sharded into 256 subdirectories by the first two name characters.48/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.49/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).5051FAT32 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.5244== Synchronization53== Synchronization455446Stick 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.55Stick 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 signPublicKeys = [ "client-1:..." ];66 signPublicKeys = [ "client-1:..." ];58 # Optional user feedback script: receives copying/switching/done/noop/failed67 # Optional user feedback script: receives copying/switching/done/noop/failed59 # hook = ./usbd-hook.sh;68 # hook = ./usbd-hook.sh;69 # Optional builtin feedback: blink connected Nitrokey 3 devices while working.70 # Blinking stopped and the machine rebooted - success; stopped without71 # a reboot - failed, send the stick back (it carries the encrypted logs).72 # The blink is the standard CTAPHID wink: white, firmware-fixed duration,73 # no colors - the nk3 admin app exposes nothing else.74 # nk3Wink = true;75 # Optional journal back-channel, encrypted to this OpenPGP key76 # logRecipientFile = ./operator.asc;60 };77 };61}78}62----79----68[source,shell]85[source,shell]69----86----70mkfs.vfat -n FLEETUSBD /dev/sdX187mkfs.vfat -n FLEETUSBD /dev/sdX171fleet usbd write HOSTNAME --sign-key ./client-1.secret88fleet usbd write HOSTNAME72----89----739074The signing keypair is generated once with `nix key generate-secret --key-name client-1`.91The 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`):9293[source,nix]94----95{96 hosts.HOSTNAME.usbd.signingKeyFiles = [ "client-1.secret" ];97}98----99100Paths are resolved relative to the fleet project directory and deliberately are not nix paths, so the secret key is never imported into the store.101102Reading a journal dump from the stick:103104[source,shell]105----106gpg -d logs/2026-07-07-10-10-20-1a2b.export.gpg | systemd-journal-remote -o /tmp/metis.journal -107journalctl --file /tmp/metis.journal -b108----7510976== Not a requirement110== Not a requirement7711178Rollback prevention:: not a concern for any of the current clients.112Rollback prevention:: not a concern for any of the current clients.79Update authenticity in the daemon:: handled by nix itself via store signatures during the copy; nothing to do on the usbd level.113Update authenticity in the daemon:: handled by nix itself via store signatures during the copy; nothing to do on the usbd level.80Chunking of large paths:: attic-style sub-path chunking would help large paths that change slightly, but conflicts with ease of synchronization; skipped.114Chunking of large paths:: attic-style sub-path chunking would help large paths that change slightly, but conflicts with ease of synchronization; skipped.81Status/log back-channel on the stick:: postponed, depends on the client.115Encrypted 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.82GC of old generations:: not a daemon problem.116GC of old generations:: not a daemon problem.83Multi-host sticks:: conflicts with incremental stick rewrite; one stick per host for now.117Hiding 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.84118flake.nixdiffbeforeafterboth175175176 pkg-config176 pkg-config177 openssl177 openssl178 udev178 rustPlatform.bindgenHook179 rustPlatform.bindgenHook179 inputs'.nix.packages.nix-expr-c180 inputs'.nix.packages.nix-expr-c180 inputs'.nix.packages.nix-flake-c181 inputs'.nix.packages.nix-flake-cmodules/module-list.nixdiffbeforeafterboth6 ./nixos.nix6 ./nixos.nix7 ./nixpkgs.nix7 ./nixpkgs.nix8 ./secrets.nix8 ./secrets.nix9 ./usbd.nix9]10]1011modules/nixos/usbd.nixdiffbeforeafterboth47 failed <error>.47 failed <error>.48 '';48 '';49 };49 };50 nk3Wink = mkOption {51 type = types.bool;52 default = false;53 description = ''54 Blink connected Nitrokey 3 devices while an update is running.55 Blinking stopped and the machine rebooted - update succeeded;56 blinking stopped without a reboot - update failed.57 '';58 };59 logRecipientFile = mkOption {60 type = types.nullOr types.path;61 default = null;62 description = ''63 Armored OpenPGP public key. When set, the daemon dumps the journal64 of the last 48 hours onto the stick after every run, encrypted to65 this key.66 '';67 };50 extraArgs = mkOption {68 extraArgs = mkOption {51 type = types.listOf types.str;69 type = types.listOf types.str;52 default = [ ];70 default = [ ];64 deviceUnit82 deviceUnit65 "local-fs.target"83 "local-fs.target"66 ];84 ];85 path = [86 pkgs.gnupg87 config.systemd.package88 ];67 serviceConfig = {89 serviceConfig = {68 Type = "oneshot";90 Type = "oneshot";69 ExecStartPre = "-${pkgs.dosfstools}/bin/fsck.vfat -a ${devicePath}";91 ExecStartPre = "-${pkgs.dosfstools}/bin/fsck.vfat -a ${devicePath}";77 "--hook"99 "--hook"78 cfg.hook100 cfg.hook79 ]101 ]80 ++ cfg.extraArgs102 ++ optionals cfg.nk3Wink [ "--nk3-wink" ]103 ++ optionals (cfg.logRecipientFile != null) [104 "--log-recipient-file"105 cfg.logRecipientFile106 ]107 ++ cfg.extraArgs81 );108 );82 };109 };83 unitConfig = {110 unitConfig = {modules/usbd.nixdiffbeforeafterbothno changes
pkgs/fleet-usbd.nixdiffbeforeafterboth6 stdenv,6 stdenv,7 pkg-config,7 pkg-config,8 rustPlatform,8 rustPlatform,9 udev,9}:10}:10let11let11 system = stdenv.hostPlatform.system;12 system = stdenv.hostPlatform.system;28 inputs.nix.packages.${system}.nix-expr-c29 inputs.nix.packages.${system}.nix-expr-c29 inputs.nix.packages.${system}.nix-flake-c30 inputs.nix.packages.${system}.nix-flake-c30 inputs.nix.packages.${system}.nix-fetchers-c31 inputs.nix.packages.${system}.nix-fetchers-c32 udev31 ];33 ];32 nativeBuildInputs = [34 nativeBuildInputs = [33 pkg-config35 pkg-config