1use std::{2 collections::{BTreeMap, BTreeSet, HashSet},3 future::Future,4 io::Write,5 ops::Deref,6 path::PathBuf,7 pin::Pin,8 str::FromStr,9 sync::{Arc, OnceLock},10};1112use anyhow::{Context, Result, anyhow, bail, ensure};13use camino::{Utf8Path, Utf8PathBuf};14use chrono::{DateTime, Utc};15use fleet_shared::SecretData;16use nix_eval::{Store, Value, drv::DrvGraph, eval_store, nix_go, nix_go_json, util::assert_warn};17use remowt_client::{AgentBundle, Remowt};18use remowt_endpoints::fs::FsClient;19use remowt_fleet::NixClient;20use remowt_link_shared::BifConfig;21use remowt_ui_prompt::auto::AutoPrompter;22use remowt_ui_prompt::bifrost::PromptEndpoints;23use remowt_ui_prompt::{PrependSourcePrompter, Source};24use tempfile::NamedTempFile;25use tokio::{sync::OnceCell, task::spawn_blocking};26use tracing::{info, warn};2728use crate::fleetdata::{29 FleetData, FleetSecretData, FleetSecretDistribution, FleetSecretPart, SecretOwner,30};3132pub struct FleetConfigInternals {33 pub prefer_identities: BTreeSet<SecretOwner>,34 pub now: DateTime<Utc>,3536 37 pub directory: PathBuf,38 39 pub local_system: String,40 pub data: Arc<FleetData>,41 42 pub config_field: Value,43 44 pub flake_outputs: Value,45 46 pub localhost: String,4748 49 pub default_pkgs: Value,50 51 pub nixpkgs: Value,5253 pub local_host: OnceLock<Arc<ConfigHost>>,54}555657#[derive(Clone)]58pub struct Config(pub Arc<FleetConfigInternals>);5960impl Deref for Config {61 type Target = FleetConfigInternals;6263 fn deref(&self) -> &Self::Target {64 &self.065 }66}6768#[derive(Clone, PartialEq, Copy, Debug)]69pub enum DeployKind {70 71 UpgradeToFleet,72 73 Fleet,74 75 76 NixosInstall,77 78 79 80 NixosLustrate,81}8283impl FromStr for DeployKind {84 type Err = anyhow::Error;85 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {86 match s {87 "upgrade-to-fleet" => Ok(Self::UpgradeToFleet),88 "fleet" => Ok(Self::Fleet),89 "nixos-install" => Ok(Self::NixosInstall),90 "nixos-lustrate" => Ok(Self::NixosLustrate),91 v => bail!(92 "unknown deploy_kind: {v}; expected on of \"upgrade-to-fleet\", \"fleet\", \"nixos-install\", \"nixos-lustrate\""93 ),94 }95 }96}97pub struct ConfigHost {98 config: Config,99 pub name: String,100 groups: OnceLock<Vec<String>>,101102 103 deploy_kind: OnceCell<DeployKind>,104 session_destination: OnceLock<String>,105 legacy_ssh_store: OnceLock<bool>,106107 pub host_config: Option<Value>,108 pub nixos_config: OnceLock<Value>,109 pub nixos_unchecked_config: OnceLock<Value>,110 pub pkgs_override: Option<Value>,111112 113 pub local: bool,114 pub remowt: OnceLock<Remowt>,115 nix_store: OnceLock<Arc<Store>>,116 nix_plugin: OnceCell<()>,117}118119const NIX_PLUGIN_ID: u16 = 2;120121fn agents_dir() -> Result<PathBuf> {122 std::env::var_os("REMOWT_AGENTS_DIR")123 .map(PathBuf::from)124 .or_else(|| option_env!("REMOWT_AGENTS_DIR").map(PathBuf::from))125 .ok_or_else(|| {126 anyhow!("no remowt-agents bundle; set REMOWT_AGENTS_DIR to a remowt-agents output")127 })128}129130fn agent_bundle() -> Result<AgentBundle> {131 AgentBundle::from_dir(agents_dir()?)132}133134enum RemoteDerivationMode {135 CopySigned,136 Copy,137 Attic,138 Cachix,139}140141impl ConfigHost {142 pub fn set_session_destination(&self, dest: String) {143 self.session_destination144 .set(dest)145 .expect("session destination is already set")146 }147 pub fn set_deploy_kind(&self, kind: DeployKind) {148 self.deploy_kind149 .set(kind)150 .expect("deploy kind is already set");151 }152 pub fn set_legacy_ssh_store(&self, legacy: bool) {153 self.legacy_ssh_store154 .set(legacy)155 .expect("legacy ssh store is already set")156 }157 pub async fn deploy_kind(&self) -> Result<DeployKind> {158 self.deploy_kind.get_or_try_init(|| async {159 let remowt = self.remowt().await?;160 let fs = remowt.endpoints::<FsClient<_>>();161 let is_fleet_managed = match fs.file_exists(Utf8PathBuf::from("/etc/FLEET_HOST")).await {162 Ok(v) => v,163 Err(e) => {164 bail!("failed to query remote system kind: {e}");165 }166 };167 if !is_fleet_managed {168 bail!(169 "{}",170 indoc::indoc! {"171 host is not marked as managed by fleet172 if you're not trying to lustrate/install system from scratch,173 you should either174 1. manually create /etc/FLEET_HOST file on the target host,175 2. use ?deploy_kind=fleet host argument if you're upgrading from older version of fleet176 3. use ?deploy_kind=upgrade_to_fleet if you're upgrading from plain nixos to fleet-managed nixos177 for installation use ?deploy_kind=nixos_install / ?deploy_kind=nixos_lustrate 178 "}179 );180 }181 Ok(DeployKind::Fleet)182 }).await.copied()183 }184 async fn connection(&self) -> Result<Remowt> {185 if let Some(conn) = self.remowt.get() {186 return Ok(conn.clone());187 }188 let bundle = agent_bundle()?;189 let conn = if self.local {190 Remowt::connect_local(&bundle, "remowt-fleet".to_owned())191 .await192 .context("starting local remowt agent")?193 } else {194 let dest = self195 .session_destination196 .get()197 .cloned()198 .unwrap_or_else(|| self.name.clone());199 Remowt::connect(&dest, &bundle, "remowt-fleet".to_owned())200 .await201 .map_err(|e| anyhow!("remowt error while connecting to {}: {e:#?}", self.name))?202 };203 PromptEndpoints(PrependSourcePrompter {204 prompter: AutoPrompter::new().await,205 source: if self.local {206 vec![]207 } else {208 vec![Source(std::borrow::Cow::Owned(format!(209 "ssh host: {}",210 self.name211 )))]212 },213 description: "".to_owned(),214 })215 .register_endpoints(&mut conn.rpc());216 let _ = self.remowt.set(conn);217 Ok(self.remowt.get().expect("just set").clone())218 }219220 221 pub async fn remowt(&self) -> Result<Remowt> {222 self.connection().await223 }224225 pub async fn nix_client(&self) -> Result<NixClient<BifConfig>> {226 let remowt = self.remowt().await?;227 let plugin_id = self.ensure_nix_plugin().await?;228 Ok(remowt.plugin_endpoints(plugin_id))229 }230 pub fn ensure_nix_plugin(&self) -> Pin<Box<dyn Future<Output = Result<u16>> + Send + '_>> {231 Box::pin(async {232 self.nix_plugin233 .get_or_try_init(|| async {234 let pkgs = self.pkgs()?;235 let name = "remowt-plugin-fleet";236 let plugin = nix_go!(pkgs[{ name }]);237 let built = plugin238 .build("out")239 .context("failed to build the fleet nix plugin")?;240 let copied = self241 .remote_derivation(&built)242 .await243 .context("failed to copy the fleet nix plugin to the host store")?;244 let bin = copied.join("bin/remowt-plugin-fleet");245 self.remowt()246 .await?247 .run0_load_plugin_path(NIX_PLUGIN_ID, bin.as_str())248 .await249 .context("failed to load the fleet nix plugin")?;250 anyhow::Ok(())251 })252 .await?;253 Ok(NIX_PLUGIN_ID)254 })255 }256257 async fn nix_store(&self) -> Result<Arc<Store>> {258 if let Some(store) = self.nix_store.get() {259 return Ok(store.clone());260 }261 let conn = self.connection().await?;262 let socket = match self.deploy_kind().await? {263 DeployKind::NixosInstall => {264 remowt_fleet::nix_store_socket(conn, "/mnt?require-sigs=false").await?265 }266 _ => remowt_fleet::nix_store_socket(conn, "auto").await?,267 };268 let uri = format!("unix://{}", socket.display());269 let store = Arc::new(Store::open(&uri)?);270 let _ = self.nix_store.set(store);271 Ok(self.nix_store.get().expect("just set").clone())272 }273274 pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {275 ensure!(data.encrypted, "secret is not encrypted");276 let remowt = self.remowt().await?;277 let mut cmd = remowt.cmd("fleet-install-secrets");278 cmd.arg("decrypt").eqarg("--secret", data.to_string());279 let encoded = cmd280 .sudo()281 .run_string()282 .await283 .context("failed to call remote host for decrypt")?;284 let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;285 ensure!(!data.encrypted, "secret came out encrypted");286 Ok(data.data)287 }288 pub async fn reencrypt_distribution(289 &self,290 data: &FleetSecretDistribution,291 targets: BTreeSet<SecretOwner>,292 now: DateTime<Utc>,293 ) -> Result<FleetSecretDistribution> {294 let mut parts = BTreeMap::new();295 for (part_name, part) in &data.secret.parts {296 parts.insert(297 part_name.clone(),298 if part.raw.encrypted {299 FleetSecretPart {300 raw: self.reencrypt(part.raw.clone(), targets.clone()).await?,301 }302 } else {303 part.clone()304 },305 );306 }307 let secret = FleetSecretData {308 created_at: data.secret.created_at,309 expires_at: data.secret.expires_at,310 generation_data: data.secret.generation_data.clone(),311 parts,312 };313 Ok(FleetSecretDistribution::new(targets, secret, now))314 }315 pub async fn reencrypt(316 &self,317 data: SecretData,318 targets: BTreeSet<SecretOwner>,319 ) -> Result<SecretData> {320 let remowt = self.remowt().await?;321 ensure!(data.encrypted, "secret is not encrypted");322 let mut cmd = remowt.cmd("fleet-install-secrets");323 cmd.arg("reencrypt").eqarg("--secret", data.to_string());324 for target in targets {325 let key = self.config.key(&target).await?;326 cmd.eqarg("--targets", key);327 }328 let encoded = cmd329 .sudo()330 .run_string()331 .await332 .context("failed to call remote host for decrypt")?;333 let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;334 ensure!(data.encrypted, "secret came out not encrypted");335 Ok(data)336 }337 338 pub async fn remote_derivation(&self, path: impl AsRef<Utf8Path>) -> Result<Utf8PathBuf> {339 let path = path.as_ref().to_owned();340 if self.local {341 342 return Ok(path);343 }344 let sign: Pin<Box<dyn Future<Output = Result<()>> + Send>> = {345 let path = path.clone();346 let graph = DrvGraph::resolve(&eval_store(), &path)347 .context("failed to resolve graph to be uploaded")?;348 info!("signing {} paths", graph.nodes.len());349350 Box::pin(async move {351 let local = self.config.local_host();352 let plugin_id = local.ensure_nix_plugin().await?;353 let nix = local354 .remowt()355 .await?356 .plugin_endpoints::<remowt_fleet::NixClient<_>>(plugin_id);357 nix.sign_closure(path, Utf8PathBuf::from("/etc/nix/private-key"))358 .await359 .map_err(|e| anyhow!("{e:?}"))?360 .map_err(|e| anyhow!("{e}"))?;361 Ok(())362 })363 };364 if let Err(e) = sign.await {365 warn!("failed to sign store paths: {e}");366 }367 let store = self.nix_store().await?;368 let eval_store = eval_store();369 {370 let path = path.clone();371 spawn_blocking(move || eval_store.copy_to(&store, path.as_ref()))372 .await373 .expect("copy_to panicked")374 .context("copying closure to remote store")?;375 }376 Ok(path)377 }378}379380struct HostSecretDefinition(Value);381382impl ConfigHost {383 384 385 pub fn tags(&self) -> Result<Vec<String>> {386 if let Some(v) = self.groups.get() {387 return Ok(v.clone());388 }389 let Some(host_config) = &self.host_config else {390 return Ok(vec![]);391 };392 let tags: Vec<String> = nix_go_json!(host_config.tags);393394 let _ = self.groups.set(tags.clone());395396 Ok(tags)397 }398 pub fn nixos_config(&self) -> Result<Value> {399 if let Some(v) = self.nixos_config.get() {400 return Ok(v.clone());401 }402 let Some(host_config) = &self.host_config else {403 bail!("local host has no nixos_config");404 };405 let nixos_config = nix_go!(host_config.nixos.config);406 assert_warn("nixos config evaluation", &nixos_config)?;407408 let _ = self.nixos_config.set(nixos_config.clone());409410 Ok(nixos_config)411 }412 pub fn nixos_unchecked_config(&self) -> Result<Value> {413 if let Some(v) = self.nixos_unchecked_config.get() {414 return Ok(v.clone());415 }416 let Some(host_config) = &self.host_config else {417 bail!("local host has no nixos_config");418 };419 let nixos_config = nix_go!(host_config.nixos_unchecked.config);420421 let _ = self.nixos_unchecked_config.set(nixos_config.clone());422423 Ok(nixos_config)424 }425426 pub fn list_defined_secrets(&self) -> Result<Vec<String>> {427 let nixos = self.nixos_unchecked_config()?;428 let secrets = nix_go!(nixos.secrets);429 secrets.list_fields()430 }431432 433 pub fn pkgs(&self) -> Result<Value> {434 if let Some(value) = &self.pkgs_override {435 return Ok(value.clone());436 }437 let Some(host_config) = &self.host_config else {438 bail!("local host has no host_config");439 };440 441 Ok(nix_go!(host_config.nixos.options._module.args.value.pkgs))442 }443}444445#[derive(Clone)]446pub struct SharedSecretDefinition(Value);447impl SharedSecretDefinition {448 pub fn expected_owners(&self) -> Result<BTreeSet<SecretOwner>> {449 let secret = &self.0;450 Ok(nix_go_json!(secret.expectedOwners))451 }452 pub fn allow_different(&self) -> Result<bool> {453 let secret = &self.0;454 Ok(nix_go_json!(secret.allowDifferent))455 }456 pub fn regenerate_on_owner_added(&self) -> Result<bool> {457 let secret = &self.0;458 Ok(nix_go_json!(secret.regenerateOnOwnerAdded))459 }460 pub fn regenerate_on_owner_removed(&self) -> Result<bool> {461 let secret = &self.0;462 Ok(nix_go_json!(secret.regenerateOnOwnerRemoved))463 }464 pub fn generator(&self) -> Result<Value> {465 let secret = &self.0;466 Ok(nix_go!(secret.generator))467 }468}469470impl Config {471 pub fn tagged_hostnames(&self, tag: &str) -> Result<Vec<String>> {472 let config = &self.config_field;473 let tagged: Vec<String> = nix_go_json!(config.taggedWith[{ tag }]);474 Ok(tagged)475 }476 pub fn expand_owner_set(&self, owners: Vec<String>) -> Result<BTreeSet<String>> {477 let mut out = BTreeSet::new();478 for owner in owners {479 if let Some(tag) = owner.strip_prefix('@') {480 let hosts = self.tagged_hostnames(tag)?;481 out.extend(hosts);482 } else {483 out.insert(owner);484 }485 }486 Ok(out)487 }488 pub fn local_host(&self) -> Arc<ConfigHost> {489 self.local_host490 .get_or_init(|| {491 Arc::new(ConfigHost {492 config: self.clone(),493 name: "<virtual localhost>".to_owned(),494 host_config: None,495 nixos_config: OnceLock::new(),496 nixos_unchecked_config: OnceLock::new(),497 groups: {498 let cell = OnceLock::new();499 let _ = cell.set(vec![]);500 cell501 },502 pkgs_override: Some(self.default_pkgs.clone()),503504 local: true,505 remowt: OnceLock::new(),506 nix_store: OnceLock::new(),507 nix_plugin: OnceCell::new(),508 deploy_kind: OnceCell::new(),509 session_destination: OnceLock::new(),510 legacy_ssh_store: OnceLock::new(),511 })512 })513 .clone()514 }515516 pub fn preferred_hosts(517 &self,518 filter: impl Fn(&str) -> bool,519 ) -> Result<impl Iterator<Item = Result<ConfigHost>>> {520 let prefer = self521 .prefer_identities522 .iter()523 .filter_map(|v| v.as_host())524 .collect::<HashSet<_>>();525 let config = &self.config_field;526 let mut names = nix_go!(config.hosts).list_fields()?;527 names.retain(|s| filter(s));528 names.sort_by_key(|h| prefer.contains(h.as_str()));529530 Ok(names.into_iter().map(|h| self.host(&h)))531 }532533 pub fn host(&self, name: &str) -> Result<ConfigHost> {534 let config = &self.config_field;535 let host_config = nix_go!(config.hosts[{ name }]);536537 Ok(ConfigHost {538 config: self.clone(),539 name: name.to_owned(),540 host_config: Some(host_config),541 nixos_config: OnceLock::new(),542 nixos_unchecked_config: OnceLock::new(),543 groups: OnceLock::new(),544 pkgs_override: None,545546 547 local: self.localhost == name,548 remowt: OnceLock::new(),549 nix_store: OnceLock::new(),550 nix_plugin: OnceCell::new(),551 deploy_kind: OnceCell::new(),552 session_destination: OnceLock::new(),553 legacy_ssh_store: OnceLock::new(),554 })555 }556 pub fn list_hosts(&self) -> Result<Vec<ConfigHost>> {557 let config = &self.config_field;558 let names = nix_go!(config.hosts).list_fields()?;559 let mut out = vec![];560 for name in names {561 out.push(self.host(&name)?);562 }563 Ok(out)564 }565 566 pub fn system_config(&self, host: &str) -> Result<Value> {567 let fleet_field = &self.config_field;568 Ok(nix_go!(fleet_field.hosts[{ host }].nixos.config))569 }570571 pub fn secret_definition(&self, secret: &str) -> Result<Option<SharedSecretDefinition>> {572 let config = &self.config_field;573 let shared_secrets = nix_go!(config.secrets);574 if !shared_secrets.has_field(secret)? {575 return Ok(None);576 }577 Ok(Some(SharedSecretDefinition(nix_go!(578 shared_secrets[secret]579 ))))580 }581582 pub fn save(&self) -> Result<()> {583 let mut tempfile = NamedTempFile::new_in(self.directory.clone()).context("failed to create updated version of fleet.nix in the same directory as original.\nDo you have write access to it? Access only to the fleet.nix won't be enough, the directory is used for atomic overwrite operation.\nIt is not recommended to use fleet by root anyway, move fleet project to your home directory.")?;584 let data = nixlike::serialize(&*self.data)?;585 tempfile.write_all(586 format!(587 "# This file contains fleet state and shouldn't be edited by hand\n\n{data}\n\n# vim: ts=2 et nowrap\n"588 )589 .as_bytes(),590 )?;591 let mut fleet_data_path = self.directory.clone();592 fleet_data_path.push("fleet.nix");593 tempfile.persist(fleet_data_path)?;594 Ok(())595 }596}