42 files changed
--- 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]]
--- 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"
--- 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}");
--- 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,
--- 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)
-// }
--- 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};
--- 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();
--- 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
--- 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,
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 88 #[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}108109110pub 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 233 234 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 243 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 284 return a.cmp(&b);285 }286 (None, None) => {}287 }288289 290 a.owners.len().cmp(&b.owners.len())291}292293impl FleetSecretDistributions {294 295 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 305 306 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(¤t_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 354 self.prune_missing_parts(expected_parts, Some(&owner));355 }356 357 358 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 371 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 418 419 }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 450 451 }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 461 462 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 619 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}
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 89 #[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}110111112pub 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 235 236 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 245 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 286 return a.cmp(&b);287 }288 (None, None) => {}289 }290291 292 a.owners.len().cmp(&b.owners.len())293}294295impl FleetSecretDistributions {296 297 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 307 308 #[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(¤t_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 358 self.prune_missing_parts(expected_parts, Some(&owner));359 }360 361 362 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 373 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 420 421 }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 452 453 }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 463 464 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 621 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}
--- 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(),
})
--- 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;
--- /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()
+ }
+}
--- 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
--- 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
--- 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}");
--- 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"
--- 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 = {
--- 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; };
}
--- 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";
--- /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
+ ''
--- /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";
+}
--- 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]
--- 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())?;
--- 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,
- })
-}
--- 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<()> {
--- 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
}
--- 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]
--- 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");
--- 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]
--- 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]
--- 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}"),
}
});
}
--- 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.")
--- 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]
--- 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 {
--- 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
--- 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 {
--- 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,
}
--- 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]
--- 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
}
}
--- 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);
}
--- 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",
+]