git.delta.rocks / fleet / refs/commits / 48d120aa4aef

difftreelog

ci use in-tree remowt-agent

qztpqskoYaroslav Bolyukin2026-06-18parent: #0ea4b47.patch.diff

42 files changed

modifiedCargo.lockdiffbeforeafterboth
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -722,9 +722,9 @@
 
 [[package]]
 name = "camino"
-version = "1.2.2"
+version = "1.2.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48"
+checksum = "b4ce8d3bd5823c7504d3f579f13e7b2f3da252fcb938c594d5680ee508bf846f"
 dependencies = [
  "serde_core",
 ]
@@ -1899,9 +1899,9 @@
  "fleet-shared",
  "hex",
  "hmac 0.13.0",
- "pbkdf2 0.12.2",
+ "pbkdf2 0.13.0",
  "rand 0.10.1",
- "sha2 0.10.9",
+ "sha2 0.11.0",
  "x25519-dalek",
 ]
 
@@ -5011,6 +5011,7 @@
  "nix-eval",
  "remowt-client",
  "remowt-endpoints",
+ "remowt-link-shared",
  "serde",
  "thiserror 2.0.18",
  "tokio",
@@ -5031,13 +5032,13 @@
  "iroh-base",
  "n0-watcher",
  "noq-udp",
- "remowt-ui-prompt",
  "serde",
  "serde_json",
  "thiserror 2.0.18",
  "tokio",
  "tokio-util",
  "tracing",
+ "uuid",
 ]
 
 [[package]]
@@ -5093,11 +5094,11 @@
  "anyhow",
  "bifrostlink",
  "bifrostlink-macros",
+ "remowt-link-shared",
  "serde",
  "thiserror 2.0.18",
  "tokio",
  "tracing",
- "zbus",
 ]
 
 [[package]]
modifiedCargo.tomldiffbeforeafterboth
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -15,11 +15,11 @@
 remowt-fleet = { path = "./crates/remowt-fleet" }
 
 remowt-client = { version = "0.1.9", path = "remowt/crates/remowt-client" }
-remowt-polkit-shared = { version = "0.1.9", path = "remowt/crates/polkit-shared" }
+remowt-endpoints = { version = "0.1.9", path = "remowt/crates/remowt-endpoints" }
 remowt-link-shared = { version = "0.1.9", path = "remowt/crates/remowt-link-shared" }
 remowt-plugin = { version = "0.1.9", path = "remowt/crates/remowt-plugin" }
+remowt-polkit-shared = { version = "0.1.9", path = "remowt/crates/polkit-shared" }
 remowt-ui-prompt = { version = "0.1.9", path = "remowt/crates/remowt-ui-prompt" }
-remowt-endpoints = { version = "0.1.9", path = "remowt/crates/remowt-endpoints" }
 
 bifrostlink = "0.2.0"
 bifrostlink-macros = "0.2.0"
@@ -27,16 +27,9 @@
 
 iroh = { version = "1.0.0", features = ["unstable-custom-transports"] }
 iroh-base = "1.0.0"
-noq-udp = { version = "1.0.0", default-features = false }
 n0-watcher = "1.0.0"
+noq-udp = { version = "1.0.0", default-features = false }
 
-uuid = { version = "1", features = ["v4"] }
-russh = { version = "0.61.2", default-features = false, features = [
-        "ring",
-        "flate2",
-        "rsa",
-] }
-russh-config = "0.58.0"
 age = { version = "0.11", features = ["plugin", "ssh"] }
 anyhow = "1.0"
 base64 = "0.22.1"
@@ -64,13 +57,15 @@
 opentelemetry-appender-tracing = "0.32.0"
 opentelemetry-otlp = { version = "0.32.0", features = ["grpc-tonic", "gzip-tonic", "http-json", "reqwest-rustls"] }
 opentelemetry_sdk = "0.32.0"
-pbkdf2 = "0.12"
+pbkdf2 = "0.13"
 peg = "0.8.5"
 pkg-config = "0.3.30"
 rand = "0.10.0"
+russh = { version = "0.61.2", default-features = false, features = ["flate2", "ring", "rsa"] }
+russh-config = "0.58.0"
 serde = { version = "1.0", features = ["derive"] }
 serde_json = "1.0"
-sha2 = "0.10"
+sha2 = "0.11"
 shlex = "2.0.1"
 tabled = "0.21.0"
 tempfile = "3.20"
@@ -81,14 +76,15 @@
 tracing = "0.1"
 tracing-indicatif = "0.3.13"
 tracing-opentelemetry = "0.33.0"
+uuid = { version = "1", features = ["v4"] }
 # For fixed coloring needs to be updated to https://github.com/tokio-rs/tracing/pull/3484
+tokio-util = "0.7.11"
 tracing-subscriber = { version = "0.3.19", features = ["env-filter", "fmt"] }
 unicode_categories = "0.1.1"
 vte = { version = "0.15.0", features = ["ansi"] }
 x25519-dalek = { version = "2.0.1", features = ["getrandom"] }
 zbus = "5.16.0"
 zbus_polkit = "5.0.0"
-tokio-util = "0.7.11"
 
 [profile.dev]
 panic = "abort"
modifiedcmds/fleet/src/cmds/build_systems.rsdiffbeforeafterboth
--- a/cmds/fleet/src/cmds/build_systems.rs
+++ b/cmds/fleet/src/cmds/build_systems.rs
@@ -5,8 +5,9 @@
 use clap::Parser;
 use fleet_base::{
 	deploy::{DeployAction, deploy_task, upload_task},
-	host::{Config, DeployKind, GenerationStorage},
+	host::{Config, DeployKind},
 	opts::FleetOpts,
+	pins::GenerationStorage,
 };
 use futures::{StreamExt as _, stream::FuturesUnordered};
 use nix_eval::nix_go;
@@ -145,8 +146,7 @@
 					}
 
 					let remote_path =
-						match upload_task(&config, &host, GenerationStorage::Deployer, built).await
-						{
+						match upload_task(&host, GenerationStorage::Deployer, built).await {
 							Ok(v) => v,
 							Err(e) => {
 								error!("upload failed: {e}");
modifiedcmds/fleet/src/cmds/rollback.rsdiffbeforeafterboth
--- a/cmds/fleet/src/cmds/rollback.rs
+++ b/cmds/fleet/src/cmds/rollback.rs
@@ -4,8 +4,9 @@
 use clap::Parser;
 use fleet_base::{
 	deploy::{DeployAction, deploy_task, upload_task},
-	host::{Config, ConfigHost, Generation, GenerationStorage},
+	host::{Config, ConfigHost},
 	opts::FleetOpts,
+	pins::{Generation, GenerationStorage},
 };
 use tabled::Table;
 use tracing::{info, warn};
@@ -103,13 +104,8 @@
 						Table::new(&generations)
 					);
 				};
-				let remote_path = upload_task(
-					config,
-					&host,
-					generation.location,
-					generation.store_path.clone(),
-				)
-				.await?;
+				let remote_path =
+					upload_task(&host, generation.location, generation.store_path.clone()).await?;
 
 				deploy_task(
 					action,
deletedcmds/fleet/src/extra_args.rsdiffbeforeafterboth
--- a/cmds/fleet/src/extra_args.rs
+++ /dev/null
@@ -1,18 +0,0 @@
-use std::ffi::{OsStr, OsString};
-
-use anyhow::{Result, anyhow};
-
-pub fn parse_os(os: &OsStr) -> Result<Vec<OsString>> {
-	Ok(shlex::bytes::split(os.as_encoded_bytes())
-		.ok_or_else(|| anyhow!("invalid arguments"))?
-		.into_iter()
-		.map(|a| {
-			// Unpaired surrogates are not touched
-			unsafe { OsString::from_encoded_bytes_unchecked(a) }
-		})
-		.collect())
-}
-// pub fn parse(s: &str) -> Result<Vec<OsString>> {
-// 	let osstr = OsString::try_from(s)?;
-// 	parse_os(&osstr)
-// }
modifiedcmds/fleet/src/main.rsdiffbeforeafterboth
--- a/cmds/fleet/src/main.rs
+++ b/cmds/fleet/src/main.rs
@@ -1,8 +1,6 @@
 #![recursion_limit = "512"]
 
 pub(crate) mod cmds;
-// pub(crate) mod command;
-pub(crate) mod extra_args;
 
 use std::{process::ExitCode, sync::Arc};
 
modifiedcmds/generator-helper/src/main.rsdiffbeforeafterboth
--- a/cmds/generator-helper/src/main.rs
+++ b/cmds/generator-helper/src/main.rs
@@ -14,7 +14,7 @@
 use clap::{Parser, ValueEnum};
 use ed25519_dalek::SecretKey;
 use fleet_shared::SecretData;
-use hmac::Mac as _;
+use hmac::{KeyInit as _, Mac as _};
 use rand::{
 	Rng as _,
 	distr::{Alphanumeric, Distribution, SampleString, Uniform},
@@ -338,8 +338,8 @@
 					);
 
 					type HmacSha256 = hmac::Hmac<sha2::Sha256>;
-					let mut mac = <HmacSha256 as hmac::Mac>::new_from_slice(&salted)
-						.expect("HMAC accepts any key length");
+					let mut mac =
+						HmacSha256::new_from_slice(&salted).expect("HMAC accepts any key length");
 					mac.update(b"Client Key");
 					let client_key = mac.finalize().into_bytes();
 
@@ -347,8 +347,8 @@
 					hasher.update(client_key);
 					let stored_key = hasher.finalize();
 
-					let mut mac = <HmacSha256 as hmac::Mac>::new_from_slice(&salted)
-						.expect("HMAC accepts any key length");
+					let mut mac =
+						HmacSha256::new_from_slice(&salted).expect("HMAC accepts any key length");
 					mac.update(b"Server Key");
 					let server_key = mac.finalize().into_bytes();
 
modifiedcmds/remowt-plugin-fleet/Cargo.tomldiffbeforeafterboth
--- a/cmds/remowt-plugin-fleet/Cargo.toml
+++ b/cmds/remowt-plugin-fleet/Cargo.toml
@@ -2,7 +2,7 @@
 name = "remowt-plugin-fleet"
 description = "Remowt plugin exposing fleet's Nix endpoint to a running agent"
 version.workspace = true
-edition = "2021"
+edition.workspace = true
 
 [dependencies]
 remowt-fleet.workspace = true
modifiedcrates/fleet-base/src/deploy.rsdiffbeforeafterboth
--- a/crates/fleet-base/src/deploy.rs
+++ b/crates/fleet-base/src/deploy.rs
@@ -11,7 +11,8 @@
 use tokio::time::sleep;
 use tracing::{Instrument as _, error, info, info_span, warn};
 
-use crate::host::{Config, ConfigHost, DeployKind, Generation, GenerationStorage};
+use crate::host::{ConfigHost, DeployKind};
+use crate::pins::{Generation, GenerationStorage};
 
 #[derive(ValueEnum, Clone, Copy)]
 pub enum DeployAction {
@@ -260,13 +261,12 @@
 			{
 				// It is ok, if there was no reboot - then timer might not be running.
 			}
-			if action.should_schedule_rollback_run() {
-				if let Err(e) = elevated_systemd
+			if action.should_schedule_rollback_run()
+				&& let Err(e) = elevated_systemd
 					.stop("rollback-watchdog-run.timer".to_owned())
 					.await
-				{
-					error!("failed to disarm rollback run: {e}");
-				}
+			{
+				error!("failed to disarm rollback run: {e}");
 			}
 		} else if let Err(_e) = elevated_fs
 			.rm_file(Utf8PathBuf::from("/etc/fleet_rollback_marker"))
@@ -280,7 +280,6 @@
 }
 
 pub async fn upload_task(
-	config: &Config,
 	host: &ConfigHost,
 	location: GenerationStorage,
 	generation: Utf8PathBuf,
modifiedcrates/fleet-base/src/fleetdata.rsdiffbeforeafterboth
before · crates/fleet-base/src/fleetdata.rs
1use std::{2	cmp::Ordering,3	collections::{4		BTreeMap, BTreeSet,5		btree_map::{self, Entry},6	},7	fmt,8	io::{self, Cursor},9	sync::RwLock,10};1112use age::Recipient;13use chrono::{DateTime, Utc};14use fleet_shared::SecretData;15use rand::{16	distr::{Alphanumeric, SampleString as _},17	rng,18};19use serde::{20	Deserialize, Serialize,21	de::{self, Error},22};23use serde_json::Value;24use tracing::info;2526#[derive(Serialize, Deserialize, Default)]27#[serde(rename_all = "camelCase")]28pub struct HostData {29	#[serde(default)]30	#[serde(skip_serializing_if = "String::is_empty")]31	pub encryption_key: String,32}3334const VERSION: &str = "0.1.0";35pub struct FleetDataVersion;36impl Serialize for FleetDataVersion {37	fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>38	where39		S: serde::Serializer,40	{41		VERSION.serialize(serializer)42	}43}44impl<'de> Deserialize<'de> for FleetDataVersion {45	fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>46	where47		D: serde::Deserializer<'de>,48	{49		let version = String::deserialize(deserializer)?;50		if version != VERSION {51			return Err(D::Error::custom(format!(52				"fleet.nix data version mismatch, expected {VERSION}, got {version}.\nFollow the docs for migration instruction"53			)));54		}55		Ok(Self)56	}57}5859fn generate_gc_prefix() -> String {60	let id = Alphanumeric.sample_string(&mut rng(), 8);61	format!("fleet-gc-{id}")62}6364#[derive(Serialize, Deserialize)]65#[serde(rename_all = "camelCase")]66pub struct ManagerKey {67	pub name: String,68	pub key: String,69}7071#[derive(Serialize, Deserialize)]72#[serde(rename_all = "camelCase")]73pub struct FleetData {74	pub version: FleetDataVersion,75	#[serde(default = "generate_gc_prefix")]76	pub gc_root_prefix: String,7778	#[serde(default, skip_serializing_if = "Vec::is_empty")]79	pub manager_keys: Vec<ManagerKey>,8081	#[serde(default)]82	pub hosts: RwLock<BTreeMap<String, HostData>>,8384	#[serde(default, alias = "shared_secrets")]85	pub secrets: RwLock<FleetSecrets>,8687	// extra_name => anything88	#[serde(default)]89	pub extra: RwLock<BTreeMap<String, Value>>,9091	#[serde(default)]92	#[serde(skip_serializing)]93	host_secrets: BTreeMap<SecretOwner, BTreeMap<String, FleetSecretDistribution>>,94}95impl FleetData {96	pub fn from_str(s: &str) -> anyhow::Result<Self> {97		let mut data: Self = nixlike::parse_str(s)?;98		if !data.host_secrets.is_empty() {99			info!("migrating host secrets into shared secrets structure");100			data.secrets101				.write()102				.expect("no poisoning")103				.merge_from_hosts(std::mem::take(&mut data.host_secrets));104		}105		Ok(data)106	}107}108109/// Returns None if recipients.is_empty()110pub fn encrypt_secret_data<'r>(111	recipients: impl IntoIterator<Item = &'r Box<dyn Recipient>>,112	data: Vec<u8>,113) -> Option<SecretData> {114	let mut encrypted = vec![];115	let mut encryptor = age::Encryptor::with_recipients(recipients.into_iter().map(|v| &**v))116		.ok()?117		.wrap_output(&mut encrypted)118		.expect("in memory write");119	io::copy(&mut Cursor::new(data), &mut encryptor).expect("in memory copy");120	encryptor.finish().expect("in memory flush");121	Some(SecretData {122		data: encrypted,123		encrypted: true,124	})125}126127#[derive(Serialize, Deserialize, Clone, Debug)]128pub struct FleetSecretPart {129	pub raw: SecretData,130}131132#[derive(Serialize, Deserialize, Clone, Debug)]133#[serde(rename_all = "camelCase")]134#[must_use]135pub struct FleetSecretData {136	pub created_at: DateTime<Utc>,137	#[serde(default, skip_serializing_if = "Option::is_none", alias = "expire_at")]138	pub expires_at: Option<DateTime<Utc>>,139140	#[serde(flatten)]141	pub parts: BTreeMap<String, FleetSecretPart>,142143	#[serde(default, skip_serializing_if = "Value::is_null")]144	pub generation_data: Value,145}146147#[derive(Serialize, Deserialize, Clone, Debug, PartialOrd, Ord, PartialEq, Eq)]148#[repr(transparent)]149pub struct SecretOwner(String);150151impl fmt::Display for SecretOwner {152	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {153		write!(f, "host:{}", self.0)154	}155}156157impl SecretOwner {158	pub fn host(s: impl AsRef<str>) -> SecretOwner {159		SecretOwner(s.as_ref().to_owned())160	}161	pub fn as_host(&self) -> Option<&str> {162		Some(&self.0)163	}164}165166#[derive(Serialize, Deserialize, Clone, Debug)]167#[serde(rename_all = "camelCase")]168#[must_use]169pub struct FleetSecretDistribution {170	#[serde(default)]171	owners: BTreeSet<SecretOwner>,172	#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]173	owners_pending_prune: BTreeMap<SecretOwner, String>,174175	#[serde(flatten)]176	pub secret: FleetSecretData,177178	#[serde(default, skip_serializing_if = "Option::is_none")]179	pending_prune: Option<String>,180	#[serde(default, skip_serializing, alias = "managed")]181	_deprecated_managed: bool,182}183184const EMPTY_PENDING_PRUNE: &BTreeMap<SecretOwner, String> = &BTreeMap::new();185impl FleetSecretDistribution {186	pub fn new(owners: BTreeSet<SecretOwner>, secret: FleetSecretData, now: DateTime<Utc>) -> Self {187		assert!(188			!owners.is_empty(),189			"distribution should have at least one owner"190		);191		if let Some(expires_at) = &secret.expires_at {192			assert!(193				*expires_at > now,194				"secret should not be expired on creation"195			);196		}197		Self {198			owners,199			secret,200			owners_pending_prune: BTreeMap::new(),201			pending_prune: None,202			_deprecated_managed: true,203		}204	}205206	fn owners_ex(&self, including_pruned: bool) -> impl Iterator<Item = &SecretOwner> {207		let pending_prune = if including_pruned {208			&self.owners_pending_prune209		} else {210			EMPTY_PENDING_PRUNE211		};212		self.owners.iter().chain(pending_prune.keys())213	}214	pub fn owners(&self) -> impl Iterator<Item = &SecretOwner> {215		self.owners_ex(false)216	}217	pub fn owners_pending_prune(&self) -> impl Iterator<Item = &SecretOwner> {218		self.owners_pending_prune.keys()219	}220	pub fn is_pending_prune(&self) -> bool {221		self.pending_prune.is_some()222	}223224	pub fn prune(&mut self, reason: String) {225		assert!(226			self.pending_prune.is_none(),227			"it shouldn't be possible to prune the same distribution twice using public api"228		);229		self.pending_prune = Some(reason);230	}231	pub fn prune_owners(&mut self, owners: &BTreeSet<SecretOwner>, reason: String) {232		// if self.owners.iter().all(|o| owners.contains(o)) && self.owners_pending_prune.is_empty() {233		// 	self.prune(format!("all owners were pruned: {reason}"));234		// 	return;235		// }236		for owner in owners {237			if self.owners.remove(owner) {238				self.owners_pending_prune239					.insert(owner.to_owned(), reason.clone());240			}241		}242		// if self.owners.is_empty() {243		// 	self.prune("no owners left".to_owned());244		// }245	}246	pub fn unprune_owner(&mut self, owner: SecretOwner) {247		if self.owners_pending_prune.remove(&owner).is_some() {248			self.owners.insert(owner);249		}250	}251}252253#[derive(Clone, Debug, Default)]254#[must_use]255pub struct FleetSecretDistributions {256	stored: Vec<FleetSecretDistribution>,257}258259fn compare_dists(260	a: &FleetSecretDistribution,261	b: &FleetSecretDistribution,262	prefer_identities: &BTreeSet<SecretOwner>,263	include_pruned_owners: bool,264) -> Ordering {265	use Ordering::*;266	if prefer_identities.is_empty() {267		let a_has = a268			.owners_ex(include_pruned_owners)269			.any(|o| prefer_identities.contains(o));270		let b_has = b271			.owners_ex(include_pruned_owners)272			.any(|o| prefer_identities.contains(o));273		match (a_has, b_has) {274			(true, false) => return Greater,275			(false, true) => return Less,276			_ => {}277		}278	}279	match (a.secret.expires_at, b.secret.expires_at) {280		(None, Some(_)) => return Greater,281		(Some(_), None) => return Less,282		(Some(a), Some(b)) => {283			// Later is better284			return a.cmp(&b);285		}286		(None, None) => {}287	}288289	// Which one is easier to access290	a.owners.len().cmp(&b.owners.len())291}292293impl FleetSecretDistributions {294	/// Drop expired distributions295	fn prune_expired(&mut self, now: DateTime<Utc>) {296		for ele in self.distributions_mut() {297			if let Some(expires_at) = ele.secret.expires_at {298				if expires_at < now {299					ele.prune(format!("expired during check at {now}"));300				}301			}302		}303	}304	/// Perform all pruning relevant to shared secrets305	/// Also see expected_owner_removed306	pub fn prune_shared(307		&mut self,308		expected_owners: &BTreeSet<SecretOwner>,309		unique: bool,310		expected_parts: &BTreeMap<String, GeneratorPart>,311		expected_generation_data: &Value,312		regenerate_on_owner_removed: bool,313		regenerate_on_owner_added: bool,314		prefer_identities: &BTreeSet<SecretOwner>,315		now: DateTime<Utc>,316	) {317		self.prune_expired(now);318		self.prune_generation_data(expected_generation_data, None);319		self.prune_missing_parts(expected_parts, None);320321		let current_owners = self.owners().cloned().collect::<BTreeSet<SecretOwner>>();322323		let mut to_add = expected_owners.difference(&current_owners);324		if to_add.next().is_some() && unique && regenerate_on_owner_added {325			for dist in self.distributions_mut() {326				dist.prune(format!(327					"owners missing, can't add new distribution, regeneration preferred"328				));329			}330			return;331		}332333		for to_remove in current_owners.difference(&expected_owners) {334			self.entry(to_remove.clone()).remove(335				regenerate_on_owner_removed,336				"owner was removed from expected owners list, regenerate_on_owner_removed is set"337					.to_string(),338			);339		}340		if unique {341			self.prune_nonunique(prefer_identities);342		}343	}344	pub fn prune_host(345		&mut self,346		owner: SecretOwner,347		expected_parts: &BTreeMap<String, GeneratorPart>,348		expected_generation_data: &Value,349		now: DateTime<Utc>,350	) {351		self.prune_expired(now);352		self.prune_generation_data(expected_generation_data, Some(&owner));353		// TODO: Owner-based pruning is warranted (e.g host no longer has secret defined)354		self.prune_missing_parts(expected_parts, Some(&owner));355	}356	/// Position of best distributions as in iterator returned by distributions()357	/// None if distributions not found358	fn best_idx(359		&self,360		prefer_identities: &BTreeSet<SecretOwner>,361		include_pruned_owners: bool,362	) -> Option<usize> {363		self.distributions()364			.enumerate()365			.max_by(|(_, a), (_, b)| {366				compare_dists(&a, &b, prefer_identities, include_pruned_owners)367			})368			.map(|(p, _)| p)369	}370	/// Secret wants to be the same on all hosts, leave only one unpruned version of it371	fn prune_nonunique(&mut self, prefer_identities: &BTreeSet<SecretOwner>) {372		if self.distributions().next().is_none() {373			return;374		}375		let best = self.best_idx(prefer_identities, false).expect("not empty");376		for (i, dist) in self.distributions_mut().enumerate() {377			if i != best {378				dist.prune(379					"secret wants to be the same on all hosts, only the best one was left"380						.to_owned(),381				);382			}383		}384	}385386	pub fn try_unprune(&mut self, owner: SecretOwner) -> Option<&FleetSecretDistribution> {387		assert!(self.get(&owner).is_none(), "secret is not pruned for host");388		if let Some(dist) = self389			.distributions_mut()390			.find(|v| v.owners_pending_prune.contains_key(&owner))391		{392			dist.unprune_owner(owner);393			Some(dist)394		} else {395			None396		}397	}398399	pub fn best_distribution_for_reencryption(400		&mut self,401		prefer_identities: &BTreeSet<SecretOwner>,402	) -> Option<&mut FleetSecretDistribution> {403		let best_idx = self.best_idx(prefer_identities, true)?;404		self.distributions_mut().nth(best_idx)405	}406407	fn prune_missing_parts(408		&mut self,409		expected_parts: &BTreeMap<String, GeneratorPart>,410		filter_owner: Option<&SecretOwner>,411	) {412		'dist: for ele in self.distributions_mut() {413			if let Some(filter_owner) = filter_owner {414				if !ele.owners.contains(filter_owner) {415					continue;416				}417				// Note: secret still can have multiple owners even if it is host-owned418				// in this case we expect that all owners using the same generator, so we can prune distribution for all of them419			}420			for (name, part) in expected_parts {421				let Some(stored_part) = ele.secret.parts.get(name) else {422					ele.prune(format!("secret definition added new part: {name}"));423					continue 'dist;424				};425				if part.encrypted != stored_part.raw.encrypted {426					ele.prune(format!(427						"secret definition now requires part to be {}",428						if part.encrypted {429							"encrypted"430						} else {431							"non-encrypted"432						}433					));434					continue 'dist;435				}436			}437		}438	}439	fn prune_generation_data(440		&mut self,441		expected_generation_data: &Value,442		filter_owner: Option<&SecretOwner>,443	) {444		for ele in self.distributions_mut() {445			if let Some(filter_owner) = filter_owner {446				if !ele.owners.contains(filter_owner) {447					continue;448				}449				// Note: secret still can have multiple owners even if it is host-owned450				// in this case we expect that all owners using the same generator, so we can prune distribution for all of them451			}452			if ele.secret.generation_data != *expected_generation_data {453				ele.prune(format!(454					"expected generation data mismatch: {expected_generation_data:?}"455				));456			}457		}458	}459460	/// Prune all distributions with no unpruned owners.461	/// For ease of reencryption where possible, it is only called on persistence, when in memory - pruned owners are kept and462	/// can decrypt their secrets.463	fn prune_dead(&mut self) {464		for ele in self.distributions_mut() {465			if ele.owners.is_empty() {466				ele.prune("no owners left".to_owned());467			}468		}469	}470471	pub fn all_distributions(&self) -> impl Iterator<Item = &FleetSecretDistribution> {472		self.stored.iter()473	}474	pub fn distributions(&self) -> impl Iterator<Item = &FleetSecretDistribution> {475		self.stored.iter().filter(|v| v.pending_prune.is_none())476	}477	pub fn distributions_mut(&mut self) -> impl Iterator<Item = &mut FleetSecretDistribution> {478		self.stored.iter_mut().filter(|v| v.pending_prune.is_none())479	}480	pub fn owners(&self) -> impl Iterator<Item = &SecretOwner> {481		self.distributions().flat_map(|v| v.owners.iter())482	}483	#[allow(484		clippy::len_without_is_empty,485		reason = "should not be empty for a long time"486	)]487	pub fn len(&self) -> usize {488		self.distributions().count()489	}490491	pub fn get(&self, owner: &SecretOwner) -> Option<&FleetSecretDistribution> {492		self.distributions().find(|d| d.owners.contains(owner))493	}494	fn entry(&mut self, owner: SecretOwner) -> DistEntry<'_> {495		let Some((idx, dist)) = self496			.distributions()497			.enumerate()498			.find(|(_, d)| d.owners.contains(&owner))499		else {500			return DistEntry::Vacant(VacantDistEntry {501				distributions: self,502				owners: BTreeSet::from([owner]),503			});504		};505		DistEntry::Occupied(OccupiedDistEntry {506			owners: dist.owners.clone(),507			distributions: self,508			idx,509		})510	}511	pub fn extend(&mut self, dist: FleetSecretDistribution, reason: String) {512		for ele in self.distributions_mut() {513			ele.prune_owners(&dist.owners, reason.clone());514		}515		self.stored.push(dist);516	}517	pub fn contains(&self, owner: &SecretOwner) -> bool {518		self.distributions().any(|d| d.owners.contains(owner))519	}520}521522struct OccupiedDistEntry<'d> {523	distributions: &'d mut FleetSecretDistributions,524	idx: usize,525	owners: BTreeSet<SecretOwner>,526}527impl<'d> OccupiedDistEntry<'d> {528	fn remove(self, whole_dist: bool, reason: String) -> VacantDistEntry<'d> {529		let dist = &mut self.distributions.stored[self.idx];530		if whole_dist {531			dist.prune(reason);532		} else {533			dist.prune_owners(&self.owners, reason);534		}535		VacantDistEntry {536			distributions: self.distributions,537			owners: self.owners,538		}539	}540	fn set(self, secret: FleetSecretData, reason: String) -> Self {541		self.remove(false, reason).set(secret)542	}543}544struct VacantDistEntry<'d> {545	distributions: &'d mut FleetSecretDistributions,546	owners: BTreeSet<SecretOwner>,547}548impl<'d> VacantDistEntry<'d> {549	fn set(self, secret: FleetSecretData) -> OccupiedDistEntry<'d> {550		let Self {551			distributions,552			owners,553		} = self;554		let idx = distributions.stored.len();555		distributions.stored.push(FleetSecretDistribution {556			owners: owners.clone(),557			secret,558559			owners_pending_prune: BTreeMap::new(),560			pending_prune: None,561			_deprecated_managed: true,562		});563		OccupiedDistEntry {564			distributions,565			owners,566			idx,567		}568	}569}570571enum DistEntry<'d> {572	Vacant(VacantDistEntry<'d>),573	Occupied(OccupiedDistEntry<'d>),574}575impl DistEntry<'_> {576	fn remove(self, whole_dist: bool, reason: String) -> Self {577		match self {578			DistEntry::Vacant(_) => self,579			DistEntry::Occupied(o) => Self::Vacant(o.remove(whole_dist, reason)),580		}581	}582	fn set(self, secret: FleetSecretData, reason: String) -> Self {583		Self::Occupied(match self {584			DistEntry::Vacant(e) => e.set(secret),585			DistEntry::Occupied(e) => e.set(secret, reason),586		})587	}588}589590impl Serialize for FleetSecretDistributions {591	fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>592	where593		S: serde::Serializer,594	{595		let mut v = self.clone();596		v.prune_dead();597		let mut found_hosts = BTreeSet::new();598		for ele in v.distributions() {599			if ele.pending_prune.is_some() {600				continue;601			}602			if ele.owners.is_empty() {603				panic!("consistency: secret distribution has no defined owners");604			}605			for ele in ele.owners.iter() {606				if !found_hosts.insert(ele) {607					panic!(608						"consistency: secret distribution contains duplicate entry for the same host",609					);610				}611			}612		}613		match v.stored.len() {614			0 => panic!("consistency: empty distributions"),615			1 => v.stored[0].serialize(serializer),616			_ => {617				let mut sorted = v.stored.clone();618				// Store outdated distributions last619				sorted.sort_by_key(|v| v.pending_prune.is_some() as u32);620				sorted.serialize(serializer)621			}622		}623	}624}625impl<'de> Deserialize<'de> for FleetSecretDistributions {626	fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>627	where628		D: serde::Deserializer<'de>,629	{630		#[derive(Deserialize)]631		#[serde(untagged)]632		enum Distributions {633			One(FleetSecretDistribution),634			Many(Vec<FleetSecretDistribution>),635		}636		let d = Distributions::deserialize(deserializer)?;637		let stored = match d {638			Distributions::One(d) => vec![d],639			Distributions::Many(ds) => ds,640		};641		if stored.is_empty() {642			return Err(de::Error::custom("consistency: empty distributions"));643		}644		let mut found_hosts = BTreeSet::new();645		for ele in stored.iter() {646			if ele.pending_prune.is_some() {647				continue;648			}649			if ele.owners.is_empty() {650				return Err(de::Error::custom(651					"consistency: secret distribution has no defined owners",652				));653			}654			for ele in ele.owners.iter() {655				if !found_hosts.insert(ele) {656					return Err(de::Error::custom(657						"consistency: secret distribution contains duplicate entry for the same host",658					));659				}660			}661		}662		Ok(Self { stored })663	}664}665666#[derive(Deserialize, Default)]667pub struct FleetSecrets(BTreeMap<String, FleetSecretDistributions>);668669impl Serialize for FleetSecrets {670	fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>671	where672		S: serde::Serializer,673	{674		let data: BTreeMap<String, FleetSecretDistributions> = self675			.0676			.iter()677			.filter(|(_, v)| !v.stored.is_empty())678			.map(|(k, v)| (k.clone(), v.clone()))679			.collect();680681		data.serialize(serializer)682	}683}684685impl FleetSecrets {686	pub fn keys(&self) -> btree_map::Keys<String, FleetSecretDistributions> {687		self.0.keys()688	}689690	pub fn keys_for_owner(&self, owner: &SecretOwner) -> impl Iterator<Item = &String> {691		self.0692			.iter()693			.filter(|(_, d)| d.contains(owner))694			.map(|(n, _)| n)695	}696697	pub fn set_data(&mut self, secret: String, data: FleetSecretDistribution) {698		match self.0.entry(secret) {699			Entry::Vacant(e) => {700				e.insert(FleetSecretDistributions { stored: vec![data] });701			}702			Entry::Occupied(mut e) => {703				let dists = e.get_mut();704				dists.extend(data, "secret data was replaced".to_owned())705			}706		}707	}708	pub fn get(&self, secret: &str) -> Option<&FleetSecretDistributions> {709		self.0.get(secret)710	}711	pub fn get_mut(&mut self, secret: &str) -> Option<&mut FleetSecretDistributions> {712		self.0.get_mut(secret)713	}714715	pub fn get_or_create(&mut self, secret: &str) -> &mut FleetSecretDistributions {716		self.0717			.entry(secret.to_owned())718			.or_insert(FleetSecretDistributions::default())719	}720721	pub fn contains(&self, secret: &str) -> bool {722		self.0.contains_key(secret)723	}724	pub fn remove(&mut self, secret: &str) {725		self.0.remove(secret);726	}727728	fn merge_from_hosts(729		&mut self,730		host_secrets: BTreeMap<SecretOwner, BTreeMap<String, FleetSecretDistribution>>,731	) {732		for (host, host_secrets) in host_secrets {733			for (secret_name, mut secret_data) in host_secrets {734				secret_data.owners.insert(host.clone());735				self.set_data(secret_name, secret_data);736			}737		}738	}739740	pub fn prune_host(&mut self, host: &SecretOwner, expected_nonshared: BTreeSet<String>) {741		for (name, dists) in self.0.iter_mut() {742			if expected_nonshared.contains(name) {743				continue;744			}745			for dist in dists.distributions_mut() {746				if dist.owners.contains(host) {747					dist.prune_owners(748						&BTreeSet::from([host.to_owned()]),749						"host no longer defines this secret".to_owned(),750					);751				}752			}753		}754	}755}756757#[derive(Debug, Clone)]758pub struct Expectations {759	pub owners: BTreeSet<SecretOwner>,760	pub generation_data: serde_json::Value,761	pub parts: BTreeMap<String, GeneratorPart>,762}763#[derive(Deserialize, Debug, Clone)]764pub struct GeneratorPart {765	pub encrypted: bool,766}767768#[derive(Debug, Clone, Copy)]769pub struct RegenerationConstraints {770	pub allow_different: bool,771	pub regenerate_on_owner_added: bool,772	pub regenerate_on_owner_removed: bool,773}774impl RegenerationConstraints {775	pub fn host_personal() -> Self {776		Self {777			allow_different: false,778			regenerate_on_owner_added: true,779			regenerate_on_owner_removed: true,780		}781	}782	pub fn without_preferences(self) -> Self {783		Self {784			allow_different: self.allow_different,785			regenerate_on_owner_added: false,786			regenerate_on_owner_removed: false,787		}788	}789}
after · crates/fleet-base/src/fleetdata.rs
1use std::{2	cmp::Ordering,3	collections::{4		BTreeMap, BTreeSet,5		btree_map::{self, Entry},6	},7	fmt,8	io::{self, Cursor},9	str::FromStr,10	sync::RwLock,11};1213use age::Recipient;14use chrono::{DateTime, Utc};15use fleet_shared::SecretData;16use rand::{17	distr::{Alphanumeric, SampleString as _},18	rng,19};20use serde::{21	Deserialize, Serialize,22	de::{self, Error},23};24use serde_json::Value;25use tracing::info;2627#[derive(Serialize, Deserialize, Default)]28#[serde(rename_all = "camelCase")]29pub struct HostData {30	#[serde(default)]31	#[serde(skip_serializing_if = "String::is_empty")]32	pub encryption_key: String,33}3435const VERSION: &str = "0.1.0";36pub struct FleetDataVersion;37impl Serialize for FleetDataVersion {38	fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>39	where40		S: serde::Serializer,41	{42		VERSION.serialize(serializer)43	}44}45impl<'de> Deserialize<'de> for FleetDataVersion {46	fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>47	where48		D: serde::Deserializer<'de>,49	{50		let version = String::deserialize(deserializer)?;51		if version != VERSION {52			return Err(D::Error::custom(format!(53				"fleet.nix data version mismatch, expected {VERSION}, got {version}.\nFollow the docs for migration instruction"54			)));55		}56		Ok(Self)57	}58}5960fn generate_gc_prefix() -> String {61	let id = Alphanumeric.sample_string(&mut rng(), 8);62	format!("fleet-gc-{id}")63}6465#[derive(Serialize, Deserialize)]66#[serde(rename_all = "camelCase")]67pub struct ManagerKey {68	pub name: String,69	pub key: String,70}7172#[derive(Serialize, Deserialize)]73#[serde(rename_all = "camelCase")]74pub struct FleetData {75	pub version: FleetDataVersion,76	#[serde(default = "generate_gc_prefix")]77	pub gc_root_prefix: String,7879	#[serde(default, skip_serializing_if = "Vec::is_empty")]80	pub manager_keys: Vec<ManagerKey>,8182	#[serde(default)]83	pub hosts: RwLock<BTreeMap<String, HostData>>,8485	#[serde(default, alias = "shared_secrets")]86	pub secrets: RwLock<FleetSecrets>,8788	// extra_name => anything89	#[serde(default)]90	pub extra: RwLock<BTreeMap<String, Value>>,9192	#[serde(default)]93	#[serde(skip_serializing)]94	host_secrets: BTreeMap<SecretOwner, BTreeMap<String, FleetSecretDistribution>>,95}96impl FromStr for FleetData {97	type Err = anyhow::Error;98	fn from_str(s: &str) -> anyhow::Result<Self> {99		let mut data: Self = nixlike::parse_str(s)?;100		if !data.host_secrets.is_empty() {101			info!("migrating host secrets into shared secrets structure");102			data.secrets103				.write()104				.expect("no poisoning")105				.merge_from_hosts(std::mem::take(&mut data.host_secrets));106		}107		Ok(data)108	}109}110111/// Returns None if recipients.is_empty()112pub fn encrypt_secret_data<'r>(113	recipients: impl IntoIterator<Item = &'r Box<dyn Recipient>>,114	data: Vec<u8>,115) -> Option<SecretData> {116	let mut encrypted = vec![];117	let mut encryptor = age::Encryptor::with_recipients(recipients.into_iter().map(|v| &**v))118		.ok()?119		.wrap_output(&mut encrypted)120		.expect("in memory write");121	io::copy(&mut Cursor::new(data), &mut encryptor).expect("in memory copy");122	encryptor.finish().expect("in memory flush");123	Some(SecretData {124		data: encrypted,125		encrypted: true,126	})127}128129#[derive(Serialize, Deserialize, Clone, Debug)]130pub struct FleetSecretPart {131	pub raw: SecretData,132}133134#[derive(Serialize, Deserialize, Clone, Debug)]135#[serde(rename_all = "camelCase")]136#[must_use]137pub struct FleetSecretData {138	pub created_at: DateTime<Utc>,139	#[serde(default, skip_serializing_if = "Option::is_none", alias = "expire_at")]140	pub expires_at: Option<DateTime<Utc>>,141142	#[serde(flatten)]143	pub parts: BTreeMap<String, FleetSecretPart>,144145	#[serde(default, skip_serializing_if = "Value::is_null")]146	pub generation_data: Value,147}148149#[derive(Serialize, Deserialize, Clone, Debug, PartialOrd, Ord, PartialEq, Eq)]150#[repr(transparent)]151pub struct SecretOwner(String);152153impl fmt::Display for SecretOwner {154	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {155		write!(f, "host:{}", self.0)156	}157}158159impl SecretOwner {160	pub fn host(s: impl AsRef<str>) -> SecretOwner {161		SecretOwner(s.as_ref().to_owned())162	}163	pub fn as_host(&self) -> Option<&str> {164		Some(&self.0)165	}166}167168#[derive(Serialize, Deserialize, Clone, Debug)]169#[serde(rename_all = "camelCase")]170#[must_use]171pub struct FleetSecretDistribution {172	#[serde(default)]173	owners: BTreeSet<SecretOwner>,174	#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]175	owners_pending_prune: BTreeMap<SecretOwner, String>,176177	#[serde(flatten)]178	pub secret: FleetSecretData,179180	#[serde(default, skip_serializing_if = "Option::is_none")]181	pending_prune: Option<String>,182	#[serde(default, skip_serializing, alias = "managed")]183	_deprecated_managed: bool,184}185186const EMPTY_PENDING_PRUNE: &BTreeMap<SecretOwner, String> = &BTreeMap::new();187impl FleetSecretDistribution {188	pub fn new(owners: BTreeSet<SecretOwner>, secret: FleetSecretData, now: DateTime<Utc>) -> Self {189		assert!(190			!owners.is_empty(),191			"distribution should have at least one owner"192		);193		if let Some(expires_at) = &secret.expires_at {194			assert!(195				*expires_at > now,196				"secret should not be expired on creation"197			);198		}199		Self {200			owners,201			secret,202			owners_pending_prune: BTreeMap::new(),203			pending_prune: None,204			_deprecated_managed: true,205		}206	}207208	fn owners_ex(&self, including_pruned: bool) -> impl Iterator<Item = &SecretOwner> {209		let pending_prune = if including_pruned {210			&self.owners_pending_prune211		} else {212			EMPTY_PENDING_PRUNE213		};214		self.owners.iter().chain(pending_prune.keys())215	}216	pub fn owners(&self) -> impl Iterator<Item = &SecretOwner> {217		self.owners_ex(false)218	}219	pub fn owners_pending_prune(&self) -> impl Iterator<Item = &SecretOwner> {220		self.owners_pending_prune.keys()221	}222	pub fn is_pending_prune(&self) -> bool {223		self.pending_prune.is_some()224	}225226	pub fn prune(&mut self, reason: String) {227		assert!(228			self.pending_prune.is_none(),229			"it shouldn't be possible to prune the same distribution twice using public api"230		);231		self.pending_prune = Some(reason);232	}233	pub fn prune_owners(&mut self, owners: &BTreeSet<SecretOwner>, reason: String) {234		// if self.owners.iter().all(|o| owners.contains(o)) && self.owners_pending_prune.is_empty() {235		// 	self.prune(format!("all owners were pruned: {reason}"));236		// 	return;237		// }238		for owner in owners {239			if self.owners.remove(owner) {240				self.owners_pending_prune241					.insert(owner.to_owned(), reason.clone());242			}243		}244		// if self.owners.is_empty() {245		// 	self.prune("no owners left".to_owned());246		// }247	}248	pub fn unprune_owner(&mut self, owner: SecretOwner) {249		if self.owners_pending_prune.remove(&owner).is_some() {250			self.owners.insert(owner);251		}252	}253}254255#[derive(Clone, Debug, Default)]256#[must_use]257pub struct FleetSecretDistributions {258	stored: Vec<FleetSecretDistribution>,259}260261fn compare_dists(262	a: &FleetSecretDistribution,263	b: &FleetSecretDistribution,264	prefer_identities: &BTreeSet<SecretOwner>,265	include_pruned_owners: bool,266) -> Ordering {267	use Ordering::*;268	if prefer_identities.is_empty() {269		let a_has = a270			.owners_ex(include_pruned_owners)271			.any(|o| prefer_identities.contains(o));272		let b_has = b273			.owners_ex(include_pruned_owners)274			.any(|o| prefer_identities.contains(o));275		match (a_has, b_has) {276			(true, false) => return Greater,277			(false, true) => return Less,278			_ => {}279		}280	}281	match (a.secret.expires_at, b.secret.expires_at) {282		(None, Some(_)) => return Greater,283		(Some(_), None) => return Less,284		(Some(a), Some(b)) => {285			// Later is better286			return a.cmp(&b);287		}288		(None, None) => {}289	}290291	// Which one is easier to access292	a.owners.len().cmp(&b.owners.len())293}294295impl FleetSecretDistributions {296	/// Drop expired distributions297	fn prune_expired(&mut self, now: DateTime<Utc>) {298		for ele in self.distributions_mut() {299			if let Some(expires_at) = ele.secret.expires_at300				&& expires_at < now301			{302				ele.prune(format!("expired during check at {now}"));303			}304		}305	}306	/// Perform all pruning relevant to shared secrets307	/// Also see expected_owner_removed308	#[allow(clippy::too_many_arguments)]309	pub fn prune_shared(310		&mut self,311		expected_owners: &BTreeSet<SecretOwner>,312		unique: bool,313		expected_parts: &BTreeMap<String, GeneratorPart>,314		expected_generation_data: &Value,315		regenerate_on_owner_removed: bool,316		regenerate_on_owner_added: bool,317		prefer_identities: &BTreeSet<SecretOwner>,318		now: DateTime<Utc>,319	) {320		self.prune_expired(now);321		self.prune_generation_data(expected_generation_data, None);322		self.prune_missing_parts(expected_parts, None);323324		let current_owners = self.owners().cloned().collect::<BTreeSet<SecretOwner>>();325326		let mut to_add = expected_owners.difference(&current_owners);327		if to_add.next().is_some() && unique && regenerate_on_owner_added {328			for dist in self.distributions_mut() {329				dist.prune(330					"owners missing, can't add new distribution, regeneration preferred"331						.to_string(),332				);333			}334			return;335		}336337		for to_remove in current_owners.difference(expected_owners) {338			self.entry(to_remove.clone()).remove(339				regenerate_on_owner_removed,340				"owner was removed from expected owners list, regenerate_on_owner_removed is set"341					.to_string(),342			);343		}344		if unique {345			self.prune_nonunique(prefer_identities);346		}347	}348	pub fn prune_host(349		&mut self,350		owner: SecretOwner,351		expected_parts: &BTreeMap<String, GeneratorPart>,352		expected_generation_data: &Value,353		now: DateTime<Utc>,354	) {355		self.prune_expired(now);356		self.prune_generation_data(expected_generation_data, Some(&owner));357		// TODO: Owner-based pruning is warranted (e.g host no longer has secret defined)358		self.prune_missing_parts(expected_parts, Some(&owner));359	}360	/// Position of best distributions as in iterator returned by distributions()361	/// None if distributions not found362	fn best_idx(363		&self,364		prefer_identities: &BTreeSet<SecretOwner>,365		include_pruned_owners: bool,366	) -> Option<usize> {367		self.distributions()368			.enumerate()369			.max_by(|(_, a), (_, b)| compare_dists(a, b, prefer_identities, include_pruned_owners))370			.map(|(p, _)| p)371	}372	/// Secret wants to be the same on all hosts, leave only one unpruned version of it373	fn prune_nonunique(&mut self, prefer_identities: &BTreeSet<SecretOwner>) {374		if self.distributions().next().is_none() {375			return;376		}377		let best = self.best_idx(prefer_identities, false).expect("not empty");378		for (i, dist) in self.distributions_mut().enumerate() {379			if i != best {380				dist.prune(381					"secret wants to be the same on all hosts, only the best one was left"382						.to_owned(),383				);384			}385		}386	}387388	pub fn try_unprune(&mut self, owner: SecretOwner) -> Option<&FleetSecretDistribution> {389		assert!(self.get(&owner).is_none(), "secret is not pruned for host");390		if let Some(dist) = self391			.distributions_mut()392			.find(|v| v.owners_pending_prune.contains_key(&owner))393		{394			dist.unprune_owner(owner);395			Some(dist)396		} else {397			None398		}399	}400401	pub fn best_distribution_for_reencryption(402		&mut self,403		prefer_identities: &BTreeSet<SecretOwner>,404	) -> Option<&mut FleetSecretDistribution> {405		let best_idx = self.best_idx(prefer_identities, true)?;406		self.distributions_mut().nth(best_idx)407	}408409	fn prune_missing_parts(410		&mut self,411		expected_parts: &BTreeMap<String, GeneratorPart>,412		filter_owner: Option<&SecretOwner>,413	) {414		'dist: for ele in self.distributions_mut() {415			if let Some(filter_owner) = filter_owner416				&& !ele.owners.contains(filter_owner)417			{418				continue;419				// Note: secret still can have multiple owners even if it is host-owned420				// in this case we expect that all owners using the same generator, so we can prune distribution for all of them421			}422			for (name, part) in expected_parts {423				let Some(stored_part) = ele.secret.parts.get(name) else {424					ele.prune(format!("secret definition added new part: {name}"));425					continue 'dist;426				};427				if part.encrypted != stored_part.raw.encrypted {428					ele.prune(format!(429						"secret definition now requires part to be {}",430						if part.encrypted {431							"encrypted"432						} else {433							"non-encrypted"434						}435					));436					continue 'dist;437				}438			}439		}440	}441	fn prune_generation_data(442		&mut self,443		expected_generation_data: &Value,444		filter_owner: Option<&SecretOwner>,445	) {446		for ele in self.distributions_mut() {447			if let Some(filter_owner) = filter_owner448				&& !ele.owners.contains(filter_owner)449			{450				continue;451				// Note: secret still can have multiple owners even if it is host-owned452				// in this case we expect that all owners using the same generator, so we can prune distribution for all of them453			}454			if ele.secret.generation_data != *expected_generation_data {455				ele.prune(format!(456					"expected generation data mismatch: {expected_generation_data:?}"457				));458			}459		}460	}461462	/// Prune all distributions with no unpruned owners.463	/// For ease of reencryption where possible, it is only called on persistence, when in memory - pruned owners are kept and464	/// can decrypt their secrets.465	fn prune_dead(&mut self) {466		for ele in self.distributions_mut() {467			if ele.owners.is_empty() {468				ele.prune("no owners left".to_owned());469			}470		}471	}472473	pub fn all_distributions(&self) -> impl Iterator<Item = &FleetSecretDistribution> {474		self.stored.iter()475	}476	pub fn distributions(&self) -> impl Iterator<Item = &FleetSecretDistribution> {477		self.stored.iter().filter(|v| v.pending_prune.is_none())478	}479	pub fn distributions_mut(&mut self) -> impl Iterator<Item = &mut FleetSecretDistribution> {480		self.stored.iter_mut().filter(|v| v.pending_prune.is_none())481	}482	pub fn owners(&self) -> impl Iterator<Item = &SecretOwner> {483		self.distributions().flat_map(|v| v.owners.iter())484	}485	#[allow(486		clippy::len_without_is_empty,487		reason = "should not be empty for a long time"488	)]489	pub fn len(&self) -> usize {490		self.distributions().count()491	}492493	pub fn get(&self, owner: &SecretOwner) -> Option<&FleetSecretDistribution> {494		self.distributions().find(|d| d.owners.contains(owner))495	}496	fn entry(&mut self, owner: SecretOwner) -> DistEntry<'_> {497		let Some((idx, dist)) = self498			.distributions()499			.enumerate()500			.find(|(_, d)| d.owners.contains(&owner))501		else {502			return DistEntry::Vacant(VacantDistEntry {503				distributions: self,504				owners: BTreeSet::from([owner]),505			});506		};507		DistEntry::Occupied(OccupiedDistEntry {508			owners: dist.owners.clone(),509			distributions: self,510			idx,511		})512	}513	pub fn extend(&mut self, dist: FleetSecretDistribution, reason: String) {514		for ele in self.distributions_mut() {515			ele.prune_owners(&dist.owners, reason.clone());516		}517		self.stored.push(dist);518	}519	pub fn contains(&self, owner: &SecretOwner) -> bool {520		self.distributions().any(|d| d.owners.contains(owner))521	}522}523524pub struct OccupiedDistEntry<'d> {525	distributions: &'d mut FleetSecretDistributions,526	idx: usize,527	owners: BTreeSet<SecretOwner>,528}529impl<'d> OccupiedDistEntry<'d> {530	fn remove(self, whole_dist: bool, reason: String) -> VacantDistEntry<'d> {531		let dist = &mut self.distributions.stored[self.idx];532		if whole_dist {533			dist.prune(reason);534		} else {535			dist.prune_owners(&self.owners, reason);536		}537		VacantDistEntry {538			distributions: self.distributions,539			owners: self.owners,540		}541	}542	pub fn set(self, secret: FleetSecretData, reason: String) -> Self {543		self.remove(false, reason).set(secret)544	}545}546pub struct VacantDistEntry<'d> {547	distributions: &'d mut FleetSecretDistributions,548	owners: BTreeSet<SecretOwner>,549}550impl<'d> VacantDistEntry<'d> {551	pub fn set(self, secret: FleetSecretData) -> OccupiedDistEntry<'d> {552		let Self {553			distributions,554			owners,555		} = self;556		let idx = distributions.stored.len();557		distributions.stored.push(FleetSecretDistribution {558			owners: owners.clone(),559			secret,560561			owners_pending_prune: BTreeMap::new(),562			pending_prune: None,563			_deprecated_managed: true,564		});565		OccupiedDistEntry {566			distributions,567			owners,568			idx,569		}570	}571}572573pub enum DistEntry<'d> {574	Vacant(VacantDistEntry<'d>),575	Occupied(OccupiedDistEntry<'d>),576}577impl DistEntry<'_> {578	pub fn remove(self, whole_dist: bool, reason: String) -> Self {579		match self {580			DistEntry::Vacant(_) => self,581			DistEntry::Occupied(o) => Self::Vacant(o.remove(whole_dist, reason)),582		}583	}584	pub fn set(self, secret: FleetSecretData, reason: String) -> Self {585		Self::Occupied(match self {586			DistEntry::Vacant(e) => e.set(secret),587			DistEntry::Occupied(e) => e.set(secret, reason),588		})589	}590}591592impl Serialize for FleetSecretDistributions {593	fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>594	where595		S: serde::Serializer,596	{597		let mut v = self.clone();598		v.prune_dead();599		let mut found_hosts = BTreeSet::new();600		for ele in v.distributions() {601			if ele.pending_prune.is_some() {602				continue;603			}604			if ele.owners.is_empty() {605				panic!("consistency: secret distribution has no defined owners");606			}607			for ele in ele.owners.iter() {608				if !found_hosts.insert(ele) {609					panic!(610						"consistency: secret distribution contains duplicate entry for the same host",611					);612				}613			}614		}615		match v.stored.len() {616			0 => panic!("consistency: empty distributions"),617			1 => v.stored[0].serialize(serializer),618			_ => {619				let mut sorted = v.stored.clone();620				// Store outdated distributions last621				sorted.sort_by_key(|v| v.pending_prune.is_some() as u32);622				sorted.serialize(serializer)623			}624		}625	}626}627impl<'de> Deserialize<'de> for FleetSecretDistributions {628	fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>629	where630		D: serde::Deserializer<'de>,631	{632		#[derive(Deserialize)]633		#[serde(untagged)]634		enum Distributions {635			One(FleetSecretDistribution),636			Many(Vec<FleetSecretDistribution>),637		}638		let d = Distributions::deserialize(deserializer)?;639		let stored = match d {640			Distributions::One(d) => vec![d],641			Distributions::Many(ds) => ds,642		};643		if stored.is_empty() {644			return Err(de::Error::custom("consistency: empty distributions"));645		}646		let mut found_hosts = BTreeSet::new();647		for ele in stored.iter() {648			if ele.pending_prune.is_some() {649				continue;650			}651			if ele.owners.is_empty() {652				return Err(de::Error::custom(653					"consistency: secret distribution has no defined owners",654				));655			}656			for ele in ele.owners.iter() {657				if !found_hosts.insert(ele) {658					return Err(de::Error::custom(659						"consistency: secret distribution contains duplicate entry for the same host",660					));661				}662			}663		}664		Ok(Self { stored })665	}666}667668#[derive(Deserialize, Default)]669pub struct FleetSecrets(BTreeMap<String, FleetSecretDistributions>);670671impl Serialize for FleetSecrets {672	fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>673	where674		S: serde::Serializer,675	{676		let data: BTreeMap<String, FleetSecretDistributions> = self677			.0678			.iter()679			.filter(|(_, v)| !v.stored.is_empty())680			.map(|(k, v)| (k.clone(), v.clone()))681			.collect();682683		data.serialize(serializer)684	}685}686687impl FleetSecrets {688	pub fn keys(&self) -> btree_map::Keys<'_, String, FleetSecretDistributions> {689		self.0.keys()690	}691692	pub fn keys_for_owner(&self, owner: &SecretOwner) -> impl Iterator<Item = &String> {693		self.0694			.iter()695			.filter(|(_, d)| d.contains(owner))696			.map(|(n, _)| n)697	}698699	pub fn set_data(&mut self, secret: String, data: FleetSecretDistribution) {700		match self.0.entry(secret) {701			Entry::Vacant(e) => {702				e.insert(FleetSecretDistributions { stored: vec![data] });703			}704			Entry::Occupied(mut e) => {705				let dists = e.get_mut();706				dists.extend(data, "secret data was replaced".to_owned())707			}708		}709	}710	pub fn get(&self, secret: &str) -> Option<&FleetSecretDistributions> {711		self.0.get(secret)712	}713	pub fn get_mut(&mut self, secret: &str) -> Option<&mut FleetSecretDistributions> {714		self.0.get_mut(secret)715	}716717	pub fn get_or_create(&mut self, secret: &str) -> &mut FleetSecretDistributions {718		self.0.entry(secret.to_owned()).or_default()719	}720721	pub fn contains(&self, secret: &str) -> bool {722		self.0.contains_key(secret)723	}724	pub fn remove(&mut self, secret: &str) {725		self.0.remove(secret);726	}727728	fn merge_from_hosts(729		&mut self,730		host_secrets: BTreeMap<SecretOwner, BTreeMap<String, FleetSecretDistribution>>,731	) {732		for (host, host_secrets) in host_secrets {733			for (secret_name, mut secret_data) in host_secrets {734				secret_data.owners.insert(host.clone());735				self.set_data(secret_name, secret_data);736			}737		}738	}739740	pub fn prune_host(&mut self, host: &SecretOwner, expected_nonshared: BTreeSet<String>) {741		for (name, dists) in self.0.iter_mut() {742			if expected_nonshared.contains(name) {743				continue;744			}745			for dist in dists.distributions_mut() {746				if dist.owners.contains(host) {747					dist.prune_owners(748						&BTreeSet::from([host.to_owned()]),749						"host no longer defines this secret".to_owned(),750					);751				}752			}753		}754	}755}756757#[derive(Debug, Clone)]758pub struct Expectations {759	pub owners: BTreeSet<SecretOwner>,760	pub generation_data: serde_json::Value,761	pub parts: BTreeMap<String, GeneratorPart>,762}763#[derive(Deserialize, Debug, Clone)]764pub struct GeneratorPart {765	pub encrypted: bool,766}767768#[derive(Debug, Clone, Copy)]769pub struct RegenerationConstraints {770	pub allow_different: bool,771	pub regenerate_on_owner_added: bool,772	pub regenerate_on_owner_removed: bool,773}774impl RegenerationConstraints {775	pub fn host_personal() -> Self {776		Self {777			allow_different: false,778			regenerate_on_owner_added: true,779			regenerate_on_owner_removed: true,780		}781	}782	pub fn without_preferences(self) -> Self {783		Self {784			allow_different: self.allow_different,785			regenerate_on_owner_added: false,786			regenerate_on_owner_removed: false,787		}788	}789}
modifiedcrates/fleet-base/src/host.rsdiffbeforeafterboth
--- a/crates/fleet-base/src/host.rs
+++ b/crates/fleet-base/src/host.rs
@@ -13,18 +13,17 @@
 use camino::{Utf8Path, Utf8PathBuf};
 use chrono::{DateTime, Utc};
 use fleet_shared::SecretData;
-use nix_eval::{Store, Value, eval_store, nix_go, nix_go_json, util::assert_warn};
+use nix_eval::{Store, Value, drv::DrvGraph, eval_store, nix_go, nix_go_json, util::assert_warn};
 use remowt_client::{AgentBundle, Remowt};
 use remowt_endpoints::fs::FsClient;
-use remowt_link_shared::Address;
+use remowt_fleet::NixClient;
+use remowt_link_shared::BifConfig;
 use remowt_ui_prompt::auto::AutoPrompter;
 use remowt_ui_prompt::bifrost::PromptEndpoints;
 use remowt_ui_prompt::{PrependSourcePrompter, Source};
-use tabled::Tabled;
 use tempfile::NamedTempFile;
-use time::UtcDateTime;
-use tokio::task::spawn_blocking;
-use tracing::warn;
+use tokio::{sync::OnceCell, task::spawn_blocking};
+use tracing::{info, warn};
 
 use crate::fleetdata::{
 	FleetData, FleetSecretData, FleetSecretDistribution, FleetSecretPart, SecretOwner,
@@ -101,7 +100,7 @@
 	groups: OnceLock<Vec<String>>,
 
 	// TODO: Both of those values are taken from host opts, there should be a cleaner way to specify it
-	deploy_kind: OnceLock<DeployKind>,
+	deploy_kind: OnceCell<DeployKind>,
 	session_destination: OnceLock<String>,
 	legacy_ssh_store: OnceLock<bool>,
 
@@ -114,7 +113,7 @@
 	pub local: bool,
 	pub remowt: OnceLock<Remowt>,
 	nix_store: OnceLock<Arc<Store>>,
-	nix_plugin: tokio::sync::OnceCell<()>,
+	nix_plugin: OnceCell<()>,
 }
 
 const NIX_PLUGIN_ID: u16 = 2;
@@ -130,75 +129,16 @@
 
 fn agent_bundle() -> Result<AgentBundle> {
 	AgentBundle::from_dir(agents_dir()?)
-}
-
-#[derive(Debug, Clone, Copy)]
-pub enum GenerationStorage {
-	Deployer,
-	Machine,
-	Pusher,
-}
-impl GenerationStorage {
-	fn prefix(&self) -> &'static str {
-		match self {
-			GenerationStorage::Deployer => "deployer.",
-			GenerationStorage::Machine => "",
-			GenerationStorage::Pusher => "pusher.",
-		}
-	}
 }
 
-#[derive(Tabled, Debug)]
-pub struct Generation {
-	#[tabled(rename = "ID", format("{}", self.rollback_id()))]
-	pub id: u32,
-	#[tabled(rename = "Current")]
-	pub current: bool,
-	#[tabled(rename = "Created at")]
-	pub datetime: UtcDateTime,
-	#[tabled(format = "{:?}")]
-	pub store_path: Utf8PathBuf,
-	#[tabled(skip)]
-	pub location: GenerationStorage,
-}
-impl Generation {
-	pub fn rollback_id(&self) -> String {
-		format!("{}{}", self.location.prefix(), self.id)
-	}
+enum RemoteDerivationMode {
+	CopySigned,
+	Copy,
+	Attic,
+	Cachix,
 }
 
 impl ConfigHost {
-	pub async fn list_generations(&self, profile: &str) -> Result<Vec<Generation>> {
-		let plugin_id = self.ensure_nix_plugin().await?;
-		let nix = self
-			.remowt()
-			.await?
-			.plugin_endpoints::<remowt_fleet::NixClient<_>>(plugin_id);
-		let raw = nix
-			.list_generations(profile.to_owned())
-			.await
-			.map_err(|e| anyhow!("{e:?}"))?
-			.map_err(|e| anyhow!("{e}"))?;
-		raw.into_iter()
-			.map(|g| {
-				let id: u32 =
-					g.id.try_into()
-						.with_context(|| format!("generation id {} doesn't fit in u32", g.id))?;
-				let datetime = UtcDateTime::from_unix_timestamp(g.creation_time_unix)
-					.with_context(|| {
-						format!("invalid generation timestamp {}", g.creation_time_unix)
-					})?;
-				Ok(Generation {
-					id,
-					current: g.current,
-					datetime,
-					store_path: g.store_path,
-					location: GenerationStorage::Machine,
-				})
-			})
-			.collect()
-	}
-
 	pub fn set_session_destination(&self, dest: String) {
 		self.session_destination
 			.set(dest)
@@ -215,34 +155,31 @@
 			.expect("legacy ssh store is already set")
 	}
 	pub async fn deploy_kind(&self) -> Result<DeployKind> {
-		if let Some(kind) = self.deploy_kind.get() {
-			return Ok(*kind);
-		}
-		let remowt = self.remowt().await?;
-		let fs = remowt.endpoints::<FsClient<_>>();
-		let is_fleet_managed = match fs.file_exists(Utf8PathBuf::from("/etc/FLEET_HOST")).await {
-			Ok(v) => v,
-			Err(e) => {
-				bail!("failed to query remote system kind: {e}");
+		self.deploy_kind.get_or_try_init(|| async {
+			let remowt = self.remowt().await?;
+			let fs = remowt.endpoints::<FsClient<_>>();
+			let is_fleet_managed = match fs.file_exists(Utf8PathBuf::from("/etc/FLEET_HOST")).await {
+				Ok(v) => v,
+				Err(e) => {
+					bail!("failed to query remote system kind: {e}");
+				}
+			};
+			if !is_fleet_managed {
+				bail!(
+					"{}",
+					indoc::indoc! {"
+					host is not marked as managed by fleet
+					if you're not trying to lustrate/install system from scratch,
+					you should either
+						1. manually create /etc/FLEET_HOST file on the target host,
+						2. use ?deploy_kind=fleet host argument if you're upgrading from older version of fleet
+						3. use ?deploy_kind=upgrade_to_fleet if you're upgrading from plain nixos to fleet-managed nixos
+					for installation use ?deploy_kind=nixos_install / ?deploy_kind=nixos_lustrate 
+				"}
+				);
 			}
-		};
-		if !is_fleet_managed {
-			bail!(
-				"{}",
-				indoc::indoc! {"
-				host is not marked as managed by fleet
-				if you're not trying to lustrate/install system from scratch,
-				you should either
-					1. manually create /etc/FLEET_HOST file on the target host,
-					2. use ?deploy_kind=fleet host argument if you're upgrading from older version of fleet
-					3. use ?deploy_kind=upgrade_to_fleet if you're upgrading from plain nixos to fleet-managed nixos
-				for installation use ?deploy_kind=nixos_install / ?deploy_kind=nixos_lustrate 
-			"}
-			);
-		}
-		// TOCTOU is possible
-		let _ = self.deploy_kind.set(DeployKind::Fleet);
-		Ok(*self.deploy_kind.get().expect("deploy kind is just set"))
+			Ok(DeployKind::Fleet)
+		}).await.copied()
 	}
 	async fn connection(&self) -> Result<Remowt> {
 		if let Some(conn) = self.remowt.get() {
@@ -285,6 +222,11 @@
 		self.connection().await
 	}
 
+	pub async fn nix_client(&self) -> Result<NixClient<BifConfig>> {
+		let remowt = self.remowt().await?;
+		let plugin_id = self.ensure_nix_plugin().await?;
+		Ok(remowt.plugin_endpoints(plugin_id))
+	}
 	pub fn ensure_nix_plugin(&self) -> Pin<Box<dyn Future<Output = Result<u16>> + Send + '_>> {
 		Box::pin(async {
 			self.nix_plugin
@@ -305,12 +247,6 @@
 						.run0_load_plugin_path(NIX_PLUGIN_ID, bin.as_str())
 						.await
 						.context("failed to load the fleet nix plugin")?;
-					self.remowt()
-						.await?
-						.rpc()
-						.wait_for_connection_to(Address::Plugin(NIX_PLUGIN_ID))
-						.await
-						.map_err(|_| anyhow!("failed to wait for plugin"))?;
 					anyhow::Ok(())
 				})
 				.await?;
@@ -407,6 +343,10 @@
 		}
 		let sign: Pin<Box<dyn Future<Output = Result<()>> + Send>> = {
 			let path = path.clone();
+			let graph = DrvGraph::resolve(&eval_store(), &path)
+				.context("failed to resolve graph to be uploaded")?;
+			info!("signing {} paths", graph.nodes.len());
+
 			Box::pin(async move {
 				let local = self.config.local_host();
 				let plugin_id = local.ensure_nix_plugin().await?;
@@ -564,8 +504,8 @@
 					local: true,
 					remowt: OnceLock::new(),
 					nix_store: OnceLock::new(),
-					nix_plugin: tokio::sync::OnceCell::new(),
-					deploy_kind: OnceLock::new(),
+					nix_plugin: OnceCell::new(),
+					deploy_kind: OnceCell::new(),
 					session_destination: OnceLock::new(),
 					legacy_ssh_store: OnceLock::new(),
 				})
@@ -607,8 +547,8 @@
 			local: self.localhost == name,
 			remowt: OnceLock::new(),
 			nix_store: OnceLock::new(),
-			nix_plugin: tokio::sync::OnceCell::new(),
-			deploy_kind: OnceLock::new(),
+			nix_plugin: OnceCell::new(),
+			deploy_kind: OnceCell::new(),
 			session_destination: OnceLock::new(),
 			legacy_ssh_store: OnceLock::new(),
 		})
modifiedcrates/fleet-base/src/lib.rsdiffbeforeafterboth
--- a/crates/fleet-base/src/lib.rs
+++ b/crates/fleet-base/src/lib.rs
@@ -3,4 +3,5 @@
 pub mod host;
 mod keys;
 pub mod opts;
+pub mod pins;
 pub mod primops;
addedcrates/fleet-base/src/pins.rsdiffbeforeafterboth
--- /dev/null
+++ b/crates/fleet-base/src/pins.rs
@@ -0,0 +1,69 @@
+use anyhow::{Context as _, Result, anyhow};
+use camino::Utf8PathBuf;
+use tabled::Tabled;
+use time::UtcDateTime;
+
+use crate::host::ConfigHost;
+
+#[derive(Debug, Clone, Copy)]
+pub enum GenerationStorage {
+	Deployer,
+	Machine,
+	Pusher,
+}
+impl GenerationStorage {
+	fn prefix(&self) -> &'static str {
+		match self {
+			GenerationStorage::Deployer => "deployer.",
+			GenerationStorage::Machine => "",
+			GenerationStorage::Pusher => "pusher.",
+		}
+	}
+}
+
+#[derive(Tabled, Debug)]
+pub struct Generation {
+	#[tabled(rename = "ID", format("{}", self.rollback_id()))]
+	pub id: u32,
+	#[tabled(rename = "Current")]
+	pub current: bool,
+	#[tabled(rename = "Created at")]
+	pub datetime: UtcDateTime,
+	#[tabled(format = "{:?}")]
+	pub store_path: Utf8PathBuf,
+	#[tabled(skip)]
+	pub location: GenerationStorage,
+}
+impl Generation {
+	pub fn rollback_id(&self) -> String {
+		format!("{}{}", self.location.prefix(), self.id)
+	}
+}
+impl ConfigHost {
+	pub async fn list_generations(&self, profile: &str) -> Result<Vec<Generation>> {
+		let nix = self.nix_client().await?;
+		let raw = nix
+			.list_generations(profile.to_owned())
+			.await
+			.map_err(|e| anyhow!("{e:?}"))?
+			.map_err(|e| anyhow!("{e}"))?;
+		raw.into_iter()
+			.map(|g| {
+				let id: u32 =
+					g.id.try_into()
+						.with_context(|| format!("generation id {} doesn't fit in u32", g.id))?;
+				let datetime = UtcDateTime::from_unix_timestamp(g.creation_time_unix)
+					.with_context(|| {
+						format!("invalid generation timestamp {}", g.creation_time_unix)
+					})?;
+				Ok(Generation {
+					id,
+					current: g.current,
+					datetime,
+					store_path: g.store_path,
+					location: GenerationStorage::Machine,
+				})
+			})
+			.collect()
+	}
+}
modifiedcrates/nixlike/fuzz/Cargo.tomldiffbeforeafterboth
--- a/crates/nixlike/fuzz/Cargo.toml
+++ b/crates/nixlike/fuzz/Cargo.toml
@@ -4,7 +4,7 @@
 version = "0.0.0"
 authors = ["Automatically generated"]
 publish = false
-edition = "2024"
+edition.workspace = true
 
 [package.metadata]
 cargo-fuzz = true
modifiedcrates/remowt-fleet/Cargo.tomldiffbeforeafterboth
--- a/crates/remowt-fleet/Cargo.toml
+++ b/crates/remowt-fleet/Cargo.toml
@@ -2,7 +2,7 @@
 name = "remowt-fleet"
 description = "Remowt endpoints for fleet"
 version.workspace = true
-edition = "2021"
+edition.workspace = true
 
 [dependencies]
 bifrostlink.workspace = true
@@ -18,3 +18,4 @@
 tokio = { workspace = true, features = ["io-util", "net", "process", "rt"] }
 tracing.workspace = true
 uuid.workspace = true
+remowt-link-shared.workspace = true
modifiedcrates/remowt-fleet/src/lib.rsdiffbeforeafterboth
--- a/crates/remowt-fleet/src/lib.rs
+++ b/crates/remowt-fleet/src/lib.rs
@@ -7,6 +7,7 @@
 use nix_eval::eval_store;
 use remowt_client::Remowt;
 use remowt_endpoints::nix_daemon::NixDaemonClient;
+use remowt_link_shared::iroh_tunnel::TunnelAddr;
 use serde::{Deserialize, Serialize};
 use tokio::net::UnixListener;
 use tokio::task::spawn_blocking;
@@ -98,8 +99,10 @@
 				continue;
 			}
 		};
-		let sock_str = remote_sock.as_str().to_owned();
-		match nix.serve_store(store.clone(), sock_str).await {
+		match nix
+			.serve_store(store.clone(), TunnelAddr::Unix(remote_sock))
+			.await
+		{
 			Ok(Ok(())) => {}
 			Ok(Err(e)) => {
 				error!("nix bridge: {e}");
modifiedflake.lockdiffbeforeafterboth
--- a/flake.lock
+++ b/flake.lock
@@ -171,35 +171,6 @@
         "type": "github"
       }
     },
-    "remowt-agents": {
-      "inputs": {
-        "crane": [
-          "crane"
-        ],
-        "flake-parts": [
-          "flake-parts"
-        ],
-        "nixpkgs": [
-          "nixpkgs"
-        ],
-        "rust-overlay": "rust-overlay",
-        "shelly": [
-          "shelly"
-        ]
-      },
-      "locked": {
-        "lastModified": 1781491763,
-        "ref": "refs/heads/trunk",
-        "rev": "8b18e8b236d428ed8fbf5818272d17f5f538c13c",
-        "revCount": 40,
-        "type": "git",
-        "url": "https://gerrit.delta.rocks/remowt"
-      },
-      "original": {
-        "type": "git",
-        "url": "https://gerrit.delta.rocks/remowt"
-      }
-    },
     "root": {
       "inputs": {
         "crane": "crane",
@@ -207,33 +178,12 @@
         "fleet-tf": "fleet-tf",
         "nix": "nix",
         "nixpkgs": "nixpkgs",
-        "remowt-agents": "remowt-agents",
-        "rust-overlay": "rust-overlay_2",
+        "rust-overlay": "rust-overlay",
         "shelly": "shelly",
         "treefmt-nix": "treefmt-nix"
       }
     },
     "rust-overlay": {
-      "inputs": {
-        "nixpkgs": [
-          "remowt-agents",
-          "nixpkgs"
-        ]
-      },
-      "locked": {
-        "lastModified": 1769309768,
-        "owner": "oxalica",
-        "repo": "rust-overlay",
-        "rev": "140c9dc582cb73ada2d63a2180524fcaa744fad5",
-        "type": "github"
-      },
-      "original": {
-        "owner": "oxalica",
-        "repo": "rust-overlay",
-        "type": "github"
-      }
-    },
-    "rust-overlay_2": {
       "inputs": {
         "nixpkgs": [
           "nixpkgs"
modifiedflake.nixdiffbeforeafterboth
--- a/flake.nix
+++ b/flake.nix
@@ -23,13 +23,6 @@
       url = "github:numtide/treefmt-nix";
       inputs.nixpkgs.follows = "nixpkgs";
     };
-    remowt-agents = {
-      url = "git+https://gerrit.delta.rocks/remowt";
-      inputs.nixpkgs.follows = "nixpkgs";
-      inputs.flake-parts.follows = "flake-parts";
-      inputs.crane.follows = "crane";
-      inputs.shelly.follows = "shelly";
-    };
     # DeterminateSystem's nix fork is controversial, but I don't mind it,
     # and it has lazy-trees support which is useful for fleet.
     nix = {
modifiedpkgs/default.nixdiffbeforeafterboth
--- a/pkgs/default.nix
+++ b/pkgs/default.nix
@@ -3,9 +3,17 @@
   craneLib,
   inputs,
 }:
+let
+  remowt-agents-bundle = callPackage ./remowt-agents-bundle.nix { inherit craneLib; };
+in
 {
   fleet = callPackage ./fleet.nix { inherit craneLib inputs; };
-  remowt-plugin-fleet = callPackage ./remowt-plugin-fleet.nix { inherit craneLib inputs; };
   fleet-install-secrets = callPackage ./fleet-install-secrets.nix { inherit craneLib; };
   fleet-generator-helper = callPackage ./fleet-generator-helper.nix { inherit craneLib; };
+
+  inherit remowt-agents-bundle;
+  remowt-plugin-fleet = callPackage ./remowt-plugin-fleet.nix {
+    inherit craneLib inputs remowt-agents-bundle;
+  };
+  remowt-ssh = callPackage ./remowt-ssh.nix { inherit craneLib remowt-agents-bundle; };
 }
modifiedpkgs/fleet.nixdiffbeforeafterboth
--- a/pkgs/fleet.nix
+++ b/pkgs/fleet.nix
@@ -3,6 +3,7 @@
   craneLib,
   installShellFiles,
   inputs,
+  remowt-agents-bundle,
 
   stdenv,
   pkg-config,
@@ -26,7 +27,7 @@
 
   cargoExtraArgs = "--locked -p ${pname}";
 
-  REMOWT_AGENTS_DIR = "${inputs.remowt-agents.packages.${system}.remowt-agents}";
+  REMOWT_AGENTS_DIR = "${remowt-agents-bundle}";
   # TODO: built-in fleet prompter should be a prodash widget, or it should require
   # tty remowt prompter running on host machine idk.
   ROFI = "${rofi}/bin/rofi";
addedpkgs/remowt-agents-bundle.nixdiffbeforeafterboth
--- /dev/null
+++ b/pkgs/remowt-agents-bundle.nix
@@ -0,0 +1,73 @@
+{
+  craneLib,
+  lib,
+  pkgs,
+  runCommandLocal,
+}:
+let
+  crateName = "remowt-agent";
+
+  buildFor =
+    {
+      target,
+      crossPkgs,
+    }:
+    let
+      cc = crossPkgs.stdenv.cc;
+      ccBin = "${cc}/bin/${cc.targetPrefix}";
+      ut = builtins.replaceStrings [ "-" ] [ "_" ] target;
+      linkerEnv = "CARGO_TARGET_${lib.toUpper ut}_LINKER";
+    in
+    craneLib.buildPackage (
+      {
+        src = craneLib.cleanCargoSource ../.;
+        pname = "${crateName}-${target}";
+
+        cargoExtraArgs = "--locked -p ${crateName}";
+
+        CARGO_BUILD_TARGET = target;
+        CARGO_BUILD_RUSTFLAGS = "-C target-feature=+crt-static";
+
+        depsBuildBuild = [ cc ];
+        doCheck = false;
+      }
+      // {
+        ${linkerEnv} = "${ccBin}cc";
+        "CC_${ut}" = "${ccBin}cc";
+        "CXX_${ut}" = "${ccBin}c++";
+        "AR_${ut}" = "${ccBin}ar";
+      }
+    );
+  x86_64 = buildFor {
+    target = "x86_64-unknown-linux-musl";
+    crossPkgs = pkgs;
+  };
+  aarch64 = buildFor {
+    target = "aarch64-unknown-linux-musl";
+    crossPkgs = pkgs.pkgsCross.aarch64-multiplatform-musl;
+  };
+  armv7l = buildFor {
+    target = "armv7-unknown-linux-musleabihf";
+    crossPkgs = pkgs.pkgsCross.armv7l-hf-multiplatform.pkgsMusl;
+  };
+in
+runCommandLocal "remowt-agents-bundle"
+  {
+    passthru = {
+      perArch = {
+        inherit x86_64 aarch64 armv7l;
+      };
+    };
+  }
+  ''
+    mkdir -p $out
+    cp ${x86_64}/bin/remowt-agent  $out/remowt-agent-x86_64
+    cp ${aarch64}/bin/remowt-agent $out/remowt-agent-aarch64
+    cp ${armv7l}/bin/remowt-agent  $out/remowt-agent-armv7l
+    chmod +w $out/remowt-agent-*
+
+    for arch in x86_64 aarch64 armv7l; do
+      hash=$(sha256sum "$out/remowt-agent-$arch" | cut -d' ' -f1)
+      printf '%s %s\n' "$arch" "$hash" >> $out/hashes
+    done
+  ''
addedpkgs/remowt-ssh.nixdiffbeforeafterboth
--- /dev/null
+++ b/pkgs/remowt-ssh.nix
@@ -0,0 +1,17 @@
+{
+  craneLib,
+  remowt-agents-bundle,
+  rofi,
+}:
+let
+  pname = "remowt-ssh";
+in
+craneLib.buildPackage {
+  inherit pname;
+  src = craneLib.cleanCargoSource ../.;
+
+  cargoExtraArgs = "--locked -p ${pname}";
+
+  REMOWT_AGENTS_DIR = "${remowt-agents-bundle}";
+  ROFI = "${rofi}/bin/rofi";
+}
modifiedremowt/cmds/remowt-agent/Cargo.tomldiffbeforeafterboth
--- a/remowt/cmds/remowt-agent/Cargo.toml
+++ b/remowt/cmds/remowt-agent/Cargo.toml
@@ -2,7 +2,7 @@
 name = "remowt-agent"
 description = "remowt on-host agent serving fs/pty/systemd endpoints over bifrostlink"
 version.workspace = true
-edition = "2021"
+edition.workspace = true
 license.workspace = true
 
 [dependencies]
modifiedremowt/cmds/remowt-agent/src/askpass.rsdiffbeforeafterboth
--- a/remowt/cmds/remowt-agent/src/askpass.rs
+++ b/remowt/cmds/remowt-agent/src/askpass.rs
@@ -1,47 +1,22 @@
 use std::borrow::Cow;
 use std::io::Write as _;
 
-use anyhow::Context as _;
-use remowt_ui_prompt::dbus::{DbusPrompterInterface, DbusPrompterProxy, BUS_NAME, PROMPTER_PATH};
+use bifrostlink::declarative::RemoteEndpoints as _;
+use remowt_link_shared::{gateway, Address, BifConfig};
+use remowt_ui_prompt::bifrost::PromptEndpointsClient;
 use remowt_ui_prompt::{Prompter, Source};
-use tracing::debug;
-use zbus::Connection;
-
-pub async fn serve<P>(conn: &Connection, prompter: P) -> anyhow::Result<()>
-where
-	P: Prompter + 'static,
-{
-	conn.object_server()
-		.at(PROMPTER_PATH, DbusPrompterInterface(prompter))
-		.await?;
-	match conn.request_name(BUS_NAME).await {
-		Ok(()) => {}
-		Err(zbus::Error::NameTaken) => {
-			debug!("{BUS_NAME} already owned, chaining to upstream");
-		}
-		Err(e) => return Err(e.into()),
-	}
-	Ok(())
-}
 
 pub async fn ask(prompt: &str, description: String) -> anyhow::Result<()> {
-	let conn = Connection::session()
-		.await
-		.context("connecting to the session bus (DBUS_SESSION_BUS_ADDRESS)")?;
-	let proxy = DbusPrompterProxy::builder(&conn)
-		.destination(BUS_NAME)?
-		.path(PROMPTER_PATH)?
-		.build()
-		.await?;
-
-	let password = proxy
-		.prompt_text(
-			false,
-			prompt,
-			&description,
-			&[Source(Cow::Borrowed("remowt-askpass"))],
-		)
-		.await?;
+	let rpc = gateway::connect(&gateway::socket_path()?).await?;
+	let prompter = PromptEndpointsClient::<BifConfig>::wrap(rpc.remote(Address::User));
+	let password = Prompter::prompt_text(
+		&prompter,
+		false,
+		prompt,
+		&description,
+		&[Source(Cow::Borrowed("remowt-askpass"))],
+	)
+	.await?;
 
 	let mut out = std::io::stdout().lock();
 	out.write_all(password.as_bytes())?;
deletedremowt/cmds/remowt-agent/src/bus.rsdiffbeforeafterboth
--- a/remowt/cmds/remowt-agent/src/bus.rs
+++ /dev/null
@@ -1,40 +0,0 @@
-use std::process::Stdio;
-
-use anyhow::Context as _;
-use futures::StreamExt as _;
-use tokio::process::{Child, Command};
-use tokio_util::codec::{FramedRead, LinesCodec};
-use zbus::Connection;
-
-pub struct PrivateBus {
-	pub address: String,
-	pub conn: Connection,
-	_child: Child,
-}
-
-pub async fn spawn() -> anyhow::Result<PrivateBus> {
-	let mut child = Command::new("dbus-daemon")
-		.args(["--session", "--nofork", "--print-address"])
-		.stdout(Stdio::piped())
-		.kill_on_drop(true)
-		.spawn()
-		.context("spawning dbus-daemon for the private bus")?;
-
-	let stdout = child.stdout.take().expect("piped");
-	let address = FramedRead::new(stdout, LinesCodec::new())
-		.next()
-		.await
-		.context("dbus-daemon exited before printing its address")?
-		.context("reading dbus-daemon address")?;
-
-	let conn = zbus::connection::Builder::address(address.as_str())?
-		.build()
-		.await
-		.context("connecting to the private bus")?;
-
-	Ok(PrivateBus {
-		address,
-		conn,
-		_child: child,
-	})
-}
modifiedremowt/cmds/remowt-agent/src/editor.rsdiffbeforeafterboth
--- a/remowt/cmds/remowt-agent/src/editor.rs
+++ b/remowt/cmds/remowt-agent/src/editor.rs
@@ -3,87 +3,23 @@
 use std::time::Duration;
 use std::{fs, io};
 
-use anyhow::{bail, Context as _};
+use anyhow::{anyhow, bail, Context as _};
+use bifrostlink::declarative::RemoteEndpoints as _;
 use nix::libc;
 use remowt_link_shared::editor::EditorEndpointsClient;
+use remowt_link_shared::{gateway, Address, BifConfig};
 use tokio::process::Command;
-use zbus::{fdo, interface, proxy, Connection};
-
-use remowt_link_shared::BifConfig;
-
-const BUS_NAME: &str = "lach.RemowtEditor";
-const SERVICE_PATH: &str = "/lach/Editor";
-
-pub struct EditorService {
-	editor: EditorEndpointsClient<BifConfig>,
-}
 
-#[interface(name = "lach.RemowtEditor")]
-impl EditorService {
-	/// Attach the User's GUI to the nvim server at `socket_path` (on the remote),
-	/// blocking until the user is done.
-	async fn edit(&self, socket_path: String) -> fdo::Result<()> {
-		self.editor
-			.open_editor(socket_path)
-			.await
-			.map_err(|e| fdo::Error::Failed(format!("requesting editor on the User: {e}")))?
-			.map_err(|e| fdo::Error::Failed(format!("editor failed: {e}")))?;
-		Ok(())
-	}
-
-	async fn forward_tcp(&self, addr: String) -> fdo::Result<u16> {
-		let local = self
-			.editor
-			.expose_tcp(addr)
-			.await
-			.map_err(|e| fdo::Error::Failed(format!("requesting tcp forward on the User: {e}")))?
-			.map_err(|e| fdo::Error::Failed(format!("tcp forward failed: {e}")))?;
-		Ok(local)
-	}
-
-	async fn forward_udp(&self, addr: String) -> fdo::Result<u16> {
-		let local = self
-			.editor
-			.expose_udp(addr)
-			.await
-			.map_err(|e| fdo::Error::Failed(format!("requesting udp forward on the User: {e}")))?
-			.map_err(|e| fdo::Error::Failed(format!("udp forward failed: {e}")))?;
-		Ok(local)
-	}
-}
-
-pub async fn serve(
-	conn: &Connection,
-	editor: EditorEndpointsClient<BifConfig>,
-) -> anyhow::Result<()> {
-	conn.object_server()
-		.at(SERVICE_PATH, EditorService { editor })
-		.await?;
-	conn.request_name(BUS_NAME).await?;
-	Ok(())
-}
-
-#[proxy(interface = "lach.RemowtEditor")]
-trait RemowtEditor {
-	async fn edit(&self, socket_path: &str) -> fdo::Result<()>;
-	async fn forward_tcp(&self, addr: &str) -> fdo::Result<u16>;
-	async fn forward_udp(&self, addr: &str) -> fdo::Result<u16>;
-}
-
 pub async fn forward(udp: bool, addr: String) -> anyhow::Result<()> {
-	let conn = Connection::session()
-		.await
-		.context("connecting to the session bus (DBUS_SESSION_BUS_ADDRESS)")?;
-	let proxy = RemowtEditorProxy::builder(&conn)
-		.destination(BUS_NAME)?
-		.path(SERVICE_PATH)?
-		.build()
-		.await?;
+	let rpc = gateway::connect(&gateway::socket_path()?).await?;
+	let editor = EditorEndpointsClient::<BifConfig>::wrap(rpc.remote(Address::User));
 	let local = if udp {
-		proxy.forward_udp(&addr).await?
+		editor.expose_udp(addr).await
 	} else {
-		proxy.forward_tcp(&addr).await?
-	};
+		editor.expose_tcp(addr).await
+	}
+	.map_err(|e| anyhow!("requesting forward on the User: {e}"))?
+	.map_err(|e| anyhow!("forward failed: {e}"))?;
 	println!("{local}");
 	Ok(())
 }
@@ -125,15 +61,13 @@
 		.await
 		.context("nvim did not start its server")?;
 
-	let conn = Connection::session()
+	let rpc = gateway::connect(&gateway::socket_path()?).await?;
+	let editor = EditorEndpointsClient::<BifConfig>::wrap(rpc.remote(Address::User));
+	let result = editor
+		.open_editor(sock_str)
 		.await
-		.context("connecting to the session bus (DBUS_SESSION_BUS_ADDRESS)")?;
-	let proxy = RemowtEditorProxy::builder(&conn)
-		.destination(BUS_NAME)?
-		.path(SERVICE_PATH)?
-		.build()
-		.await?;
-	let result = proxy.edit(&sock_str).await;
+		.map_err(|e| anyhow!("requesting editor on the User: {e}"))
+		.and_then(|r| r.map_err(|e| anyhow!("editor failed: {e}")));
 
 	if tokio::time::timeout(Duration::from_secs(2), child.wait())
 		.await
@@ -143,8 +77,7 @@
 	}
 	let _ = fs::remove_file(&sock);
 
-	result?;
-	Ok(())
+	result
 }
 
 async fn wait_for_socket(path: &Path) -> anyhow::Result<()> {
modifiedremowt/cmds/remowt-agent/src/main.rsdiffbeforeafterboth
--- a/remowt/cmds/remowt-agent/src/main.rs
+++ b/remowt/cmds/remowt-agent/src/main.rs
@@ -7,8 +7,8 @@
 use std::path::PathBuf;
 use std::sync::{Arc, Mutex, OnceLock};
 
+use bifrostlink::Rpc;
 use bifrostlink::declarative::RemoteEndpoints;
-use bifrostlink::Rpc;
 use bifrostlink_ports::stdio::from_stdio;
 use bifrostlink_ports::unix_socket::from_socket;
 use clap::Parser;
@@ -17,9 +17,9 @@
 	subprocess::Subprocess, systemd::Systemd,
 };
 use remowt_link_shared::iroh_tunnel::TunnelDialer;
-use remowt_link_shared::{editor::EditorEndpointsClient, Address, BifConfig};
-use remowt_polkit_shared::{emphasize, Identity, PidDisplay};
-use remowt_ui_prompt::bifrost::PromptEndpointsClient;
+use remowt_link_shared::{Address, BifConfig, gateway};
+use remowt_polkit_shared::{Identity, PidDisplay, emphasize};
+use remowt_ui_prompt::bifrost::{PromptEndpointsClient, serve_prompts};
 use remowt_ui_prompt::rofi::RofiPrompter;
 use remowt_ui_prompt::{PrependSourcePrompter, Prompter, Source};
 use tokio::fs;
@@ -29,13 +29,12 @@
 use tracing::{debug, trace};
 use zbus::fdo;
 use zbus::zvariant::{OwnedValue, Str};
-use zbus::{interface, Connection};
+use zbus::{Connection, interface};
 use zbus_polkit::policykit1::Subject;
 
 use self::helper::{Helper, SocketHelper, SuidHelper};
 
 pub mod askpass;
-pub mod bus;
 pub mod editor;
 pub mod helper;
 
@@ -132,19 +131,18 @@
 					0 => {
 						return Err(fdo::Error::AuthFailed(
 							"no identity to authenticate as".to_owned(),
-						))
+						));
 					}
 					1 => 0,
-					_ => {
-						prompter
-							.prompt_enum(
-								"Identity",
-								"Select identity to use for polkit authorization",
-								&identity_displays,
-								&[],
-							)
-							.await?
-					}
+					_ => prompter
+						.prompt_enum(
+							"Identity",
+							"Select identity to use for polkit authorization",
+							&identity_displays,
+							&[],
+						)
+						.await
+						.map_err(prompt_err)?,
 				};
 				debug!("identity chosen");
 
@@ -198,6 +196,15 @@
 	}
 }
 
+fn prompt_err(value: remowt_ui_prompt::Error) -> fdo::Error {
+	use remowt_ui_prompt::Error;
+	match value {
+		Error::Cancel => fdo::Error::NoReply("input was cancelled".to_owned()),
+		Error::Remote(e) => fdo::Error::NoReply(format!("remote error occured: {e}")),
+		Error::InputError(e) => fdo::Error::Failed(e),
+	}
+}
+
 const OBJ_PATH: &str = "/org/freedesktop/PolicyKit1/AuthenticationAgent";
 
 #[derive(Parser)]
@@ -268,10 +275,12 @@
 	};
 	register_auth_agent(&system_conn, Agent::new(helper, RofiPrompter)).await?;
 
-	let session_conn = Connection::session().await?;
-	askpass::serve(&session_conn, RofiPrompter).await?;
+	let mut rpc = Rpc::<BifConfig>::new(Address::User);
+	serve_prompts(&mut rpc, RofiPrompter);
+
+	gateway::serve(rpc.clone(), &gateway::local_socket()?).await?;
 
-	let _keep_alive = (system_conn, session_conn);
+	let _keep_alive = (system_conn, rpc);
 	pending().await
 }
 async fn main_real_agent(
@@ -298,13 +307,9 @@
 	remowt_plugin::host::serve(&mut rpc);
 
 	let user_prompter = PromptEndpointsClient::wrap(rpc.remote(Address::User));
-	let editor_client = EditorEndpointsClient::wrap(rpc.remote(Address::User));
-
-	let bus = bus::spawn().await?;
-	askpass::serve(&bus.conn, user_prompter.clone()).await?;
-	editor::serve(&bus.conn, editor_client).await?;
 
 	let helpers = tempfile::Builder::new().prefix("remowt-path.").tempdir()?;
+	let gateway_socket = helpers.path().join(gateway::SOCKET_NAME);
 	let exe = std::env::current_exe()?;
 	let askpass_helper = helpers.path().join("remowt-askpass");
 	let editor_helper = helpers.path().join("remowt-editor");
@@ -342,7 +347,7 @@
 		std::env::set_var("SSH_ASKPASS_REQUIRE", "force");
 		std::env::set_var("EDITOR", &editor_helper);
 		std::env::set_var("VISUAL", &editor_helper);
-		std::env::set_var("DBUS_SESSION_BUS_ADDRESS", &bus.address);
+		std::env::set_var(gateway::SOCKET_ENV, &gateway_socket);
 	}
 
 	let port = match path {
@@ -351,10 +356,9 @@
 	};
 	rpc.add_direct(Address::User, port, bifrostlink::Rtt(0));
 
+	gateway::serve(rpc.clone(), &gateway_socket).await?;
+
 	let polkit_conn = if !privileged && !local {
-		// The unprivileged agent doubles as a polkit authentication agent so
-		// `run0` (e.g. our own elevation) routes its prompt to the User over
-		// bifrost instead of failing on a tty-less session.
 		let conn = Connection::system().await?;
 		let helper = SocketHelper {
 			fallback: SuidHelper,
@@ -365,7 +369,7 @@
 		None
 	};
 
-	let _keep_alive = (bus, helpers, polkit_conn);
+	let _keep_alive = (helpers, polkit_conn);
 	pending().await
 }
 
modifiedremowt/cmds/remowt-ssh/Cargo.tomldiffbeforeafterboth
--- a/remowt/cmds/remowt-ssh/Cargo.toml
+++ b/remowt/cmds/remowt-ssh/Cargo.toml
@@ -2,7 +2,7 @@
 name = "remowt-ssh"
 description = "SSH transport client for connecting to a remowt agent"
 version.workspace = true
-edition = "2021"
+edition.workspace = true
 license.workspace = true
 
 [dependencies]
modifiedremowt/cmds/remowt-ssh/src/main.rsdiffbeforeafterboth
--- a/remowt/cmds/remowt-ssh/src/main.rs
+++ b/remowt/cmds/remowt-ssh/src/main.rs
@@ -75,14 +75,8 @@
 			description: "".to_owned(),
 		},
 	);
-	if let Some(sess) = conn.ssh() {
-		serve_editor(
-			&mut rpc,
-			SshEditor {
-				sess,
-				conn: conn.clone(),
-			},
-		);
+	if conn.ssh().is_some() {
+		serve_editor(&mut rpc, SshEditor { conn: conn.clone() });
 	}
 
 	debug!("entering shell");
modifiedremowt/crates/polkit-shared/Cargo.tomldiffbeforeafterboth
--- a/remowt/crates/polkit-shared/Cargo.toml
+++ b/remowt/crates/polkit-shared/Cargo.toml
@@ -2,7 +2,7 @@
 name = "remowt-polkit-shared"
 description = "Shared polkit/PAM types for remowt"
 version.workspace = true
-edition = "2021"
+edition.workspace = true
 license.workspace = true
 
 [dependencies]
modifiedremowt/crates/remowt-client/Cargo.tomldiffbeforeafterboth
--- a/remowt/crates/remowt-client/Cargo.toml
+++ b/remowt/crates/remowt-client/Cargo.toml
@@ -2,7 +2,7 @@
 name = "remowt-client"
 description = "russh-based client connection to a remowt agent"
 version.workspace = true
-edition = "2021"
+edition.workspace = true
 license.workspace = true
 
 [dependencies]
modifiedremowt/crates/remowt-client/src/editor.rsdiffbeforeafterboth
--- a/remowt/crates/remowt-client/src/editor.rs
+++ b/remowt/crates/remowt-client/src/editor.rs
@@ -6,14 +6,12 @@
 use remowt_endpoints::forward::ForwardClient;
 use remowt_link_shared::editor::{EditorBackend, Error};
 use remowt_link_shared::BifConfig;
-use russh::client::Handle;
 use tokio::net::{TcpListener, UdpSocket, UnixListener};
 use tracing::error;
 
-use crate::{Remowt, SshHandler};
+use crate::Remowt;
 
 pub struct SshEditor {
-	pub sess: Arc<Handle<SshHandler>>,
 	pub conn: Remowt,
 }
 impl EditorBackend for SshEditor {
@@ -22,21 +20,41 @@
 		let _ = std::fs::remove_file(&local);
 		let listener = UnixListener::bind(&local).map_err(|e| Error::Failed(e.to_string()))?;
 
-		let sess = self.sess.clone();
+		let conn = self.conn.clone();
 		let forward = tokio::spawn(async move {
 			loop {
 				let Ok((mut stream, _)) = listener.accept().await else {
 					break;
 				};
-				let sess = sess.clone();
+				let conn = conn.clone();
 				let remote = socket_path.clone();
 				tokio::spawn(async move {
-					match sess.channel_open_direct_streamlocal(remote).await {
-						Ok(ch) => {
-							let mut remote = ch.into_stream();
+					// Rides the iroh fast tunnel when established, else an
+					// ssh-forwarded unix socket.
+					let (forwarded, tunnel) = match conn.bind_fast_tunnel("editor", false).await {
+						Ok(v) => v,
+						Err(e) => {
+							error!("editor: bind tunnel failed: {e}");
+							return;
+						}
+					};
+					let fclient: ForwardClient<BifConfig> = conn.endpoints();
+					match fclient.connect_unix(tunnel, remote).await {
+						Ok(Ok(())) => {}
+						Ok(Err(e)) => {
+							error!("editor: agent connect_unix failed: {e}");
+							return;
+						}
+						Err(e) => {
+							error!("editor: connect_unix rpc failed: {e}");
+							return;
+						}
+					}
+					match forwarded.accept().await {
+						Ok(mut remote) => {
 							let _ = tokio::io::copy_bidirectional(&mut stream, &mut remote).await;
 						}
-						Err(e) => error!("opening direct-streamlocal to nvim failed: {e}"),
+						Err(e) => error!("editor: accept tunnel failed: {e}"),
 					}
 				});
 			}
modifiedremowt/crates/remowt-client/src/lib.rsdiffbeforeafterboth
--- a/remowt/crates/remowt-client/src/lib.rs
+++ b/remowt/crates/remowt-client/src/lib.rs
@@ -4,7 +4,7 @@
 use std::sync::atomic::AtomicU64;
 use std::sync::{Arc, Mutex};
 
-use anyhow::{anyhow, bail, ensure, Context as _, Result};
+use anyhow::{Context as _, Result, anyhow, bail, ensure};
 use bifrostlink::declarative::RemoteEndpoints;
 use bifrostlink::{Remote, Rpc, Rtt};
 use camino::{Utf8Path, Utf8PathBuf};
@@ -12,12 +12,12 @@
 use remowt_link_shared::plugin::PluginEndpointsClient;
 use remowt_link_shared::port::child_port;
 use remowt_link_shared::{Address, BifConfig};
-use russh::client::{connect, Config, Handle, Handler, Msg, Session};
+use russh::Channel;
+use russh::client::{Config, Handle, Handler, Msg, Session, connect};
+use russh::keys::agent::AgentIdentity;
 use russh::keys::agent::client::AgentClient;
-use russh::keys::agent::AgentIdentity;
 use russh::keys::check_known_hosts;
 use russh::keys::ssh_key::PublicKey;
-use russh::Channel;
 use tempfile::TempDir;
 use tokio::io::AsyncRead;
 use tokio::net::UnixListener;
@@ -287,8 +287,12 @@
 			.unwrap_or_else(|| env::var("USER").unwrap_or_else(|_| "root".to_owned()));
 
 		let subs: Subs = Arc::new(Mutex::new(HashMap::new()));
+		let config = Config {
+			nodelay: true,
+			..Config::default()
+		};
 		let mut sess = connect(
-			Arc::new(Config::default()),
+			Arc::new(config),
 			(hostname.clone(), port),
 			SshHandler {
 				host: hostname,
@@ -549,9 +553,7 @@
 		}
 	}
 
-	/// Bind a data tunnel, preferring the iroh fast path when it is up. Escalated tunnels
-	/// (the privileged agent, a separate process with no iroh connection of its own) and the
-	/// local transport always use the ssh/unix path.
+	/// Bind a data tunnel, preferring the iroh fast path when it is up.
 	pub async fn bind_fast_tunnel(
 		&self,
 		hint: &str,
@@ -586,7 +588,7 @@
 
 	async fn try_setup_iroh(&self) -> Result<()> {
 		use remowt_endpoints::iroh_tunnel::IrohTunnelClient;
-		use remowt_link_shared::iroh_tunnel::{build_endpoint, ssh_custom_addr, REMOWT_ALPN};
+		use remowt_link_shared::iroh_tunnel::{REMOWT_ALPN, build_endpoint, ssh_custom_addr};
 
 		let (listener, sock) = self.bind_runtime_unix("iroh-xport").await?;
 		let secret = iroh::SecretKey::generate();
@@ -605,7 +607,7 @@
 		);
 		let conn = ep.connect(addr, REMOWT_ALPN).await?;
 		ensure!(conn.remote_id() == agent_id, "iroh peer identity mismatch");
-		info!("iroh fast tunnel established");
+		debug!("iroh fast tunnel established");
 
 		let subs = self.0.iroh.subs.clone();
 		let accept_conn = conn.clone();
@@ -633,16 +635,6 @@
 						debug!("iroh accept loop ended: {e}");
 						break;
 					}
-				}
-			}
-		});
-
-		let log_conn = conn.clone();
-		tokio::spawn(async move {
-			tokio::time::sleep(std::time::Duration::from_secs(2)).await;
-			for p in log_conn.paths().iter() {
-				if p.is_selected() {
-					info!(rtt = ?p.rtt(), "iroh selected path: {}", p.remote_addr());
 				}
 			}
 		});
@@ -688,10 +680,10 @@
 }
 
 fn local_runtime_dir() -> Result<(Utf8PathBuf, Option<TempDir>)> {
-	if let Ok(dir) = env::var("XDG_RUNTIME_DIR") {
-		if !dir.is_empty() {
-			return Ok((Utf8PathBuf::from(dir), None));
-		}
+	if let Ok(dir) = env::var("XDG_RUNTIME_DIR")
+		&& !dir.is_empty()
+	{
+		return Ok((Utf8PathBuf::from(dir), None));
 	}
 	let tmp = tempfile::Builder::new()
 		.prefix("remowt.")
modifiedremowt/crates/remowt-endpoints/Cargo.tomldiffbeforeafterboth
--- a/remowt/crates/remowt-endpoints/Cargo.toml
+++ b/remowt/crates/remowt-endpoints/Cargo.toml
@@ -2,7 +2,7 @@
 name = "remowt-endpoints"
 description = "Nix daemon proxy"
 version.workspace = true
-edition = "2021"
+edition.workspace = true
 license.workspace = true
 
 [dependencies]
modifiedremowt/crates/remowt-endpoints/src/forward.rsdiffbeforeafterboth
--- a/remowt/crates/remowt-endpoints/src/forward.rs
+++ b/remowt/crates/remowt-endpoints/src/forward.rs
@@ -3,12 +3,12 @@
 use std::sync::atomic::{AtomicU64, Ordering};
 use std::sync::{Arc, Mutex};
 
+use bifrostlink::Config;
 use bifrostlink::declarative::endpoints;
-use bifrostlink::Config;
 use remowt_link_shared::iroh_tunnel::{TunnelAddr, TunnelDialer};
 use serde::{Deserialize, Serialize};
 use std::result::Result;
-use tokio::net::{TcpStream, UdpSocket};
+use tokio::net::{TcpStream, UdpSocket, UnixStream};
 use tracing::warn;
 
 #[derive(Serialize, Deserialize, Debug, thiserror::Error)]
@@ -107,6 +107,24 @@
 
 		Ok(session)
 	}
+
+	#[endpoints(id = 3)]
+	async fn connect_unix(&self, tunnel: TunnelAddr, path: String) -> Result<(), Error> {
+		let stream = self
+			.dialer
+			.connect_tunnel(&tunnel)
+			.await
+			.map_err(|e| Error::Tunnel(e.to_string()))?;
+		let unix = UnixStream::connect(&path)
+			.await
+			.map_err(|e| Error::Connect(path, e.to_string()))?;
+		tokio::spawn(async move {
+			let mut stream = stream;
+			let mut unix = unix;
+			let _ = tokio::io::copy_bidirectional(&mut stream, &mut unix).await;
+		});
+		Ok(())
+	}
 }
 
 fn unspecified_for(target: &SocketAddr) -> SocketAddr {
modifiedremowt/crates/remowt-link-shared/Cargo.tomldiffbeforeafterboth
--- a/remowt/crates/remowt-link-shared/Cargo.toml
+++ b/remowt/crates/remowt-link-shared/Cargo.toml
@@ -2,7 +2,7 @@
 name = "remowt-link-shared"
 description = "Shared bifrostlink endpoint wiring for remowt"
 version.workspace = true
-edition = "2021"
+edition.workspace = true
 license.workspace = true
 
 [dependencies]
@@ -13,13 +13,13 @@
 serde = { workspace = true, features = ["derive"] }
 serde_json.workspace = true
 thiserror.workspace = true
-tokio = { workspace = true, features = ["fs", "io-util", "macros", "net", "sync", "rt"] }
+tokio = { workspace = true, features = ["fs", "io-util", "macros", "net", "rt", "sync"] }
 tokio-util = { workspace = true, features = ["codec"] }
 tracing.workspace = true
-remowt-ui-prompt.workspace = true
+uuid = { workspace = true, features = ["v4"] }
 
+futures.workspace = true
 iroh.workspace = true
 iroh-base.workspace = true
-noq-udp.workspace = true
 n0-watcher.workspace = true
-futures.workspace = true
+noq-udp.workspace = true
modifiedremowt/crates/remowt-link-shared/src/lib.rsdiffbeforeafterboth
--- a/remowt/crates/remowt-link-shared/src/lib.rs
+++ b/remowt/crates/remowt-link-shared/src/lib.rs
@@ -7,6 +7,7 @@
 use serde::{Deserialize, Serialize};
 
 pub mod editor;
+pub mod gateway;
 pub mod iroh_tunnel;
 pub mod port;
 
@@ -16,6 +17,7 @@
 	Agent,
 	AgentPrivileged,
 	Plugin(u16),
+	Ephemeral(uuid::Uuid),
 }
 impl AddressT for Address {}
 
@@ -27,9 +29,6 @@
 	ListenerDead,
 	#[error("response: {0}")]
 	Response(String),
-
-	#[error(transparent)]
-	Ui(#[from] remowt_ui_prompt::Error),
 }
 
 impl From<ListenerForYourRequestHasBeenDeadError> for Error {
@@ -38,8 +37,8 @@
 	}
 }
 impl From<serde_json::Error> for Error {
-	fn from(_value: serde_json::Error) -> Self {
-		Self::ListenerDead
+	fn from(e: serde_json::Error) -> Self {
+		Self::Response(format!("{e}"))
 	}
 }
 impl From<Error> for ResponseError {
modifiedremowt/crates/remowt-link-shared/src/plugin.rsdiffbeforeafterboth
--- a/remowt/crates/remowt-link-shared/src/plugin.rs
+++ b/remowt/crates/remowt-link-shared/src/plugin.rs
@@ -10,6 +10,8 @@
 	BadName,
 	#[error("spawning plugin failed: {0}")]
 	Spawn(String),
+	#[error("failed to wait for connection, plugin process has died?")]
+	ConnectionWaitError,
 	#[error("agent is shutting down")]
 	Gone,
 }
modifiedremowt/crates/remowt-plugin/Cargo.tomldiffbeforeafterboth
--- a/remowt/crates/remowt-plugin/Cargo.toml
+++ b/remowt/crates/remowt-plugin/Cargo.toml
@@ -2,7 +2,7 @@
 name = "remowt-plugin"
 description = "Plugin host and protocol for remowt agents"
 version.workspace = true
-edition = "2021"
+edition.workspace = true
 license.workspace = true
 
 [dependencies]
modifiedremowt/crates/remowt-plugin/src/host.rsdiffbeforeafterboth
--- a/remowt/crates/remowt-plugin/src/host.rs
+++ b/remowt/crates/remowt-plugin/src/host.rs
@@ -8,6 +8,7 @@
 use remowt_link_shared::plugin::{Error, PluginEndpoints, PluginHost};
 use remowt_link_shared::port::child_port;
 use remowt_link_shared::{Address, BifConfig};
+use tokio::select;
 
 pub fn serve(rpc: &mut Rpc<BifConfig>) {
 	let host = Host {
@@ -25,7 +26,7 @@
 }
 
 impl Host {
-	fn spawn(&self, id: u16, path: impl AsRef<OsStr>) -> Result<(), Error> {
+	async fn spawn(&self, id: u16, path: impl AsRef<OsStr>) -> Result<(), Error> {
 		let rpc = self.rpc.clone().upgrade().ok_or(Error::Gone)?;
 
 		let mut child = Command::new(path)
@@ -40,8 +41,20 @@
 		let stdout = child.stdout.take().expect("stdout piped");
 
 		let addr = Address::Plugin(id);
-		rpc.add_direct(addr, child_port(stdout, stdin), Rtt(0));
+		rpc.add_direct(addr.clone(), child_port(stdout, stdin), Rtt(0));
+
+		select! {
+			e = rpc.wait_for_connection_to(addr) => {
+				if e.is_err() {
+					return Err(Error::ConnectionWaitError)
+				}
+			},
+			_ = child.wait() => {
+				return Err(Error::ConnectionWaitError)
+			}
+		};
 		self.children.lock().expect("not poisoned").push(child);
+
 		Ok(())
 	}
 }
@@ -58,13 +71,13 @@
 		let dir = exe
 			.parent()
 			.ok_or_else(|| Error::Spawn("primary agent has no parent directory".to_owned()))?;
-		self.spawn(id, dir.join(&name))
+		self.spawn(id, dir.join(&name)).await
 	}
 
 	async fn load_plugin_path(&self, id: u16, path: String) -> Result<(), Error> {
 		if path.is_empty() || path.contains('\0') {
 			return Err(Error::BadName);
 		}
-		self.spawn(id, path)
+		self.spawn(id, path).await
 	}
 }
modifiedremowt/crates/remowt-ui-prompt/src/bifrost.rsdiffbeforeafterboth
--- a/remowt/crates/remowt-ui-prompt/src/bifrost.rs
+++ b/remowt/crates/remowt-ui-prompt/src/bifrost.rs
@@ -103,7 +103,6 @@
 where
 	P: Prompter + Send + Sync + 'static,
 	C: Config,
-	C::Error: From<Error>,
 {
 	PromptEndpoints(prompt).register_endpoints(rpc);
 }
modifiedrust-toolchain.tomldiffbeforeafterboth
--- a/rust-toolchain.toml
+++ b/rust-toolchain.toml
@@ -1,3 +1,8 @@
 [toolchain]
 channel = "stable"
 components = ["rustfmt", "clippy", "rust-analyzer", "rust-src"]
+targets = [
+	"x86_64-unknown-linux-musl",
+	"aarch64-unknown-linux-musl",
+	"armv7-unknown-linux-musleabihf",
+]