1use std::{2 cell::OnceCell,3 collections::BTreeSet,4 ffi::{OsStr, OsString},5 fmt::Display,6 io::Write,7 ops::Deref,8 path::PathBuf,9 str::FromStr,10 sync::{Arc, Mutex, MutexGuard, OnceLock},11};1213use anyhow::{Context, Result, anyhow, bail, ensure};14use fleet_shared::SecretData;15use nix_eval::{Value, nix_go, nix_go_json, util::assert_warn};16use openssh::{ControlPersist, SessionBuilder};17use serde::de::DeserializeOwned;18use tabled::Tabled;19use tempfile::NamedTempFile;20use time::{UtcDateTime, format_description};21use tracing::warn;2223use crate::{24 command::MyCommand,25 fleetdata::{FleetData, FleetSecretData, FleetSecretDistribution, FleetSecretDistributions},26};2728pub struct FleetConfigInternals {29 30 pub directory: PathBuf,31 32 pub local_system: String,33 pub data: Arc<Mutex<FleetData>>,34 pub nix_args: Vec<OsString>,35 36 pub config_field: Value,37 38 pub flake_outputs: Value,39 40 pub localhost: String,4142 43 pub default_pkgs: Value,44 45 pub nixpkgs: Value,46}474849#[derive(Clone)]50pub struct Config(pub Arc<FleetConfigInternals>);5152impl Deref for Config {53 type Target = FleetConfigInternals;5455 fn deref(&self) -> &Self::Target {56 &self.057 }58}5960#[derive(Clone, Copy, Debug)]61pub enum EscalationStrategy {62 Sudo,63 Run0,64 Su,65}6667#[derive(Clone, PartialEq, Copy, Debug)]68pub enum DeployKind {69 70 UpgradeToFleet,71 72 Fleet,73 74 75 NixosInstall,76 77 78 79 NixosLustrate,80}8182impl FromStr for DeployKind {83 type Err = anyhow::Error;84 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {85 match s {86 "upgrade-to-fleet" => Ok(Self::UpgradeToFleet),87 "fleet" => Ok(Self::Fleet),88 "nixos-install" => Ok(Self::NixosInstall),89 "nixos-lustrate" => Ok(Self::NixosLustrate),90 v => bail!(91 "unknown deploy_kind: {v}; expected on of \"upgrade-to-fleet\", \"fleet\", \"nixos-install\", \"nixos-lustrate\""92 ),93 }94 }95}96pub struct ConfigHost {97 config: Config,98 pub name: String,99 groups: OnceCell<Vec<String>>,100101 102 deploy_kind: OnceCell<DeployKind>,103 session_destination: OnceCell<String>,104 legacy_ssh_store: OnceCell<bool>,105106 pub host_config: Option<Value>,107 pub nixos_config: OnceCell<Value>,108 pub nixos_unchecked_config: OnceCell<Value>,109 pub pkgs_override: Option<Value>,110111 112 pub local: bool,113 pub session: OnceLock<Arc<openssh::Session>>,114}115116#[derive(Debug, Clone, Copy)]117pub enum GenerationStorage {118 Deployer,119 Machine,120 Pusher,121}122impl GenerationStorage {123 fn prefix(&self) -> &'static str {124 match self {125 GenerationStorage::Deployer => "deployer.",126 GenerationStorage::Machine => "",127 GenerationStorage::Pusher => "pusher.",128 }129 }130}131132#[derive(Tabled, Debug)]133pub struct Generation {134 #[tabled(rename = "ID", format("{}", self.rollback_id()))]135 pub id: u32,136 #[tabled(rename = "Current")]137 pub current: bool,138 #[tabled(rename = "Created at")]139 pub datetime: UtcDateTime,140 #[tabled(format = "{:?}")]141 pub store_path: PathBuf,142 #[tabled(skip)]143 pub location: GenerationStorage,144}145impl Generation {146 pub fn rollback_id(&self) -> String {147 format!("{}{}", self.location.prefix(), self.id)148 }149}150151fn parse_generation_line(g: &str) -> Option<Generation> {152 let mut parts = g.split_whitespace();153 let id = parts.next()?;154 let id: u32 = id.parse().ok()?;155 let date = parts.next()?;156 let time = parts.next()?;157 let current = if let Some(current) = parts.next() {158 if current == "(current)" {159 Some(true)160 } else {161 None162 }163 } else {164 Some(false)165 };166 let current = current?;167 if parts.next().is_some() {168 warn!("unexpected text after generation: {g}");169 }170171 let format = format_description::parse("[year]-[month]-[day] [hour]:[minute]:[second]")172 .expect("valid format");173 let datetime = UtcDateTime::parse(&format!("{date} {time}"), &format).ok()?;174175 Some(Generation {176 id,177 current,178 datetime,179 store_path: PathBuf::new(),180 location: GenerationStorage::Machine,181 })182}183184impl ConfigHost {185 pub async fn list_generations(&self, profile: &str) -> Result<Vec<Generation>> {186 let mut cmd = self.cmd("nix-env").await?;187 cmd.comparg("--profile", format!("/nix/var/nix/profiles/{profile}"))188 .arg("--list-generations")189 .env("TZ", "UTC");190 191 let data = cmd.sudo().run_string().await?;192 let mut generations = data193 .split('\n')194 .map(|e| e.trim())195 .filter(|&l| !l.is_empty())196 .filter_map(|g| {197 let generation = parse_generation_line(g);198 if generation.is_none() {199 warn!("bad generation: {g}");200 };201 generation202 })203 .collect::<Vec<_>>();204 for ele in generations.iter_mut() {205 let mut cmd = self.cmd("readlink").await?;206 cmd.arg("--")207 .arg(format!("/nix/var/nix/profiles/{profile}-{}-link", ele.id));208 let path = cmd.run_string().await?;209 ele.store_path = PathBuf::from(path.trim_end_matches("\n"));210 }211212 Ok(generations)213 }214215 pub fn set_session_destination(&self, dest: String) {216 self.session_destination217 .set(dest)218 .expect("session destination is already set")219 }220 pub fn set_deploy_kind(&self, kind: DeployKind) {221 self.deploy_kind222 .set(kind)223 .expect("deploy kind is already set");224 }225 pub fn set_legacy_ssh_store(&self, legacy: bool) {226 self.legacy_ssh_store227 .set(legacy)228 .expect("legacy ssh store is already set")229 }230 pub async fn deploy_kind(&self) -> Result<DeployKind> {231 if let Some(kind) = self.deploy_kind.get() {232 return Ok(*kind);233 }234 let is_fleet_managed = match self.file_exists("/etc/FLEET_HOST").await {235 Ok(v) => v,236 Err(e) => {237 bail!("failed to query remote system kind: {e}");238 }239 };240 if !is_fleet_managed {241 bail!(242 "{}",243 indoc::indoc! {"244 host is not marked as managed by fleet245 if you're not trying to lustrate/install system from scratch,246 you should either247 1. manually create /etc/FLEET_HOST file on the target host,248 2. use ?deploy_kind=fleet host argument if you're upgrading from older version of fleet249 3. use ?deploy_kind=upgrade_to_fleet if you're upgrading from plain nixos to fleet-managed nixos250 "}251 );252 }253 254 let _ = self.deploy_kind.set(DeployKind::Fleet);255 Ok(*self.deploy_kind.get().expect("deploy kind is just set"))256 }257 pub async fn escalation_strategy(&self) -> Result<EscalationStrategy> {258 259 260 if (self.find_in_path("sudo").await).is_ok() {261 return Ok(EscalationStrategy::Sudo);262 }263 if (self.find_in_path("run0").await).is_ok() {264 return Ok(EscalationStrategy::Run0);265 }266 Ok(EscalationStrategy::Su)267 }268 async fn open_session(&self) -> Result<Arc<openssh::Session>> {269 assert!(!self.local, "do not open ssh connection to local session");270 271 if let Some(session) = &self.session.get() {272 return Ok((*session).clone());273 };274 let mut session = SessionBuilder::default();275 session.control_persist(ControlPersist::ClosedAfterInitialConnection);276277 let dest = self.session_destination.get().unwrap_or(&self.name);278 let session = session279 .connect(&dest)280 .await281 .map_err(|e| anyhow!("ssh error while connecting to {}: {e:#?}", self.name))?;282 let session = Arc::new(session);283 self.session.set(session.clone()).expect("TOCTOU happened");284 Ok(session)285 }286 pub async fn mktemp_dir(&self) -> Result<String> {287 let mut cmd = self.cmd("mktemp").await?;288 cmd.arg("-d");289 let path = cmd.run_string().await?;290 Ok(path.trim_end().to_owned())291 }292 pub async fn file_exists(&self, path: impl AsRef<OsStr>) -> Result<bool> {293 let mut cmd = self.cmd("sh").await?;294 cmd.arg("-c")295 .arg("test -e \"$1\" && echo true || echo false")296 .arg("_")297 .arg(path);298 cmd.run_value().await299 }300 pub async fn read_file_bin(&self, path: impl AsRef<OsStr>) -> Result<Vec<u8>> {301 let mut cmd = self.cmd("cat").await?;302 cmd.arg(path);303 cmd.run_bytes().await304 }305 pub async fn read_file_text(&self, path: impl AsRef<OsStr>) -> Result<String> {306 let mut cmd = self.cmd("cat").await?;307 cmd.arg(path);308 cmd.run_string().await309 }310 pub async fn read_dir(&self, path: impl AsRef<OsStr>) -> Result<Vec<String>> {311 let mut cmd = self.cmd("ls").await?;312 cmd.arg(path);313 let out = cmd.run_string().await?;314 let mut lines = out.split('\n');315 if let Some(last) = lines.next_back() {316 ensure!(last.is_empty(), "output of ls should end with newline");317 }318 Ok(lines.map(ToOwned::to_owned).collect())319 }320 #[allow(dead_code)]321 pub async fn read_file_json<D: DeserializeOwned>(&self, path: impl AsRef<OsStr>) -> Result<D> {322 let text = self.read_file_text(path).await?;323 Ok(serde_json::from_str(&text)?)324 }325 pub async fn read_env(&self, env: &str) -> Result<String> {326 let mut cmd = self.cmd("printenv").await?;327 cmd.arg(env);328 cmd.run_string().await329 }330 pub async fn find_in_path(&self, command: &str) -> Result<String> {331 332 333 334 335 336 337 338 339 340 341 342 343 let mut cmd = self344 .cmd_escalation(345 346 EscalationStrategy::Su,347 "which",348 )349 .await?;350 cmd.arg(command);351 cmd.run_string().await352 }353 pub async fn read_file_value<D: FromStr>(&self, path: impl AsRef<OsStr>) -> Result<D>354 where355 <D as FromStr>::Err: Display,356 {357 let text = self.read_file_text(path).await?;358 D::from_str(&text).map_err(|e| anyhow!("failed to parse value: {e}"))359 }360 pub async fn cmd(&self, cmd: impl AsRef<OsStr>) -> Result<MyCommand> {361 self.cmd_escalation(self.escalation_strategy().await?, cmd)362 .await363 }364 pub async fn cmd_escalation(365 &self,366 escalation: EscalationStrategy,367 cmd: impl AsRef<OsStr>,368 ) -> Result<MyCommand> {369 if self.local {370 Ok(MyCommand::new(escalation, cmd))371 } else {372 let session = self.open_session().await?;373 Ok(MyCommand::new_on(escalation, cmd, session))374 }375 }376 pub async fn nix_cmd(&self) -> Result<MyCommand> {377 let mut nix = self.cmd("nix").await?;378 nix.args([379 "--extra-experimental-features",380 "nix-command",381 "--extra-experimental-features",382 "flakes",383 ]);384 Ok(nix)385 }386387 pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {388 ensure!(data.encrypted, "secret is not encrypted");389 let mut cmd = self.cmd("fleet-install-secrets").await?;390 cmd.arg("decrypt").eqarg("--secret", data.to_string());391 let encoded = cmd392 .sudo()393 .run_string()394 .await395 .context("failed to call remote host for decrypt")?;396 let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;397 ensure!(!data.encrypted, "secret came out encrypted");398 Ok(data.data)399 }400 pub async fn reencrypt(&self, data: SecretData, targets: Vec<String>) -> Result<SecretData> {401 ensure!(data.encrypted, "secret is not encrypted");402 let mut cmd = self.cmd("fleet-install-secrets").await?;403 cmd.arg("reencrypt").eqarg("--secret", data.to_string());404 for target in targets {405 let key = self.config.key(&target).await?;406 cmd.eqarg("--targets", key);407 }408 let encoded = cmd409 .sudo()410 .run_string()411 .await412 .context("failed to call remote host for decrypt")?;413 let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;414 ensure!(data.encrypted, "secret came out not encrypted");415 Ok(data)416 }417 418 pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {419 if self.local {420 421 return Ok(path.to_owned());422 }423 let mut nix = MyCommand::new(424 425 EscalationStrategy::Su,426 "nix",427 );428 nix.arg("copy").arg("--substitute-on-destination");429430 let proto = if self.legacy_ssh_store.get().cloned().unwrap_or(false) {431 "ssh"432 } else {433 "ssh-ng"434 };435436 match self.deploy_kind().await? {437 DeployKind::Fleet | DeployKind::UpgradeToFleet | DeployKind::NixosLustrate => {438 nix.comparg("--to", format!("{proto}://{}", self.name));439 }440 DeployKind::NixosInstall => {441 nix442 443 .arg("--no-check-sigs")444 .comparg(445 "--to",446 format!("{proto}://root@{}?remote-store=/mnt", self.name),447 );448 }449 }450 nix.arg(path);451 nix.run_nix().await.context("nix copy")?;452 Ok(path.to_owned())453 }454 pub async fn systemctl_stop(&self, name: &str) -> Result<()> {455 let mut cmd = self.cmd("systemctl").await?;456 cmd.arg("stop").arg(name);457 cmd.sudo().run().await458 }459 pub async fn systemctl_start(&self, name: &str) -> Result<()> {460 let mut cmd = self.cmd("systemctl").await?;461 cmd.arg("start").arg(name);462 cmd.sudo().run().await463 }464465 pub async fn rm_file(&self, path: impl AsRef<OsStr>, sudo: bool) -> Result<()> {466 let mut cmd = self.cmd("rm").await?;467 cmd.arg("-f").arg(path);468 if sudo {469 cmd = cmd.sudo()470 }471 cmd.run().await472 }473}474475struct HostSecretDefinition(Value);476477impl ConfigHost {478 479 480 pub fn tags(&self) -> Result<Vec<String>> {481 if let Some(v) = self.groups.get() {482 return Ok(v.clone());483 }484 let Some(host_config) = &self.host_config else {485 return Ok(vec![]);486 };487 let tags: Vec<String> = nix_go_json!(host_config.tags);488489 let _ = self.groups.set(tags.clone());490491 Ok(tags)492 }493 pub fn nixos_config(&self) -> Result<Value> {494 if let Some(v) = self.nixos_config.get() {495 return Ok(v.clone());496 }497 let Some(host_config) = &self.host_config else {498 bail!("local host has no nixos_config");499 };500 let nixos_config = nix_go!(host_config.nixos.config);501 assert_warn("nixos config evaluation", &nixos_config)?;502503 let _ = self.nixos_config.set(nixos_config.clone());504505 Ok(nixos_config)506 }507 pub fn nixos_unchecked_config(&self) -> Result<Value> {508 if let Some(v) = self.nixos_unchecked_config.get() {509 return Ok(v.clone());510 }511 let Some(host_config) = &self.host_config else {512 bail!("local host has no nixos_config");513 };514 let nixos_config = nix_go!(host_config.nixos_unchecked.config);515516 let _ = self.nixos_unchecked_config.set(nixos_config.clone());517518 Ok(nixos_config)519 }520521 pub fn list_defined_secrets(&self) -> Result<Vec<String>> {522 let nixos = self.nixos_unchecked_config()?;523 let secrets = nix_go!(nixos.secrets);524 secrets.list_fields()525 }526527 528 pub fn pkgs(&self) -> Result<Value> {529 if let Some(value) = &self.pkgs_override {530 return Ok(value.clone());531 }532 let Some(host_config) = &self.host_config else {533 bail!("local host has no host_config");534 };535 536 Ok(nix_go!(host_config.nixos.options._module.args.value.pkgs))537 }538}539540pub struct SharedSecretDefinition(Value);541impl SharedSecretDefinition {542 pub fn expected_owners(&self) -> Result<BTreeSet<String>> {543 let secret = &self.0;544 Ok(nix_go_json!(secret.expectedOwners))545 }546 pub fn generator(&self) -> Result<Value> {547 let secret = &self.0;548 Ok(nix_go!(secret.generator))549 }550}551552impl Config {553 pub fn tagged_hostnames(&self, tag: &str) -> Result<Vec<String>> {554 let config = &self.config_field;555 let tagged: Vec<String> = nix_go_json!(config.taggedWith[{ tag }]);556 Ok(tagged)557 }558 pub fn expand_owner_set(&self, owners: Vec<String>) -> Result<BTreeSet<String>> {559 let mut out = BTreeSet::new();560 for owner in owners {561 if let Some(tag) = owner.strip_prefix('@') {562 let hosts = self.tagged_hostnames(tag)?;563 out.extend(hosts);564 } else {565 out.insert(owner);566 }567 }568 Ok(out)569 }570 pub fn local_host(&self) -> ConfigHost {571 ConfigHost {572 config: self.clone(),573 name: "<virtual localhost>".to_owned(),574 host_config: None,575 nixos_config: OnceCell::new(),576 nixos_unchecked_config: OnceCell::new(),577 groups: {578 let cell = OnceCell::new();579 let _ = cell.set(vec![]);580 cell581 },582 pkgs_override: Some(self.default_pkgs.clone()),583584 local: true,585 session: OnceLock::new(),586 deploy_kind: OnceCell::new(),587 session_destination: OnceCell::new(),588 legacy_ssh_store: OnceCell::new(),589 }590 }591592 pub fn host(&self, name: &str) -> Result<ConfigHost> {593 let config = &self.config_field;594 let host_config = nix_go!(config.hosts[{ name }]);595596 Ok(ConfigHost {597 config: self.clone(),598 name: name.to_owned(),599 host_config: Some(host_config),600 nixos_config: OnceCell::new(),601 nixos_unchecked_config: OnceCell::new(),602 groups: OnceCell::new(),603 pkgs_override: None,604605 606 local: self.localhost == name,607 session: OnceLock::new(),608 deploy_kind: OnceCell::new(),609 session_destination: OnceCell::new(),610 legacy_ssh_store: OnceCell::new(),611 })612 }613 pub fn list_hosts(&self) -> Result<Vec<ConfigHost>> {614 let config = &self.config_field;615 let names = nix_go!(config.hosts).list_fields()?;616 let mut out = vec![];617 for name in names {618 out.push(self.host(&name)?);619 }620 Ok(out)621 }622 623 pub fn system_config(&self, host: &str) -> Result<Value> {624 let fleet_field = &self.config_field;625 Ok(nix_go!(fleet_field.hosts[{ host }].nixos.config))626 }627628 629 pub fn list_configured_shared(&self) -> Result<Vec<String>> {630 let config_field = &self.config_field;631 nix_go!(config_field.sharedSecrets).list_fields()632 }633 pub fn has_shared(&self, name: &str) -> bool {634 let data = self.data();635 data.secrets.contains(name)636 }637 pub fn replace_shared(&self, name: String, shared: FleetSecretDistribution) {638 let mut data = self.data_mut();639 data.secrets.set_data(name, shared);640 }641 pub fn remove_shared(&self, secret: &str) {642 let mut data = self.data_mut();643 data.secrets.remove(secret);644 }645646 pub fn list_secrets_for_owner(&self, host: &str) -> Vec<String> {647 let data = self.data_mut();648 data.secrets.keys_for_owner(host).cloned().collect()649 }650 pub fn list_secrets(&self) -> Vec<String> {651 let data = self.data_mut();652 data.secrets.keys().cloned().collect()653 }654655 pub fn has_secret(&self, host: &str, secret: &str) -> bool {656 let data = self.data();657 data.secrets.contains_for_owner(secret, host)658 }659 pub fn insert_secret(&self, host: String, secret: String, value: FleetSecretData) {660 let mut data = self.data_mut();661 data.secrets.set_single_data(secret, host, value);662 }663 pub fn remove_secret(&self, host: &str, secret: &str) {664 let mut data = self.data_mut();665 data.secrets.drop_owner_no_reencrypt(secret, host);666 }667668 pub fn host_secret(&self, host: &str, secret: &str) -> Option<FleetSecretDistribution> {669 let data = self.data();670 data.secrets.get_single(secret, host).cloned()671 }672 pub fn shared_secret(&self, secret: &str) -> Option<FleetSecretDistributions> {673 let data = self.data();674 data.secrets.get(secret).cloned()675 }676677 pub fn secret_definition(&self, secret: &str) -> Result<Option<SharedSecretDefinition>> {678 let config = &self.config_field;679 let shared_secrets = nix_go!(config.secrets);680 if !shared_secrets.has_field(secret)? {681 return Ok(None);682 }683 Ok(Some(SharedSecretDefinition(nix_go!(684 shared_secrets[secret]685 ))))686 }687688 689 690 691 692 693 694 695 pub fn data(&'_ self) -> MutexGuard<'_, FleetData> {696 self.data.lock().unwrap()697 }698 pub fn data_mut(&'_ self) -> MutexGuard<'_, FleetData> {699 self.data.lock().unwrap()700 }701 pub fn save(&self) -> Result<()> {702 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.")?;703 let data = nixlike::serialize(&self.data() as &FleetData)?;704 tempfile.write_all(705 format!(706 "# This file contains fleet state and shouldn't be edited by hand\n\n{data}\n\n# vim: ts=2 et nowrap\n"707 )708 .as_bytes(),709 )?;710 let mut fleet_data_path = self.directory.clone();711 fleet_data_path.push("fleet.nix");712 tempfile.persist(fleet_data_path)?;713 Ok(())714 }715}