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, nix_go, nix_go_json, util::assert_warn};17use remowt_client::{AgentBundle, Remowt};18use remowt_endpoints::fs::FsClient;19use remowt_link_shared::Address;20use remowt_ui_prompt::auto::AutoPrompter;21use remowt_ui_prompt::bifrost::PromptEndpoints;22use remowt_ui_prompt::{PrependSourcePrompter, Source};23use tabled::Tabled;24use tempfile::NamedTempFile;25use time::UtcDateTime;26use tokio::task::spawn_blocking;27use tracing::{info, warn};2829use crate::fleetdata::{30 FleetData, FleetSecretData, FleetSecretDistribution, FleetSecretPart, SecretOwner,31};3233pub struct FleetConfigInternals {34 pub prefer_identities: BTreeSet<SecretOwner>,35 pub now: DateTime<Utc>,3637 38 pub directory: PathBuf,39 40 pub local_system: String,41 pub data: Arc<FleetData>,42 43 pub config_field: Value,44 45 pub flake_outputs: Value,46 47 pub localhost: String,4849 50 pub default_pkgs: Value,51 52 pub nixpkgs: Value,5354 pub local_host: OnceLock<Arc<ConfigHost>>,55}565758#[derive(Clone)]59pub struct Config(pub Arc<FleetConfigInternals>);6061impl Deref for Config {62 type Target = FleetConfigInternals;6364 fn deref(&self) -> &Self::Target {65 &self.066 }67}6869#[derive(Clone, PartialEq, Copy, Debug)]70pub enum DeployKind {71 72 UpgradeToFleet,73 74 Fleet,75 76 77 NixosInstall,78 79 80 81 NixosLustrate,82}8384impl FromStr for DeployKind {85 type Err = anyhow::Error;86 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {87 match s {88 "upgrade-to-fleet" => Ok(Self::UpgradeToFleet),89 "fleet" => Ok(Self::Fleet),90 "nixos-install" => Ok(Self::NixosInstall),91 "nixos-lustrate" => Ok(Self::NixosLustrate),92 v => bail!(93 "unknown deploy_kind: {v}; expected on of \"upgrade-to-fleet\", \"fleet\", \"nixos-install\", \"nixos-lustrate\""94 ),95 }96 }97}98pub struct ConfigHost {99 config: Config,100 pub name: String,101 groups: OnceLock<Vec<String>>,102103 104 deploy_kind: OnceLock<DeployKind>,105 session_destination: OnceLock<String>,106 legacy_ssh_store: OnceLock<bool>,107108 pub host_config: Option<Value>,109 pub nixos_config: OnceLock<Value>,110 pub nixos_unchecked_config: OnceLock<Value>,111 pub pkgs_override: Option<Value>,112113 114 pub local: bool,115 pub remowt: OnceLock<Remowt>,116 nix_store: OnceLock<Arc<Store>>,117 nix_plugin: tokio::sync::OnceCell<()>,118}119120const NIX_PLUGIN_ID: u16 = 2;121122fn agents_dir() -> Result<PathBuf> {123 std::env::var_os("REMOWT_AGENTS_DIR")124 .map(PathBuf::from)125 .or_else(|| option_env!("REMOWT_AGENTS_DIR").map(PathBuf::from))126 .ok_or_else(|| {127 anyhow!("no remowt-agents bundle; set REMOWT_AGENTS_DIR to a remowt-agents output")128 })129}130131fn agent_bundle() -> Result<AgentBundle> {132 AgentBundle::from_dir(agents_dir()?)133}134135#[derive(Debug, Clone, Copy)]136pub enum GenerationStorage {137 Deployer,138 Machine,139 Pusher,140}141impl GenerationStorage {142 fn prefix(&self) -> &'static str {143 match self {144 GenerationStorage::Deployer => "deployer.",145 GenerationStorage::Machine => "",146 GenerationStorage::Pusher => "pusher.",147 }148 }149}150151#[derive(Tabled, Debug)]152pub struct Generation {153 #[tabled(rename = "ID", format("{}", self.rollback_id()))]154 pub id: u32,155 #[tabled(rename = "Current")]156 pub current: bool,157 #[tabled(rename = "Created at")]158 pub datetime: UtcDateTime,159 #[tabled(format = "{:?}")]160 pub store_path: Utf8PathBuf,161 #[tabled(skip)]162 pub location: GenerationStorage,163}164impl Generation {165 pub fn rollback_id(&self) -> String {166 format!("{}{}", self.location.prefix(), self.id)167 }168}169170impl ConfigHost {171 pub async fn list_generations(&self, profile: &str) -> Result<Vec<Generation>> {172 let plugin_id = self.ensure_nix_plugin().await?;173 let nix = self174 .remowt()175 .await?176 .plugin_endpoints::<remowt_fleet::NixClient<_>>(plugin_id);177 let raw = nix178 .list_generations(profile.to_owned())179 .await180 .map_err(|e| anyhow!("{e:?}"))?181 .map_err(|e| anyhow!("{e}"))?;182 raw.into_iter()183 .map(|g| {184 let id: u32 =185 g.id.try_into()186 .with_context(|| format!("generation id {} doesn't fit in u32", g.id))?;187 let datetime = UtcDateTime::from_unix_timestamp(g.creation_time_unix)188 .with_context(|| {189 format!("invalid generation timestamp {}", g.creation_time_unix)190 })?;191 Ok(Generation {192 id,193 current: g.current,194 datetime,195 store_path: g.store_path,196 location: GenerationStorage::Machine,197 })198 })199 .collect()200 }201202 pub fn set_session_destination(&self, dest: String) {203 self.session_destination204 .set(dest)205 .expect("session destination is already set")206 }207 pub fn set_deploy_kind(&self, kind: DeployKind) {208 self.deploy_kind209 .set(kind)210 .expect("deploy kind is already set");211 }212 pub fn set_legacy_ssh_store(&self, legacy: bool) {213 self.legacy_ssh_store214 .set(legacy)215 .expect("legacy ssh store is already set")216 }217 pub async fn deploy_kind(&self) -> Result<DeployKind> {218 if let Some(kind) = self.deploy_kind.get() {219 return Ok(*kind);220 }221 let remowt = self.remowt().await?;222 let fs = remowt.endpoints::<FsClient<_>>();223 let is_fleet_managed = match fs.file_exists(Utf8PathBuf::from("/etc/FLEET_HOST")).await {224 Ok(v) => v,225 Err(e) => {226 bail!("failed to query remote system kind: {e}");227 }228 };229 if !is_fleet_managed {230 bail!(231 "{}",232 indoc::indoc! {"233 host is not marked as managed by fleet234 if you're not trying to lustrate/install system from scratch,235 you should either236 1. manually create /etc/FLEET_HOST file on the target host,237 2. use ?deploy_kind=fleet host argument if you're upgrading from older version of fleet238 3. use ?deploy_kind=upgrade_to_fleet if you're upgrading from plain nixos to fleet-managed nixos239 for installation use ?deploy_kind=nixos_install / ?deploy_kind=nixos_lustrate 240 "}241 );242 }243 244 let _ = self.deploy_kind.set(DeployKind::Fleet);245 Ok(*self.deploy_kind.get().expect("deploy kind is just set"))246 }247 async fn connection(&self) -> Result<Remowt> {248 if let Some(conn) = self.remowt.get() {249 return Ok(conn.clone());250 }251 let bundle = agent_bundle()?;252 let conn = if self.local {253 Remowt::connect_local(&bundle)254 .await255 .context("starting local remowt agent")?256 } else {257 let dest = self258 .session_destination259 .get()260 .cloned()261 .unwrap_or_else(|| self.name.clone());262 Remowt::connect(&dest, &bundle)263 .await264 .map_err(|e| anyhow!("remowt error while connecting to {}: {e:#?}", self.name))?265 };266 PromptEndpoints(PrependSourcePrompter {267 prompter: AutoPrompter::new().await,268 source: if self.local {269 vec![]270 } else {271 vec![Source(std::borrow::Cow::Owned(format!(272 "ssh host: {}",273 self.name274 )))]275 },276 description: "".to_owned(),277 })278 .register_endpoints(&mut conn.rpc());279 let _ = self.remowt.set(conn);280 Ok(self.remowt.get().expect("just set").clone())281 }282283 284 pub async fn remowt(&self) -> Result<Remowt> {285 Ok(self.connection().await?)286 }287288 pub fn ensure_nix_plugin(&self) -> Pin<Box<dyn Future<Output = Result<u16>> + Send + '_>> {289 Box::pin(async {290 self.nix_plugin291 .get_or_try_init(|| async {292 let pkgs = self.pkgs()?;293 let name = "remowt-plugin-fleet";294 let plugin = nix_go!(pkgs[{ name }]);295 let built = plugin296 .build("out")297 .context("failed to build the fleet nix plugin")?;298 let copied = self299 .remote_derivation(&built)300 .await301 .context("failed to copy the fleet nix plugin to the host store")?;302 let bin = copied.join("bin/remowt-plugin-fleet");303 self.remowt()304 .await?305 .run0_load_plugin_path(NIX_PLUGIN_ID, bin.as_str())306 .await307 .context("failed to load the fleet nix plugin")?;308 self.remowt()309 .await?310 .rpc()311 .wait_for_connection_to(Address::Plugin(NIX_PLUGIN_ID))312 .await313 .map_err(|e| anyhow!("failed to wait for plugin"))?;314 anyhow::Ok(())315 })316 .await?;317 Ok(NIX_PLUGIN_ID)318 })319 }320321 async fn nix_store(&self) -> Result<Arc<Store>> {322 if let Some(store) = self.nix_store.get() {323 return Ok(store.clone());324 }325 let conn = self.connection().await?;326 let socket = match self.deploy_kind().await? {327 DeployKind::NixosInstall => {328 remowt_fleet::nix_store_socket(conn, "/mnt?require-sigs=false").await?329 }330 _ => remowt_fleet::nix_store_socket(conn, "auto").await?,331 };332 let uri = format!("unix://{}", socket.display());333 let store = Arc::new(Store::open(&uri)?);334 let _ = self.nix_store.set(store);335 Ok(self.nix_store.get().expect("just set").clone())336 }337338 pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {339 ensure!(data.encrypted, "secret is not encrypted");340 let remowt = self.remowt().await?;341 let mut cmd = remowt.cmd("fleet-install-secrets");342 cmd.arg("decrypt").eqarg("--secret", data.to_string());343 let encoded = cmd344 .sudo()345 .run_string()346 .await347 .context("failed to call remote host for decrypt")?;348 let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;349 ensure!(!data.encrypted, "secret came out encrypted");350 Ok(data.data)351 }352 pub async fn reencrypt_distribution(353 &self,354 data: &FleetSecretDistribution,355 targets: BTreeSet<SecretOwner>,356 now: DateTime<Utc>,357 ) -> Result<FleetSecretDistribution> {358 let mut parts = BTreeMap::new();359 for (part_name, part) in &data.secret.parts {360 parts.insert(361 part_name.clone(),362 if part.raw.encrypted {363 FleetSecretPart {364 raw: self.reencrypt(part.raw.clone(), targets.clone()).await?,365 }366 } else {367 part.clone()368 },369 );370 }371 let secret = FleetSecretData {372 created_at: data.secret.created_at,373 expires_at: data.secret.expires_at,374 generation_data: data.secret.generation_data.clone(),375 parts,376 };377 Ok(FleetSecretDistribution::new(targets, secret, now))378 }379 pub async fn reencrypt(380 &self,381 data: SecretData,382 targets: BTreeSet<SecretOwner>,383 ) -> Result<SecretData> {384 let remowt = self.remowt().await?;385 ensure!(data.encrypted, "secret is not encrypted");386 let mut cmd = remowt.cmd("fleet-install-secrets");387 cmd.arg("reencrypt").eqarg("--secret", data.to_string());388 for target in targets {389 let key = self.config.key(&target).await?;390 cmd.eqarg("--targets", key);391 }392 let encoded = cmd393 .sudo()394 .run_string()395 .await396 .context("failed to call remote host for decrypt")?;397 let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;398 ensure!(data.encrypted, "secret came out not encrypted");399 Ok(data)400 }401 402 pub async fn remote_derivation(&self, path: impl AsRef<Utf8Path>) -> Result<Utf8PathBuf> {403 let path = path.as_ref().to_owned();404 if self.local {405 406 return Ok(path);407 }408 let sign: Pin<Box<dyn Future<Output = Result<()>> + Send>> = {409 let path = path.clone();410 Box::pin(async move {411 let local = self.config.local_host();412 let plugin_id = local.ensure_nix_plugin().await?;413 let nix = local414 .remowt()415 .await?416 .plugin_endpoints::<remowt_fleet::NixClient<_>>(plugin_id);417 nix.sign_closure(path, Utf8PathBuf::from("/etc/nix/private-key"))418 .await419 .map_err(|e| anyhow!("{e:?}"))?420 .map_err(|e| anyhow!("{e}"))?;421 Ok(())422 })423 };424 if let Err(e) = sign.await {425 warn!("failed to sign store paths: {e}");426 }427 let store = self.nix_store().await?;428 {429 let path = path.clone();430 spawn_blocking(move || nix_eval::copy_closure_to(&store, path.as_ref()))431 .await?432 .context("copying closure to remote store")?;433 }434 Ok(path)435 }436}437438struct HostSecretDefinition(Value);439440impl ConfigHost {441 442 443 pub fn tags(&self) -> Result<Vec<String>> {444 if let Some(v) = self.groups.get() {445 return Ok(v.clone());446 }447 let Some(host_config) = &self.host_config else {448 return Ok(vec![]);449 };450 let tags: Vec<String> = nix_go_json!(host_config.tags);451452 let _ = self.groups.set(tags.clone());453454 Ok(tags)455 }456 pub fn nixos_config(&self) -> Result<Value> {457 if let Some(v) = self.nixos_config.get() {458 return Ok(v.clone());459 }460 let Some(host_config) = &self.host_config else {461 bail!("local host has no nixos_config");462 };463 let nixos_config = nix_go!(host_config.nixos.config);464 assert_warn("nixos config evaluation", &nixos_config)?;465466 let _ = self.nixos_config.set(nixos_config.clone());467468 Ok(nixos_config)469 }470 pub fn nixos_unchecked_config(&self) -> Result<Value> {471 if let Some(v) = self.nixos_unchecked_config.get() {472 return Ok(v.clone());473 }474 let Some(host_config) = &self.host_config else {475 bail!("local host has no nixos_config");476 };477 let nixos_config = nix_go!(host_config.nixos_unchecked.config);478479 let _ = self.nixos_unchecked_config.set(nixos_config.clone());480481 Ok(nixos_config)482 }483484 pub fn list_defined_secrets(&self) -> Result<Vec<String>> {485 let nixos = self.nixos_unchecked_config()?;486 let secrets = nix_go!(nixos.secrets);487 secrets.list_fields()488 }489490 491 pub fn pkgs(&self) -> Result<Value> {492 if let Some(value) = &self.pkgs_override {493 return Ok(value.clone());494 }495 let Some(host_config) = &self.host_config else {496 bail!("local host has no host_config");497 };498 499 Ok(nix_go!(host_config.nixos.options._module.args.value.pkgs))500 }501}502503#[derive(Clone)]504pub struct SharedSecretDefinition(Value);505impl SharedSecretDefinition {506 pub fn expected_owners(&self) -> Result<BTreeSet<SecretOwner>> {507 let secret = &self.0;508 Ok(nix_go_json!(secret.expectedOwners))509 }510 pub fn allow_different(&self) -> Result<bool> {511 let secret = &self.0;512 Ok(nix_go_json!(secret.allowDifferent))513 }514 pub fn regenerate_on_owner_added(&self) -> Result<bool> {515 let secret = &self.0;516 Ok(nix_go_json!(secret.regenerateOnOwnerAdded))517 }518 pub fn regenerate_on_owner_removed(&self) -> Result<bool> {519 let secret = &self.0;520 Ok(nix_go_json!(secret.regenerateOnOwnerRemoved))521 }522 pub fn generator(&self) -> Result<Value> {523 let secret = &self.0;524 Ok(nix_go!(secret.generator))525 }526}527528impl Config {529 pub fn tagged_hostnames(&self, tag: &str) -> Result<Vec<String>> {530 let config = &self.config_field;531 let tagged: Vec<String> = nix_go_json!(config.taggedWith[{ tag }]);532 Ok(tagged)533 }534 pub fn expand_owner_set(&self, owners: Vec<String>) -> Result<BTreeSet<String>> {535 let mut out = BTreeSet::new();536 for owner in owners {537 if let Some(tag) = owner.strip_prefix('@') {538 let hosts = self.tagged_hostnames(tag)?;539 out.extend(hosts);540 } else {541 out.insert(owner);542 }543 }544 Ok(out)545 }546 pub fn local_host(&self) -> Arc<ConfigHost> {547 self.local_host548 .get_or_init(|| {549 Arc::new(ConfigHost {550 config: self.clone(),551 name: "<virtual localhost>".to_owned(),552 host_config: None,553 nixos_config: OnceLock::new(),554 nixos_unchecked_config: OnceLock::new(),555 groups: {556 let cell = OnceLock::new();557 let _ = cell.set(vec![]);558 cell559 },560 pkgs_override: Some(self.default_pkgs.clone()),561562 local: true,563 remowt: OnceLock::new(),564 nix_store: OnceLock::new(),565 nix_plugin: tokio::sync::OnceCell::new(),566 deploy_kind: OnceLock::new(),567 session_destination: OnceLock::new(),568 legacy_ssh_store: OnceLock::new(),569 })570 })571 .clone()572 }573574 pub fn preferred_hosts(575 &self,576 filter: impl Fn(&str) -> bool,577 ) -> Result<impl Iterator<Item = Result<ConfigHost>>> {578 let prefer = self579 .prefer_identities580 .iter()581 .filter_map(|v| v.as_host())582 .collect::<HashSet<_>>();583 let config = &self.config_field;584 let mut names = nix_go!(config.hosts).list_fields()?;585 names.retain(|s| filter(s));586 names.sort_by_key(|h| prefer.contains(h.as_str()));587588 Ok(names.into_iter().map(|h| self.host(&h)))589 }590591 pub fn host(&self, name: &str) -> Result<ConfigHost> {592 let config = &self.config_field;593 let host_config = nix_go!(config.hosts[{ name }]);594595 Ok(ConfigHost {596 config: self.clone(),597 name: name.to_owned(),598 host_config: Some(host_config),599 nixos_config: OnceLock::new(),600 nixos_unchecked_config: OnceLock::new(),601 groups: OnceLock::new(),602 pkgs_override: None,603604 605 local: self.localhost == name,606 remowt: OnceLock::new(),607 nix_store: OnceLock::new(),608 nix_plugin: tokio::sync::OnceCell::new(),609 deploy_kind: OnceLock::new(),610 session_destination: OnceLock::new(),611 legacy_ssh_store: OnceLock::new(),612 })613 }614 pub fn list_hosts(&self) -> Result<Vec<ConfigHost>> {615 let config = &self.config_field;616 let names = nix_go!(config.hosts).list_fields()?;617 let mut out = vec![];618 for name in names {619 out.push(self.host(&name)?);620 }621 Ok(out)622 }623 624 pub fn system_config(&self, host: &str) -> Result<Value> {625 let fleet_field = &self.config_field;626 Ok(nix_go!(fleet_field.hosts[{ host }].nixos.config))627 }628629 pub fn secret_definition(&self, secret: &str) -> Result<Option<SharedSecretDefinition>> {630 let config = &self.config_field;631 let shared_secrets = nix_go!(config.secrets);632 if !shared_secrets.has_field(secret)? {633 return Ok(None);634 }635 Ok(Some(SharedSecretDefinition(nix_go!(636 shared_secrets[secret]637 ))))638 }639640 pub fn save(&self) -> Result<()> {641 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.")?;642 let data = nixlike::serialize(&*self.data)?;643 tempfile.write_all(644 format!(645 "# This file contains fleet state and shouldn't be edited by hand\n\n{data}\n\n# vim: ts=2 et nowrap\n"646 )647 .as_bytes(),648 )?;649 let mut fleet_data_path = self.directory.clone();650 fleet_data_path.push("fleet.nix");651 tempfile.persist(fleet_data_path)?;652 Ok(())653 }654}