1use std::{2 collections::{BTreeMap, BTreeSet, HashSet},3 ffi::{OsStr, OsString},4 fmt::Display,5 io::Write,6 ops::Deref,7 path::PathBuf,8 str::FromStr,9 sync::{Arc, Mutex, MutexGuard, OnceLock},10};1112use anyhow::{Context, Result, anyhow, bail, ensure};13use chrono::{DateTime, Utc};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::{26 FleetData, FleetSecretData, FleetSecretDistribution, FleetSecretPart, SecretOwner,27 },28};2930pub struct FleetConfigInternals {31 pub prefer_identities: BTreeSet<SecretOwner>,32 pub now: DateTime<Utc>,3334 35 pub directory: PathBuf,36 37 pub local_system: String,38 pub data: Arc<FleetData>,39 pub nix_args: Vec<OsString>,40 41 pub config_field: Value,42 43 pub flake_outputs: Value,44 45 pub localhost: String,4647 48 pub default_pkgs: Value,49 50 pub nixpkgs: Value,51}525354#[derive(Clone)]55pub struct Config(pub Arc<FleetConfigInternals>);5657impl Deref for Config {58 type Target = FleetConfigInternals;5960 fn deref(&self) -> &Self::Target {61 &self.062 }63}6465#[derive(Clone, Copy, Debug)]66pub enum EscalationStrategy {67 Sudo,68 Run0,69 Su,70}7172#[derive(Clone, PartialEq, Copy, Debug)]73pub enum DeployKind {74 75 UpgradeToFleet,76 77 Fleet,78 79 80 NixosInstall,81 82 83 84 NixosLustrate,85}8687impl FromStr for DeployKind {88 type Err = anyhow::Error;89 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {90 match s {91 "upgrade-to-fleet" => Ok(Self::UpgradeToFleet),92 "fleet" => Ok(Self::Fleet),93 "nixos-install" => Ok(Self::NixosInstall),94 "nixos-lustrate" => Ok(Self::NixosLustrate),95 v => bail!(96 "unknown deploy_kind: {v}; expected on of \"upgrade-to-fleet\", \"fleet\", \"nixos-install\", \"nixos-lustrate\""97 ),98 }99 }100}101pub struct ConfigHost {102 config: Config,103 pub name: String,104 groups: OnceLock<Vec<String>>,105106 107 deploy_kind: OnceLock<DeployKind>,108 session_destination: OnceLock<String>,109 legacy_ssh_store: OnceLock<bool>,110111 pub host_config: Option<Value>,112 pub nixos_config: OnceLock<Value>,113 pub nixos_unchecked_config: OnceLock<Value>,114 pub pkgs_override: Option<Value>,115116 117 pub local: bool,118 pub session: OnceLock<Arc<openssh::Session>>,119}120121#[derive(Debug, Clone, Copy)]122pub enum GenerationStorage {123 Deployer,124 Machine,125 Pusher,126}127impl GenerationStorage {128 fn prefix(&self) -> &'static str {129 match self {130 GenerationStorage::Deployer => "deployer.",131 GenerationStorage::Machine => "",132 GenerationStorage::Pusher => "pusher.",133 }134 }135}136137#[derive(Tabled, Debug)]138pub struct Generation {139 #[tabled(rename = "ID", format("{}", self.rollback_id()))]140 pub id: u32,141 #[tabled(rename = "Current")]142 pub current: bool,143 #[tabled(rename = "Created at")]144 pub datetime: UtcDateTime,145 #[tabled(format = "{:?}")]146 pub store_path: PathBuf,147 #[tabled(skip)]148 pub location: GenerationStorage,149}150impl Generation {151 pub fn rollback_id(&self) -> String {152 format!("{}{}", self.location.prefix(), self.id)153 }154}155156fn parse_generation_line(g: &str) -> Option<Generation> {157 let mut parts = g.split_whitespace();158 let id = parts.next()?;159 let id: u32 = id.parse().ok()?;160 let date = parts.next()?;161 let time = parts.next()?;162 let current = if let Some(current) = parts.next() {163 if current == "(current)" {164 Some(true)165 } else {166 None167 }168 } else {169 Some(false)170 };171 let current = current?;172 if parts.next().is_some() {173 warn!("unexpected text after generation: {g}");174 }175176 let format = format_description::parse("[year]-[month]-[day] [hour]:[minute]:[second]")177 .expect("valid format");178 let datetime = UtcDateTime::parse(&format!("{date} {time}"), &format).ok()?;179180 Some(Generation {181 id,182 current,183 datetime,184 store_path: PathBuf::new(),185 location: GenerationStorage::Machine,186 })187}188189impl ConfigHost {190 pub async fn list_generations(&self, profile: &str) -> Result<Vec<Generation>> {191 let mut cmd = self.cmd("nix-env").await?;192 cmd.comparg("--profile", format!("/nix/var/nix/profiles/{profile}"))193 .arg("--list-generations")194 .env("TZ", "UTC");195 196 let data = cmd.sudo().run_string().await?;197 let mut generations = data198 .split('\n')199 .map(|e| e.trim())200 .filter(|&l| !l.is_empty())201 .filter_map(|g| {202 let generation = parse_generation_line(g);203 if generation.is_none() {204 warn!("bad generation: {g}");205 };206 generation207 })208 .collect::<Vec<_>>();209 for ele in generations.iter_mut() {210 let mut cmd = self.cmd("readlink").await?;211 cmd.arg("--")212 .arg(format!("/nix/var/nix/profiles/{profile}-{}-link", ele.id));213 let path = cmd.run_string().await?;214 ele.store_path = PathBuf::from(path.trim_end_matches("\n"));215 }216217 Ok(generations)218 }219220 pub fn set_session_destination(&self, dest: String) {221 self.session_destination222 .set(dest)223 .expect("session destination is already set")224 }225 pub fn set_deploy_kind(&self, kind: DeployKind) {226 self.deploy_kind227 .set(kind)228 .expect("deploy kind is already set");229 }230 pub fn set_legacy_ssh_store(&self, legacy: bool) {231 self.legacy_ssh_store232 .set(legacy)233 .expect("legacy ssh store is already set")234 }235 pub async fn deploy_kind(&self) -> Result<DeployKind> {236 if let Some(kind) = self.deploy_kind.get() {237 return Ok(*kind);238 }239 let is_fleet_managed = match self.file_exists("/etc/FLEET_HOST").await {240 Ok(v) => v,241 Err(e) => {242 bail!("failed to query remote system kind: {e}");243 }244 };245 if !is_fleet_managed {246 bail!(247 "{}",248 indoc::indoc! {"249 host is not marked as managed by fleet250 if you're not trying to lustrate/install system from scratch,251 you should either252 1. manually create /etc/FLEET_HOST file on the target host,253 2. use ?deploy_kind=fleet host argument if you're upgrading from older version of fleet254 3. use ?deploy_kind=upgrade_to_fleet if you're upgrading from plain nixos to fleet-managed nixos255 "}256 );257 }258 259 let _ = self.deploy_kind.set(DeployKind::Fleet);260 Ok(*self.deploy_kind.get().expect("deploy kind is just set"))261 }262 pub async fn escalation_strategy(&self) -> Result<EscalationStrategy> {263 264 265 if (self.find_in_path("sudo").await).is_ok() {266 return Ok(EscalationStrategy::Sudo);267 }268 if (self.find_in_path("run0").await).is_ok() {269 return Ok(EscalationStrategy::Run0);270 }271 Ok(EscalationStrategy::Su)272 }273 async fn open_session(&self) -> Result<Arc<openssh::Session>> {274 assert!(!self.local, "do not open ssh connection to local session");275 276 if let Some(session) = &self.session.get() {277 return Ok((*session).clone());278 };279 let mut session = SessionBuilder::default();280 session.control_persist(ControlPersist::ClosedAfterInitialConnection);281282 let dest = self.session_destination.get().unwrap_or(&self.name);283 let session = session284 .connect(&dest)285 .await286 .map_err(|e| anyhow!("ssh error while connecting to {}: {e:#?}", self.name))?;287 let session = Arc::new(session);288 self.session.set(session.clone()).expect("TOCTOU happened");289 Ok(session)290 }291 pub async fn mktemp_dir(&self) -> Result<String> {292 let mut cmd = self.cmd("mktemp").await?;293 cmd.arg("-d");294 let path = cmd.run_string().await?;295 Ok(path.trim_end().to_owned())296 }297 pub async fn file_exists(&self, path: impl AsRef<OsStr>) -> Result<bool> {298 let mut cmd = self.cmd("sh").await?;299 cmd.arg("-c")300 .arg("test -e \"$1\" && echo true || echo false")301 .arg("_")302 .arg(path);303 cmd.run_value().await304 }305 pub async fn read_file_bin(&self, path: impl AsRef<OsStr>) -> Result<Vec<u8>> {306 let mut cmd = self.cmd("cat").await?;307 cmd.arg(path);308 cmd.run_bytes().await309 }310 pub async fn read_file_text(&self, path: impl AsRef<OsStr>) -> Result<String> {311 let mut cmd = self.cmd("cat").await?;312 cmd.arg(path);313 cmd.run_string().await314 }315 pub async fn read_dir(&self, path: impl AsRef<OsStr>) -> Result<Vec<String>> {316 let mut cmd = self.cmd("ls").await?;317 cmd.arg(path);318 let out = cmd.run_string().await?;319 let mut lines = out.split('\n');320 if let Some(last) = lines.next_back() {321 ensure!(last.is_empty(), "output of ls should end with newline");322 }323 Ok(lines.map(ToOwned::to_owned).collect())324 }325 #[allow(dead_code)]326 pub async fn read_file_json<D: DeserializeOwned>(&self, path: impl AsRef<OsStr>) -> Result<D> {327 let text = self.read_file_text(path).await?;328 Ok(serde_json::from_str(&text)?)329 }330 pub async fn read_env(&self, env: &str) -> Result<String> {331 let mut cmd = self.cmd("printenv").await?;332 cmd.arg(env);333 cmd.run_string().await334 }335 pub async fn find_in_path(&self, command: &str) -> Result<String> {336 337 338 339 340 341 342 343 344 345 346 347 348 let mut cmd = self349 .cmd_escalation(350 351 EscalationStrategy::Su,352 "which",353 )354 .await?;355 cmd.arg(command);356 cmd.run_string().await357 }358 pub async fn read_file_value<D: FromStr>(&self, path: impl AsRef<OsStr>) -> Result<D>359 where360 <D as FromStr>::Err: Display,361 {362 let text = self.read_file_text(path).await?;363 D::from_str(&text).map_err(|e| anyhow!("failed to parse value: {e}"))364 }365 pub async fn cmd(&self, cmd: impl AsRef<OsStr>) -> Result<MyCommand> {366 self.cmd_escalation(self.escalation_strategy().await?, cmd)367 .await368 }369 pub async fn cmd_escalation(370 &self,371 escalation: EscalationStrategy,372 cmd: impl AsRef<OsStr>,373 ) -> Result<MyCommand> {374 if self.local {375 Ok(MyCommand::new(escalation, cmd))376 } else {377 let session = self.open_session().await?;378 Ok(MyCommand::new_on(escalation, cmd, session))379 }380 }381 pub async fn nix_cmd(&self) -> Result<MyCommand> {382 let mut nix = self.cmd("nix").await?;383 nix.args([384 "--extra-experimental-features",385 "nix-command",386 "--extra-experimental-features",387 "flakes",388 ]);389 Ok(nix)390 }391392 pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {393 ensure!(data.encrypted, "secret is not encrypted");394 let mut cmd = self.cmd("fleet-install-secrets").await?;395 cmd.arg("decrypt").eqarg("--secret", data.to_string());396 let encoded = cmd397 .sudo()398 .run_string()399 .await400 .context("failed to call remote host for decrypt")?;401 let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;402 ensure!(!data.encrypted, "secret came out encrypted");403 Ok(data.data)404 }405 pub async fn reencrypt_distribution(406 &self,407 data: &FleetSecretDistribution,408 targets: BTreeSet<SecretOwner>,409 now: DateTime<Utc>,410 ) -> Result<FleetSecretDistribution> {411 let mut parts = BTreeMap::new();412 for (part_name, part) in &data.secret.parts {413 parts.insert(414 part_name.clone(),415 if part.raw.encrypted {416 FleetSecretPart {417 raw: self.reencrypt(part.raw.clone(), targets.clone()).await?,418 }419 } else {420 part.clone()421 },422 );423 }424 let secret = FleetSecretData {425 created_at: data.secret.created_at,426 expires_at: data.secret.expires_at,427 generation_data: data.secret.generation_data.clone(),428 parts,429 };430 Ok(FleetSecretDistribution::new(targets, secret, now))431 }432 pub async fn reencrypt(433 &self,434 data: SecretData,435 targets: BTreeSet<SecretOwner>,436 ) -> Result<SecretData> {437 ensure!(data.encrypted, "secret is not encrypted");438 let mut cmd = self.cmd("fleet-install-secrets").await?;439 cmd.arg("reencrypt").eqarg("--secret", data.to_string());440 for target in targets {441 let key = self.config.key(&target).await?;442 cmd.eqarg("--targets", key);443 }444 let encoded = cmd445 .sudo()446 .run_string()447 .await448 .context("failed to call remote host for decrypt")?;449 let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;450 ensure!(data.encrypted, "secret came out not encrypted");451 Ok(data)452 }453 454 pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {455 if self.local {456 457 return Ok(path.to_owned());458 }459 let mut sign = MyCommand::new(460 461 462 EscalationStrategy::Sudo,463 "nix",464 );465 sign.arg("store")466 .arg("sign")467 .comparg("--key-file", "/etc/nix/private-key")468 .arg("-r")469 .arg(&path);470 if let Err(e) = sign.sudo().run_nix().await {471 warn!("failed to sign store paths: {e}");472 }473 let mut nix = MyCommand::new(474 475 EscalationStrategy::Su,476 "nix",477 );478 nix.arg("copy").arg("--substitute-on-destination");479480 let proto = if self.legacy_ssh_store.get().cloned().unwrap_or(false) {481 "ssh"482 } else {483 "ssh-ng"484 };485486 match self.deploy_kind().await? {487 DeployKind::Fleet | DeployKind::UpgradeToFleet | DeployKind::NixosLustrate => {488 nix.comparg("--to", format!("{proto}://{}", self.name));489 }490 DeployKind::NixosInstall => {491 nix492 493 .arg("--no-check-sigs")494 .comparg(495 "--to",496 format!("{proto}://root@{}?remote-store=/mnt", self.name),497 );498 }499 }500 nix.arg(path);501 nix.run_nix().await.context("nix copy")?;502 Ok(path.to_owned())503 }504 pub async fn systemctl_stop(&self, name: &str) -> Result<()> {505 let mut cmd = self.cmd("systemctl").await?;506 cmd.arg("stop").arg(name);507 cmd.sudo().run().await508 }509 pub async fn systemctl_start(&self, name: &str) -> Result<()> {510 let mut cmd = self.cmd("systemctl").await?;511 cmd.arg("start").arg(name);512 cmd.sudo().run().await513 }514515 pub async fn rm_file(&self, path: impl AsRef<OsStr>, sudo: bool) -> Result<()> {516 let mut cmd = self.cmd("rm").await?;517 cmd.arg("-f").arg(path);518 if sudo {519 cmd = cmd.sudo()520 }521 cmd.run().await522 }523}524525struct HostSecretDefinition(Value);526527impl ConfigHost {528 529 530 pub fn tags(&self) -> Result<Vec<String>> {531 if let Some(v) = self.groups.get() {532 return Ok(v.clone());533 }534 let Some(host_config) = &self.host_config else {535 return Ok(vec![]);536 };537 let tags: Vec<String> = nix_go_json!(host_config.tags);538539 let _ = self.groups.set(tags.clone());540541 Ok(tags)542 }543 pub fn nixos_config(&self) -> Result<Value> {544 if let Some(v) = self.nixos_config.get() {545 return Ok(v.clone());546 }547 let Some(host_config) = &self.host_config else {548 bail!("local host has no nixos_config");549 };550 let nixos_config = nix_go!(host_config.nixos.config);551 assert_warn("nixos config evaluation", &nixos_config)?;552553 let _ = self.nixos_config.set(nixos_config.clone());554555 Ok(nixos_config)556 }557 pub fn nixos_unchecked_config(&self) -> Result<Value> {558 if let Some(v) = self.nixos_unchecked_config.get() {559 return Ok(v.clone());560 }561 let Some(host_config) = &self.host_config else {562 bail!("local host has no nixos_config");563 };564 let nixos_config = nix_go!(host_config.nixos_unchecked.config);565566 let _ = self.nixos_unchecked_config.set(nixos_config.clone());567568 Ok(nixos_config)569 }570571 pub fn list_defined_secrets(&self) -> Result<Vec<String>> {572 let nixos = self.nixos_unchecked_config()?;573 let secrets = nix_go!(nixos.secrets);574 secrets.list_fields()575 }576577 578 pub fn pkgs(&self) -> Result<Value> {579 if let Some(value) = &self.pkgs_override {580 return Ok(value.clone());581 }582 let Some(host_config) = &self.host_config else {583 bail!("local host has no host_config");584 };585 586 Ok(nix_go!(host_config.nixos.options._module.args.value.pkgs))587 }588}589590#[derive(Clone)]591pub struct SharedSecretDefinition(Value);592impl SharedSecretDefinition {593 pub fn expected_owners(&self) -> Result<BTreeSet<SecretOwner>> {594 let secret = &self.0;595 Ok(nix_go_json!(secret.expectedOwners))596 }597 pub fn allow_different(&self) -> Result<bool> {598 let secret = &self.0;599 Ok(nix_go_json!(secret.allowDifferent))600 }601 pub fn regenerate_on_owner_added(&self) -> Result<bool> {602 let secret = &self.0;603 Ok(nix_go_json!(secret.regenerateOnOwnerAdded))604 }605 pub fn regenerate_on_owner_removed(&self) -> Result<bool> {606 let secret = &self.0;607 Ok(nix_go_json!(secret.regenerateOnOwnerRemoved))608 }609 pub fn generator(&self) -> Result<Value> {610 let secret = &self.0;611 Ok(nix_go!(secret.generator))612 }613}614615impl Config {616 pub fn tagged_hostnames(&self, tag: &str) -> Result<Vec<String>> {617 let config = &self.config_field;618 let tagged: Vec<String> = nix_go_json!(config.taggedWith[{ tag }]);619 Ok(tagged)620 }621 pub fn expand_owner_set(&self, owners: Vec<String>) -> Result<BTreeSet<String>> {622 let mut out = BTreeSet::new();623 for owner in owners {624 if let Some(tag) = owner.strip_prefix('@') {625 let hosts = self.tagged_hostnames(tag)?;626 out.extend(hosts);627 } else {628 out.insert(owner);629 }630 }631 Ok(out)632 }633 pub fn local_host(&self) -> ConfigHost {634 ConfigHost {635 config: self.clone(),636 name: "<virtual localhost>".to_owned(),637 host_config: None,638 nixos_config: OnceLock::new(),639 nixos_unchecked_config: OnceLock::new(),640 groups: {641 let cell = OnceLock::new();642 let _ = cell.set(vec![]);643 cell644 },645 pkgs_override: Some(self.default_pkgs.clone()),646647 local: true,648 session: OnceLock::new(),649 deploy_kind: OnceLock::new(),650 session_destination: OnceLock::new(),651 legacy_ssh_store: OnceLock::new(),652 }653 }654655 pub fn preferred_hosts(656 &self,657 filter: impl Fn(&str) -> bool,658 ) -> Result<impl Iterator<Item = Result<ConfigHost>>> {659 let prefer = self660 .prefer_identities661 .iter()662 .filter_map(|v| v.as_host())663 .collect::<HashSet<_>>();664 let config = &self.config_field;665 let mut names = nix_go!(config.hosts).list_fields()?;666 names.retain(|s| filter(s));667 names.sort_by_key(|h| prefer.contains(h.as_str()));668669 Ok(names.into_iter().map(|h| self.host(&h)))670 }671672 pub fn host(&self, name: &str) -> Result<ConfigHost> {673 let config = &self.config_field;674 let host_config = nix_go!(config.hosts[{ name }]);675676 Ok(ConfigHost {677 config: self.clone(),678 name: name.to_owned(),679 host_config: Some(host_config),680 nixos_config: OnceLock::new(),681 nixos_unchecked_config: OnceLock::new(),682 groups: OnceLock::new(),683 pkgs_override: None,684685 686 local: self.localhost == name,687 session: OnceLock::new(),688 deploy_kind: OnceLock::new(),689 session_destination: OnceLock::new(),690 legacy_ssh_store: OnceLock::new(),691 })692 }693 pub fn list_hosts(&self) -> Result<Vec<ConfigHost>> {694 let config = &self.config_field;695 let names = nix_go!(config.hosts).list_fields()?;696 let mut out = vec![];697 for name in names {698 out.push(self.host(&name)?);699 }700 Ok(out)701 }702 703 pub fn system_config(&self, host: &str) -> Result<Value> {704 let fleet_field = &self.config_field;705 Ok(nix_go!(fleet_field.hosts[{ host }].nixos.config))706 }707708 pub fn secret_definition(&self, secret: &str) -> Result<Option<SharedSecretDefinition>> {709 let config = &self.config_field;710 let shared_secrets = nix_go!(config.secrets);711 if !shared_secrets.has_field(secret)? {712 return Ok(None);713 }714 Ok(Some(SharedSecretDefinition(nix_go!(715 shared_secrets[secret]716 ))))717 }718719 pub fn save(&self) -> Result<()> {720 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.")?;721 let data = nixlike::serialize(&*self.data)?;722 tempfile.write_all(723 format!(724 "# This file contains fleet state and shouldn't be edited by hand\n\n{data}\n\n# vim: ts=2 et nowrap\n"725 )726 .as_bytes(),727 )?;728 let mut fleet_data_path = self.directory.clone();729 fleet_data_path.push("fleet.nix");730 tempfile.persist(fleet_data_path)?;731 Ok(())732 }733}