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 compute_closure(store: *mut Store, path: &str) -> CxxBuildResult;145146 #[allow(clippy::missing_safety_doc)]147 unsafe fn nar_from_path(148 store: *mut Store,149 path: &str,150 sink_data: usize,151 sink: fn(usize, &[u8]) -> bool,152 ) -> String;153 }154}155156#[derive(Debug, PartialEq, Eq)]157pub enum NixType {158 Thunk,159 Int,160 Float,161 Bool,162 String,163 Path,164 Null,165 Attrs,166 List,167 Function,168 External,169}170impl NixType {171 fn from_int(c: c_uint) -> Self {172 match c {173 0 => Self::Thunk,174 1 => Self::Int,175 2 => Self::Float,176 3 => Self::Bool,177 4 => Self::String,178 5 => Self::Path,179 6 => Self::Null,180 7 => Self::Attrs,181 8 => Self::List,182 9 => Self::Function,183 10 => Self::External,184 _ => unreachable!("unknown nix type: {c}"),185 }186 }187}188189enum FunctorKind {190 Function,191 Functor,192}193194#[derive(Debug)]195#[repr(i32)]196pub enum NixErrorKind {197 Unknown = err_NIX_ERR_UNKNOWN,198 Overflow = err_NIX_ERR_OVERFLOW,199 Key = err_NIX_ERR_KEY,200 Generic = err_NIX_ERR_NIX_ERROR,201}202impl NixErrorKind {203 fn from_int(v: c_int) -> Option<Self> {204 Some(match v {205 0 => return None,206 nix_raw::err_NIX_ERR_UNKNOWN => Self::Unknown,207 nix_raw::err_NIX_ERR_OVERFLOW => Self::Overflow,208 nix_raw::err_NIX_ERR_KEY => Self::Key,209 nix_raw::err_NIX_ERR_NIX_ERROR => Self::Generic,210 _ => {211 debug_assert!(false, "unexpected nix error kind: {v}");212 Self::Unknown213 }214 })215 }216}217218pub fn gc_now() {219 unsafe { gc_now_raw() };220}221222pub fn gc_register_my_thread() {223 assert_eq!(unsafe { GC_thread_is_registered() }, 0);224225 let mut sb = GC_stack_base {226 mem_base: null_mut(),227 };228 let r = unsafe { GC_get_stack_base(&mut sb) };229 if r as u32 != GC_SUCCESS {230 panic!("failed to get thread stack base");231 }232 unsafe { GC_register_my_thread(&sb) };233}234pub fn gc_unregister_my_thread() {235 assert_eq!(unsafe { GC_thread_is_registered() }, 1);236237 unsafe { GC_unregister_my_thread() };238}239240pub struct ThreadRegisterGuard {}241impl ThreadRegisterGuard {242 #[allow(clippy::new_without_default)]243 pub fn new() -> Self {244 gc_register_my_thread();245 Self {}246 }247}248impl Drop for ThreadRegisterGuard {249 fn drop(&mut self) {250 gc_unregister_my_thread();251 }252}253254#[repr(transparent)]255pub struct NixContext(*mut c_context);256impl NixContext {257 pub fn set_err_raw(&mut self, err: NixErrorKind, msg: &CStr) {258 unsafe { set_err_msg(self.0, err as c_int, msg.as_ptr()) };259 }260 pub fn set_err(&mut self, err: anyhow::Error) {261 let fmt = format!("{err:?}").replace("\0", "\\0");262 self.set_err_raw(263 NixErrorKind::Generic,264 &CString::new(fmt).expect("NUL bytes were just replaced"),265 );266 }267 pub fn new() -> Self {268 let ctx = unsafe { c_context_create() };269 Self(ctx)270 }271 fn error_kind(&self) -> Option<NixErrorKind> {272 let code = unsafe { err_code(self.0) };273 NixErrorKind::from_int(code)274 }275 fn error<'t>(&self) -> Option<(Cow<'t, str>, Option<Box<ErrorInfoBuilder>>)> {276 if let NixErrorKind::Generic = self.error_kind()? {277 let ei = unsafe { logging::nix_logging_cxx::extract_error_info(self.0) };278 let mut err_out = String::new();279 unsafe {280 err_info_msg(281 null_mut(),282 self.0,283 Some(copy_nix_str),284 (&raw mut err_out).cast(),285 )286 };287 return Some((Cow::Owned(err_out), Some(ei)));288 };289290 // TODO: Can throw error (resulting in panic) if unable to retrieve error. Should be able to resolve by passing context as a first argument,291 // but it looks ugly292 let str = unsafe { err_msg(null_mut(), self.0, null_mut()) };293 Some((unsafe { CStr::from_ptr(str) }.to_string_lossy(), None))294 }295 fn clean_err(&mut self) {296 unsafe {297 clear_err(self.0);298 }299 }300301 fn bail_if_error(&self) -> Result<()> {302 if let Some((err, stack)) = self.error() {303 let mut e = Err(anyhow!("{err}"));304 if let Some(stack) = stack {305 for ele in stack.stack_frames {306 e = e.with_context(|| {307 if ele.pos.is_empty() {308 ele.msg309 } else {310 format!("{} at {}", ele.msg, ele.pos)311 }312 })313 }314 }315 return e.context("<nix frames>");316 };317 Ok(())318 }319320 fn run_in_context<T>(&mut self, f: impl FnOnce(*mut c_context) -> T) -> Result<T> {321 self.clean_err();322 let o = f(self.0);323 self.bail_if_error()?;324 self.clean_err();325 Ok(o)326 }327}328329impl Default for NixContext {330 fn default() -> Self {331 Self::new()332 }333}334impl Drop for NixContext {335 fn drop(&mut self) {336 unsafe {337 c_context_free(self.0);338 }339 }340}341struct GlobalState {342 // Store should be valid as long as EvalState is valid343 #[allow(dead_code)]344 store: Arc<Store>,345 state: EvalState,346}347impl GlobalState {348 fn new() -> Result<Self> {349 let mut ctx = NixContext::new();350 let store = Arc::new(351 ctx.run_in_context(|c| unsafe { store_open(c, c"auto".as_ptr(), null_mut()) })352 .map(Store)?,353 );354355 let builder = ctx.run_in_context(|c| unsafe { eval_state_builder_new(c, store.0) })?;356 ctx.run_in_context(|c| unsafe { eval_state_builder_load(c, builder) })?;357 ctx.run_in_context(|c| unsafe {358 eval_state_builder_set_eval_setting(359 c,360 builder,361 c"lazy-trees".as_ptr(),362 c"true".as_ptr(),363 )364 })?;365 ctx.run_in_context(|c| unsafe {366 eval_state_builder_set_eval_setting(367 c,368 builder,369 c"lazy-locks".as_ptr(),370 c"true".as_ptr(),371 )372 })?;373 let state = ctx374 .run_in_context(|c| unsafe { eval_state_build(c, builder) })375 .map(EvalState)?;376377 Ok(Self { store, state })378 }379}380381struct ThreadState {382 ctx: NixContext,383}384impl ThreadState {385 fn new() -> Result<Self> {386 let ctx = NixContext::new();387388 Ok(Self { ctx })389 }390}391392static GLOBAL_STATE: LazyLock<GlobalState> =393 LazyLock::new(|| GlobalState::new().expect("global state init shouldn't fail"));394395thread_local! {396 static THREAD_STATE: RefCell<ThreadState> = RefCell::new(ThreadState::new().expect("thread state init shouldn't fail"));397}398pub(crate) fn with_default_context<T>(399 f: impl FnOnce(*mut c_context, *mut c_eval_state) -> T,400) -> Result<T> {401 let global = &GLOBAL_STATE.state;402 let (ctx, state) = THREAD_STATE.with_borrow_mut(|w| (w.ctx.0, global.0));403 let mut ctx = NixContext(ctx);404 let v = ctx.run_in_context(|c| f(c, state));405 // It is reused for thread406 std::mem::forget(ctx);407 v408}409410pub fn set_setting(s: &CStr, v: &CStr) -> Result<()> {411 with_default_context(|c, _| unsafe { setting_set(c, s.as_ptr(), v.as_ptr()) }).map(|_| ())412}413414pub fn get_setting(s: &CStr) -> Result<String> {415 let mut out = String::new();416 with_default_context(|c, _| unsafe {417 setting_get(c, s.as_ptr(), Some(copy_nix_str), (&raw mut out).cast())418 })?;419 Ok(out)420}421422#[derive(Debug)]423pub struct AddedFile {424 pub store_path: Utf8PathBuf,425 pub hash: String,426}427428#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]429pub struct ProfileGeneration {430 pub id: u64,431 pub store_path: Utf8PathBuf,432 pub creation_time_unix: i64,433 pub current: bool,434}435436#[instrument]437pub fn list_generations(profile_path: &str) -> Result<Vec<ProfileGeneration>> {438 let res = nix_cxx::list_generations(profile_path);439 if !res.error.is_empty() {440 bail!(441 "failed to list generations at {profile_path}: {}",442 res.error443 );444 }445 Ok(res446 .generations447 .into_iter()448 .map(|g| ProfileGeneration {449 id: g.id,450 store_path: Utf8PathBuf::from(g.store_path),451 creation_time_unix: g.creation_time_unix,452 current: g.current,453 })454 .collect())455}456457pub struct FetchSettings(*mut fetchers_settings);458impl FetchSettings {459 pub fn new() -> Self {460 Self::try_new().expect("allocation should not fail")461 }462 fn try_new() -> Result<Self> {463 with_default_context(|c, _| unsafe { fetchers_settings_new(c) }).map(Self)464 }465 pub fn set(&mut self, setting: &CStr, value: &CStr) {466 unsafe {467 set_fetcher_setting(self.0.cast(), setting.as_ptr(), value.as_ptr());468 };469 }470}471unsafe impl Send for FetchSettings {}472unsafe impl Sync for FetchSettings {}473474impl Default for FetchSettings {475 fn default() -> Self {476 Self::new()477 }478}479480impl Drop for FetchSettings {481 fn drop(&mut self) {482 unsafe { fetchers_settings_free(self.0) };483 }484}485pub struct FlakeSettings(*mut flake_settings);486impl FlakeSettings {487 pub fn new() -> Result<Self> {488 with_default_context(|c, _| unsafe { flake_settings_new(c) }).map(Self)489 }490}491unsafe impl Send for FlakeSettings {}492unsafe impl Sync for FlakeSettings {}493impl Drop for FlakeSettings {494 fn drop(&mut self) {495 unsafe {496 flake_settings_free(self.0);497 }498 }499}500501pub struct FlakeReferenceParseFlags(*mut flake_reference_parse_flags);502impl FlakeReferenceParseFlags {503 pub fn new(settings: &FlakeSettings) -> Result<Self> {504 with_default_context(|c, _| unsafe { flake_reference_parse_flags_new(c, settings.0) })505 .map(Self)506 }507 pub fn set_base_dir(&mut self, dir: &str) -> Result<()> {508 with_default_context(|c, _| {509 unsafe {510 flake_reference_parse_flags_set_base_directory(511 c,512 self.0,513 dir.as_ptr().cast(),514 dir.len(),515 )516 };517 })518 }519}520impl Drop for FlakeReferenceParseFlags {521 fn drop(&mut self) {522 unsafe {523 flake_reference_parse_flags_free(self.0);524 }525 }526}527pub struct FlakeLockFlags(*mut flake_lock_flags);528impl FlakeLockFlags {529 pub fn new(settings: &FlakeSettings) -> Result<Self> {530 let o = with_default_context(|c, _| unsafe { flake_lock_flags_new(c, settings.0) })531 .map(Self)?;532 // with_default_context(|c, _| unsafe { flake_lock_flags_set_mode_virtual(c, o.0) })?;533534 Ok(o)535 }536}537impl Drop for FlakeLockFlags {538 fn drop(&mut self) {539 unsafe {540 flake_lock_flags_free(self.0);541 }542 }543}544545pub(crate) unsafe extern "C" fn copy_nix_str(546 start: *const c_char,547 n: c_uint,548 user_data: *mut c_void,549) {550 let s = unsafe { slice::from_raw_parts(start.cast::<u8>(), n as usize) };551 let s = std::str::from_utf8(s).expect("c string has invalid utf-8");552 unsafe { *user_data.cast::<String>() = s.to_owned() };553}554555pub struct Store(*mut c_store);556unsafe impl Send for Store {}557unsafe impl Sync for Store {}558559#[derive(Debug, Clone)]560pub struct PathInfo {561 pub nar_hash: String,562 pub nar_size: u64,563 pub references: Vec<Utf8PathBuf>,564 pub sigs: Vec<String>,565}566567pub fn eval_store() -> Arc<Store> {568 GLOBAL_STATE.store.clone()569}570571impl Store {572 pub fn open(uri: &str) -> Result<Self> {573 let uri = CString::new(uri)?;574 let ptr = with_default_context(|c, _| unsafe { store_open(c, uri.as_ptr(), null_mut()) })?;575 if ptr.is_null() {576 bail!("failed to open store");577 }578 Ok(Store(ptr))579 }580581 pub fn parse_path(&self, path: &Utf8Path) -> Result<StorePath> {582 let path = CString::new(path.as_str()).expect("valid cstr");583 with_default_context(|c, _| {584 StorePath(unsafe { store_parse_path(c, self.0, path.as_ptr()) })585 })586 }587588 #[instrument(skip(self))]589 pub fn sign_closure(&self, path: &Utf8Path, key_file: &Utf8Path) -> Result<()> {590 let err = with_default_context(|_, _| unsafe {591 nix_cxx::sign_closure(self.as_ptr().cast(), path.as_str(), key_file.as_str())592 })?593 .to_string();594595 if err.is_empty() {596 Ok(())597 } else {598 bail!("failed to sign {path}: {err}");599 }600 }601602 #[instrument(skip(self, dst))]603 pub fn copy_to(&self, dst: &Store, path: &Utf8Path) -> Result<()> {604 let sp = self605 .parse_path(path)606 .context("failed to parse store path")?;607 let rc = with_default_context(|c, _| unsafe {608 store_copy_closure(c, self.as_ptr(), dst.0, sp.as_ptr())609 })?;610 if rc != err_NIX_OK {611 bail!("store_copy_closure failed (code {rc})");612 }613 Ok(())614 }615616 /// Would only work with local store.617 #[instrument(skip(self))]618 pub fn switch_profile(&self, profile: &str, path: &Utf8Path) -> Result<()> {619 let msg = unsafe { nix_cxx::switch_profile(self.as_ptr().cast(), profile, path.as_str()) };620 if msg.is_empty() {621 Ok(())622 } else {623 bail!("failed to switch profile {profile}: {msg}");624 }625 }626627 #[instrument(skip(self))]628 pub fn add_file(&self, name: &str, path: &Utf8Path) -> Result<AddedFile> {629 let msg = unsafe { nix_cxx::add_file_to_store(self.as_ptr().cast(), name, path.as_str()) };630 if !msg.error.is_empty() {631 bail!("failed to add {path} to store: {}", msg.error)632 }633 Ok(AddedFile {634 store_path: Utf8PathBuf::from(msg.store_path),635 hash: msg.hash,636 })637 }638639 #[instrument(skip(self, paths))]640 pub fn substitute_paths(&self, paths: &[Utf8PathBuf]) -> Result<Vec<Utf8PathBuf>> {641 let joined = paths.into_iter().join("\n");642 let res = unsafe { nix_cxx::substitute_paths(self.as_ptr().cast(), &joined) };643 if !res.error.is_empty() {644 warn!("substitute_paths reported: {}", res.error);645 }646 Ok(res.outputs.into_iter().map(Utf8PathBuf::from).collect())647 }648649 #[instrument(skip(self))]650 pub fn is_valid_path(&self, path: &Utf8Path) -> bool {651 unsafe { nix_cxx::is_valid_path(self.as_ptr().cast(), path.as_str()) }652 }653654 #[instrument(skip(self))]655 pub fn query_path_info(&self, path: &Utf8Path) -> Result<PathInfo> {656 let res = unsafe { nix_cxx::query_path_info(self.as_ptr().cast(), path.as_str()) };657 if !res.error.is_empty() {658 bail!("failed to query path info for {path}: {}", res.error);659 }660 Ok(PathInfo {661 nar_hash: res.nar_hash,662 nar_size: res.nar_size,663 references: res.references.into_iter().map(Utf8PathBuf::from).collect(),664 sigs: res.sigs,665 })666 }667668 #[instrument(skip(self))]669 pub fn compute_closure(&self, path: &Utf8Path) -> Result<Vec<Utf8PathBuf>> {670 let res = unsafe { nix_cxx::compute_closure(self.as_ptr().cast(), path.as_str()) };671 if !res.error.is_empty() {672 bail!("failed to compute closure of {path}: {}", res.error);673 }674 Ok(res.outputs.into_iter().map(Utf8PathBuf::from).collect())675 }676677 #[instrument(skip(self, out))]678 pub fn nar_from_path(&self, path: &Utf8Path, out: &mut dyn std::io::Write) -> Result<()> {679 struct SinkState<'a> {680 out: &'a mut dyn std::io::Write,681 error: Option<std::io::Error>,682 }683 fn sink(state: usize, data: &[u8]) -> bool {684 let state = unsafe { &mut *(state as *mut SinkState) };685 match state.out.write_all(data) {686 Ok(()) => true,687 Err(e) => {688 state.error = Some(e);689 false690 }691 }692 }693 let mut state = SinkState { out, error: None };694 let msg = unsafe {695 nix_cxx::nar_from_path(696 self.as_ptr().cast(),697 path.as_str(),698 (&raw mut state) as usize,699 sink,700 )701 };702 if let Some(e) = state.error {703 return Err(anyhow!(e).context(format!("nar sink failed for {path}")));704 }705 if !msg.is_empty() {706 bail!("failed to dump nar of {path}: {msg}");707 }708 Ok(())709 }710711 #[instrument(skip(self))]712 pub fn build_drv_outputs(713 &self,714 drv_path: &Utf8Path,715 output_names: &[String],716 ) -> Result<Vec<String>> {717 let joined = output_names.join("\n");718 let res =719 unsafe { nix_cxx::build_drv_outputs(self.as_ptr().cast(), drv_path.as_str(), &joined) };720 if !res.error.is_empty() {721 bail!("build of {drv_path} failed: {}", res.error);722 }723 Ok(res.outputs)724 }725726 #[instrument(skip(self))]727 pub fn store_dir(&self) -> Result<Utf8PathBuf> {728 let mut out = String::new();729 with_default_context(|c, es| unsafe {730 nix_raw::store_get_storedir(c, self.as_ptr(), Some(copy_nix_str), (&raw mut out).cast())731 })?;732 let p = Utf8PathBuf::from(out);733 assert!(p.is_absolute());734 Ok(p)735 }736737 fn as_ptr(&self) -> *mut c_store {738 self.0739 }740}741impl Drop for Store {742 fn drop(&mut self) {743 unsafe { store_free(self.0) }744 }745}746747#[repr(transparent)]748pub struct EvalState(*mut c_eval_state);749unsafe impl Send for EvalState {}750unsafe impl Sync for EvalState {}751752impl Drop for EvalState {753 fn drop(&mut self) {754 unsafe {755 state_free(self.0);756 }757 }758}759760pub struct FlakeReference(*mut flake_reference);761impl FlakeReference {762 #[instrument(name = "new-flake-reference", skip(flake, parse, fetch))]763 pub fn new(764 s: &str,765 flake: &FlakeSettings,766 parse: &FlakeReferenceParseFlags,767 fetch: &FetchSettings,768 ) -> Result<(Self, String)> {769 let mut out = null_mut();770 let mut fragment = String::new();771 // let fetch_settings = fetcher_settings;772 with_default_context(|c, _| unsafe {773 flake_reference_and_fragment_from_string(774 c,775 fetch.0,776 flake.0,777 parse.0,778 s.as_ptr().cast(),779 s.len(),780 &mut out,781 Some(copy_nix_str),782 (&raw mut fragment).cast(),783 )784 })?;785 assert!(!out.is_null());786787 Ok((Self(out), fragment))788 }789 #[instrument(name = "lock-flake", skip(self, fetch, flake, lock))]790 pub fn lock(791 &mut self,792 fetch: &FetchSettings,793 flake: &FlakeSettings,794 lock: &FlakeLockFlags,795 ) -> Result<LockedFlake> {796 with_default_context(|c, es| unsafe { flake_lock(c, fetch.0, flake.0, es, lock.0, self.0) })797 .map(LockedFlake)798 }799}800unsafe impl Send for FlakeReference {}801unsafe impl Sync for FlakeReference {}802803pub struct LockedFlake(*mut locked_flake);804impl LockedFlake {805 pub fn get_attrs(&self, settings: &mut FlakeSettings) -> Result<Value> {806 with_default_context(|c, es| unsafe {807 locked_flake_get_output_attrs(c, settings.0, es, self.0)808 })809 .map(Value)810 }811}812unsafe impl Send for LockedFlake {}813unsafe impl Sync for LockedFlake {}814impl Drop for LockedFlake {815 fn drop(&mut self) {816 unsafe {817 locked_flake_free(self.0);818 };819 }820}821822type FieldName = [u8; 64];823fn init_field_name(v: &str) -> FieldName {824 let mut f = [0; 64];825 assert!(v.len() < 64, "max field name is 63 chars");826 assert!(827 v.bytes().all(|v| v != 0),828 "nul bytes are unsupported in field name"829 );830 f[0..v.len()].copy_from_slice(v.as_bytes());831 f832}833834pub struct RealisedString(*mut realised_string);835impl fmt::Debug for RealisedString {836 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {837 self.as_str().fmt(f)838 }839}840841impl RealisedString {842 pub fn as_str(&self) -> &str {843 let len = unsafe { realised_string_get_buffer_size(self.0) };844 let data: *const u8 = unsafe { realised_string_get_buffer_start(self.0) }.cast();845 let data = unsafe { slice::from_raw_parts(data, len) };846 std::str::from_utf8(data).expect("non-utf8 strings not supported")847 }848 pub fn path_count(&self) -> usize {849 unsafe { realised_string_get_store_path_count(self.0) }850 }851 pub fn path(&self, i: usize) -> String {852 assert!(i < self.path_count());853 let path = unsafe { realised_string_get_store_path(self.0, i) };854 let mut err_out = String::new();855 unsafe { store_path_name(path, Some(copy_nix_str), (&raw mut err_out).cast()) };856 err_out857 }858}859860unsafe impl Send for RealisedString {}861impl Drop for RealisedString {862 fn drop(&mut self) {863 unsafe { realised_string_free(self.0) }864 }865}866867#[repr(transparent)]868pub struct Value(*mut value);869870unsafe impl Send for Value {}871unsafe impl Sync for Value {}872873pub trait AsFieldName {874 fn as_field_name<T>(&self, v: impl FnOnce(FieldName) -> Result<T>) -> Result<T>;875 fn to_field_name(&self) -> Result<String>;876}877impl AsFieldName for Value {878 fn as_field_name<T>(&self, v: impl FnOnce(FieldName) -> Result<T>) -> Result<T> {879 let f = self.to_string()?;880 v(init_field_name(&f))881 }882 fn to_field_name(&self) -> Result<String> {883 self.to_string()884 }885}886impl<E> AsFieldName for E887where888 E: AsRef<str>,889{890 fn as_field_name<T>(&self, v: impl FnOnce(FieldName) -> Result<T>) -> Result<T> {891 let f = self.as_ref();892 v(init_field_name(f))893 }894 fn to_field_name(&self) -> Result<String> {895 Ok(self.as_ref().to_owned())896 }897}898899struct AttrsBuilder(*mut c_bindings_builder);900impl AttrsBuilder {901 fn new(capacity: usize) -> Self {902 with_default_context(|c, es| unsafe { make_bindings_builder(c, es, capacity) })903 .map(Self)904 .expect("alloc should not fail")905 }906 fn insert(&mut self, k: &impl AsFieldName, v: Value) {907 k.as_field_name(|name| {908 with_default_context(|c, _| unsafe {909 bindings_builder_insert(c, self.0, name.as_ptr().cast(), v.0);910 // bindings_builder_insert doesn't do incref911 })912 })913 .expect("builder insert shouldn't fail");914 }915}916impl Drop for AttrsBuilder {917 fn drop(&mut self) {918 unsafe { bindings_builder_free(self.0) };919 }920}921922struct ListBuilder(*mut c_list_builder, c_uint);923impl ListBuilder {924 fn new(capacity: usize) -> Self {925 with_default_context(|c, es| unsafe { make_list_builder(c, es, capacity) })926 .map(|l| Self(l, 0))927 .expect("alloc should not fail")928 }929}930impl ListBuilder {931 fn push(&mut self, v: Value) {932 with_default_context(|c, _| unsafe {933 list_builder_insert(934 c,935 self.0,936 {937 let v = self.1;938 self.1 += 1;939 v940 },941 v.0,942 )943 })944 .expect("list insert shouldn't fail");945 }946}947impl Drop for ListBuilder {948 fn drop(&mut self) {949 unsafe { list_builder_free(self.0) };950 }951}952953impl Value {954 pub fn new_primop(v: NativeFn) -> Self {955 let out = Self::new_uninit();956 with_default_context(|c, _| unsafe { init_primop(c, out.0, v.0) })957 .expect("primop initialization should not fail");958 out959 }960 pub fn new_attrs(v: HashMap<&str, Value>) -> Self {961 let out = Self::new_uninit();962 let mut b = AttrsBuilder::new(v.len());963 for (k, v) in v {964 b.insert(&k, v);965 }966 with_default_context(|c, _| unsafe { make_attrs(c, out.0, b.0) })967 .expect("attrs initialization should not fail");968969 out970 }971 fn new_list<T: Into<Self>>(v: Vec<T>) -> Self {972 let out = Self::new_uninit();973 let mut b = ListBuilder::new(v.len());974 for v in v {975 b.push(v.into());976 }977 with_default_context(|c, _| unsafe { make_list(c, b.0, out.0) })978 .expect("list initialization should not fail");979980 out981 }982 fn new_uninit() -> Self {983 let out = with_default_context(|c, es| unsafe { alloc_value(c, es) })984 .expect("value allocation should not fail");985 Self(out)986 }987 pub fn new_str(v: &str) -> Self {988 let s = CString::new(v).expect("string should not contain NULs");989 let out = Self::new_uninit();990 // String is copied, `s` is free to be dropped991 with_default_context(|c, _| unsafe { init_string(c, out.0, s.as_ptr()) })992 .expect("string initialization should not fail");993 out994 }995 pub fn new_int(i: i64) -> Self {996 let out = Self::new_uninit();997 with_default_context(|c, _| unsafe { init_int(c, out.0, i) })998 .expect("int initialization should not fail");999 out1000 }1001 pub fn new_bool(v: bool) -> Self {1002 let out = Self::new_uninit();1003 with_default_context(|c, _| unsafe { init_bool(c, out.0, v) })1004 .expect("bool initialization should not fail");1005 out1006 }1007 // TODO: As far as I can see, there is no way to get Thunks from nix public C api, so this function is useless1008 // fn force(&mut self, st: &mut EvalState) -> Result<()> {1009 // with_default_context(|c, _| unsafe { value_force(c, st.0, self.0) })?;1010 // Ok(())1011 // }1012 pub fn type_of(&self) -> NixType {1013 let ty = with_default_context(|c, _| unsafe { get_type(c, self.0) })1014 .expect("get_type should not fail");1015 NixType::from_int(ty)1016 }1017 fn builtin_to_string(&self) -> Result<Self> {1018 let builtin = Self::eval("builtins.toString")?;1019 builtin.call(self.clone())1020 }1021 fn force(&mut self, s: *mut nix_raw::EvalState) -> Result<()> {1022 with_default_context(|c, _| unsafe { value_force(c, s, self.0) })?;1023 Ok(())1024 }1025 pub fn to_string(&self) -> Result<String> {1026 let ty = self.type_of();1027 if !matches!(ty, NixType::String) {1028 bail!("unexpected type: {ty:?}, expected string");1029 }1030 let mut str_out = String::new();1031 with_default_context(|c, _| unsafe {1032 get_string(c, self.0, Some(copy_nix_str), (&raw mut str_out).cast())1033 })?;10341035 Ok(str_out)1036 }1037 pub fn to_realised_string(&self) -> Result<RealisedString> {1038 with_default_context(|c, es| unsafe { string_realise(c, es, self.0, false) })1039 .map(RealisedString)10401041 // let store_paths = unsafe { nix_raw::realised_string_get_store_path_count(str) };1042 // for i in 0..store_paths {1043 // let store_path = unsafe { nix_raw::realised_string_get_store_path(str, i) };1044 // nix_raw::store_path_name(store_path, callback, user_data);1045 // }1046 // dbg!(store_paths);1047 // todo!();1048 }10491050 pub fn has_field(&self, field: &str) -> Result<bool> {1051 if !matches!(self.type_of(), NixType::Attrs) {1052 bail!("invalid type: expected attrs");1053 }10541055 let f = init_field_name(field);1056 with_default_context(|c, es| unsafe { has_attr_byname(c, self.0, es, f.as_ptr().cast()) })1057 }1058 // pub fn derivation_path(&self) {1059 // nix_raw::real1060 // }1061 pub fn list_fields(&self) -> Result<Vec<String>> {1062 if !matches!(self.type_of(), NixType::Attrs) {1063 bail!("invalid type: expected attrs");1064 }10651066 let len = with_default_context(|c, _| unsafe { get_attrs_size(c, self.0) })?;1067 let mut out = Vec::with_capacity(len as usize);10681069 for i in 0..len {1070 let name =1071 with_default_context(|c, es| unsafe { get_attr_name_byidx(c, self.0, es, i) })?;1072 let c = unsafe { CStr::from_ptr(name) };1073 out.push(c.to_str().expect("nix field names are utf-8").to_owned());1074 }1075 Ok(out)1076 }1077 pub fn get_elem(&self, v: usize) -> Result<Self> {1078 if !matches!(self.type_of(), NixType::List) {1079 bail!("invalid type: expected list");1080 }1081 let len = with_default_context(|c, _| unsafe { get_list_size(c, self.0) })? as usize;1082 if v >= len {1083 bail!("oob list get: {v} >= {len}");1084 }10851086 with_default_context(|c, es| unsafe { get_list_byidx(c, self.0, es, v as u32) }).map(Self)1087 }1088 pub fn attrs_update(self, other: Value /*, ignore_errors: bool*/) -> Result<Self> {1089 let attrs_update_fn = Self::eval("a: b: a // b")?;10901091 attrs_update_fn1092 .call(self)?1093 .call(other)1094 .context("attrs update")1095 }1096 pub fn get_field(&self, name: impl AsFieldName) -> Result<Self> {1097 if !matches!(self.type_of(), NixType::Attrs) {1098 bail!("invalid type: expected attrs");1099 }11001101 name.as_field_name(|name| {1102 with_default_context(|c, es| unsafe {1103 get_attr_byname(c, self.0, es, name.as_ptr().cast())1104 })1105 .map(Self)1106 })1107 .with_context(|| format!("getting field {:?}", name.to_field_name()))1108 }1109 pub fn call(&self, v: Value) -> Result<Self> {1110 let kind = self1111 .functor_kind()1112 .ok_or_else(|| anyhow!("can only call function or functor"))?;11131114 let function = match kind {1115 FunctorKind::Function => self.clone(),1116 FunctorKind::Functor => {1117 let f = self1118 .get_field("__functor")1119 .context("getting functor value")?;1120 assert_eq!(1121 f.type_of(),1122 NixType::Function,1123 "invalid functor encountered"1124 );1125 f1126 }1127 };11281129 let out = Value::new_uninit();1130 with_default_context(|c, es| unsafe { value_call(c, es, function.0, v.0, out.0) })?;11311132 Ok(out)1133 }1134 pub fn eval(v: &str) -> Result<Self> {1135 let s = CString::new(v).expect("expression shouldn't have internal NULs");1136 let out = Self::new_uninit();1137 with_default_context(|c, es| unsafe {1138 expr_eval_from_string(c, es, s.as_ptr(), c"/root".as_ptr(), out.0)1139 })?;1140 Ok(out)1141 }1142 #[instrument(name = "build", skip(self), fields(output))]1143 pub fn build(&self, output: &str) -> Result<Utf8PathBuf> {1144 if !self.is_derivation() {1145 bail!("expected derivation to build")1146 }1147 let output_name = self1148 .get_field("outputName")1149 .context("getting output name field")?1150 .to_string()?;1151 let v = if output_name != output {1152 let out = self.get_field(output).context("getting target output")?;1153 if !out.is_derivation() {1154 bail!("unknown output: {output}");1155 }1156 out1157 } else {1158 self.clone()1159 };11601161 let drv_path = Utf8PathBuf::from(1162 v.get_field("drvPath")1163 .context("getting drvPath")?1164 .to_string()?,1165 );1166 let graph = Arc::new(drv::DrvGraph::resolve(&eval_store(), &drv_path)?);1167 let _guard = logging::register_build_graph(&Span::current(), &graph);11681169 scheduler::build_graph_sync(graph.clone(), vec![output.to_owned()])?;11701171 let s = v.builtin_to_string()?;1172 let rs = s.to_realised_string()?;1173 let out_path = rs.as_str().to_owned();1174 Ok(Utf8PathBuf::from(out_path))1175 }1176 pub fn as_json<T: DeserializeOwned>(&self) -> Result<T> {1177 let to_json = Self::eval("builtins.toJSON")?;1178 let s = to_json.call(self.clone())?.to_string()?;1179 Ok(serde_json::from_str(&s)?)1180 }1181 pub fn serialized<T: Serialize>(v: &T) -> Result<Self> {1182 Self::eval(&nixlike::serialize(v)?)1183 }11841185 // Convert to string/evaluate derivations/etc1186 // fn to_string_weak(&self) -> Result<String> {1187 // // TODO: For now, it works exactly like to_string, see the comment for fn force()1188 // self.to_string()1189 // }11901191 fn is_derivation(&self) -> bool {1192 if !matches!(self.type_of(), NixType::Attrs) {1193 return false;1194 }1195 let Some(ty) = self.get_field("type").ok() else {1196 return false;1197 };1198 matches!(ty.to_string().as_deref(), Ok("derivation"))1199 }1200 fn functor_kind(&self) -> Option<FunctorKind> {1201 match self.type_of() {1202 NixType::Attrs => self1203 .has_field("__functor")1204 .expect("has_field shouldn't fail for attrs")1205 .then_some(FunctorKind::Functor),1206 NixType::Function => Some(FunctorKind::Function),1207 _ => None,1208 }1209 }1210 pub fn is_function(&self) -> bool {1211 self.functor_kind().is_some()1212 }1213 pub fn is_null(&self) -> bool {1214 matches!(self.type_of(), NixType::Null)1215 }1216 pub fn is_string(&self) -> bool {1217 matches!(self.type_of(), NixType::String)1218 }1219 pub fn is_attrs(&self) -> bool {1220 matches!(self.type_of(), NixType::Attrs)1221 }1222}12231224impl From<String> for Value {1225 fn from(value: String) -> Self {1226 Value::new_str(&value)1227 }1228}1229impl From<bool> for Value {1230 fn from(value: bool) -> Self {1231 Value::new_bool(value)1232 }1233}1234impl From<&str> for Value {1235 fn from(value: &str) -> Self {1236 Value::new_str(value)1237 }1238}1239impl<T> From<Vec<T>> for Value1240where1241 T: Into<Value>,1242{1243 fn from(value: Vec<T>) -> Self {1244 Value::new_list(value)1245 }1246}12471248impl Clone for Value {1249 fn clone(&self) -> Self {1250 with_default_context(|c, _| unsafe { value_incref(c, self.0) })1251 .expect("value incref should not fail");1252 Self(self.0)1253 }1254}1255impl Drop for Value {1256 fn drop(&mut self) {1257 with_default_context(|c, _| unsafe { value_decref(c, self.0) })1258 .expect("value drop should not fail");1259 }1260}12611262static TOKIO_FOR_NIX: OnceLock<Arc<tokio::runtime::Runtime>> = OnceLock::new();12631264pub fn init_libraries() {1265 unsafe { GC_allow_register_threads() };12661267 let mut ctx = NixContext::new();1268 ctx.run_in_context(|c| unsafe { libutil_init(c) })1269 .expect("util init should not fail");1270 ctx.run_in_context(|c| unsafe { libstore_init(c) })1271 .expect("store init should not fail");1272 ctx.run_in_context(|c| unsafe { libexpr_init(c) })1273 .expect("expr init should not fail");12741275 nix_logging_cxx::apply_tracing_logger();1276}12771278pub fn init_tokio_for_nix(tokio: Arc<tokio::runtime::Runtime>) {1279 TOKIO_FOR_NIX1280 .set(tokio)1281 .expect("tokio for nix should only be initialized once");1282}12831284pub fn await_in_nix<F: Send + 'static>(f: impl Future<Output = F> + Send + 'static) -> F {1285 // It should be possible to do Handle::current(), but some of the planned features don't work well with that1286 let runtime = TOKIO_FOR_NIX1287 .get()1288 .expect("init_tokio_for_nix was not called");1289 std::thread::spawn(move || runtime.block_on(f))1290 .join()1291 .expect("await_in_nix inner thread panicked")1292}12931294unsafe extern "C" fn nix_primop_closure_adapter<const N: usize>(1295 user_data: *mut c_void,1296 mut context: *mut c_context,1297 state: *mut nix_raw::EvalState,1298 args: *mut *mut value,1299 ret: *mut value,1300) {1301 let user_closure: &UserClosure<N> = unsafe { &*user_data.cast_const().cast() };1302 let args: [&Value; N] = array::from_fn(|i| {1303 let v: &mut Value = unsafe { &mut *args.add(i).cast() };1304 v as &Value1305 });1306 let ctx: &mut NixContext = unsafe { transmute(&mut context) };13071308 let state: &EvalState = unsafe { std::mem::transmute(&state) };13091310 match user_closure(state, args) {1311 Ok(v) => {1312 unsafe { copy_value(context, ret, v.0) };1313 }1314 Err(e) => {1315 ctx.set_err(e);1316 }1317 }1318}13191320type UserClosure<const N: usize> = Box<dyn Fn(&EvalState, [&Value; N]) -> Result<Value>>;13211322pub struct NativeFn(*mut PrimOp);1323impl NativeFn {1324 pub fn new<const N: usize>(1325 name: &'static CStr,1326 doc: &'static CStr,1327 args: [&'static CStr; N],1328 f: impl Fn(&EvalState, [&Value; N]) -> Result<Value> + 'static,1329 ) -> Self {1330 // Double-boxing to make it thin pointer, as vtable gets outside of first Box1331 let closure: Box<UserClosure<N>> = Box::new(Box::new(f));1332 let f: PrimOpFun = Some(nix_primop_closure_adapter::<N>);1333 let mut args = args.into_iter().map(|v| v.as_ptr()).collect_vec();1334 args.push(null());1335 let args = args.as_mut_ptr();1336 let primop = unsafe {1337 alloc_primop(1338 null_mut(),1339 f,1340 N as i32,1341 name.as_ptr(),1342 args,1343 doc.as_ptr(),1344 Box::into_raw(closure).cast(),1345 )1346 };13471348 assert!(!primop.is_null(), "primop allocation should not fail");13491350 Self(primop)1351 }1352 pub fn register(self) {1353 unsafe { register_primop(null_mut(), self.0) };1354 }1355}13561357pub struct StorePath(*mut c_store_path);1358impl StorePath {1359 fn as_ptr(&self) -> *mut c_store_path {1360 self.01361 }1362}13631364impl Drop for StorePath {1365 fn drop(&mut self) {1366 unsafe { store_path_free(self.0) }1367 }1368}13691370#[test_log::test]1371fn test_native() -> Result<()> {1372 init_libraries();1373 NativeFn::new(1374 c"__uppercaseSuffix2",1375 c"make string uppercase and add suffix",1376 [c"str", c"suffix"],1377 |_, [str, suffix]: [&Value; 2]| {1378 let str = str.to_string()?;1379 let suffix = suffix.to_string()?;1380 Ok(Value::new_str(&format!("{}{suffix}", str.to_uppercase())))1381 },1382 )1383 .register();13841385 let mut fetch_settings = FetchSettings::new();1386 fetch_settings.set(c"warn-dirty", c"false");13871388 let manifest = format!("git+file://{}/../../", env!("CARGO_MANIFEST_DIR"));1389 let flake = FlakeSettings::new()?;1390 let parse = FlakeReferenceParseFlags::new(&flake)?;1391 let (mut r, _) = FlakeReference::new(&manifest, &flake, &parse, &fetch_settings)?;1392 let lock = FlakeLockFlags::new(&flake)?;1393 let locked = r.lock(&fetch_settings, &flake, &lock)?;1394 let attrs = locked.get_attrs(&mut FlakeSettings::new()?)?;13951396 let builtins = Value::eval("builtins")?;1397 assert_eq!(builtins.type_of(), NixType::Attrs);13981399 assert_eq!(attrs.type_of(), NixType::Attrs);1400 let test_data = nix_go!(attrs.testData);14011402 let test_string: String = nix_go_json!(test_data.testString);1403 assert_eq!(test_string, "hello");14041405 let s = nix_go!(attrs.packages["x86_64-linux"].fleet.drvPath);1406 let s = CString::new(s.to_string()?).expect("path str is cstring");14071408 let uppercase_suffix = Value::new_primop(NativeFn::new(1409 c"uppercase_suffix",1410 c"make string uppercase and add suffix",1411 [c"str", c"suffix"],1412 |es, [str, suffix]: [&Value; 2]| {1413 let str = str.to_string()?;1414 let suffix = suffix.to_string()?;1415 Ok(Value::new_str(&format!("{}{suffix}", str.to_uppercase())))1416 },1417 ));14181419 let test_result: String = nix_go_json!(test_data.testPrimop(uppercase_suffix));1420 assert_eq!(test_result, "PREFIX_BODY_SUFFIX");1421 let test_result: String = nix_go_json!(builtins.uppercaseSuffix2("test")("suffix"));1422 assert_eq!(test_result, "TESTsuffix");14231424 let drv_path =1425 nix_go!(attrs.packages["x86_64-linux"]["fleet-install-secrets"].drvPath).to_string()?;1426 let graph = drv::DrvGraph::resolve(&drv_path)?;1427 eprintln!(1428 "fleet-install-secrets dependency graph: {} nodes",1429 graph.nodes.len()1430 );1431 for (path, node) in &graph.nodes {1432 if !node.input_drvs.is_empty() {1433 eprintln!(" {} ({} deps)", node.name, node.input_drvs.len());1434 }1435 }14361437 Ok(())1438}14391440// pub struct GcAlloc;1441// unsafe impl GlobalAlloc for GcAlloc {1442// unsafe fn alloc(&self, l: Layout) -> *mut u8 {1443// let ptr = unsafe { GC_malloc(l.size()) };1444// ptr.cast()1445// }1446// unsafe fn dealloc(&self, ptr: *mut u8, _: Layout) {1447// // unsafe { GC_free(ptr.cast()) };1448// }1449//1450// unsafe fn realloc(&self, ptr: *mut u8, _: Layout, new_size: usize) -> *mut u8 {1451// let ptr = unsafe { GC_realloc(ptr.cast(), new_size) };1452// ptr.cast()1453// }1454// }1455//1456// #[global_allocator]1457// 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