difftreelog
fix temp root
18 files changed
Cargo.lockdiffbeforeafterboth--- a/Cargo.lock
+++ b/Cargo.lock
@@ -607,7 +607,7 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
"cexpr",
"clang-sys",
"itertools 0.13.0",
@@ -623,6 +623,12 @@
[[package]]
name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
@@ -1275,6 +1281,29 @@
]
[[package]]
+name = "ctaphid"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa622743e1747e48d25170854b552b3ddc753125a39a6732e57c5c7a5ff7fe11"
+dependencies = [
+ "ctaphid-types",
+ "hex",
+ "hidapi",
+ "log",
+ "rand_core 0.6.4",
+ "tap",
+]
+
+[[package]]
+name = "ctaphid-types"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4714cdd86d5134532b9decaa6774db0a6851ecd07e96a2f239332ae1f3239350"
+dependencies = [
+ "bitflags 1.3.2",
+]
+
+[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1623,7 +1652,7 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
"block2",
"libc",
"objc2",
@@ -2036,11 +2065,14 @@
"base64 0.22.1",
"bytes",
"camino",
+ "chrono",
"clap",
+ "ctaphid",
"fleet-usb",
"futures",
"goodlog-subscriber",
"hex",
+ "hidapi",
"hostname",
"http-body-util",
"hyper",
@@ -2048,6 +2080,7 @@
"nix",
"nix-eval",
"rand 0.10.1",
+ "tempfile",
"tokio",
"tracing",
]
@@ -2539,6 +2572,18 @@
]
[[package]]
+name = "hidapi"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "798154e4b6570af74899d71155fb0072d5b17e6aa12f39c8ef22c60fb8ec99e7"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+ "winapi",
+]
+
+[[package]]
name = "hkdf"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3431,7 +3476,7 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
"libc",
"plain",
"redox_syscall 0.8.1",
@@ -3731,7 +3776,7 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9854ea6ad14e3f4698a7f03b65bce0833dd2d81d594a0e4a984170537146b6"
dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
"libc",
"log",
"netlink-packet-core",
@@ -3743,7 +3788,7 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2288fcb784eb3defd5fb16f4c4160d5f477de192eac730f43e1d11c24d9a007"
dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
"libc",
"log",
"netlink-packet-core",
@@ -3819,7 +3864,7 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
"cfg-if",
"cfg_aliases",
"libc",
@@ -4063,7 +4108,7 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
"block2",
"dispatch2",
"libc",
@@ -4076,7 +4121,7 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c71e34919aba0d701380d911702455038a8a3587467fe0141d6a71501e7ffe48"
dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
"objc2",
"objc2-core-foundation",
"objc2-foundation",
@@ -4096,7 +4141,7 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
"block2",
"libc",
"objc2",
@@ -4109,7 +4154,7 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a"
dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
"objc2",
"objc2-core-foundation",
]
@@ -4130,7 +4175,7 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396"
dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
"dispatch2",
"libc",
"objc2",
@@ -4840,7 +4885,7 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
"num-traits",
"rand 0.9.4",
"rand_chacha 0.9.0",
@@ -5067,7 +5112,7 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
]
[[package]]
@@ -5076,7 +5121,7 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7"
dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
]
[[package]]
@@ -5389,7 +5434,7 @@
checksum = "bbf893f64684e58da8a68d56a5e84d1cf0440226274c515770fe267707a7d0b0"
dependencies = [
"aes 0.9.1",
- "bitflags",
+ "bitflags 2.13.0",
"block-padding 0.4.2",
"byteorder",
"bytes",
@@ -5553,7 +5598,7 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
"errno",
"libc",
"linux-raw-sys 0.4.15",
@@ -5566,7 +5611,7 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
"errno",
"libc",
"linux-raw-sys 0.12.1",
@@ -5769,7 +5814,7 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
@@ -6025,7 +6070,7 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a75cbde1bf934313596a004973e462f9a82caa814dcf1a5f507bdf51597eeb4"
dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
]
[[package]]
@@ -6253,7 +6298,7 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
"core-foundation 0.9.4",
"system-configuration-sys",
]
@@ -6299,6 +6344,12 @@
checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
[[package]]
+name = "tap"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
+
+[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6682,7 +6733,7 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
"bytes",
"futures-util",
"http",
@@ -7036,7 +7087,7 @@
checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd"
dependencies = [
"arrayvec",
- "bitflags",
+ "bitflags 2.13.0",
"cursor-icon",
"log",
"memchr",
Cargo.tomldiffbeforeafterboth--- a/Cargo.toml
+++ b/Cargo.toml
@@ -41,12 +41,14 @@
chrono = { version = "0.4.41", features = ["serde"] }
clap = { version = "4.5", features = ["derive", "env", "unicode", "wrap_help"] }
clap_complete = "4.5"
+ctaphid = "0.3.1"
console-subscriber = "0.5.0"
cxx = "1.0.168"
cxx-build = "1.0.168"
ed25519-dalek = "3.0.0-rc.0"
futures = "0.3.31"
hex = "0.4.3"
+hidapi = { version = "1.2.6", default-features = false, features = ["linux-static-hidraw"] }
hmac = "0.13.0"
hostname = "0.4.1"
http-body-util = "0.1"
cmds/fleet/src/cmds/usbd.rsdiffbeforeafterboth--- a/cmds/fleet/src/cmds/usbd.rs
+++ b/cmds/fleet/src/cmds/usbd.rs
@@ -2,6 +2,7 @@
collections::HashSet,
fs::File,
io::{self, Write as _},
+ os::unix::fs::OpenOptionsExt as _,
sync::Arc,
};
@@ -12,15 +13,16 @@
use clap::Parser;
use fleet_base::{fleetdata::SecretOwner, host::Config};
use fleet_usb::{
- MANIFEST_DONE_NAME, MANIFEST_NAME, MANIFEST_VERSION,
+ DATA_DIR, GC_DIR, MANIFEST_VERSION,
manifest::{Manifest, PathEntry, encrypt_manifest},
+ manifest_done_name, manifest_name,
names::{self, NAMING_SECRET_SIZE, NamingSecret},
stream::EncryptWriter,
};
-use nix_eval::{Store, eval_store};
+use nix_eval::{Store, eval_store, nix_go, nix_go_json};
use rand::Rng as _;
use tokio::task::spawn_blocking;
-use tracing::{info, warn};
+use tracing::{debug, info};
use zbus::zvariant::{self, OwnedObjectPath};
use super::build_systems::build_task;
@@ -54,8 +56,9 @@
/// Write into this directory instead of discovering and mounting the stick via udisks
#[clap(long)]
mount_point: Option<Utf8PathBuf>,
- /// Nix secret signing key; its public counterpart must be in trusted-public-keys on the host
- #[clap(long)]
+ /// Nix secret signing key, overrides hosts.<name>.usbd.signingKeyFiles;
+ /// the public counterpart must be in trusted-public-keys on the host
+ #[clap(long, verbatim_doc_comment)]
sign_key: Option<Utf8PathBuf>,
#[clap(long, default_value = "toplevel-fleet")]
build_attr: String,
@@ -66,16 +69,29 @@
let host = config.host(&self.hostname)?;
let hostname = host.name.clone();
let recipient = config.recipient(&SecretOwner::host(&hostname)).await?;
- let secret = naming_secret(config, &hostname)?;
+ let secret = naming_secret(config)?;
let built = build_task(config.clone(), hostname.clone(), &self.build_attr).await?;
- if let Some(key) = self.sign_key.clone() {
+ let mut sign_keys: Vec<Utf8PathBuf> = self.sign_key.clone().into_iter().collect();
+ if sign_keys.is_empty()
+ && let Some(host_config) = &host.host_config
+ {
+ let usbd = nix_go!(host_config.usbd);
+ let files: Vec<String> = nix_go_json!(usbd.signingKeyFiles);
+ for file in files {
+ let path = Utf8PathBuf::try_from(config.directory.join(&file))
+ .context("fleet directory should be utf8")?;
+ sign_keys.push(path);
+ }
+ }
+ let mut sign_key_names = Vec::new();
+ for key in sign_keys {
+ info!("signing with {key}");
+ sign_key_names.push(sign_key_name(&key)?);
let store = eval_store();
let path = built.clone();
spawn_blocking(move || store.sign_closure(&path, &key)).await??;
- } else {
- warn!(target: TARGET, "closure is not signed (no --sign-key), the host will refuse it unless paths are signed by other means");
}
let (stick, dir) = match &self.mount_point {
@@ -88,20 +104,25 @@
};
info!(target: TARGET, "writing update to {dir}");
- let store = eval_store();
+ // Reopened to avoid nix Store::pathInfoCache: infos cached during the build
+ // would otherwise hide the signatures added by sign_closure above.
+ let store = Arc::new(spawn_blocking(|| Store::open("auto")).await??);
let manifest = {
let secret = secret.clone();
let hostname = hostname.clone();
let built = built.clone();
let dir = dir.clone();
- spawn_blocking(move || write_closure(&store, &secret, &dir, &hostname, &built))
- .await??
+ spawn_blocking(move || {
+ write_closure(&store, &secret, &dir, &hostname, &built, &sign_key_names)
+ })
+ .await??
};
let encrypted = encrypt_manifest(&manifest, std::iter::once(&*recipient))?;
{
let dir = dir.clone();
- spawn_blocking(move || write_manifest(&dir, &encrypted)).await??;
+ let host_id = names::host_id(&secret, &hostname);
+ spawn_blocking(move || write_manifest(&dir, &host_id, &encrypted)).await??;
}
if let Some(stick) = stick {
@@ -112,36 +133,44 @@
}
}
-fn naming_secret(config: &Config, host: &str) -> Result<NamingSecret> {
- let mut extra = config.data.extra.write().expect("no poisoning");
- let usbd = extra
- .entry("usbd".to_owned())
- .or_insert_with(|| serde_json::json!({}));
- let usbd = usbd
- .as_object_mut()
- .context("fleet.nix extra.usbd should be an object")?;
- let secrets = usbd
- .entry("namingSecrets")
- .or_insert_with(|| serde_json::json!({}));
- let secrets = secrets
- .as_object_mut()
- .context("fleet.nix extra.usbd.namingSecrets should be an object")?;
- if let Some(value) = secrets.get(host) {
- let value = value
- .as_str()
- .context("naming secret should be a base64 string")?;
- let bytes = BASE64.decode(value).context("naming secret base64")?;
+fn sign_key_name(key_file: &Utf8Path) -> Result<String> {
+ let key = std::fs::read_to_string(key_file)
+ .with_context(|| format!("reading signing key {key_file}"))?;
+ let (name, _) = key
+ .trim()
+ .split_once(':')
+ .with_context(|| format!("signing key {key_file} should look like name:base64"))?;
+ Ok(name.to_owned())
+}
+
+fn naming_secret(config: &Config) -> Result<NamingSecret> {
+ let config_field = &config.config_field;
+ let file: String = nix_go_json!(config_field.usbd.namingSecretFile);
+ let path = Utf8PathBuf::try_from(config.directory.join(&file))
+ .context("fleet directory should be utf8")?;
+ if path.exists() {
+ let value = std::fs::read_to_string(&path)?;
+ let bytes = BASE64
+ .decode(value.trim())
+ .with_context(|| format!("naming secret base64 in {path}"))?;
let bytes: [u8; NAMING_SECRET_SIZE] = bytes
.try_into()
- .map_err(|_| anyhow!("naming secret should be {NAMING_SECRET_SIZE} bytes"))?;
+ .map_err(|_| anyhow!("naming secret in {path} should be {NAMING_SECRET_SIZE} bytes"))?;
Ok(NamingSecret(bytes))
} else {
let mut bytes = [0u8; NAMING_SECRET_SIZE];
rand::rng().fill_bytes(&mut bytes);
- secrets.insert(
- host.to_owned(),
- serde_json::Value::String(BASE64.encode(bytes)),
- );
+ if let Some(parent) = path.parent() {
+ std::fs::create_dir_all(parent)?;
+ }
+ let mut file = std::fs::OpenOptions::new()
+ .write(true)
+ .create_new(true)
+ .mode(0o600)
+ .open(&path)
+ .with_context(|| format!("creating naming secret file {path}"))?;
+ writeln!(file, "{}", BASE64.encode(bytes))?;
+ info!(target: TARGET, "generated new usbd naming secret at {path}");
Ok(NamingSecret(bytes))
}
}
@@ -152,38 +181,100 @@
dir: &Utf8Path,
hostname: &str,
toplevel: &Utf8Path,
+ sign_key_names: &[String],
) -> Result<Manifest> {
+ let data_dir = dir.join(DATA_DIR);
+ let gc_dir = dir.join(GC_DIR);
+ std::fs::create_dir_all(&data_dir)?;
+ std::fs::create_dir_all(&gc_dir)?;
+
let mut closure = store.compute_closure(toplevel)?;
closure.sort();
info!(target: TARGET, "update closure contains {} paths", closure.len());
let mut existing = HashSet::new();
- for entry in dir.read_dir_utf8().context("reading stick")? {
- existing.insert(entry?.file_name().to_owned());
+ for shard in data_dir.read_dir_utf8().context("reading stick")? {
+ let shard = shard?;
+ if !shard.file_type()?.is_dir() {
+ continue;
+ }
+ for entry in shard.path().read_dir_utf8()? {
+ existing.insert(entry?.file_name().to_owned());
+ }
}
let total = closure.len();
let mut entries = Vec::with_capacity(total);
+ let mut reused = 0;
for (i, path) in closure.iter().enumerate() {
- let entry = write_path(store, secret, dir, &mut existing, path)
+ let (entry, written) = write_path(store, secret, &data_dir, &mut existing, path)
.with_context(|| format!("writing {path}"))?;
- info!(target: TARGET, "[{}/{total}] {path}", i + 1);
+ if written {
+ info!(target: TARGET, "[{}/{total}] {path}", i + 1);
+ } else {
+ debug!(target: TARGET, "[{}/{total}] {path} (reused)", i + 1);
+ reused += 1;
+ }
+ for name in sign_key_names {
+ if !entry
+ .sigs
+ .iter()
+ .any(|sig| sig.strip_prefix(name).is_some_and(|r| r.starts_with(':')))
+ {
+ bail!(
+ "{path} has no signature by the configured key {name}, refusing to write an update the host would reject; sigs present: {:?}",
+ entry.sigs
+ );
+ }
+ }
entries.push(entry);
}
+ info!(target: TARGET, "{} paths written, {reused} reused", total - reused);
- let referenced = entries
- .iter()
- .flat_map(|e| e.chunks.iter())
- .map(String::as_str)
- .collect::<HashSet<_>>();
- for name in &existing {
- let stale_data = names::is_data_name(name) && !referenced.contains(name.as_str());
- let stale_other = name.starts_with(TMP_PREFIX) || name == MANIFEST_DONE_NAME;
- if stale_data || stale_other {
- std::fs::remove_file(dir.join(name))
- .with_context(|| format!("deleting stale {name}"))?;
+ {
+ let tmp = gc_dir.join(format!("{TMP_PREFIX}gc"));
+ let mut file = File::create(&tmp)?;
+ for entry in &entries {
+ for chunk in &entry.chunks {
+ writeln!(file, "{chunk}")?;
+ }
+ }
+ file.sync_all()?;
+ drop(file);
+ std::fs::rename(&tmp, gc_dir.join(names::host_id(secret, hostname)))?;
+ }
+
+ let mut referenced = HashSet::new();
+ for entry in gc_dir.read_dir_utf8()? {
+ let entry = entry?;
+ if entry.file_name().starts_with(TMP_PREFIX) {
+ std::fs::remove_file(entry.path())?;
+ continue;
+ }
+ let list = std::fs::read_to_string(entry.path())
+ .with_context(|| format!("reading gc list {}", entry.file_name()))?;
+ referenced.extend(list.lines().filter(|l| !l.is_empty()).map(str::to_owned));
+ }
+
+ for shard in data_dir.read_dir_utf8()? {
+ let shard = shard?;
+ if !shard.file_type()?.is_dir() {
+ if shard.file_name().starts_with(TMP_PREFIX) {
+ std::fs::remove_file(shard.path())?;
+ }
+ continue;
}
+ for entry in shard.path().read_dir_utf8()? {
+ let entry = entry?;
+ let name = entry.file_name();
+ let stale_data = names::is_data_name(name) && !referenced.contains(name);
+ if stale_data || name.starts_with(TMP_PREFIX) {
+ std::fs::remove_file(entry.path())
+ .with_context(|| format!("deleting stale {name}"))?;
+ }
+ }
}
+ let _ = std::fs::remove_file(dir.join(manifest_done_name(&names::host_id(secret, hostname))));
Ok(Manifest {
version: MANIFEST_VERSION,
@@ -199,7 +290,7 @@
dir: &Utf8Path,
existing: &mut HashSet<String>,
path: &Utf8Path,
-) -> Result<PathEntry> {
+) -> Result<(PathEntry, bool)> {
let info = store.query_path_info(path)?;
let key = names::file_key(secret, &info.nar_hash);
@@ -217,6 +308,7 @@
}
}
+ let written = chunks.is_none();
let chunks = if let Some(chunks) = chunks {
chunks
} else {
@@ -234,31 +326,36 @@
.map(|i| names::chunk_name(secret, &info.nar_hash, i, count))
.collect::<Vec<_>>();
for (tmp, name) in tmp_files.iter().zip(&chunks) {
- std::fs::rename(tmp, dir.join(name))?;
+ let dest = dir.join(names::data_rel_path(name));
+ std::fs::create_dir_all(dest.parent().expect("sharded path has a parent"))?;
+ std::fs::rename(tmp, dest)?;
}
existing.extend(chunks.iter().cloned());
chunks
};
- Ok(PathEntry {
- store_path: path.to_owned(),
- nar_hash: info.nar_hash,
- nar_size: info.nar_size,
- references: info.references,
- sigs: info.sigs,
- compression: "zstd".to_owned(),
- key: BASE64.encode(key),
- chunks,
- })
+ Ok((
+ PathEntry {
+ store_path: path.to_owned(),
+ nar_hash: info.nar_hash,
+ nar_size: info.nar_size,
+ references: info.references,
+ sigs: info.sigs,
+ compression: "zstd".to_owned(),
+ key: BASE64.encode(key),
+ chunks,
+ },
+ written,
+ ))
}
-fn write_manifest(dir: &Utf8Path, encrypted: &[u8]) -> Result<()> {
+fn write_manifest(dir: &Utf8Path, host_id: &str, encrypted: &[u8]) -> Result<()> {
let tmp = dir.join(format!("{TMP_PREFIX}manifest"));
let mut file = File::create(&tmp)?;
file.write_all(encrypted)?;
file.sync_all()?;
drop(file);
- std::fs::rename(&tmp, dir.join(MANIFEST_NAME))?;
+ std::fs::rename(&tmp, dir.join(manifest_name(host_id)))?;
nix::unistd::sync();
Ok(())
}
cmds/usbd/Cargo.tomldiffbeforeafterboth--- a/cmds/usbd/Cargo.toml
+++ b/cmds/usbd/Cargo.toml
@@ -11,10 +11,13 @@
base64.workspace = true
bytes.workspace = true
camino.workspace = true
+chrono.workspace = true
clap.workspace = true
+ctaphid.workspace = true
fleet-usb.workspace = true
futures.workspace = true
goodlog-subscriber.workspace = true
+hidapi.workspace = true
hostname.workspace = true
hex.workspace = true
http-body-util.workspace = true
@@ -23,5 +26,6 @@
nix = { workspace = true, features = ["mount"] }
nix-eval.workspace = true
rand.workspace = true
+tempfile.workspace = true
tokio = { workspace = true, features = ["net", "process"] }
tracing.workspace = true
cmds/usbd/src/main.rsdiffbeforeafterboth--- 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) => {
cmds/usbd/src/server.rsdiffbeforeafterboth--- a/cmds/usbd/src/server.rs
+++ b/cmds/usbd/src/server.rs
@@ -4,7 +4,7 @@
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;
use bytes::Bytes;
-use camino::Utf8PathBuf;
+use camino::{Utf8Path, Utf8PathBuf};
use fleet_usb::{manifest::Manifest, manifest::PathEntry, stream::DecryptReader};
use futures::SinkExt as _;
use http_body_util::{BodyExt as _, Full, StreamBody, combinators::BoxBody};
@@ -39,7 +39,8 @@
}
impl CacheServer {
- pub async fn start(dir: Utf8PathBuf, manifest: &Manifest) -> Result<Self> {
+ pub async fn start(dir: &Utf8Path, manifest: &Manifest) -> Result<Self> {
+ let dir = dir.join(fleet_usb::DATA_DIR);
let mut token = [0u8; 16];
rand::rng().fill_bytes(&mut token);
let token = hex::encode(token);
@@ -167,7 +168,7 @@
let files = entry
.chunks
.iter()
- .map(|c| state.dir.join(c))
+ .map(|c| state.dir.join(fleet_usb::names::data_rel_path(c)))
.collect::<Vec<_>>();
let (mut tx, rx) = futures::channel::mpsc::channel::<Result<Frame<Bytes>, std::io::Error>>(8);
crates/fleet-usb/src/lib.rsdiffbeforeafterboth--- a/crates/fleet-usb/src/lib.rs
+++ b/crates/fleet-usb/src/lib.rs
@@ -2,6 +2,17 @@
pub mod names;
pub mod stream;
-pub const MANIFEST_NAME: &str = "usbd-update";
-pub const MANIFEST_DONE_NAME: &str = "usbd-update.done";
+pub const MANIFEST_PREFIX: &str = "usbd-update-";
+pub const MANIFEST_DONE_SUFFIX: &str = ".done";
pub const MANIFEST_VERSION: u32 = 1;
+pub const DATA_DIR: &str = "update-data";
+pub const GC_DIR: &str = "gc";
+pub const LOGS_DIR: &str = "logs";
+
+pub fn manifest_name(host_id: &str) -> String {
+ format!("{MANIFEST_PREFIX}{host_id}")
+}
+
+pub fn manifest_done_name(host_id: &str) -> String {
+ format!("{MANIFEST_PREFIX}{host_id}{MANIFEST_DONE_SUFFIX}")
+}
crates/fleet-usb/src/manifest.rsdiffbeforeafterboth--- a/crates/fleet-usb/src/manifest.rs
+++ b/crates/fleet-usb/src/manifest.rs
@@ -44,11 +44,14 @@
Ok(out)
}
-pub fn decrypt_manifest(data: &[u8], identity: &dyn Identity) -> Result<Manifest> {
+/// Ok(None) means the manifest is valid but encrypted for a different host.
+pub fn decrypt_manifest(data: &[u8], identity: &dyn Identity) -> Result<Option<Manifest>> {
let decryptor = age::Decryptor::new(data).context("age decrypt")?;
- let mut reader = decryptor
- .decrypt(std::iter::once(identity))
- .context("manifest is not encrypted for this identity")?;
+ let mut reader = match decryptor.decrypt(std::iter::once(identity)) {
+ Ok(reader) => reader,
+ Err(age::DecryptError::NoMatchingKeys) => return Ok(None),
+ Err(e) => return Err(e).context("decrypting manifest"),
+ };
let mut json = Vec::new();
reader.read_to_end(&mut json)?;
let manifest: Manifest = serde_json::from_slice(&json).context("manifest json")?;
@@ -58,5 +61,5 @@
manifest.version
);
}
- Ok(manifest)
+ Ok(Some(manifest))
}
crates/fleet-usb/src/names.rsdiffbeforeafterboth--- a/crates/fleet-usb/src/names.rs
+++ b/crates/fleet-usb/src/names.rs
@@ -18,14 +18,14 @@
}
pub fn file_key(secret: &NamingSecret, nar_hash: &str) -> [u8; 32] {
- derive(secret, &[b"fleet-usb.v1.key\0", nar_hash.as_bytes()])
+ derive(secret, &[b"fleet-usb.v1.key", nar_hash.as_bytes()])
}
pub fn chunk_name(secret: &NamingSecret, nar_hash: &str, index: u32, count: u32) -> String {
let h = derive(
secret,
&[
- b"fleet-usb.v1.name\0",
+ b"fleet-usb.v1.name",
nar_hash.as_bytes(),
&index.to_le_bytes(),
&count.to_le_bytes(),
@@ -34,6 +34,15 @@
hex::encode(&h[..16])
}
+pub fn host_id(secret: &NamingSecret, host: &str) -> String {
+ let h = derive(secret, &[b"fleet-usb.v1.host", host.as_bytes()]);
+ hex::encode(&h[..16])
+}
+
+pub fn data_rel_path(name: &str) -> String {
+ format!("{}/{name}", &name[..2])
+}
+
pub fn is_data_name(name: &str) -> bool {
name.len() == 32
&& name
crates/nix-eval/src/lib.ccdiffbeforeafterboth--- a/crates/nix-eval/src/lib.cc
+++ b/crates/nix-eval/src/lib.cc
@@ -204,6 +204,17 @@
return out;
}
+rust::String add_temp_root(Store *store, rust::Str path) {
+ try {
+ auto nixStore = store->ptr;
+ auto sp = nixStore->parseStorePath(std::string(path));
+ nixStore->addTempRoot(sp);
+ return rust::String();
+ } catch (const std::exception &e) {
+ return rust::String(e.what());
+ }
+}
+
CxxBuildResult compute_closure(Store *store, rust::Str path) {
CxxBuildResult res{rust::String(), {}};
try {
crates/nix-eval/src/lib.hhdiffbeforeafterboth--- a/crates/nix-eval/src/lib.hh
+++ b/crates/nix-eval/src/lib.hh
@@ -34,6 +34,8 @@
struct CxxPathInfo;
CxxPathInfo query_path_info(Store *store, rust::Str path);
+rust::String add_temp_root(Store *store, rust::Str path);
+
CxxBuildResult compute_closure(Store *store, rust::Str path);
rust::String nar_from_path(Store *store, rust::Str path, size_t sink_data,
crates/nix-eval/src/lib.rsdiffbeforeafterboth1use std::borrow::Cow;2use std::cell::RefCell;3use std::collections::HashMap;4use std::ffi::{CStr, CString, c_char, c_int, c_uint, c_void};5use std::ptr::{null, null_mut};6use std::sync::{Arc, LazyLock, OnceLock};7use std::{array, fmt, slice};89use anyhow::{Context, anyhow, bail};10use camino::{Utf8Path, Utf8PathBuf};11use itertools::Itertools;12use serde::Serialize;13use serde::de::DeserializeOwned;14use std::mem::transmute;1516pub use anyhow::Result;17use tracing::{Span, instrument, warn};1819use self::logging::{ErrorInfoBuilder, nix_logging_cxx};20use self::nix_cxx::set_fetcher_setting;21use self::nix_raw::{22 BindingsBuilder as c_bindings_builder, EvalState as c_eval_state, GC_SUCCESS,23 GC_allow_register_threads, GC_get_stack_base, GC_register_my_thread, GC_stack_base,24 GC_thread_is_registered, GC_unregister_my_thread, ListBuilder as c_list_builder, PrimOp,25 PrimOpFun, Store as c_store, StorePath as c_store_path, alloc_primop, alloc_value,26 bindings_builder_free, bindings_builder_insert, c_context, c_context_create, c_context_free,27 clear_err, copy_value, err_NIX_ERR_KEY, err_NIX_ERR_NIX_ERROR, err_NIX_ERR_OVERFLOW,28 err_NIX_ERR_UNKNOWN, err_NIX_OK, err_code, err_info_msg, err_msg, eval_state_build,29 eval_state_builder_load, eval_state_builder_new, eval_state_builder_set_eval_setting,30 expr_eval_from_string, fetchers_settings, fetchers_settings_free, fetchers_settings_new,31 flake_lock, flake_lock_flags, flake_lock_flags_free, flake_lock_flags_new, flake_reference,32 flake_reference_and_fragment_from_string, flake_reference_parse_flags,33 flake_reference_parse_flags_free, flake_reference_parse_flags_new,34 flake_reference_parse_flags_set_base_directory, flake_settings, flake_settings_free,35 flake_settings_new, gc_now as gc_now_raw, get_attr_byname, get_attr_name_byidx, get_attrs_size,36 get_list_byidx, get_list_size, get_string, get_type, has_attr_byname, init_bool, init_int,37 init_primop, init_string, libexpr_init, libstore_init, libutil_init, list_builder_free,38 list_builder_insert, locked_flake, locked_flake_free, locked_flake_get_output_attrs,39 make_attrs, make_bindings_builder, make_list, make_list_builder, realised_string,40 realised_string_free, realised_string_get_buffer_size, realised_string_get_buffer_start,41 realised_string_get_store_path, realised_string_get_store_path_count, register_primop,42 set_err_msg, setting_get, setting_set, state_free, store_copy_closure, store_free, store_open,43 store_parse_path, store_path_free, store_path_name, string_realise, value, value_call,44 value_decref, value_force, value_incref,45};4647// Contains macros helpers48pub mod drv;49pub mod logging;50#[doc(hidden)]51pub mod macros;52pub mod scheduler;5354#[doc(hidden)]55pub mod __macro_support {56 pub use std::collections::hash_map::HashMap;5758 pub use anyhow::Context;59 pub use tokio::task::block_in_place;60}61pub mod util;6263#[allow(64 non_upper_case_globals,65 non_camel_case_types,66 non_snake_case,67 dead_code68)]69mod nix_raw {70 include!(concat!(env!("OUT_DIR"), "/bindings.rs"));71}72#[cxx::bridge]73pub mod nix_cxx {74 struct AddFileToStoreResult {75 error: String,76 store_path: String,77 hash: String,78 }79 struct CxxProfileGeneration {80 id: u64,81 store_path: String,82 creation_time_unix: i64,83 current: bool,84 }85 struct CxxListGenerationsResult {86 error: String,87 generations: Vec<CxxProfileGeneration>,88 }89 struct CxxBuildResult {90 error: String,91 outputs: Vec<String>,92 }93 struct CxxPathInfo {94 error: String,95 nar_hash: String,96 nar_size: u64,97 references: Vec<String>,98 sigs: Vec<String>,99 }100 unsafe extern "C++" {101 type nix_fetchers_settings;102 type Store;103 include!("nix-eval/src/lib.hh");104105 #[allow(clippy::missing_safety_doc)]106 unsafe fn set_fetcher_setting(107 settings: *mut nix_fetchers_settings,108 setting: *const c_char,109 value: *const c_char,110 );111112 #[allow(clippy::missing_safety_doc)]113 unsafe fn switch_profile(store: *mut Store, profile: &str, store_path: &str) -> String;114115 #[allow(clippy::missing_safety_doc)]116 unsafe fn sign_closure(store: *mut Store, store_path: &str, key_file: &str) -> String;117118 #[allow(clippy::missing_safety_doc)]119 unsafe fn add_file_to_store(120 store: *mut Store,121 name: &str,122 path: &str,123 ) -> AddFileToStoreResult;124125 fn list_generations(profile_path: &str) -> CxxListGenerationsResult;126127 #[allow(clippy::missing_safety_doc)]128 unsafe fn build_drv_outputs(129 store: *mut Store,130 drv_path: &str,131 output_names_joined: &str,132 ) -> CxxBuildResult;133134 #[allow(clippy::missing_safety_doc)]135 unsafe fn substitute_paths(store: *mut Store, paths_joined: &str) -> CxxBuildResult;136137 #[allow(clippy::missing_safety_doc)]138 unsafe fn is_valid_path(store: *mut Store, path: &str) -> bool;139140 #[allow(clippy::missing_safety_doc)]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;145146 #[allow(clippy::missing_safety_doc)]147 unsafe fn compute_closure(store: *mut Store, path: &str) -> CxxBuildResult;148149 #[allow(clippy::missing_safety_doc)]150 unsafe fn nar_from_path(151 store: *mut Store,152 path: &str,153 sink_data: usize,154 sink: fn(usize, &[u8]) -> bool,155 ) -> String;156 }157}158159#[derive(Debug, PartialEq, Eq)]160pub enum NixType {161 Thunk,162 Int,163 Float,164 Bool,165 String,166 Path,167 Null,168 Attrs,169 List,170 Function,171 External,172}173impl NixType {174 fn from_int(c: c_uint) -> Self {175 match c {176 0 => Self::Thunk,177 1 => Self::Int,178 2 => Self::Float,179 3 => Self::Bool,180 4 => Self::String,181 5 => Self::Path,182 6 => Self::Null,183 7 => Self::Attrs,184 8 => Self::List,185 9 => Self::Function,186 10 => Self::External,187 _ => unreachable!("unknown nix type: {c}"),188 }189 }190}191192enum FunctorKind {193 Function,194 Functor,195}196197#[derive(Debug)]198#[repr(i32)]199pub enum NixErrorKind {200 Unknown = err_NIX_ERR_UNKNOWN,201 Overflow = err_NIX_ERR_OVERFLOW,202 Key = err_NIX_ERR_KEY,203 Generic = err_NIX_ERR_NIX_ERROR,204}205impl NixErrorKind {206 fn from_int(v: c_int) -> Option<Self> {207 Some(match v {208 0 => return None,209 nix_raw::err_NIX_ERR_UNKNOWN => Self::Unknown,210 nix_raw::err_NIX_ERR_OVERFLOW => Self::Overflow,211 nix_raw::err_NIX_ERR_KEY => Self::Key,212 nix_raw::err_NIX_ERR_NIX_ERROR => Self::Generic,213 _ => {214 debug_assert!(false, "unexpected nix error kind: {v}");215 Self::Unknown216 }217 })218 }219}220221pub fn gc_now() {222 unsafe { gc_now_raw() };223}224225pub fn gc_register_my_thread() {226 assert_eq!(unsafe { GC_thread_is_registered() }, 0);227228 let mut sb = GC_stack_base {229 mem_base: null_mut(),230 };231 let r = unsafe { GC_get_stack_base(&mut sb) };232 if r as u32 != GC_SUCCESS {233 panic!("failed to get thread stack base");234 }235 unsafe { GC_register_my_thread(&sb) };236}237pub fn gc_unregister_my_thread() {238 assert_eq!(unsafe { GC_thread_is_registered() }, 1);239240 unsafe { GC_unregister_my_thread() };241}242243pub struct ThreadRegisterGuard {}244impl ThreadRegisterGuard {245 #[allow(clippy::new_without_default)]246 pub fn new() -> Self {247 gc_register_my_thread();248 Self {}249 }250}251impl Drop for ThreadRegisterGuard {252 fn drop(&mut self) {253 gc_unregister_my_thread();254 }255}256257#[repr(transparent)]258pub struct NixContext(*mut c_context);259impl NixContext {260 pub fn set_err_raw(&mut self, err: NixErrorKind, msg: &CStr) {261 unsafe { set_err_msg(self.0, err as c_int, msg.as_ptr()) };262 }263 pub fn set_err(&mut self, err: anyhow::Error) {264 let fmt = format!("{err:?}").replace("\0", "\\0");265 self.set_err_raw(266 NixErrorKind::Generic,267 &CString::new(fmt).expect("NUL bytes were just replaced"),268 );269 }270 pub fn new() -> Self {271 let ctx = unsafe { c_context_create() };272 Self(ctx)273 }274 fn error_kind(&self) -> Option<NixErrorKind> {275 let code = unsafe { err_code(self.0) };276 NixErrorKind::from_int(code)277 }278 fn error<'t>(&self) -> Option<(Cow<'t, str>, Option<Box<ErrorInfoBuilder>>)> {279 if let NixErrorKind::Generic = self.error_kind()? {280 let ei = unsafe { logging::nix_logging_cxx::extract_error_info(self.0) };281 let mut err_out = String::new();282 unsafe {283 err_info_msg(284 null_mut(),285 self.0,286 Some(copy_nix_str),287 (&raw mut err_out).cast(),288 )289 };290 return Some((Cow::Owned(err_out), Some(ei)));291 };292293 // TODO: Can throw error (resulting in panic) if unable to retrieve error. Should be able to resolve by passing context as a first argument,294 // but it looks ugly295 let str = unsafe { err_msg(null_mut(), self.0, null_mut()) };296 Some((unsafe { CStr::from_ptr(str) }.to_string_lossy(), None))297 }298 fn clean_err(&mut self) {299 unsafe {300 clear_err(self.0);301 }302 }303304 fn bail_if_error(&self) -> Result<()> {305 if let Some((err, stack)) = self.error() {306 let mut e = Err(anyhow!("{err}"));307 if let Some(stack) = stack {308 for ele in stack.stack_frames {309 e = e.with_context(|| {310 if ele.pos.is_empty() {311 ele.msg312 } else {313 format!("{} at {}", ele.msg, ele.pos)314 }315 })316 }317 }318 return e.context("<nix frames>");319 };320 Ok(())321 }322323 fn run_in_context<T>(&mut self, f: impl FnOnce(*mut c_context) -> T) -> Result<T> {324 self.clean_err();325 let o = f(self.0);326 self.bail_if_error()?;327 self.clean_err();328 Ok(o)329 }330}331332impl Default for NixContext {333 fn default() -> Self {334 Self::new()335 }336}337impl Drop for NixContext {338 fn drop(&mut self) {339 unsafe {340 c_context_free(self.0);341 }342 }343}344struct GlobalState {345 // Store should be valid as long as EvalState is valid346 #[allow(dead_code)]347 store: Arc<Store>,348 state: EvalState,349}350impl GlobalState {351 fn new() -> Result<Self> {352 let mut ctx = NixContext::new();353 let store = Arc::new(354 ctx.run_in_context(|c| unsafe { store_open(c, c"auto".as_ptr(), null_mut()) })355 .map(Store)?,356 );357358 let builder = ctx.run_in_context(|c| unsafe { eval_state_builder_new(c, store.0) })?;359 ctx.run_in_context(|c| unsafe { eval_state_builder_load(c, builder) })?;360 ctx.run_in_context(|c| unsafe {361 eval_state_builder_set_eval_setting(362 c,363 builder,364 c"lazy-trees".as_ptr(),365 c"true".as_ptr(),366 )367 })?;368 ctx.run_in_context(|c| unsafe {369 eval_state_builder_set_eval_setting(370 c,371 builder,372 c"lazy-locks".as_ptr(),373 c"true".as_ptr(),374 )375 })?;376 let state = ctx377 .run_in_context(|c| unsafe { eval_state_build(c, builder) })378 .map(EvalState)?;379380 Ok(Self { store, state })381 }382}383384struct ThreadState {385 ctx: NixContext,386}387impl ThreadState {388 fn new() -> Result<Self> {389 let ctx = NixContext::new();390391 Ok(Self { ctx })392 }393}394395static GLOBAL_STATE: LazyLock<GlobalState> =396 LazyLock::new(|| GlobalState::new().expect("global state init shouldn't fail"));397398thread_local! {399 static THREAD_STATE: RefCell<ThreadState> = RefCell::new(ThreadState::new().expect("thread state init shouldn't fail"));400}401pub(crate) fn with_default_context<T>(402 f: impl FnOnce(*mut c_context, *mut c_eval_state) -> T,403) -> Result<T> {404 let global = &GLOBAL_STATE.state;405 let (ctx, state) = THREAD_STATE.with_borrow_mut(|w| (w.ctx.0, global.0));406 let mut ctx = NixContext(ctx);407 let v = ctx.run_in_context(|c| f(c, state));408 // It is reused for thread409 std::mem::forget(ctx);410 v411}412413pub fn set_setting(s: &CStr, v: &CStr) -> Result<()> {414 with_default_context(|c, _| unsafe { setting_set(c, s.as_ptr(), v.as_ptr()) }).map(|_| ())415}416417pub fn get_setting(s: &CStr) -> Result<String> {418 let mut out = String::new();419 with_default_context(|c, _| unsafe {420 setting_get(c, s.as_ptr(), Some(copy_nix_str), (&raw mut out).cast())421 })?;422 Ok(out)423}424425#[derive(Debug)]426pub struct AddedFile {427 pub store_path: Utf8PathBuf,428 pub hash: String,429}430431#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]432pub struct ProfileGeneration {433 pub id: u64,434 pub store_path: Utf8PathBuf,435 pub creation_time_unix: i64,436 pub current: bool,437}438439#[instrument]440pub fn list_generations(profile_path: &str) -> Result<Vec<ProfileGeneration>> {441 let res = nix_cxx::list_generations(profile_path);442 if !res.error.is_empty() {443 bail!(444 "failed to list generations at {profile_path}: {}",445 res.error446 );447 }448 Ok(res449 .generations450 .into_iter()451 .map(|g| ProfileGeneration {452 id: g.id,453 store_path: Utf8PathBuf::from(g.store_path),454 creation_time_unix: g.creation_time_unix,455 current: g.current,456 })457 .collect())458}459460pub struct FetchSettings(*mut fetchers_settings);461impl FetchSettings {462 pub fn new() -> Self {463 Self::try_new().expect("allocation should not fail")464 }465 fn try_new() -> Result<Self> {466 with_default_context(|c, _| unsafe { fetchers_settings_new(c) }).map(Self)467 }468 pub fn set(&mut self, setting: &CStr, value: &CStr) {469 unsafe {470 set_fetcher_setting(self.0.cast(), setting.as_ptr(), value.as_ptr());471 };472 }473}474unsafe impl Send for FetchSettings {}475unsafe impl Sync for FetchSettings {}476477impl Default for FetchSettings {478 fn default() -> Self {479 Self::new()480 }481}482483impl Drop for FetchSettings {484 fn drop(&mut self) {485 unsafe { fetchers_settings_free(self.0) };486 }487}488pub struct FlakeSettings(*mut flake_settings);489impl FlakeSettings {490 pub fn new() -> Result<Self> {491 with_default_context(|c, _| unsafe { flake_settings_new(c) }).map(Self)492 }493}494unsafe impl Send for FlakeSettings {}495unsafe impl Sync for FlakeSettings {}496impl Drop for FlakeSettings {497 fn drop(&mut self) {498 unsafe {499 flake_settings_free(self.0);500 }501 }502}503504pub struct FlakeReferenceParseFlags(*mut flake_reference_parse_flags);505impl FlakeReferenceParseFlags {506 pub fn new(settings: &FlakeSettings) -> Result<Self> {507 with_default_context(|c, _| unsafe { flake_reference_parse_flags_new(c, settings.0) })508 .map(Self)509 }510 pub fn set_base_dir(&mut self, dir: &str) -> Result<()> {511 with_default_context(|c, _| {512 unsafe {513 flake_reference_parse_flags_set_base_directory(514 c,515 self.0,516 dir.as_ptr().cast(),517 dir.len(),518 )519 };520 })521 }522}523impl Drop for FlakeReferenceParseFlags {524 fn drop(&mut self) {525 unsafe {526 flake_reference_parse_flags_free(self.0);527 }528 }529}530pub struct FlakeLockFlags(*mut flake_lock_flags);531impl FlakeLockFlags {532 pub fn new(settings: &FlakeSettings) -> Result<Self> {533 let o = with_default_context(|c, _| unsafe { flake_lock_flags_new(c, settings.0) })534 .map(Self)?;535 // with_default_context(|c, _| unsafe { flake_lock_flags_set_mode_virtual(c, o.0) })?;536537 Ok(o)538 }539}540impl Drop for FlakeLockFlags {541 fn drop(&mut self) {542 unsafe {543 flake_lock_flags_free(self.0);544 }545 }546}547548pub(crate) unsafe extern "C" fn copy_nix_str(549 start: *const c_char,550 n: c_uint,551 user_data: *mut c_void,552) {553 let s = unsafe { slice::from_raw_parts(start.cast::<u8>(), n as usize) };554 let s = std::str::from_utf8(s).expect("c string has invalid utf-8");555 unsafe { *user_data.cast::<String>() = s.to_owned() };556}557558pub struct Store(*mut c_store);559unsafe impl Send for Store {}560unsafe impl Sync for Store {}561562#[derive(Debug, Clone)]563pub struct PathInfo {564 pub nar_hash: String,565 pub nar_size: u64,566 pub references: Vec<Utf8PathBuf>,567 pub sigs: Vec<String>,568}569570pub fn eval_store() -> Arc<Store> {571 GLOBAL_STATE.store.clone()572}573574impl Store {575 pub fn open(uri: &str) -> Result<Self> {576 let uri = CString::new(uri)?;577 let ptr = with_default_context(|c, _| unsafe { store_open(c, uri.as_ptr(), null_mut()) })?;578 if ptr.is_null() {579 bail!("failed to open store");580 }581 Ok(Store(ptr))582 }583584 pub fn parse_path(&self, path: &Utf8Path) -> Result<StorePath> {585 let path = CString::new(path.as_str()).expect("valid cstr");586 with_default_context(|c, _| {587 StorePath(unsafe { store_parse_path(c, self.0, path.as_ptr()) })588 })589 }590591 #[instrument(skip(self))]592 pub fn sign_closure(&self, path: &Utf8Path, key_file: &Utf8Path) -> Result<()> {593 let err = with_default_context(|_, _| unsafe {594 nix_cxx::sign_closure(self.as_ptr().cast(), path.as_str(), key_file.as_str())595 })?596 .to_string();597598 if err.is_empty() {599 Ok(())600 } else {601 bail!("failed to sign {path}: {err}");602 }603 }604605 #[instrument(skip(self, dst))]606 pub fn copy_to(&self, dst: &Store, path: &Utf8Path) -> Result<()> {607 let sp = self608 .parse_path(path)609 .context("failed to parse store path")?;610 let rc = with_default_context(|c, _| unsafe {611 store_copy_closure(c, self.as_ptr(), dst.0, sp.as_ptr())612 })?;613 if rc != err_NIX_OK {614 bail!("store_copy_closure failed (code {rc})");615 }616 Ok(())617 }618619 /// Would only work with local store.620 #[instrument(skip(self))]621 pub fn switch_profile(&self, profile: &str, path: &Utf8Path) -> Result<()> {622 let msg = unsafe { nix_cxx::switch_profile(self.as_ptr().cast(), profile, path.as_str()) };623 if msg.is_empty() {624 Ok(())625 } else {626 bail!("failed to switch profile {profile}: {msg}");627 }628 }629630 #[instrument(skip(self))]631 pub fn add_file(&self, name: &str, path: &Utf8Path) -> Result<AddedFile> {632 let msg = unsafe { nix_cxx::add_file_to_store(self.as_ptr().cast(), name, path.as_str()) };633 if !msg.error.is_empty() {634 bail!("failed to add {path} to store: {}", msg.error)635 }636 Ok(AddedFile {637 store_path: Utf8PathBuf::from(msg.store_path),638 hash: msg.hash,639 })640 }641642 #[instrument(skip(self, paths))]643 pub fn substitute_paths(&self, paths: &[Utf8PathBuf]) -> Result<Vec<Utf8PathBuf>> {644 let joined = paths.into_iter().join("\n");645 let res = unsafe { nix_cxx::substitute_paths(self.as_ptr().cast(), &joined) };646 if !res.error.is_empty() {647 warn!("substitute_paths reported: {}", res.error);648 }649 Ok(res.outputs.into_iter().map(Utf8PathBuf::from).collect())650 }651652 #[instrument(skip(self))]653 pub fn is_valid_path(&self, path: &Utf8Path) -> bool {654 unsafe { nix_cxx::is_valid_path(self.as_ptr().cast(), path.as_str()) }655 }656657 #[instrument(skip(self))]658 pub fn query_path_info(&self, path: &Utf8Path) -> Result<PathInfo> {659 let res = unsafe { nix_cxx::query_path_info(self.as_ptr().cast(), path.as_str()) };660 if !res.error.is_empty() {661 bail!("failed to query path info for {path}: {}", res.error);662 }663 Ok(PathInfo {664 nar_hash: res.nar_hash,665 nar_size: res.nar_size,666 references: res.references.into_iter().map(Utf8PathBuf::from).collect(),667 sigs: res.sigs,668 })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 }680681 #[instrument(skip(self))]682 pub fn compute_closure(&self, path: &Utf8Path) -> Result<Vec<Utf8PathBuf>> {683 let res = unsafe { nix_cxx::compute_closure(self.as_ptr().cast(), path.as_str()) };684 if !res.error.is_empty() {685 bail!("failed to compute closure of {path}: {}", res.error);686 }687 Ok(res.outputs.into_iter().map(Utf8PathBuf::from).collect())688 }689690 #[instrument(skip(self, out))]691 pub fn nar_from_path(&self, path: &Utf8Path, out: &mut dyn std::io::Write) -> Result<()> {692 struct SinkState<'a> {693 out: &'a mut dyn std::io::Write,694 error: Option<std::io::Error>,695 }696 fn sink(state: usize, data: &[u8]) -> bool {697 let state = unsafe { &mut *(state as *mut SinkState) };698 match state.out.write_all(data) {699 Ok(()) => true,700 Err(e) => {701 state.error = Some(e);702 false703 }704 }705 }706 let mut state = SinkState { out, error: None };707 let msg = unsafe {708 nix_cxx::nar_from_path(709 self.as_ptr().cast(),710 path.as_str(),711 (&raw mut state) as usize,712 sink,713 )714 };715 if let Some(e) = state.error {716 return Err(anyhow!(e).context(format!("nar sink failed for {path}")));717 }718 if !msg.is_empty() {719 bail!("failed to dump nar of {path}: {msg}");720 }721 Ok(())722 }723724 #[instrument(skip(self))]725 pub fn build_drv_outputs(726 &self,727 drv_path: &Utf8Path,728 output_names: &[String],729 ) -> Result<Vec<String>> {730 let joined = output_names.join("\n");731 let res =732 unsafe { nix_cxx::build_drv_outputs(self.as_ptr().cast(), drv_path.as_str(), &joined) };733 if !res.error.is_empty() {734 bail!("build of {drv_path} failed: {}", res.error);735 }736 Ok(res.outputs)737 }738739 #[instrument(skip(self))]740 pub fn store_dir(&self) -> Result<Utf8PathBuf> {741 let mut out = String::new();742 with_default_context(|c, es| unsafe {743 nix_raw::store_get_storedir(c, self.as_ptr(), Some(copy_nix_str), (&raw mut out).cast())744 })?;745 let p = Utf8PathBuf::from(out);746 assert!(p.is_absolute());747 Ok(p)748 }749750 fn as_ptr(&self) -> *mut c_store {751 self.0752 }753}754impl Drop for Store {755 fn drop(&mut self) {756 unsafe { store_free(self.0) }757 }758}759760#[repr(transparent)]761pub struct EvalState(*mut c_eval_state);762unsafe impl Send for EvalState {}763unsafe impl Sync for EvalState {}764765impl Drop for EvalState {766 fn drop(&mut self) {767 unsafe {768 state_free(self.0);769 }770 }771}772773pub struct FlakeReference(*mut flake_reference);774impl FlakeReference {775 #[instrument(name = "new-flake-reference", skip(flake, parse, fetch))]776 pub fn new(777 s: &str,778 flake: &FlakeSettings,779 parse: &FlakeReferenceParseFlags,780 fetch: &FetchSettings,781 ) -> Result<(Self, String)> {782 let mut out = null_mut();783 let mut fragment = String::new();784 // let fetch_settings = fetcher_settings;785 with_default_context(|c, _| unsafe {786 flake_reference_and_fragment_from_string(787 c,788 fetch.0,789 flake.0,790 parse.0,791 s.as_ptr().cast(),792 s.len(),793 &mut out,794 Some(copy_nix_str),795 (&raw mut fragment).cast(),796 )797 })?;798 assert!(!out.is_null());799800 Ok((Self(out), fragment))801 }802 #[instrument(name = "lock-flake", skip(self, fetch, flake, lock))]803 pub fn lock(804 &mut self,805 fetch: &FetchSettings,806 flake: &FlakeSettings,807 lock: &FlakeLockFlags,808 ) -> Result<LockedFlake> {809 with_default_context(|c, es| unsafe { flake_lock(c, fetch.0, flake.0, es, lock.0, self.0) })810 .map(LockedFlake)811 }812}813unsafe impl Send for FlakeReference {}814unsafe impl Sync for FlakeReference {}815816pub struct LockedFlake(*mut locked_flake);817impl LockedFlake {818 pub fn get_attrs(&self, settings: &mut FlakeSettings) -> Result<Value> {819 with_default_context(|c, es| unsafe {820 locked_flake_get_output_attrs(c, settings.0, es, self.0)821 })822 .map(Value)823 }824}825unsafe impl Send for LockedFlake {}826unsafe impl Sync for LockedFlake {}827impl Drop for LockedFlake {828 fn drop(&mut self) {829 unsafe {830 locked_flake_free(self.0);831 };832 }833}834835type FieldName = [u8; 64];836fn init_field_name(v: &str) -> FieldName {837 let mut f = [0; 64];838 assert!(v.len() < 64, "max field name is 63 chars");839 assert!(840 v.bytes().all(|v| v != 0),841 "nul bytes are unsupported in field name"842 );843 f[0..v.len()].copy_from_slice(v.as_bytes());844 f845}846847pub struct RealisedString(*mut realised_string);848impl fmt::Debug for RealisedString {849 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {850 self.as_str().fmt(f)851 }852}853854impl RealisedString {855 pub fn as_str(&self) -> &str {856 let len = unsafe { realised_string_get_buffer_size(self.0) };857 let data: *const u8 = unsafe { realised_string_get_buffer_start(self.0) }.cast();858 let data = unsafe { slice::from_raw_parts(data, len) };859 std::str::from_utf8(data).expect("non-utf8 strings not supported")860 }861 pub fn path_count(&self) -> usize {862 unsafe { realised_string_get_store_path_count(self.0) }863 }864 pub fn path(&self, i: usize) -> String {865 assert!(i < self.path_count());866 let path = unsafe { realised_string_get_store_path(self.0, i) };867 let mut err_out = String::new();868 unsafe { store_path_name(path, Some(copy_nix_str), (&raw mut err_out).cast()) };869 err_out870 }871}872873unsafe impl Send for RealisedString {}874impl Drop for RealisedString {875 fn drop(&mut self) {876 unsafe { realised_string_free(self.0) }877 }878}879880#[repr(transparent)]881pub struct Value(*mut value);882883unsafe impl Send for Value {}884unsafe impl Sync for Value {}885886pub trait AsFieldName {887 fn as_field_name<T>(&self, v: impl FnOnce(FieldName) -> Result<T>) -> Result<T>;888 fn to_field_name(&self) -> Result<String>;889}890impl AsFieldName for Value {891 fn as_field_name<T>(&self, v: impl FnOnce(FieldName) -> Result<T>) -> Result<T> {892 let f = self.to_string()?;893 v(init_field_name(&f))894 }895 fn to_field_name(&self) -> Result<String> {896 self.to_string()897 }898}899impl<E> AsFieldName for E900where901 E: AsRef<str>,902{903 fn as_field_name<T>(&self, v: impl FnOnce(FieldName) -> Result<T>) -> Result<T> {904 let f = self.as_ref();905 v(init_field_name(f))906 }907 fn to_field_name(&self) -> Result<String> {908 Ok(self.as_ref().to_owned())909 }910}911912struct AttrsBuilder(*mut c_bindings_builder);913impl AttrsBuilder {914 fn new(capacity: usize) -> Self {915 with_default_context(|c, es| unsafe { make_bindings_builder(c, es, capacity) })916 .map(Self)917 .expect("alloc should not fail")918 }919 fn insert(&mut self, k: &impl AsFieldName, v: Value) {920 k.as_field_name(|name| {921 with_default_context(|c, _| unsafe {922 bindings_builder_insert(c, self.0, name.as_ptr().cast(), v.0);923 // bindings_builder_insert doesn't do incref924 })925 })926 .expect("builder insert shouldn't fail");927 }928}929impl Drop for AttrsBuilder {930 fn drop(&mut self) {931 unsafe { bindings_builder_free(self.0) };932 }933}934935struct ListBuilder(*mut c_list_builder, c_uint);936impl ListBuilder {937 fn new(capacity: usize) -> Self {938 with_default_context(|c, es| unsafe { make_list_builder(c, es, capacity) })939 .map(|l| Self(l, 0))940 .expect("alloc should not fail")941 }942}943impl ListBuilder {944 fn push(&mut self, v: Value) {945 with_default_context(|c, _| unsafe {946 list_builder_insert(947 c,948 self.0,949 {950 let v = self.1;951 self.1 += 1;952 v953 },954 v.0,955 )956 })957 .expect("list insert shouldn't fail");958 }959}960impl Drop for ListBuilder {961 fn drop(&mut self) {962 unsafe { list_builder_free(self.0) };963 }964}965966impl Value {967 pub fn new_primop(v: NativeFn) -> Self {968 let out = Self::new_uninit();969 with_default_context(|c, _| unsafe { init_primop(c, out.0, v.0) })970 .expect("primop initialization should not fail");971 out972 }973 pub fn new_attrs(v: HashMap<&str, Value>) -> Self {974 let out = Self::new_uninit();975 let mut b = AttrsBuilder::new(v.len());976 for (k, v) in v {977 b.insert(&k, v);978 }979 with_default_context(|c, _| unsafe { make_attrs(c, out.0, b.0) })980 .expect("attrs initialization should not fail");981982 out983 }984 fn new_list<T: Into<Self>>(v: Vec<T>) -> Self {985 let out = Self::new_uninit();986 let mut b = ListBuilder::new(v.len());987 for v in v {988 b.push(v.into());989 }990 with_default_context(|c, _| unsafe { make_list(c, b.0, out.0) })991 .expect("list initialization should not fail");992993 out994 }995 fn new_uninit() -> Self {996 let out = with_default_context(|c, es| unsafe { alloc_value(c, es) })997 .expect("value allocation should not fail");998 Self(out)999 }1000 pub fn new_str(v: &str) -> Self {1001 let s = CString::new(v).expect("string should not contain NULs");1002 let out = Self::new_uninit();1003 // String is copied, `s` is free to be dropped1004 with_default_context(|c, _| unsafe { init_string(c, out.0, s.as_ptr()) })1005 .expect("string initialization should not fail");1006 out1007 }1008 pub fn new_int(i: i64) -> Self {1009 let out = Self::new_uninit();1010 with_default_context(|c, _| unsafe { init_int(c, out.0, i) })1011 .expect("int initialization should not fail");1012 out1013 }1014 pub fn new_bool(v: bool) -> Self {1015 let out = Self::new_uninit();1016 with_default_context(|c, _| unsafe { init_bool(c, out.0, v) })1017 .expect("bool initialization should not fail");1018 out1019 }1020 // TODO: As far as I can see, there is no way to get Thunks from nix public C api, so this function is useless1021 // fn force(&mut self, st: &mut EvalState) -> Result<()> {1022 // with_default_context(|c, _| unsafe { value_force(c, st.0, self.0) })?;1023 // Ok(())1024 // }1025 pub fn type_of(&self) -> NixType {1026 let ty = with_default_context(|c, _| unsafe { get_type(c, self.0) })1027 .expect("get_type should not fail");1028 NixType::from_int(ty)1029 }1030 fn builtin_to_string(&self) -> Result<Self> {1031 let builtin = Self::eval("builtins.toString")?;1032 builtin.call(self.clone())1033 }1034 fn force(&mut self, s: *mut nix_raw::EvalState) -> Result<()> {1035 with_default_context(|c, _| unsafe { value_force(c, s, self.0) })?;1036 Ok(())1037 }1038 pub fn to_string(&self) -> Result<String> {1039 let ty = self.type_of();1040 if !matches!(ty, NixType::String) {1041 bail!("unexpected type: {ty:?}, expected string");1042 }1043 let mut str_out = String::new();1044 with_default_context(|c, _| unsafe {1045 get_string(c, self.0, Some(copy_nix_str), (&raw mut str_out).cast())1046 })?;10471048 Ok(str_out)1049 }1050 pub fn to_realised_string(&self) -> Result<RealisedString> {1051 with_default_context(|c, es| unsafe { string_realise(c, es, self.0, false) })1052 .map(RealisedString)10531054 // let store_paths = unsafe { nix_raw::realised_string_get_store_path_count(str) };1055 // for i in 0..store_paths {1056 // let store_path = unsafe { nix_raw::realised_string_get_store_path(str, i) };1057 // nix_raw::store_path_name(store_path, callback, user_data);1058 // }1059 // dbg!(store_paths);1060 // todo!();1061 }10621063 pub fn has_field(&self, field: &str) -> Result<bool> {1064 if !matches!(self.type_of(), NixType::Attrs) {1065 bail!("invalid type: expected attrs");1066 }10671068 let f = init_field_name(field);1069 with_default_context(|c, es| unsafe { has_attr_byname(c, self.0, es, f.as_ptr().cast()) })1070 }1071 // pub fn derivation_path(&self) {1072 // nix_raw::real1073 // }1074 pub fn list_fields(&self) -> Result<Vec<String>> {1075 if !matches!(self.type_of(), NixType::Attrs) {1076 bail!("invalid type: expected attrs");1077 }10781079 let len = with_default_context(|c, _| unsafe { get_attrs_size(c, self.0) })?;1080 let mut out = Vec::with_capacity(len as usize);10811082 for i in 0..len {1083 let name =1084 with_default_context(|c, es| unsafe { get_attr_name_byidx(c, self.0, es, i) })?;1085 let c = unsafe { CStr::from_ptr(name) };1086 out.push(c.to_str().expect("nix field names are utf-8").to_owned());1087 }1088 Ok(out)1089 }1090 pub fn get_elem(&self, v: usize) -> Result<Self> {1091 if !matches!(self.type_of(), NixType::List) {1092 bail!("invalid type: expected list");1093 }1094 let len = with_default_context(|c, _| unsafe { get_list_size(c, self.0) })? as usize;1095 if v >= len {1096 bail!("oob list get: {v} >= {len}");1097 }10981099 with_default_context(|c, es| unsafe { get_list_byidx(c, self.0, es, v as u32) }).map(Self)1100 }1101 pub fn attrs_update(self, other: Value /*, ignore_errors: bool*/) -> Result<Self> {1102 let attrs_update_fn = Self::eval("a: b: a // b")?;11031104 attrs_update_fn1105 .call(self)?1106 .call(other)1107 .context("attrs update")1108 }1109 pub fn get_field(&self, name: impl AsFieldName) -> Result<Self> {1110 if !matches!(self.type_of(), NixType::Attrs) {1111 bail!("invalid type: expected attrs");1112 }11131114 name.as_field_name(|name| {1115 with_default_context(|c, es| unsafe {1116 get_attr_byname(c, self.0, es, name.as_ptr().cast())1117 })1118 .map(Self)1119 })1120 .with_context(|| format!("getting field {:?}", name.to_field_name()))1121 }1122 pub fn call(&self, v: Value) -> Result<Self> {1123 let kind = self1124 .functor_kind()1125 .ok_or_else(|| anyhow!("can only call function or functor"))?;11261127 let function = match kind {1128 FunctorKind::Function => self.clone(),1129 FunctorKind::Functor => {1130 let f = self1131 .get_field("__functor")1132 .context("getting functor value")?;1133 assert_eq!(1134 f.type_of(),1135 NixType::Function,1136 "invalid functor encountered"1137 );1138 f1139 }1140 };11411142 let out = Value::new_uninit();1143 with_default_context(|c, es| unsafe { value_call(c, es, function.0, v.0, out.0) })?;11441145 Ok(out)1146 }1147 pub fn eval(v: &str) -> Result<Self> {1148 let s = CString::new(v).expect("expression shouldn't have internal NULs");1149 let out = Self::new_uninit();1150 with_default_context(|c, es| unsafe {1151 expr_eval_from_string(c, es, s.as_ptr(), c"/root".as_ptr(), out.0)1152 })?;1153 Ok(out)1154 }1155 #[instrument(name = "build", skip(self), fields(output))]1156 pub fn build(&self, output: &str) -> Result<Utf8PathBuf> {1157 if !self.is_derivation() {1158 bail!("expected derivation to build")1159 }1160 let output_name = self1161 .get_field("outputName")1162 .context("getting output name field")?1163 .to_string()?;1164 let v = if output_name != output {1165 let out = self.get_field(output).context("getting target output")?;1166 if !out.is_derivation() {1167 bail!("unknown output: {output}");1168 }1169 out1170 } else {1171 self.clone()1172 };11731174 let drv_path = Utf8PathBuf::from(1175 v.get_field("drvPath")1176 .context("getting drvPath")?1177 .to_string()?,1178 );1179 let graph = Arc::new(drv::DrvGraph::resolve(&eval_store(), &drv_path)?);1180 let _guard = logging::register_build_graph(&Span::current(), &graph);11811182 scheduler::build_graph_sync(graph.clone(), vec![output.to_owned()])?;11831184 let s = v.builtin_to_string()?;1185 let rs = s.to_realised_string()?;1186 let out_path = rs.as_str().to_owned();1187 Ok(Utf8PathBuf::from(out_path))1188 }1189 pub fn as_json<T: DeserializeOwned>(&self) -> Result<T> {1190 let to_json = Self::eval("builtins.toJSON")?;1191 let s = to_json.call(self.clone())?.to_string()?;1192 Ok(serde_json::from_str(&s)?)1193 }1194 pub fn serialized<T: Serialize>(v: &T) -> Result<Self> {1195 Self::eval(&nixlike::serialize(v)?)1196 }11971198 // Convert to string/evaluate derivations/etc1199 // fn to_string_weak(&self) -> Result<String> {1200 // // TODO: For now, it works exactly like to_string, see the comment for fn force()1201 // self.to_string()1202 // }12031204 fn is_derivation(&self) -> bool {1205 if !matches!(self.type_of(), NixType::Attrs) {1206 return false;1207 }1208 let Some(ty) = self.get_field("type").ok() else {1209 return false;1210 };1211 matches!(ty.to_string().as_deref(), Ok("derivation"))1212 }1213 fn functor_kind(&self) -> Option<FunctorKind> {1214 match self.type_of() {1215 NixType::Attrs => self1216 .has_field("__functor")1217 .expect("has_field shouldn't fail for attrs")1218 .then_some(FunctorKind::Functor),1219 NixType::Function => Some(FunctorKind::Function),1220 _ => None,1221 }1222 }1223 pub fn is_function(&self) -> bool {1224 self.functor_kind().is_some()1225 }1226 pub fn is_null(&self) -> bool {1227 matches!(self.type_of(), NixType::Null)1228 }1229 pub fn is_string(&self) -> bool {1230 matches!(self.type_of(), NixType::String)1231 }1232 pub fn is_attrs(&self) -> bool {1233 matches!(self.type_of(), NixType::Attrs)1234 }1235}12361237impl From<String> for Value {1238 fn from(value: String) -> Self {1239 Value::new_str(&value)1240 }1241}1242impl From<bool> for Value {1243 fn from(value: bool) -> Self {1244 Value::new_bool(value)1245 }1246}1247impl From<&str> for Value {1248 fn from(value: &str) -> Self {1249 Value::new_str(value)1250 }1251}1252impl<T> From<Vec<T>> for Value1253where1254 T: Into<Value>,1255{1256 fn from(value: Vec<T>) -> Self {1257 Value::new_list(value)1258 }1259}12601261impl Clone for Value {1262 fn clone(&self) -> Self {1263 with_default_context(|c, _| unsafe { value_incref(c, self.0) })1264 .expect("value incref should not fail");1265 Self(self.0)1266 }1267}1268impl Drop for Value {1269 fn drop(&mut self) {1270 with_default_context(|c, _| unsafe { value_decref(c, self.0) })1271 .expect("value drop should not fail");1272 }1273}12741275static TOKIO_FOR_NIX: OnceLock<Arc<tokio::runtime::Runtime>> = OnceLock::new();12761277pub fn init_libraries() {1278 unsafe { GC_allow_register_threads() };12791280 let mut ctx = NixContext::new();1281 ctx.run_in_context(|c| unsafe { libutil_init(c) })1282 .expect("util init should not fail");1283 ctx.run_in_context(|c| unsafe { libstore_init(c) })1284 .expect("store init should not fail");1285 ctx.run_in_context(|c| unsafe { libexpr_init(c) })1286 .expect("expr init should not fail");12871288 nix_logging_cxx::apply_tracing_logger();1289}12901291pub fn init_tokio_for_nix(tokio: Arc<tokio::runtime::Runtime>) {1292 TOKIO_FOR_NIX1293 .set(tokio)1294 .expect("tokio for nix should only be initialized once");1295}12961297pub fn await_in_nix<F: Send + 'static>(f: impl Future<Output = F> + Send + 'static) -> F {1298 // It should be possible to do Handle::current(), but some of the planned features don't work well with that1299 let runtime = TOKIO_FOR_NIX1300 .get()1301 .expect("init_tokio_for_nix was not called");1302 std::thread::spawn(move || runtime.block_on(f))1303 .join()1304 .expect("await_in_nix inner thread panicked")1305}13061307unsafe extern "C" fn nix_primop_closure_adapter<const N: usize>(1308 user_data: *mut c_void,1309 mut context: *mut c_context,1310 state: *mut nix_raw::EvalState,1311 args: *mut *mut value,1312 ret: *mut value,1313) {1314 let user_closure: &UserClosure<N> = unsafe { &*user_data.cast_const().cast() };1315 let args: [&Value; N] = array::from_fn(|i| {1316 let v: &mut Value = unsafe { &mut *args.add(i).cast() };1317 v as &Value1318 });1319 let ctx: &mut NixContext = unsafe { transmute(&mut context) };13201321 let state: &EvalState = unsafe { std::mem::transmute(&state) };13221323 match user_closure(state, args) {1324 Ok(v) => {1325 unsafe { copy_value(context, ret, v.0) };1326 }1327 Err(e) => {1328 ctx.set_err(e);1329 }1330 }1331}13321333type UserClosure<const N: usize> = Box<dyn Fn(&EvalState, [&Value; N]) -> Result<Value>>;13341335pub struct NativeFn(*mut PrimOp);1336impl NativeFn {1337 pub fn new<const N: usize>(1338 name: &'static CStr,1339 doc: &'static CStr,1340 args: [&'static CStr; N],1341 f: impl Fn(&EvalState, [&Value; N]) -> Result<Value> + 'static,1342 ) -> Self {1343 // Double-boxing to make it thin pointer, as vtable gets outside of first Box1344 let closure: Box<UserClosure<N>> = Box::new(Box::new(f));1345 let f: PrimOpFun = Some(nix_primop_closure_adapter::<N>);1346 let mut args = args.into_iter().map(|v| v.as_ptr()).collect_vec();1347 args.push(null());1348 let args = args.as_mut_ptr();1349 let primop = unsafe {1350 alloc_primop(1351 null_mut(),1352 f,1353 N as i32,1354 name.as_ptr(),1355 args,1356 doc.as_ptr(),1357 Box::into_raw(closure).cast(),1358 )1359 };13601361 assert!(!primop.is_null(), "primop allocation should not fail");13621363 Self(primop)1364 }1365 pub fn register(self) {1366 unsafe { register_primop(null_mut(), self.0) };1367 }1368}13691370pub struct StorePath(*mut c_store_path);1371impl StorePath {1372 fn as_ptr(&self) -> *mut c_store_path {1373 self.01374 }1375}13761377impl Drop for StorePath {1378 fn drop(&mut self) {1379 unsafe { store_path_free(self.0) }1380 }1381}13821383#[test_log::test]1384fn test_native() -> Result<()> {1385 init_libraries();1386 NativeFn::new(1387 c"__uppercaseSuffix2",1388 c"make string uppercase and add suffix",1389 [c"str", c"suffix"],1390 |_, [str, suffix]: [&Value; 2]| {1391 let str = str.to_string()?;1392 let suffix = suffix.to_string()?;1393 Ok(Value::new_str(&format!("{}{suffix}", str.to_uppercase())))1394 },1395 )1396 .register();13971398 let mut fetch_settings = FetchSettings::new();1399 fetch_settings.set(c"warn-dirty", c"false");14001401 let manifest = format!("git+file://{}/../../", env!("CARGO_MANIFEST_DIR"));1402 let flake = FlakeSettings::new()?;1403 let parse = FlakeReferenceParseFlags::new(&flake)?;1404 let (mut r, _) = FlakeReference::new(&manifest, &flake, &parse, &fetch_settings)?;1405 let lock = FlakeLockFlags::new(&flake)?;1406 let locked = r.lock(&fetch_settings, &flake, &lock)?;1407 let attrs = locked.get_attrs(&mut FlakeSettings::new()?)?;14081409 let builtins = Value::eval("builtins")?;1410 assert_eq!(builtins.type_of(), NixType::Attrs);14111412 assert_eq!(attrs.type_of(), NixType::Attrs);1413 let test_data = nix_go!(attrs.testData);14141415 let test_string: String = nix_go_json!(test_data.testString);1416 assert_eq!(test_string, "hello");14171418 let s = nix_go!(attrs.packages["x86_64-linux"].fleet.drvPath);1419 let s = CString::new(s.to_string()?).expect("path str is cstring");14201421 let uppercase_suffix = Value::new_primop(NativeFn::new(1422 c"uppercase_suffix",1423 c"make string uppercase and add suffix",1424 [c"str", c"suffix"],1425 |es, [str, suffix]: [&Value; 2]| {1426 let str = str.to_string()?;1427 let suffix = suffix.to_string()?;1428 Ok(Value::new_str(&format!("{}{suffix}", str.to_uppercase())))1429 },1430 ));14311432 let test_result: String = nix_go_json!(test_data.testPrimop(uppercase_suffix));1433 assert_eq!(test_result, "PREFIX_BODY_SUFFIX");1434 let test_result: String = nix_go_json!(builtins.uppercaseSuffix2("test")("suffix"));1435 assert_eq!(test_result, "TESTsuffix");14361437 let drv_path =1438 nix_go!(attrs.packages["x86_64-linux"]["fleet-install-secrets"].drvPath).to_string()?;1439 let graph = drv::DrvGraph::resolve(&drv_path)?;1440 eprintln!(1441 "fleet-install-secrets dependency graph: {} nodes",1442 graph.nodes.len()1443 );1444 for (path, node) in &graph.nodes {1445 if !node.input_drvs.is_empty() {1446 eprintln!(" {} ({} deps)", node.name, node.input_drvs.len());1447 }1448 }14491450 Ok(())1451}14521453// pub struct GcAlloc;1454// unsafe impl GlobalAlloc for GcAlloc {1455// unsafe fn alloc(&self, l: Layout) -> *mut u8 {1456// let ptr = unsafe { GC_malloc(l.size()) };1457// ptr.cast()1458// }1459// unsafe fn dealloc(&self, ptr: *mut u8, _: Layout) {1460// // unsafe { GC_free(ptr.cast()) };1461// }1462//1463// unsafe fn realloc(&self, ptr: *mut u8, _: Layout, new_size: usize) -> *mut u8 {1464// let ptr = unsafe { GC_realloc(ptr.cast(), new_size) };1465// ptr.cast()1466// }1467// }1468//1469// #[global_allocator]1470// static GC: GcAlloc = GcAlloc;docs/features/usbd.adocdiffbeforeafterboth--- a/docs/features/usbd.adoc
+++ b/docs/features/usbd.adoc
@@ -2,24 +2,24 @@
For airgapped deployments/system updates for low-tech users with limited internet access, it is better to have something more manual than pusher.
-fleet-usbd is a simple update daemon, which watches for a USB stick with the system update to be inserted into the machine, reads the manifest (usbd-update) from it, copies the closure from the USB stick, switches the current system generation, moves the manifest (usbd-update.done) and reboots.
+fleet-usbd is a simple update daemon, which watches for a USB stick with the system update to be inserted into the machine, finds its manifest on it, copies the closure from the USB stick, switches the current system generation, renames the manifest to *.done and reboots.
The stick is discovered by volume label; the label is only a discovery hint, not a trust anchor. Detection is a systemd device unit on /dev/disk/by-label/<label> with the daemon service bound to it (WantedBy=), no udisks dependency.
== Update flow
1. Stick inserted, device unit starts the service, stick is mounted.
-2. Daemon decrypts the manifest, checks the system name matches this host.
+2. Daemon scans usbd-update-* files and tries to decrypt each; the one encrypted to this host's key is its manifest (the daemon has no naming secret, so it cannot derive its manifest name directly). The system name inside is checked as well.
3. If the manifest's system is already the current generation - noop. Guards against reinsertion when the .done rename previously failed.
4. Closure is copied from the stick. Hash verification is part of the copy; copying already-present paths is a noop.
5. New generation is set as the boot default (boot, not switch), so the existing on-boot health-check rollback applies.
-6. Manifest is renamed to usbd-update.done.
+6. Manifest is renamed to *.done.
7. Reboot.
== Requirements
One file per store path:: it should be possible to rewrite the system update on USB stick in incremental fashion: unchanged store paths keep their exact file name and bytes, only added/removed paths change on the stick.
-Deterministic, non-leaking names and ciphertext:: file names and file contents must be byte-stable across rewrites (otherwise incremental sync degrades to full rewrite), but must not leak store path names/contents. Mix a secret into the derivation: per-file key and file name are derived from (deployment secret, narHash); the deployment secret is an ordinary fleet shared secret owned by the target host. Derivation must use narHash, not the store path: input-addressed paths can be rebuilt nondeterministically, yielding the same store path with different contents, and a same-name-different-bytes file breaks both immutability and size-only synchronization. Note age cannot be used for the path files - its encryption is randomized, so ciphertext would churn on every rewrite.
+Deterministic, non-leaking names and ciphertext:: file names and file contents must be byte-stable across rewrites (otherwise incremental sync degrades to full rewrite), but must not leak store path names/contents. Mix a secret into the derivation: per-file key and file name are derived from (naming secret, narHash); the naming secret is project-wide, writer-side state in the file referenced by the `usbd.namingSecretFile` option (relative to the project directory, generated on first write). Hosts never need it - each manifest carries the keys for its paths - and because it is shared across hosts, a store path used by two hosts maps to the same file name and key, so shared sticks dedup naturally. Derivation must use narHash, not the store path: input-addressed paths can be rebuilt nondeterministically, yielding the same store path with different contents, and a same-name-different-bytes file breaks both immutability and size-only synchronization. Note age cannot be used for the path files - its encryption is randomized, so ciphertext would churn on every rewrite.
Manifest encrypted to the host ssh key:: the manifest is encrypted the same way fleet already performs secret encryption (age, ssh-ed25519 host key recipient). It carries the system name and the per-path metadata listed in the copying section.
Manifest should include system name:: update daemon should check if the system update is suitable for it.
User feedback via callbacks:: how the user learns "update running / done / safe to remove" differs per client (screen, beep, LED). The daemon only exposes callback hooks for overriding; no built-in feedback.
@@ -33,7 +33,7 @@
2. Daemon binds an ephemeral HTTP listener on 127.0.0.1 serving the binary cache protocol: /nix-cache-info, synthesized <hash>.narinfo (Sig: from build time, URL: nar/<derived-name>, Compression: zstd) and /nar/<derived-name>, which streaming-decrypts the stick file (chunked AEAD) into the .nar.zst; nix does the decompression and NAR hash check itself.
3. The copy goes through the nix-eval FFI (Store::open on the localhost cache, copy_to into the local store), not a subprocess. A random token path prefix in the cache URL keeps other local processes from pulling decrypted NARs off the listener.
4. Signatures are verified by nix against trusted-public-keys; the deployer signing key is pinned there by the fleet module.
-5. The copy holds a temporary GC root on the system path so a concurrent GC cannot race it. The listener is shut down after the copy, then the normal flow continues: set boot generation, rename manifest, reboot.
+5. The daemon holds a temporary GC root on every update path for the duration of the copy (temp roots protect not-yet-valid paths individually, so intermediate paths are covered before the toplevel becomes reachable), so a concurrent GC cannot race it. The listener is shut down after the copy, then the normal flow continues: set boot generation, rename manifest, reboot.
Stick writing is the mirror image and also goes through the FFI: query path infos of the system closure, sign, dump each path to NAR, zstd-compress, encrypt with the derived per-file key, write under the derived name, write the age-encrypted manifest last.
@@ -41,6 +41,15 @@
FAT32 (mkfs.vfat): universally supported and more robust against unclean ejects than exFAT. The 4 GiB file size limit is handled by splitting large encrypted files into fixed-size chunks; chunk names are derived from (deployment secret, narHash, chunk index) and the manifest lists the chunk sequence per path. This is plain size-splitting, unrelated to dedup chunking. Filesystem is case-insensitive; derived file names should be lowercase.
+Layout:
+
+/usbd-update-<derive(hostname)>:: age-encrypted manifest for one host, renamed to *.done after a successful update. One manifest per host, so several hosts can share a stick.
+/update-data/<xx>/<name>:: encrypted path chunks under derived names, sharded into 256 subdirectories by the first two name characters.
+/gc/<derive(hostname)>:: plaintext list of chunk names referenced by that host's update (bare names, the shard prefix is implied). The writer refreshes its own list, then deletes only data files referenced by no list, so several hosts' updates can share one stick without the writers deleting each other's files.
+/logs/<timestamp>-<random>.export.gpg:: journal dump in `journalctl -o export` format, written by the daemon after every run when `logRecipientFile` is set. Encrypted with OpenPGP to the operator key, so it can be decrypted with a smartcard. The random suffix avoids collisions between hosts sharing a stick (the daemon cannot derive its host id, and a plaintext hostname would leak).
+
+FAT32 allows at most 65536 directory entries per directory; a 32-character name costs 4 entries (3 LFN + 1 short), so a flat directory would cap out at ~16000 files. The 256-way shard gives each subdirectory its own budget (~4M files total), and as a side effect keeps the volume root nearly empty, so even a FAT16-formatted stick with its fixed 512-entry root directory works.
+
== Synchronization
Stick content should be synchronizable from http with a known tool that works on Windows. Flat content-addressed layout plus one manifest file satisfies this: rclone handles it (single portable exe, can be pre-installed on the stick itself with a .bat wrapper running `rclone sync --progress`; rclone has no native GUI and its experimental web GUI downloads assets from github on first run, so it is unusable here). Files are immutable under their derived names, so size-only comparison suffices. Sync should use --delete-before: rclone does not order transfers and by default deletes extraneous files last, which would require the stick to hold the old and new closure simultaneously. A partially synced stick is safe: the copy is hash-verified and the closure must be complete before the switch happens; the daemon should stat all manifest-referenced files upfront to report a partially synced stick cleanly.
@@ -57,6 +66,14 @@
signPublicKeys = [ "client-1:..." ];
# Optional user feedback script: receives copying/switching/done/noop/failed
# hook = ./usbd-hook.sh;
+ # Optional builtin feedback: blink connected Nitrokey 3 devices while working.
+ # Blinking stopped and the machine rebooted - success; stopped without
+ # a reboot - failed, send the stick back (it carries the encrypted logs).
+ # The blink is the standard CTAPHID wink: white, firmware-fixed duration,
+ # no colors - the nk3 admin app exposes nothing else.
+ # nk3Wink = true;
+ # Optional journal back-channel, encrypted to this OpenPGP key
+ # logRecipientFile = ./operator.asc;
};
}
----
@@ -68,16 +85,33 @@
[source,shell]
----
mkfs.vfat -n FLEETUSBD /dev/sdX1
-fleet usbd write HOSTNAME --sign-key ./client-1.secret
+fleet usbd write HOSTNAME
----
-The signing keypair is generated once with `nix key generate-secret --key-name client-1`.
+The signing keypair is generated once with `nix key generate-secret --key-name client-1`; the secret key file is referenced from the fleet configuration (or overridden with `--sign-key`):
+
+[source,nix]
+----
+{
+ hosts.HOSTNAME.usbd.signingKeyFiles = [ "client-1.secret" ];
+}
+----
+
+Paths are resolved relative to the fleet project directory and deliberately are not nix paths, so the secret key is never imported into the store.
+
+Reading a journal dump from the stick:
+[source,shell]
+----
+gpg -d logs/2026-07-07-10-10-20-1a2b.export.gpg | systemd-journal-remote -o /tmp/metis.journal -
+journalctl --file /tmp/metis.journal -b
+----
+
== Not a requirement
Rollback prevention:: not a concern for any of the current clients.
Update authenticity in the daemon:: handled by nix itself via store signatures during the copy; nothing to do on the usbd level.
Chunking of large paths:: attic-style sub-path chunking would help large paths that change slightly, but conflicts with ease of synchronization; skipped.
-Status/log back-channel on the stick:: postponed, depends on the client.
+Encrypted journal on the host:: journald has no built-in journal encryption (FSS sealing is tamper-evidence only), which is why logs are exported and encrypted on the way to the stick instead.
GC of old generations:: not a daemon problem.
-Multi-host sticks:: conflicts with incremental stick rewrite; one stick per host for now.
+Hiding cross-host linkage:: the naming secret is shared project-wide for dedup, so an observer of a shared stick can tell which files two hosts have in common (not what they are). Per-host secrets would hide this at the cost of duplicating shared paths; not a concern for the current clients.
flake.nixdiffbeforeafterboth--- a/flake.nix
+++ b/flake.nix
@@ -175,6 +175,7 @@
pkg-config
openssl
+ udev
rustPlatform.bindgenHook
inputs'.nix.packages.nix-expr-c
inputs'.nix.packages.nix-flake-c
modules/module-list.nixdiffbeforeafterboth--- a/modules/module-list.nix
+++ b/modules/module-list.nix
@@ -6,4 +6,5 @@
./nixos.nix
./nixpkgs.nix
./secrets.nix
+ ./usbd.nix
]
modules/nixos/usbd.nixdiffbeforeafterboth--- a/modules/nixos/usbd.nix
+++ b/modules/nixos/usbd.nix
@@ -47,6 +47,24 @@
failed <error>.
'';
};
+ nk3Wink = mkOption {
+ type = types.bool;
+ default = false;
+ description = ''
+ Blink connected Nitrokey 3 devices while an update is running.
+ Blinking stopped and the machine rebooted - update succeeded;
+ blinking stopped without a reboot - update failed.
+ '';
+ };
+ logRecipientFile = mkOption {
+ type = types.nullOr types.path;
+ default = null;
+ description = ''
+ Armored OpenPGP public key. When set, the daemon dumps the journal
+ of the last 48 hours onto the stick after every run, encrypted to
+ this key.
+ '';
+ };
extraArgs = mkOption {
type = types.listOf types.str;
default = [ ];
@@ -64,6 +82,10 @@
deviceUnit
"local-fs.target"
];
+ path = [
+ pkgs.gnupg
+ config.systemd.package
+ ];
serviceConfig = {
Type = "oneshot";
ExecStartPre = "-${pkgs.dosfstools}/bin/fsck.vfat -a ${devicePath}";
@@ -77,6 +99,11 @@
"--hook"
cfg.hook
]
+ ++ optionals cfg.nk3Wink [ "--nk3-wink" ]
+ ++ optionals (cfg.logRecipientFile != null) [
+ "--log-recipient-file"
+ cfg.logRecipientFile
+ ]
++ cfg.extraArgs
);
};
modules/usbd.nixdiffbeforeafterboth--- /dev/null
+++ b/modules/usbd.nix
@@ -0,0 +1,55 @@
+{
+ lib,
+ fleetLib,
+ config,
+ ...
+}:
+let
+ inherit (lib.options) mkOption literalExpression;
+ inherit (lib.types) listOf str submodule;
+ inherit (fleetLib.options) mkHostsOption;
+
+ _file = ./usbd.nix;
+
+ usbdOptions = {
+ signingKeyFiles = mkOption {
+ description = ''
+ Nix secret key files `fleet usbd write` signs the update closure with,
+ resolved relative to the fleet project directory.
+ Plain strings instead of paths, so the keys are not imported into the nix store.
+ '';
+ type = listOf str;
+ default = [ ];
+ };
+ };
+in
+{
+ options = {
+ usbd = usbdOptions // {
+ namingSecretFile = mkOption {
+ description = ''
+ File with the project-wide naming secret used to derive on-stick file
+ names and encryption keys, resolved relative to the fleet project
+ directory. Generated by `fleet usbd write` when missing.
+ A plain string instead of a path, so the secret is not imported into
+ the nix store.
+ '';
+ type = str;
+ default = "usbd/naming.secret";
+ };
+ };
+ hosts = mkHostsOption {
+ inherit _file;
+ options.usbd = mkOption {
+ description = ''
+ fleet-usbd stick writing configuration for this host.
+ '';
+ type = submodule { options = usbdOptions; };
+ default = {
+ inherit (config.usbd) signingKeyFiles;
+ };
+ defaultText = literalExpression "{ inherit (fleetConfiguration.usbd) signingKeyFiles; }";
+ };
+ };
+ };
+}
pkgs/fleet-usbd.nixdiffbeforeafterboth--- a/pkgs/fleet-usbd.nix
+++ b/pkgs/fleet-usbd.nix
@@ -6,6 +6,7 @@
stdenv,
pkg-config,
rustPlatform,
+ udev,
}:
let
system = stdenv.hostPlatform.system;
@@ -28,6 +29,7 @@
inputs.nix.packages.${system}.nix-expr-c
inputs.nix.packages.${system}.nix-flake-c
inputs.nix.packages.${system}.nix-fetchers-c
+ udev
];
nativeBuildInputs = [
pkg-config