git.delta.rocks / jrsonnet / refs/commits / a59c7c970515

difftreelog

refactor rework fleet.nix secret bookkeeping

Yaroslav Bolyukin2024-05-21parent: #2c5a4bd.patch.diff
in: trunk

17 files changed

modifiedCargo.lockdiffbeforeafterboth
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -70,7 +70,7 @@
  "aes",
  "aes-gcm",
  "age-core",
- "base64",
+ "base64 0.21.7",
  "bcrypt-pbkdf",
  "bech32",
  "cbc",
@@ -102,7 +102,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "a5f11899bc2bbddd135edbc30c36b1924fa59d0746bb45beb5933fafe3fe509b"
 dependencies = [
- "base64",
+ "base64 0.21.7",
  "chacha20poly1305",
  "cookie-factory",
  "hkdf",
@@ -253,6 +253,12 @@
 checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
 
 [[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
 name = "base64ct"
 version = "1.6.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -534,6 +540,7 @@
 dependencies = [
  "bitflags",
  "crossterm_winapi",
+ "filedescriptor",
  "libc",
  "mio",
  "parking_lot",
@@ -695,6 +702,17 @@
 checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
 
 [[package]]
+name = "filedescriptor"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7199d965852c3bac31f779ef99cbb4537f80e952e2d6aa0ffeb30cce00f4f46e"
+dependencies = [
+ "libc",
+ "thiserror",
+ "winapi",
+]
+
+[[package]]
 name = "find-crate"
 version = "0.6.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -712,11 +730,12 @@
  "age-core",
  "anyhow",
  "async-trait",
- "base64",
+ "base64 0.22.1",
  "better-command",
  "chrono",
  "clap",
  "crossterm",
+ "fleet-shared",
  "futures",
  "hostname",
  "human-repr",
@@ -741,7 +760,6 @@
  "tracing-indicatif",
  "tracing-subscriber",
  "unindent",
- "z85",
 ]
 
 [[package]]
@@ -751,12 +769,22 @@
  "age",
  "anyhow",
  "clap",
+ "fleet-shared",
  "nix",
  "serde",
  "serde_json",
  "tempfile",
  "tracing",
  "tracing-subscriber",
+]
+
+[[package]]
+name = "fleet-shared"
+version = "0.1.0"
+dependencies = [
+ "base64 0.22.1",
+ "serde",
+ "unicode_categories",
  "z85",
 ]
 
@@ -1824,7 +1852,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94"
 dependencies = [
- "base64",
+ "base64 0.21.7",
  "bitflags",
  "serde",
  "serde_derive",
@@ -2013,9 +2041,9 @@
 
 [[package]]
 name = "serde"
-version = "1.0.201"
+version = "1.0.202"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "780f1cebed1629e4753a1a38a3c72d30b97ec044f0aef68cb26650a3c5cf363c"
+checksum = "226b61a0d411b2ba5ff6d7f73a476ac4f8bb900373459cd00fab8512828ba395"
 dependencies = [
  "serde_derive",
 ]
@@ -2031,9 +2059,9 @@
 
 [[package]]
 name = "serde_derive"
-version = "1.0.201"
+version = "1.0.202"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c5e405930b9796f1c00bee880d03fc7e0bb4b9a11afc776885ffe84320da2865"
+checksum = "6048858004bcff69094cd972ed40a32500f153bd3be9f716b2eed2e8217c4838"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -2571,6 +2599,12 @@
 checksum = "68f5e5f3158ecfd4b8ff6fe086db7c8467a2dfdac97fe420f2b7c4aa97af66d6"
 
 [[package]]
+name = "unicode_categories"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
+
+[[package]]
 name = "unindent"
 version = "0.2.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
modifiedCargo.tomldiffbeforeafterboth
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -9,4 +9,4 @@
 bifrostlink = "0.1.0"
 uuid = { version = "1.7.0", features = ["v4"] }
 tokio = { version = "1.36.0", features = ["fs", "rt", "macros", "sync", "time", "rt-multi-thread"] }
-
+fleet-shared = { path = "./crates/fleet-shared" }
modifiedcmds/fleet/Cargo.tomldiffbeforeafterboth
--- a/cmds/fleet/Cargo.toml
+++ b/cmds/fleet/Cargo.toml
@@ -19,10 +19,15 @@
 age-core = "0.10"
 peg = "0.8"
 age = { version = "0.10", features = ["ssh", "armor"] }
-base64 = "0.21"
+base64 = "0.22.1"
 chrono = { version = "0.4", features = ["serde"] }
-z85 = "3.0"
-clap = { version = ">=4.4, <4.5", features = ["derive", "env", "wrap_help", "unicode"] }
+# Using fixed version for rust on stable nixos branches.
+clap = { version = ">=4.4, <4.5", features = [
+	"derive",
+	"env",
+	"wrap_help",
+	"unicode",
+] }
 tracing = "0.1"
 tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
 tokio-util = { version = "0.7", features = ["codec"] }
@@ -40,7 +45,8 @@
 unindent = "0.2"
 regex = "1.10"
 openssh = "0.10"
-crossterm = "0.27"
+crossterm = { version = "0.27.0", features = ["use-dev-tty"] }
+fleet-shared.workspace = true
 
 tracing-indicatif = { version = "0.3", optional = true }
 human-repr = { version = "1.1", optional = true }
@@ -48,4 +54,9 @@
 
 [features]
 # Not quite stable
-indicatif = ["tracing-indicatif", "dep:indicatif", "human-repr", "better-command/indicatif"]
+indicatif = [
+	"tracing-indicatif",
+	"dep:indicatif",
+	"human-repr",
+	"better-command/indicatif",
+]
modifiedcmds/fleet/src/cmds/secrets/mod.rsdiffbeforeafterboth
before · cmds/fleet/src/cmds/secrets/mod.rs
1use crate::{2	better_nix_eval::Field,3	fleetdata::{FleetSecret, FleetSharedSecret, SecretData},4	host::Config,5	nix_go, nix_go_json,6};7use anyhow::{anyhow, bail, ensure, Context, Result};8use chrono::{DateTime, Utc};9use clap::{error::ErrorKind, Parser};10use crossterm::{terminal, tty::IsTty};11use itertools::Itertools;12use owo_colors::OwoColorize;13use serde::Deserialize;14use std::{15	collections::{BTreeSet, HashSet},16	ffi::OsString,17	io::{self, stdin, Cursor, Read, Write},18	path::PathBuf,19};20use tabled::{Table, Tabled};21use tempfile::NamedTempFile;22use tokio::{fs::read_to_string, process::Command};23use tracing::{error, info, info_span, warn, Instrument};2425#[derive(Parser)]26pub enum Secret {27	/// Force load host keys for all defined hosts28	ForceKeys,29	/// Add secret, data should be provided in stdin30	AddShared {31		/// Secret name32		name: String,33		/// Secret owners34		machines: Vec<String>,35		/// Override secret if already present36		#[clap(long)]37		force: bool,38		/// Secret public part39		#[clap(long)]40		public: Option<String>,41		/// Load public part from specified file42		#[clap(long)]43		public_file: Option<PathBuf>,4445		/// Create a notification on secret expiration46		#[clap(long)]47		expires_at: Option<DateTime<Utc>>,4849		/// Secret with this name already exists, override its value while keeping the same owners.50		#[clap(long)]51		re_add: bool,52	},53	/// Add secret, data should be provided in stdin54	Add {55		/// Secret name56		name: String,57		/// Secret owners58		machine: String,59		/// Override secret if already present60		#[clap(long)]61		force: bool,62		#[clap(long)]63		public: Option<String>,64		#[clap(long)]65		public_file: Option<PathBuf>,66	},67	/// Read secret from remote host, requires sudo on said host68	Read {69		name: String,70		machine: String,71		#[clap(long)]72		plaintext: bool,73	},74	ReadPublic {75		name: String,76		machine: String,77	},78	UpdateShared {79		name: String,8081		#[clap(long)]82		machines: Option<Vec<String>>,8384		#[clap(long)]85		add_machines: Vec<String>,86		#[clap(long)]87		remove_machines: Vec<String>,8889		/// Which host should we use to decrypt90		#[clap(long)]91		prefer_identities: Vec<String>,92	},93	Regenerate {94		/// Which host should we use to decrypt, in case if reencryption is required, without95		/// regeneration96		#[clap(long)]97		prefer_identities: Vec<String>,98	},99	List {},100}101102#[tracing::instrument(skip(config, secret, field, prefer_identities))]103async fn update_owner_set(104	secret_name: &str,105	config: &Config,106	mut secret: FleetSharedSecret,107	field: Field,108	updated_set: &[String],109	prefer_identities: &[String],110) -> Result<FleetSharedSecret> {111	let original_set = secret.owners.clone();112113	let set = original_set.iter().collect::<BTreeSet<_>>();114	let expected_set = updated_set.iter().collect::<BTreeSet<_>>();115116	if set == expected_set {117		info!("no need to update owner list, it is already correct");118		return Ok(secret);119	}120121	let should_regenerate = if set.difference(&expected_set).next().is_some() {122		// TODO: Remove this warning for revokable secrets.123		warn!("host was removed from secret owners, but until this host rebuild, the secret will still be stored on it.");124		nix_go_json!(field.regenerateOnOwnerRemoved)125	} else if expected_set.difference(&set).next().is_some() {126		nix_go_json!(field.regenerateOnOwnerAdded)127	} else {128		false129	};130131	if should_regenerate {132		info!("secret is owner-dependent, will regenerate");133		let generated = generate_shared(config, secret_name, field, updated_set.to_vec()).await?;134		Ok(generated)135	} else {136		let identity_holder = if !prefer_identities.is_empty() {137			prefer_identities138				.iter()139				.find(|i| original_set.iter().any(|s| s == *i))140		} else {141			secret.owners.first()142		};143		let Some(identity_holder) = identity_holder else {144			bail!("no available holder found");145		};146147		if let Some(data) = secret.secret.secret {148			let host = config.host(identity_holder).await?;149			let encrypted = host.reencrypt(data, updated_set.to_vec()).await?;150			secret.secret.secret = Some(encrypted);151		}152153		secret.owners = updated_set.to_vec();154		Ok(secret)155	}156}157158#[derive(Deserialize)]159#[serde(rename_all = "camelCase")]160enum GeneratorKind {161	Impure,162	Pure,163}164165async fn generate_pure(166	_config: &Config,167	_display_name: &str,168	_secret: Field,169	_default_generator: Field,170	_owners: &[String],171) -> Result<FleetSecret> {172	bail!("pure generators are broken for now")173}174async fn generate_impure(175	config: &Config,176	_display_name: &str,177	secret: Field,178	default_generator: Field,179	owners: &[String],180) -> Result<FleetSecret> {181	let generator = nix_go!(secret.generator);182	let on: Option<String> = nix_go_json!(default_generator.impureOn);183184	let host = if let Some(on) = &on {185		config.host(on).await?186	} else {187		config.local_host()188	};189	let on_pkgs = host.pkgs().await?;190	let call_package = nix_go!(on_pkgs.callPackage);191	let mk_encrypt_secret = nix_go!(on_pkgs.mkEncryptSecret);192193	let mut recipients = Vec::new();194	for owner in owners {195		let key = config.key(owner).await?;196		recipients.push(key);197	}198	let encrypt = nix_go!(mk_encrypt_secret(Obj {199		recipients: { recipients },200	}));201202	let generator = nix_go!(call_package(generator)(Obj {203		encrypt,204		// rustfmt_please_newline205	}));206207	let generator = generator.build().await?;208	let generator = generator209		.get("out")210		.ok_or_else(|| anyhow!("missing generateImpure out"))?;211	let generator = host.remote_derivation(generator).await?;212213	let out_parent = host.mktemp_dir().await?;214	let out = format!("{out_parent}/out");215216	let mut gen = host.cmd(generator).await?;217	gen.env("out", &out);218	if on.is_none() {219		// This path is local, thus we can feed `OsString` directly to env var... But I don't think that's necessary to handle.220		let project_path: String = config221			.directory222			.clone()223			.into_os_string()224			.into_string()225			.map_err(|s| anyhow!("fleet project path is not utf-8: {s:?}"))?;226		gen.env("FLEET_PROJECT", project_path);227	}228	gen.run().await.context("impure generator")?;229230	{231		let marker = host.read_file_text(format!("{out}/marker")).await?;232		ensure!(marker == "SUCCESS", "generation not succeeded");233	}234235	let public = host.read_file_text(format!("{out}/public")).await.ok();236	let secret = host.read_file_bin(format!("{out}/secret")).await.ok();237	if let Some(secret) = &secret {238		ensure!(239			age::Decryptor::new(Cursor::new(&secret)).is_ok(),240			"builder produced non-encrypted value as secret, this is highly insecure, and not allowed."241		);242	}243244	let created_at = host.read_file_value(format!("{out}/created_at")).await?;245	let expires_at = host.read_file_value(format!("{out}/expires_at")).await.ok();246247	Ok(FleetSecret {248		created_at,249		expires_at,250		public,251		secret: secret.map(SecretData),252	})253}254async fn generate(255	config: &Config,256	display_name: &str,257	secret: Field,258	owners: &[String],259) -> Result<FleetSecret> {260	let generator = nix_go!(secret.generator);261	// Can't properly check on nix module system level262	{263		let gen_ty = generator.type_of().await?;264		if gen_ty == "null" {265			bail!("secret has no generator defined, can't automatically generate it.");266		}267		if gen_ty != "lambda" {268			bail!("generator should be lambda, got {gen_ty}");269		}270	}271	let default_pkgs = &config.default_pkgs;272	let default_call_package = nix_go!(default_pkgs.callPackage);273	// Generators provide additional information in passthru, to access274	// passthru we should call generator, but information about where this generator is supposed to build275	// is located in passthru... Thus evaluating generator on host.276	//277	// Maybe it is also possible to do some magic with __functor?278	//279	// I don't want to make modules always responsible for additional secret data anyway,280	// so it should be in derivation, and not in the secret data itself.281	let default_generator = nix_go!(default_call_package(generator)(Obj {282		encrypt: { "exit 1" },283		// rustfmt_please_newline284	}));285286	let kind: GeneratorKind = nix_go_json!(default_generator.generatorKind);287288	match kind {289		GeneratorKind::Impure => {290			generate_impure(config, display_name, secret, default_generator, owners).await291		}292		GeneratorKind::Pure => {293			generate_pure(config, display_name, secret, default_generator, owners).await294		}295	}296}297async fn generate_shared(298	config: &Config,299	display_name: &str,300	secret: Field,301	expected_owners: Vec<String>,302) -> Result<FleetSharedSecret> {303	// let owners: Vec<String> = nix_go_json!(secret.expectedOwners);304	Ok(FleetSharedSecret {305		secret: generate(config, display_name, secret, &expected_owners).await?,306		owners: expected_owners,307	})308}309310async fn parse_public(311	public: Option<String>,312	public_file: Option<PathBuf>,313) -> Result<Option<String>> {314	Ok(match (public, public_file) {315		(Some(v), None) => Some(v),316		(None, Some(v)) => Some(read_to_string(v).await?),317		(Some(_), Some(_)) => {318			bail!("only public or public_file should be set")319		}320		(None, None) => None,321	})322}323324fn parse_machines(325	initial: Vec<String>,326	machines: Option<Vec<String>>,327	mut add_machines: Vec<String>,328	mut remove_machines: Vec<String>,329) -> Result<Vec<String>> {330	if machines.is_none() && add_machines.is_empty() && remove_machines.is_empty() {331		bail!("no operation");332	}333334	let initial_machines = initial.clone();335	let mut target_machines = initial;336	info!("Currently encrypted for {initial_machines:?}");337338	// ensure!(machines.is_some() || !add_machines.is_empty() || )339	if let Some(machines) = machines {340		ensure!(341			add_machines.is_empty() && remove_machines.is_empty(),342			"can't combine --machines and --add-machines/--remove-machines"343		);344		let target = initial_machines.iter().collect::<HashSet<_>>();345		let source = machines.iter().collect::<HashSet<_>>();346		for removed in target.difference(&source) {347			remove_machines.push((*removed).clone());348		}349		for added in source.difference(&target) {350			add_machines.push((*added).clone());351		}352	}353354	for machine in &remove_machines {355		let mut removed = false;356		while let Some(pos) = target_machines.iter().position(|m| m == machine) {357			target_machines.swap_remove(pos);358			removed = true;359		}360		if !removed {361			warn!("secret is not enabled for {machine}");362		}363	}364	for machine in &add_machines {365		if target_machines.iter().any(|m| m == machine) {366			warn!("secret is already added to {machine}");367		} else {368			target_machines.push(machine.to_owned());369		}370	}371	if !remove_machines.is_empty() {372		// TODO: maybe force secret regeneration?373		// Not that useful without revokation.374		warn!("secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret");375	}376	Ok(target_machines)377}378impl Secret {379	pub async fn run(self, config: &Config) -> Result<()> {380		match self {381			Secret::ForceKeys => {382				for host in config.list_hosts().await? {383					if config.should_skip(&host.name) {384						continue;385					}386					config.key(&host.name).await?;387				}388			}389			Secret::AddShared {390				mut machines,391				name,392				force,393				public,394				public_file,395				expires_at,396				re_add,397			} => {398				// TODO: Forbid updating secrets with set expectedOwners (= not user-managed).399400				let exists = config.has_shared(&name);401				if exists && !force && !re_add {402					bail!("secret already defined");403				}404				if re_add {405					// Fixme: use clap to limit this usage406					ensure!(!force, "--force and --readd are not compatible");407					ensure!(exists, "secret doesn't exists");408					ensure!(409						machines.is_empty(),410						"you can't use machines argument for --readd"411					);412					let shared = config.shared_secret(&name)?;413					machines = shared.owners;414				}415416				let recipients = config.recipients(machines.clone()).await?;417418				let secret = {419					let mut input = vec![];420					io::stdin().read_to_end(&mut input)?;421422					if input.is_empty() {423						None424					} else {425						Some(426							SecretData::encrypt(recipients, input)427								.ok_or_else(|| anyhow!("no recipients provided"))?,428						)429					}430				};431				let public = parse_public(public, public_file).await?;432				config.replace_shared(433					name,434					FleetSharedSecret {435						owners: machines,436						secret: FleetSecret {437							created_at: Utc::now(),438							expires_at,439							secret,440							public,441						},442					},443				);444			}445			Secret::Add {446				machine,447				name,448				force,449				public,450				public_file,451			} => {452				let recipient = config.recipient(&machine).await?;453454				let secret = {455					let mut input = vec![];456					io::stdin().read_to_end(&mut input)?;457					if input.is_empty() {458						bail!("no data provided")459					}460461					Some(SecretData::encrypt(vec![recipient], input).expect("recipient provided"))462				};463464				if config.has_secret(&machine, &name) && !force {465					bail!("secret already defined");466				}467				let public = parse_public(public, public_file).await?;468469				config.insert_secret(470					&machine,471					name,472					FleetSecret {473						created_at: Utc::now(),474						expires_at: None,475						secret,476						public,477					},478				);479			}480			#[allow(clippy::await_holding_refcell_ref)]481			Secret::Read {482				name,483				machine,484				plaintext,485			} => {486				let secret = config.host_secret(&machine, &name)?;487				let Some(secret) = secret.secret else {488					bail!("no secret {name}");489				};490				let host = config.host(&machine).await?;491				let data = host.decrypt(secret).await?;492				if plaintext {493					let s = String::from_utf8(data).context("output is not utf8")?;494					print!("{s}");495				} else {496					println!("{}", z85::encode(&data));497				}498			}499			Secret::ReadPublic { name, machine } => {500				let secret = config.host_secret(&machine, &name)?;501				let Some(public) = secret.public else {502					bail!("no secret {name}");503				};504				print!("{public}");505			}506			Secret::UpdateShared {507				name,508				machines,509				add_machines,510				remove_machines,511				prefer_identities,512			} => {513				// TODO: Forbid updating secrets with set expectedOwners (= not user-managed).514515				let secret = config.shared_secret(&name)?;516				if secret.secret.secret.is_none() {517					bail!("no secret");518				}519520				let initial_machines = secret.owners.clone();521				let target_machines = parse_machines(522					initial_machines.clone(),523					machines,524					add_machines,525					remove_machines,526				)?;527528				if target_machines.is_empty() {529					info!("no machines left for secret, removing it");530					config.remove_shared(&name);531					return Ok(());532				}533534				let config_field = &config.config_unchecked_field;535				let field = nix_go!(config_field.sharedSecrets[{ name }]);536537				let updated = update_owner_set(538					&name,539					config,540					secret,541					field,542					&target_machines,543					&prefer_identities,544				)545				.await?;546				config.replace_shared(name, updated);547			}548			Secret::Regenerate { prefer_identities } => {549				info!("checking for secrets to regenerate");550				{551					let _span = info_span!("shared").entered();552					let expected_shared_set = config553						.list_configured_shared()554						.await?555						.into_iter()556						.collect::<HashSet<_>>();557					let shared_set = config.list_shared().into_iter().collect::<HashSet<_>>();558					for missing in expected_shared_set.difference(&shared_set) {559						let config_field = &config.config_unchecked_field;560						let secret = nix_go!(config_field.sharedSecrets[{ missing }]);561						let expected_owners: Option<Vec<String>> =562							nix_go_json!(secret.expectedOwners);563						let Some(expected_owners) = expected_owners else {564							// TODO: Might still need to regenerate565							continue;566						};567						info!("generating secret: {missing}");568						let shared = generate_shared(config, missing, secret, expected_owners)569							.in_current_span()570							.await?;571						config.replace_shared(missing.to_string(), shared)572					}573				}574				for host in config.list_hosts().await? {575					let _span = info_span!("host", host = host.name).entered();576					let expected_set = host577						.list_configured_secrets()578						.in_current_span()579						.await?580						.into_iter()581						.collect::<HashSet<_>>();582					let stored_set = config583						.list_secrets(&host.name)584						.into_iter()585						.collect::<HashSet<_>>();586					for missing in expected_set.difference(&stored_set) {587						info!("generating secret: {missing}");588						let secret = host.secret_field(missing).in_current_span().await?;589						let generated =590							match generate(config, missing, secret, &[host.name.clone()])591								.in_current_span()592								.await593							{594								Ok(v) => v,595								Err(e) => {596									error!("{e:?}");597									continue;598								}599							};600						config.insert_secret(&host.name, missing.to_string(), generated)601					}602				}603				let mut to_remove = Vec::new();604				for name in &config.list_shared() {605					info!("updating secret: {name}");606					let data = config.shared_secret(name)?;607					let config_field = &config.config_unchecked_field;608					let expected_owners: Vec<String> =609						nix_go_json!(config_field.sharedSecrets[{ name }].expectedOwners);610					if expected_owners.is_empty() {611						warn!("secret was removed from fleet config: {name}, removing from data");612						to_remove.push(name.to_string());613						continue;614					}615616					let secret = nix_go!(config_field.sharedSecrets[{ name }]);617					config.replace_shared(618						name.to_owned(),619						update_owner_set(620							name,621							config,622							data,623							secret,624							&expected_owners,625							&prefer_identities,626						)627						.await?,628					);629				}630				for k in to_remove {631					config.remove_shared(&k);632				}633			}634			Secret::List {} => {635				let _span = info_span!("loading secrets").entered();636				let configured = config.list_configured_shared().await?;637				#[derive(Tabled)]638				struct SecretDisplay {639					#[tabled(rename = "Name")]640					name: String,641					#[tabled(rename = "Owners")]642					owners: String,643				}644				let mut table = vec![];645				for name in configured.iter().cloned() {646					let config = config.clone();647					let expected_owners = config.shared_secret_expected_owners(&name).await?;648					let data = config.shared_secret(&name)?;649					let owners = data650						.owners651						.iter()652						.map(|o| {653							if expected_owners.contains(o) {654								o.green().to_string()655							} else {656								o.red().to_string()657							}658						})659						.collect::<Vec<_>>();660					table.push(SecretDisplay {661						owners: owners.join(", "),662						name,663					})664				}665				info!("loaded\n{}", Table::new(table).to_string())666			}667		}668		Ok(())669	}670}
after · cmds/fleet/src/cmds/secrets/mod.rs
1use std::{2	collections::{BTreeMap, BTreeSet, HashSet},3	ffi::OsString,4	io::{self, stdin, stdout, Read, Write},5	path::PathBuf,6};78use anyhow::{anyhow, bail, ensure, Context, Result};9use chrono::{DateTime, Utc};10use clap::Parser;11use crossterm::{terminal, tty::IsTty};12use fleet_shared::SecretData;13use itertools::Itertools;14use owo_colors::OwoColorize;15use serde::Deserialize;16use tabled::{Table, Tabled};17use tokio::{fs::read, process::Command};18use tracing::{error, info, info_span, warn, Instrument};1920use crate::{21	better_nix_eval::Field,22	fleetdata::{encrypt_secret_data, FleetSecret, FleetSecretPart, FleetSharedSecret},23	host::Config,24	nix_go, nix_go_json,25};2627#[derive(Parser)]28pub enum Secret {29	/// Force load host keys for all defined hosts30	ForceKeys,31	/// Add secret, data should be provided in stdin32	AddShared {33		/// Secret name34		name: String,35		/// Secret owners36		machines: Vec<String>,37		/// Override secret if already present38		#[clap(long)]39		force: bool,40		/// Secret public part41		#[clap(long)]42		public: Option<String>,43		/// How to name public secret part44		#[clap(long, default_value = "public")]45		public_name: String,46		/// Load public part from specified file47		#[clap(long)]48		public_file: Option<PathBuf>,4950		/// Create a notification on secret expiration51		#[clap(long)]52		expires_at: Option<DateTime<Utc>>,5354		/// Secret with this name already exists, override its value while keeping the same owners.55		#[clap(long)]56		re_add: bool,5758		#[clap(default_value = "secret")]59		part_name: String,60	},61	/// Add secret, data should be provided in stdin62	Add {63		/// Secret name64		name: String,65		/// Secret owners66		machine: String,67		/// Override secret if already present68		#[clap(long)]69		force: bool,70		/// Secret public part71		#[clap(long)]72		public: Option<String>,73		/// How to name public secret part74		#[clap(long, default_value = "public")]75		public_name: String,76		/// Load public part from specified file77		#[clap(long)]78		public_file: Option<PathBuf>,7980		#[clap(default_value = "secret")]81		part_name: String,82	},83	/// Read secret from remote host, requires sudo on said host84	Read {85		name: String,86		machine: String,8788		#[clap(default_value = "secret")]89		part_name: String,90	},91	UpdateShared {92		name: String,9394		#[clap(long)]95		machines: Option<Vec<String>>,9697		#[clap(long)]98		add_machines: Vec<String>,99		#[clap(long)]100		remove_machines: Vec<String>,101102		/// Which host should we use to decrypt103		#[clap(long)]104		prefer_identities: Vec<String>,105106		#[clap(default_value = "secret")]107		part_name: String,108	},109	Regenerate {110		/// Which host should we use to decrypt, in case if reencryption is required, without111		/// regeneration112		#[clap(long)]113		prefer_identities: Vec<String>,114	},115	List {},116	Edit {117		name: String,118		machine: String,119120		#[clap(default_value = "secret")]121		part: String,122123		#[clap(long)]124		add: bool,125	},126}127128#[tracing::instrument(skip(config, secret, field, prefer_identities))]129async fn update_owner_set(130	secret_name: &str,131	config: &Config,132	mut secret: FleetSharedSecret,133	field: Field,134	updated_set: &[String],135	prefer_identities: &[String],136) -> Result<FleetSharedSecret> {137	let original_set = secret.owners.clone();138139	let set = original_set.iter().collect::<BTreeSet<_>>();140	let expected_set = updated_set.iter().collect::<BTreeSet<_>>();141142	if set == expected_set {143		info!("no need to update owner list, it is already correct");144		return Ok(secret);145	}146147	let should_regenerate = if set.difference(&expected_set).next().is_some() {148		// TODO: Remove this warning for revokable secrets.149		warn!("host was removed from secret owners, but until this host rebuild, the secret will still be stored on it.");150		nix_go_json!(field.regenerateOnOwnerRemoved)151	} else if expected_set.difference(&set).next().is_some() {152		nix_go_json!(field.regenerateOnOwnerAdded)153	} else {154		false155	};156157	if should_regenerate {158		info!("secret is owner-dependent, will regenerate");159		let generated = generate_shared(config, secret_name, field, updated_set.to_vec()).await?;160		Ok(generated)161	} else {162		let identity_holder = if !prefer_identities.is_empty() {163			prefer_identities164				.iter()165				.find(|i| original_set.iter().any(|s| s == *i))166		} else {167			secret.owners.first()168		};169		let Some(identity_holder) = identity_holder else {170			bail!("no available holder found");171		};172173		for (part_name, part) in secret.secret.parts.iter_mut() {174			let _span = info_span!("part reencryption", part_name);175			if !part.raw.encrypted {176				continue;177			}178			let host = config.host(identity_holder).await?;179			let encrypted = host180				.reencrypt(part.raw.clone(), updated_set.to_vec())181				.await?;182			part.raw = encrypted;183		}184185		secret.owners = updated_set.to_vec();186		Ok(secret)187	}188}189190#[derive(Deserialize)]191#[serde(rename_all = "camelCase")]192enum GeneratorKind {193	Impure,194	Pure,195}196197async fn generate_pure(198	_config: &Config,199	_display_name: &str,200	_secret: Field,201	_default_generator: Field,202	_owners: &[String],203) -> Result<FleetSecret> {204	bail!("pure generators are broken for now")205}206async fn generate_impure(207	config: &Config,208	_display_name: &str,209	secret: Field,210	default_generator: Field,211	owners: &[String],212) -> Result<FleetSecret> {213	let generator = nix_go!(secret.generator);214	let on: Option<String> = nix_go_json!(default_generator.impureOn);215216	let host = if let Some(on) = &on {217		config.host(on).await?218	} else {219		config.local_host()220	};221	let on_pkgs = host.pkgs().await?;222	let call_package = nix_go!(on_pkgs.callPackage);223	let mk_encrypt_secret = nix_go!(on_pkgs.mkEncryptSecret);224225	let mut recipients = Vec::new();226	for owner in owners {227		let key = config.key(owner).await?;228		recipients.push(key);229	}230	let encrypt = nix_go!(mk_encrypt_secret(Obj {231		recipients: { recipients },232	}));233234	let generator = nix_go!(call_package(generator)(Obj {235		encrypt,236		// rustfmt_please_newline237	}));238239	let generator = generator.build().await?;240	let generator = generator241		.get("out")242		.ok_or_else(|| anyhow!("missing generateImpure out"))?;243	let generator = host.remote_derivation(generator).await?;244245	let out_parent = host.mktemp_dir().await?;246	let out = format!("{out_parent}/out");247248	let mut gen = host.cmd(generator).await?;249	gen.env("out", &out);250	if on.is_none() {251		// This path is local, thus we can feed `OsString` directly to env var... But I don't think that's necessary to handle.252		let project_path: String = config253			.directory254			.clone()255			.into_os_string()256			.into_string()257			.map_err(|s| anyhow!("fleet project path is not utf-8: {s:?}"))?;258		gen.env("FLEET_PROJECT", project_path);259	}260	gen.run().await.context("impure generator")?;261262	{263		let marker = host.read_file_text(format!("{out}/marker")).await?;264		ensure!(marker == "SUCCESS", "generation not succeeded");265	}266267	let mut parts = BTreeMap::new();268	for part in host.read_dir(&out).await? {269		if part == "created_at" || part == "expired_at" || part == "marker" {270			continue;271		}272		let contents: SecretData = host273			.read_file_text(format!("{out}/{part}"))274			.await?275			.parse()276			.map_err(|e| anyhow!("failed to decode secret {out:?} part {part:?}: {e}"))?;277		parts.insert(part.to_owned(), FleetSecretPart { raw: contents });278	}279280	let created_at = host.read_file_value(format!("{out}/created_at")).await?;281	let expires_at = host.read_file_value(format!("{out}/expires_at")).await.ok();282283	Ok(FleetSecret {284		created_at,285		expires_at,286		parts,287	})288}289async fn generate(290	config: &Config,291	display_name: &str,292	secret: Field,293	owners: &[String],294) -> Result<FleetSecret> {295	let generator = nix_go!(secret.generator);296	// Can't properly check on nix module system level297	{298		let gen_ty = generator.type_of().await?;299		if gen_ty == "null" {300			bail!("secret has no generator defined, can't automatically generate it.");301		}302		if gen_ty != "lambda" {303			bail!("generator should be lambda, got {gen_ty}");304		}305	}306	let default_pkgs = &config.default_pkgs;307	let default_call_package = nix_go!(default_pkgs.callPackage);308	// Generators provide additional information in passthru, to access309	// passthru we should call generator, but information about where this generator is supposed to build310	// is located in passthru... Thus evaluating generator on host.311	//312	// Maybe it is also possible to do some magic with __functor?313	//314	// I don't want to make modules always responsible for additional secret data anyway,315	// so it should be in derivation, and not in the secret data itself.316	let default_generator = nix_go!(default_call_package(generator)(Obj {317		encrypt: { "exit 1" },318		// rustfmt_please_newline319	}));320321	let kind: GeneratorKind = nix_go_json!(default_generator.generatorKind);322323	match kind {324		GeneratorKind::Impure => {325			generate_impure(config, display_name, secret, default_generator, owners).await326		}327		GeneratorKind::Pure => {328			generate_pure(config, display_name, secret, default_generator, owners).await329		}330	}331}332async fn generate_shared(333	config: &Config,334	display_name: &str,335	secret: Field,336	expected_owners: Vec<String>,337) -> Result<FleetSharedSecret> {338	// let owners: Vec<String> = nix_go_json!(secret.expectedOwners);339	Ok(FleetSharedSecret {340		secret: generate(config, display_name, secret, &expected_owners).await?,341		owners: expected_owners,342	})343}344345async fn parse_public(346	public: Option<String>,347	public_file: Option<PathBuf>,348) -> Result<Option<SecretData>> {349	Ok(match (public, public_file) {350		(Some(v), None) => Some(SecretData {351			data: v.into(),352			encrypted: false,353		}),354		(None, Some(v)) => Some(SecretData {355			data: read(v).await?,356			encrypted: false,357		}),358		(Some(_), Some(_)) => {359			bail!("only public or public_file should be set")360		}361		(None, None) => None,362	})363}364365async fn parse_secret() -> Result<Option<Vec<u8>>> {366	let mut input = vec![];367	io::stdin().read_to_end(&mut input)?;368	if input.is_empty() {369		Ok(None)370	} else {371		Ok(Some(input))372	}373}374375fn parse_machines(376	initial: Vec<String>,377	machines: Option<Vec<String>>,378	mut add_machines: Vec<String>,379	mut remove_machines: Vec<String>,380) -> Result<Vec<String>> {381	if machines.is_none() && add_machines.is_empty() && remove_machines.is_empty() {382		bail!("no operation");383	}384385	let initial_machines = initial.clone();386	let mut target_machines = initial;387	info!("Currently encrypted for {initial_machines:?}");388389	// ensure!(machines.is_some() || !add_machines.is_empty() || )390	if let Some(machines) = machines {391		ensure!(392			add_machines.is_empty() && remove_machines.is_empty(),393			"can't combine --machines and --add-machines/--remove-machines"394		);395		let target = initial_machines.iter().collect::<HashSet<_>>();396		let source = machines.iter().collect::<HashSet<_>>();397		for removed in target.difference(&source) {398			remove_machines.push((*removed).clone());399		}400		for added in source.difference(&target) {401			add_machines.push((*added).clone());402		}403	}404405	for machine in &remove_machines {406		let mut removed = false;407		while let Some(pos) = target_machines.iter().position(|m| m == machine) {408			target_machines.swap_remove(pos);409			removed = true;410		}411		if !removed {412			warn!("secret is not enabled for {machine}");413		}414	}415	for machine in &add_machines {416		if target_machines.iter().any(|m| m == machine) {417			warn!("secret is already added to {machine}");418		} else {419			target_machines.push(machine.to_owned());420		}421	}422	if !remove_machines.is_empty() {423		// TODO: maybe force secret regeneration?424		// Not that useful without revokation.425		warn!("secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret");426	}427	Ok(target_machines)428}429impl Secret {430	pub async fn run(self, config: &Config) -> Result<()> {431		match self {432			Secret::ForceKeys => {433				for host in config.list_hosts().await? {434					if config.should_skip(&host.name) {435						continue;436					}437					config.key(&host.name).await?;438				}439			}440			Secret::AddShared {441				mut machines,442				name,443				force,444				public,445				public_name,446				public_file,447				expires_at,448				re_add,449				part_name,450			} => {451				// TODO: Forbid updating secrets with set expectedOwners (= not user-managed).452453				let exists = config.has_shared(&name);454				if exists && !force && !re_add {455					bail!("secret already defined");456				}457				if re_add {458					// Fixme: use clap to limit this usage459					ensure!(!force, "--force and --readd are not compatible");460					ensure!(exists, "secret doesn't exists");461					ensure!(462						machines.is_empty(),463						"you can't use machines argument for --readd"464					);465					let shared = config.shared_secret(&name)?;466					machines = shared.owners;467				}468469				let recipients = config.recipients(machines.clone()).await?;470471				let mut parts = BTreeMap::new();472473				let mut input = vec![];474				io::stdin().read_to_end(&mut input)?;475476				if !input.is_empty() {477					let encrypted = encrypt_secret_data(recipients, input)478						.ok_or_else(|| anyhow!("no recipients provided"))?;479					parts.insert(part_name, FleetSecretPart { raw: encrypted });480				}481482				if let Some(public) = parse_public(public, public_file).await? {483					parts.insert(public_name, FleetSecretPart { raw: public });484				}485486				config.replace_shared(487					name,488					FleetSharedSecret {489						owners: machines,490						secret: FleetSecret {491							created_at: Utc::now(),492							expires_at,493							parts,494						},495					},496				);497			}498			Secret::Add {499				machine,500				name,501				force,502				public,503				public_name,504				public_file,505				part_name,506			} => {507				if config.has_secret(&machine, &name) && !force {508					bail!("secret already defined");509				}510511				let mut parts = BTreeMap::new();512513				if let Some(secret) = parse_secret().await? {514					let recipient = config.recipient(&machine).await?;515					let encrypted =516						encrypt_secret_data(vec![recipient], secret).expect("recipient provided");517					parts.insert(part_name, FleetSecretPart { raw: encrypted });518				}519520				if let Some(public) = parse_public(public, public_file).await? {521					parts.insert(public_name, FleetSecretPart { raw: public });522				};523524				config.insert_secret(525					&machine,526					name,527					FleetSecret {528						created_at: Utc::now(),529						expires_at: None,530						parts,531					},532				);533			}534			#[allow(clippy::await_holding_refcell_ref)]535			Secret::Read {536				name,537				machine,538				part_name,539			} => {540				let secret = config.host_secret(&machine, &name)?;541				let Some(secret) = secret.parts.get(&part_name) else {542					bail!("no part {part_name} in secret {name}");543				};544				let data = if secret.raw.encrypted {545					let host = config.host(&machine).await?;546					host.decrypt(secret.raw.clone()).await?547				} else {548					secret.raw.data.clone()549				};550551				stdout().write_all(&data)?;552			}553			Secret::UpdateShared {554				name,555				machines,556				add_machines,557				remove_machines,558				prefer_identities,559				part_name,560			} => {561				// TODO: Forbid updating secrets with set expectedOwners (= not user-managed).562563				let secret = config.shared_secret(&name)?;564				if secret.secret.parts.get(&part_name).is_none() {565					bail!("no secret");566				}567568				let initial_machines = secret.owners.clone();569				let target_machines = parse_machines(570					initial_machines.clone(),571					machines,572					add_machines,573					remove_machines,574				)?;575576				if target_machines.is_empty() {577					info!("no machines left for secret, removing it");578					config.remove_shared(&name);579					return Ok(());580				}581582				let config_field = &config.config_unchecked_field;583				let field = nix_go!(config_field.sharedSecrets[{ name }]);584585				let updated = update_owner_set(586					&name,587					config,588					secret,589					field,590					&target_machines,591					&prefer_identities,592				)593				.await?;594				config.replace_shared(name, updated);595			}596			Secret::Regenerate { prefer_identities } => {597				info!("checking for secrets to regenerate");598				{599					let _span = info_span!("shared").entered();600					let expected_shared_set = config601						.list_configured_shared()602						.await?603						.into_iter()604						.collect::<HashSet<_>>();605					let shared_set = config.list_shared().into_iter().collect::<HashSet<_>>();606					for missing in expected_shared_set.difference(&shared_set) {607						let config_field = &config.config_unchecked_field;608						let secret = nix_go!(config_field.sharedSecrets[{ missing }]);609						let expected_owners: Option<Vec<String>> =610							nix_go_json!(secret.expectedOwners);611						let Some(expected_owners) = expected_owners else {612							// TODO: Might still need to regenerate613							continue;614						};615						info!("generating secret: {missing}");616						let shared = generate_shared(config, missing, secret, expected_owners)617							.in_current_span()618							.await?;619						config.replace_shared(missing.to_string(), shared)620					}621				}622				for host in config.list_hosts().await? {623					if config.should_skip(&host.name) {624						continue;625					}626627					let _span = info_span!("host", host = host.name).entered();628					let expected_set = host629						.list_configured_secrets()630						.in_current_span()631						.await?632						.into_iter()633						.collect::<HashSet<_>>();634					let stored_set = config635						.list_secrets(&host.name)636						.into_iter()637						.collect::<HashSet<_>>();638					for missing in expected_set.difference(&stored_set) {639						info!("generating secret: {missing}");640						let secret = host.secret_field(missing).in_current_span().await?;641						let generated =642							match generate(config, missing, secret, &[host.name.clone()])643								.in_current_span()644								.await645							{646								Ok(v) => v,647								Err(e) => {648									error!("{e:?}");649									continue;650								}651							};652						config.insert_secret(&host.name, missing.to_string(), generated)653					}654				}655				let mut to_remove = Vec::new();656				for name in &config.list_shared() {657					info!("updating secret: {name}");658					let data = config.shared_secret(name)?;659					let config_field = &config.config_unchecked_field;660					let expected_owners: Vec<String> =661						nix_go_json!(config_field.sharedSecrets[{ name }].expectedOwners);662					if expected_owners.is_empty() {663						warn!("secret was removed from fleet config: {name}, removing from data");664						to_remove.push(name.to_string());665						continue;666					}667668					let secret = nix_go!(config_field.sharedSecrets[{ name }]);669					config.replace_shared(670						name.to_owned(),671						update_owner_set(672							name,673							config,674							data,675							secret,676							&expected_owners,677							&prefer_identities,678						)679						.await?,680					);681				}682				for k in to_remove {683					config.remove_shared(&k);684				}685			}686			Secret::List {} => {687				let _span = info_span!("loading secrets").entered();688				let configured = config.list_configured_shared().await?;689				#[derive(Tabled)]690				struct SecretDisplay {691					#[tabled(rename = "Name")]692					name: String,693					#[tabled(rename = "Owners")]694					owners: String,695				}696				let mut table = vec![];697				for name in configured.iter().cloned() {698					let config = config.clone();699					let expected_owners = config.shared_secret_expected_owners(&name).await?;700					let data = config.shared_secret(&name)?;701					let owners = data702						.owners703						.iter()704						.map(|o| {705							if expected_owners.contains(o) {706								o.green().to_string()707							} else {708								o.red().to_string()709							}710						})711						.collect::<Vec<_>>();712					table.push(SecretDisplay {713						owners: owners.join(", "),714						name,715					})716				}717				info!("loaded\n{}", Table::new(table).to_string())718			}719			Secret::Edit {720				name,721				machine,722				part,723				add,724			} => {725				let secret = config.host_secret(&machine, &name)?;726				if let Some(data) = secret.parts.get(&part) {727					let host = config.host(&machine).await?;728					let secret = host.decrypt(data.raw.clone()).await?;729					String::from_utf8(secret).context("secret is not utf8")?730				} else if add {731					String::new()732				} else {733					bail!("part {part} not found in secret {name}. Did you mean to `--add` it?");734				};735			}736		}737		Ok(())738	}739}740741async fn edit_temp_file(742	builder: tempfile::Builder<'_, '_>,743	r: Vec<u8>,744	header: &str,745	comment: &str,746) -> Result<(Vec<u8>, Option<String>), anyhow::Error> {747	if !stdin().is_tty() {748		// TODO: Also try to open /dev/tty directly?749		bail!("stdin is not tty, can't open editor");750	}751752	use std::fmt::Write;753	let mut file = builder.tempfile()?;754755	let mut full_header = String::new();756	let mut had = false;757	for line in header.trim_end().lines() {758		had = true;759		writeln!(&mut full_header, "{comment}{line}")?;760	}761	if had {762		writeln!(&mut full_header, "{}", comment.trim_end())?;763	}764	writeln!(765		&mut full_header,766		"{comment}Do not touch this header! It will be removed automatically"767	)?;768769	file.write_all(full_header.as_bytes())?;770	file.write_all(&r)?;771772	let abs_path = file.into_temp_path();773	let editor = std::env::var_os("VISUAL")774		.or_else(|| std::env::var_os("EDITOR"))775		.unwrap_or_else(|| "vi".into());776	let editor_args = shlex::bytes::split(editor.as_encoded_bytes())777		.ok_or_else(|| anyhow!("EDITOR env var has wrong syntax"))?;778	let editor_args = editor_args779		.into_iter()780		.map(|v| {781			// Only ASCII subsequences are replaced782			unsafe { OsString::from_encoded_bytes_unchecked(v) }783		})784		.collect_vec();785	let Some((editor, args)) = editor_args.split_first() else {786		bail!("EDITOR env var has no command");787	};788	let mut command = Command::new(editor);789	command.args(args);790791	let path_arg = abs_path.canonicalize()?;792793	// TODO: Save full state, using tcget/_getmode/_setmode794	let was_raw = terminal::is_raw_mode_enabled()?;795	terminal::enable_raw_mode()?;796797	let status = command.arg(path_arg).status().await;798799	if !was_raw {800		terminal::disable_raw_mode()?;801	}802803	let success = match status {804		Ok(s) => s.success(),805		Err(e) if e.kind() == io::ErrorKind::NotFound => {806			bail!("editor not found")807		}808		Err(e) => bail!("editor spawn error: {e}"),809	};810811	let mut file = std::fs::read(&abs_path).context("read editor output")?;812	let Some(v) = file.strip_prefix(full_header.as_bytes()) else {813		todo!();814	};815	todo!();816817	// Ok((success, abs_path))818}
modifiedcmds/fleet/src/fleetdata.rsdiffbeforeafterboth
--- a/cmds/fleet/src/fleetdata.rs
+++ b/cmds/fleet/src/fleetdata.rs
@@ -1,20 +1,14 @@
-use age::Recipient;
-use anyhow::Result;
-use chrono::{DateTime, Utc};
-use itertools::Itertools;
-use nixlike::format_nix;
-use serde::{Deserialize, Deserializer, Serialize, Serializer};
 use std::{
 	collections::BTreeMap,
 	io::{self, Cursor},
 };
-use tempfile::TempDir;
-use tokio::{
-	fs::{self, File},
-	io::AsyncWriteExt,
-	process::Command,
-};
 
+use age::Recipient;
+use chrono::{DateTime, Utc};
+use fleet_shared::SecretData;
+use itertools::Itertools;
+use serde::{de::Error, Deserialize, Serialize};
+
 #[derive(Serialize, Deserialize, Default)]
 #[serde(rename_all = "camelCase")]
 pub struct HostData {
@@ -23,9 +17,36 @@
 	pub encryption_key: String,
 }
 
+const VERSION: &str = "0.1.0";
+pub struct FleetDataVersion;
+impl Serialize for FleetDataVersion {
+	fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+	where
+		S: serde::Serializer,
+	{
+		VERSION.serialize(serializer)
+	}
+}
+impl<'de> Deserialize<'de> for FleetDataVersion {
+	fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+	where
+		D: serde::Deserializer<'de>,
+	{
+		let version = String::deserialize(deserializer)?;
+		if version != VERSION {
+			return Err(D::Error::custom(format!(
+				"fleet.nix data version mismatch, expected {VERSION}, got {version}.\nFollow the docs for migration instruction"
+			)));
+		}
+		Ok(Self)
+	}
+}
+
 #[derive(Serialize, Deserialize)]
 #[serde(rename_all = "camelCase")]
 pub struct FleetData {
+	pub version: FleetDataVersion,
+
 	#[serde(default)]
 	pub hosts: BTreeMap<String, HostData>,
 	#[serde(default)]
@@ -45,41 +66,30 @@
 	pub secret: FleetSecret,
 }
 
+/// Returns None if recipients.is_empty()
+pub fn encrypt_secret_data(
+	recipients: impl IntoIterator<Item = impl Recipient + Send + 'static>,
+	data: Vec<u8>,
+) -> Option<SecretData> {
+	let mut encrypted = vec![];
+	let recipients = recipients
+		.into_iter()
+		.map(|v| Box::new(v) as Box<dyn Recipient + Send>)
+		.collect_vec();
+	let mut encryptor = age::Encryptor::with_recipients(recipients)?
+		.wrap_output(&mut encrypted)
+		.expect("in memory write");
+	io::copy(&mut Cursor::new(data), &mut encryptor).expect("in memory copy");
+	encryptor.finish().expect("in memory flush");
+	Some(SecretData {
+		data: encrypted,
+		encrypted: true,
+	})
+}
+
 #[derive(Serialize, Deserialize, Clone)]
-pub struct SecretData(
-	#[serde(
-		default,
-		skip_serializing_if = "Vec::is_empty",
-		serialize_with = "as_z85",
-		deserialize_with = "from_z85"
-	)]
-	pub Vec<u8>,
-);
-impl SecretData {
-	/// Returns None if recipients.is_empty()
-	pub fn encrypt(
-		recipients: impl IntoIterator<Item = impl Recipient + Send + 'static>,
-		data: Vec<u8>,
-	) -> Option<Self> {
-		let mut encrypted = vec![];
-		let recipients = recipients
-			.into_iter()
-			.map(|v| Box::new(v) as Box<dyn Recipient + Send>)
-			.collect_vec();
-		let mut encryptor = age::Encryptor::with_recipients(recipients)?
-			.wrap_output(&mut encrypted)
-			.expect("in memory write");
-		io::copy(&mut Cursor::new(data), &mut encryptor).expect("in memory copy");
-		encryptor.finish().expect("in memory flush");
-		Some(Self(encrypted))
-	}
-	pub fn encode_z85(&self) -> String {
-		z85::encode(&self.0)
-	}
-	pub fn decode_z85(v: &str) -> Result<Self> {
-		let v = z85::decode(v)?;
-		Ok(Self(v))
-	}
+pub struct FleetSecretPart {
+	pub raw: SecretData,
 }
 
 #[derive(Serialize, Deserialize, Clone)]
@@ -91,60 +101,7 @@
 	#[serde(default)]
 	#[serde(skip_serializing_if = "Option::is_none", alias = "expire_at")]
 	pub expires_at: Option<DateTime<Utc>>,
-	#[serde(skip_serializing_if = "Option::is_none")]
-	pub public: Option<String>,
-	#[serde(skip_serializing_if = "Option::is_none")]
-	pub secret: Option<SecretData>,
-}
-
-fn as_z85<S>(key: &[u8], serializer: S) -> Result<S::Ok, S::Error>
-where
-	S: Serializer,
-{
-	serializer.serialize_str(&z85::encode(key))
-}
 
-fn from_z85<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
-where
-	D: Deserializer<'de>,
-{
-	use serde::de::Error;
-	String::deserialize(deserializer)
-		.and_then(|string| z85::decode(string).map_err(|err| Error::custom(err.to_string())))
-}
-
-/// Isn't used yet
-#[allow(dead_code)]
-pub async fn dummy_flake() -> Result<TempDir> {
-	let data_str = fs::read_to_string("fleet.nix").await?;
-
-	let mut cmd = Command::new("nix");
-	cmd.arg("flake").arg("metadata").arg("--json");
-
-	let flake_dir = tempfile::tempdir()?;
-	let mut flake_nix = flake_dir.path().to_path_buf();
-	flake_nix.push("flake.nix");
-	// flake_dir
-
-	File::create(&flake_nix)
-		.await?
-		.write_all(
-			format_nix(&format!(
-				"
-						{{
-							outputs = {{self, ...}}: {{
-								data = {data_str};
-							}};
-						}}
-					"
-			))
-			.as_bytes(),
-		)
-		.await?;
-
-	// std::thread::sleep(Duration::MAX);
-	// flake_dir.close()
-	// FIXME
-	dbg!(&flake_nix);
-	Ok(flake_dir)
+	#[serde(flatten)]
+	pub parts: BTreeMap<String, FleetSecretPart>,
 }
modifiedcmds/fleet/src/host.rsdiffbeforeafterboth
--- a/cmds/fleet/src/host.rs
+++ b/cmds/fleet/src/host.rs
@@ -9,8 +9,9 @@
 	sync::{Arc, Mutex, MutexGuard, OnceLock},
 };
 
-use anyhow::{anyhow, bail, Context, Result};
+use anyhow::{anyhow, bail, ensure, Context, Result};
 use clap::{ArgGroup, Parser};
+use fleet_shared::SecretData;
 use openssh::SessionBuilder;
 use serde::de::DeserializeOwned;
 use tempfile::NamedTempFile;
@@ -18,7 +19,7 @@
 use crate::{
 	better_nix_eval::{Field, NixSessionPool},
 	command::MyCommand,
-	fleetdata::{FleetData, FleetSecret, FleetSharedSecret, SecretData},
+	fleetdata::{FleetData, FleetSecret, FleetSharedSecret},
 	nix_go, nix_go_json,
 };
 
@@ -89,6 +90,16 @@
 		cmd.arg(path);
 		cmd.run_string().await
 	}
+	pub async fn read_dir(&self, path: impl AsRef<OsStr>) -> Result<Vec<String>> {
+		let mut cmd = self.cmd("ls").await?;
+		cmd.arg(path);
+		let out = cmd.run_string().await?;
+		let mut lines = out.split('\n');
+		if let Some(last) = lines.next_back() {
+			ensure!(last == "", "output of ls should end with newline");
+		}
+		Ok(lines.map(ToOwned::to_owned).collect())
+	}
 	#[allow(dead_code)]
 	pub async fn read_file_json<D: DeserializeOwned>(&self, path: impl AsRef<OsStr>) -> Result<D> {
 		let text = self.read_file_text(path).await?;
@@ -111,18 +122,22 @@
 	}
 
 	pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {
+		ensure!(data.encrypted, "secret is not encrypted");
 		let mut cmd = self.cmd("fleet-install-secrets").await?;
-		cmd.arg("decrypt").eqarg("--secret", data.encode_z85());
+		cmd.arg("decrypt").eqarg("--secret", data.to_string());
 		let encoded = cmd
 			.sudo()
 			.run_string()
 			.await
 			.context("failed to call remote host for decrypt")?;
-		z85::decode(encoded.trim_end()).context("bad encoded data? outdated host?")
+		let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;
+		ensure!(!data.encrypted, "didn't decrypted secret");
+		Ok(data.data)
 	}
 	pub async fn reencrypt(&self, data: SecretData, targets: Vec<String>) -> Result<SecretData> {
+		ensure!(data.encrypted, "secret is not encrypted");
 		let mut cmd = self.cmd("fleet-install-secrets").await?;
-		cmd.arg("reencrypt").eqarg("--secret", data.encode_z85());
+		cmd.arg("reencrypt").eqarg("--secret", data.to_string());
 		for target in targets {
 			let key = self.config.key(&target).await?;
 			cmd.eqarg("--targets", key);
@@ -132,7 +147,9 @@
 			.run_string()
 			.await
 			.context("failed to call remote host for decrypt")?;
-		SecretData::decode_z85(encoded.trim_end()).context("bad encoded data? outdated host?")
+		let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;
+		ensure!(!data.encrypted, "didn't decrypted secret");
+		Ok(data)
 	}
 	/// Returns path for futureproofing, as path might change i.e on conversion to CA
 	pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {
@@ -324,7 +341,7 @@
 	}
 
 	pub fn save(&self) -> Result<()> {
-		let mut tempfile = NamedTempFile::new_in(self.directory.clone())?;
+		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.")?;
 		let data = nixlike::serialize(&self.data() as &FleetData)?;
 		tempfile.write_all(
 			format!(
modifiedcmds/install-secrets/Cargo.tomldiffbeforeafterboth
--- a/cmds/install-secrets/Cargo.toml
+++ b/cmds/install-secrets/Cargo.toml
@@ -20,4 +20,4 @@
 	"unicode",
 ] }
 tempfile = "3.10.0"
-z85 = "3.0.5"
+fleet-shared.workspace = true
modifiedcmds/install-secrets/src/main.rsdiffbeforeafterboth
--- a/cmds/install-secrets/src/main.rs
+++ b/cmds/install-secrets/src/main.rs
@@ -1,64 +1,41 @@
-use age::{ssh::Identity as SshIdentity, ssh::Recipient as SshRecipient, Decryptor};
-use age::{Encryptor, Identity, Recipient};
-use anyhow::{anyhow, bail, Context, Result};
+use std::{
+	collections::{BTreeMap, HashMap},
+	fs::{self, File},
+	io::{self, Cursor, Read, Write},
+	iter,
+	os::unix::prelude::PermissionsExt,
+	path::{Path, PathBuf},
+	str::{from_utf8, FromStr},
+};
+
+use age::{
+	ssh::{Identity as SshIdentity, Recipient as SshRecipient},
+	Decryptor, Encryptor, Identity, Recipient,
+};
+use anyhow::{anyhow, bail, ensure, Context, Result};
 use clap::Parser;
-use nix::sys::stat::Mode;
+use fleet_shared::SecretData;
 use nix::unistd::{chown, Group, User};
-use serde::{Deserialize, Deserializer};
-use std::fmt::{self, Display};
-use std::fs::{self, File};
-use std::io::{self, Cursor, Read, Write};
-use std::iter;
-use std::os::unix::prelude::PermissionsExt;
-use std::path::Path;
-use std::str::{from_utf8, FromStr};
-use std::{collections::HashMap, path::PathBuf};
-use tracing::{error, info, info_span, warn};
-use tracing_subscriber::filter::LevelFilter;
-use tracing_subscriber::EnvFilter;
-
-#[derive(Clone, Debug)]
-struct SecretWrapper(Vec<u8>);
-impl Display for SecretWrapper {
-	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-		let encoded = z85::encode(&self.0);
-		write!(f, "{encoded}")
-	}
-}
-impl FromStr for SecretWrapper {
-	type Err = z85::DecodeError;
-
-	fn from_str(s: &str) -> Result<Self, Self::Err> {
-		z85::decode(s).map(Self)
-	}
-}
-impl<'de> Deserialize<'de> for SecretWrapper {
-	fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
-	where
-		D: Deserializer<'de>,
-	{
-		let v = String::deserialize(deserializer)?;
-		let de = z85::decode(v).map_err(|err| serde::de::Error::custom(err.to_string()))?;
-		Ok(Self(de))
-	}
-}
+use serde::Deserialize;
+use tracing::{error, info_span};
+use tracing_subscriber::{filter::LevelFilter, EnvFilter};
 
 #[derive(Parser)]
 #[clap(author)]
 enum Opts {
 	/// Install secrets from json specification
 	Install { data: PathBuf },
-	/// Reencrypt secret using host key, outputting in z85 encoded string
+	/// Reencrypt secret using host key, outputting in fleet encoded string
 	Reencrypt {
 		#[clap(long)]
-		secret: SecretWrapper,
+		secret: SecretData,
 		#[clap(long)]
 		targets: Vec<String>,
 	},
-	/// Decrypt secret using host key, outputting in z85 encoded string
+	/// Decrypt secret using host key, outputting in fleet encoded string
 	Decrypt {
 		#[clap(long)]
-		secret: SecretWrapper,
+		secret: SecretData,
 		/// Shoult decoded output be printed as plaintext, instead of z85?
 		#[clap(long)]
 		plaintext: bool,
@@ -67,25 +44,29 @@
 
 #[derive(Deserialize)]
 #[serde(rename_all = "camelCase")]
+struct Part {
+	raw: SecretData,
+	path: PathBuf,
+	stable_path: PathBuf,
+}
+
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
 struct DataItem {
 	group: String,
 	mode: String,
 	owner: String,
-
-	secret: Option<SecretWrapper>,
-	public: Option<String>,
-
-	public_path: PathBuf,
-	stable_public_path: PathBuf,
+	root_path: Option<PathBuf>,
 
-	secret_path: PathBuf,
-	stable_secret_path: PathBuf,
+	#[serde(flatten)]
+	parts: BTreeMap<String, Part>,
 }
 
 type Data = HashMap<String, DataItem>;
 
-fn decrypt(input: &SecretWrapper, identity: &dyn Identity) -> Result<Vec<u8>> {
-	let mut input = Cursor::new(&input.0);
+fn decrypt(input: &SecretData, identity: &dyn Identity) -> Result<Vec<u8>> {
+	ensure!(input.encrypted, "passed data is not encrypted!");
+	let mut input = Cursor::new(&input.data);
 	let decryptor = Decryptor::new(&mut input).context("failed to init decryptor")?;
 	let decryptor = match decryptor {
 		Decryptor::Recipients(r) => r,
@@ -101,7 +82,7 @@
 		.context("failed to decrypt")?;
 	Ok(decrypted)
 }
-fn encrypt(input: &[u8], targets: Vec<String>) -> Result<SecretWrapper> {
+fn encrypt(input: &[u8], targets: Vec<String>) -> Result<SecretData> {
 	let recipients = targets
 		.into_iter()
 		.map(|t| {
@@ -119,70 +100,79 @@
 		.expect("constructor should not fail");
 	io::copy(&mut Cursor::new(input), &mut encryptor).expect("copy should not fail");
 	encryptor.finish().context("failed to finish encryption")?;
-	Ok(SecretWrapper(encrypted))
+	Ok(SecretData {
+		data: encrypted,
+		encrypted: true,
+	})
 }
 
-fn init_secret(identity: &age::ssh::Identity, value: DataItem) -> Result<()> {
-	if let Some(public) = &value.public {
-		let mut hashed = File::create(&value.public_path)?;
-		let stable_dir = value.stable_public_path.parent().expect("not root");
-		let mut stable_temp =
-			tempfile::NamedTempFile::new_in(stable_dir).context("failed to create tempfile")?;
-		hashed.write_all(public.as_bytes())?;
-		stable_temp.write_all(public.as_bytes())?;
-		stable_temp.flush()?;
-		fs::set_permissions(stable_temp.path(), fs::Permissions::from_mode(0o444))
-			.context("perm")?;
-		fs::set_permissions(&value.public_path, fs::Permissions::from_mode(0o444))
-			.context("perm")?;
+fn init_part(identity: &dyn Identity, item: &DataItem, value: &Part) -> Result<()> {
+	let stable_dir = value.stable_path.parent().expect("not root");
 
-		stable_temp
-			.persist(value.stable_public_path)
-			.context("failed to persist")?;
-	}
-	if value.secret.is_none() {
-		info!("no secret data found");
-		return Ok(());
-	}
-	let secret = value.secret.as_ref().unwrap();
-
-	let mode = Mode::from_bits(
-		u32::from_str_radix(&value.mode, 8).context("failed to parse mode as octal")?,
-	)
-	.context("failed to parse mode")?;
-	let user = User::from_name(&value.owner)
-		.context("failed to get user")?
-		.ok_or_else(|| anyhow!("user not found"))?;
-	let group = Group::from_name(&value.group)
-		.context("failed to get group")?
-		.ok_or_else(|| anyhow!("group not found"))?;
+	// Right now stable & non-stable data are both located in this dir.
+	std::fs::create_dir_all(stable_dir)?;
 
-	let stable_dir = value.stable_secret_path.parent().expect("not root");
 	let mut stable_temp =
 		tempfile::NamedTempFile::new_in(stable_dir).context("failed to create tempfile")?;
-	let mut hashed = File::create(&value.secret_path)?;
+	let mut hashed = File::create(&value.path)?;
 
-	// File is owned by root, and only root can modify it
-	let decrypted = decrypt(secret, identity)?;
-	if decrypted.is_empty() {
-		warn!("secret is decoded as empty, something is broken?");
-	}
+	let private = value.raw.encrypted;
+	let data = if private {
+		decrypt(&value.raw, identity)?
+	} else {
+		value.raw.data.to_owned()
+	};
 
-	io::copy(&mut Cursor::new(&decrypted), &mut stable_temp)
-		.context("failed to write decrypted file")?;
-	io::copy(&mut Cursor::new(decrypted), &mut hashed).context("failed to write decrypted file")?;
+	hashed.write_all(&data)?;
+	hashed.flush()?;
+	stable_temp.write_all(&data)?;
+	stable_temp.flush()?;
 
-	// Make file owned by specified user and group, then change mode
-	chown(stable_temp.path(), Some(user.uid), Some(group.gid))
-		.context("failed to apply user/group")?;
-	chown(&value.secret_path, Some(user.uid), Some(group.gid))
-		.context("failed to apply user/group")?;
-	fs::set_permissions(stable_temp.path(), fs::Permissions::from_mode(mode.bits())).unwrap();
-	fs::set_permissions(&value.secret_path, fs::Permissions::from_mode(mode.bits())).unwrap();
+	let mode = if private {
+		fs::Permissions::from_mode(
+			u32::from_str_radix(&item.mode, 8).context("failed to parse mode as octal")?,
+		)
+	} else {
+		fs::Permissions::from_mode(0o444)
+	};
+	fs::set_permissions(stable_temp.path(), mode.clone()).context("stable temp mode")?;
+	fs::set_permissions(&value.path, mode).context("hashed mode")?;
+
+	// Files are initially owned by root, thus making set mode first inaccessible to user, and then
+	// altering user/group.
+	if private {
+		let user = User::from_name(&item.owner)
+			.context("failed to get user")?
+			.ok_or_else(|| anyhow!("user not found"))?;
+		let group = Group::from_name(&item.group)
+			.context("failed to get group")?
+			.ok_or_else(|| anyhow!("group not found"))?;
+
+		chown(stable_temp.path(), Some(user.uid), Some(group.gid))
+			.context("failed to apply user/group")?;
+		chown(&value.path, Some(user.uid), Some(group.gid))
+			.context("failed to apply user/group")?;
+	}
+
 	stable_temp
-		.persist(value.stable_secret_path)
-		.context("failed to persist")?;
+		.persist(&value.stable_path)
+		.context("stable persist")?;
+	Ok(())
+}
 
+fn init_secret(identity: &age::ssh::Identity, value: &DataItem) -> Result<()> {
+	if let Some(root_path) = &value.root_path {
+		if !fs::metadata(root_path).map(|m| m.is_dir()).unwrap_or(false) {
+			fs::create_dir(root_path).context("failed to create secret directory")?;
+		}
+	}
+	for (part_id, part) in value.parts.iter() {
+		let _span = info_span!("part", part_id = part_id);
+		if let Err(e) = init_part(identity, value, part) {
+			error!("failed to init part {part_id}: {e}");
+		}
+	}
+
 	Ok(())
 }
 
@@ -214,8 +204,8 @@
 	let mut failed = false;
 	for (name, value) in data {
 		let _span = info_span!("init", name = name);
-		if let Err(e) = init_secret(&identity, value) {
-			error!("{e}");
+		if let Err(e) = init_secret(&identity, &value) {
+			error!("secret failed to initialize: {e}");
 			failed = true;
 		}
 	}
@@ -257,7 +247,13 @@
 				let s = String::from_utf8(decrypted).context("output is not utf8")?;
 				print!("{s}");
 			} else {
-				println!("{}", SecretWrapper(decrypted));
+				println!(
+					"{}",
+					SecretData {
+						data: decrypted,
+						encrypted: false
+					}
+				);
 			}
 			Ok(())
 		}
modifiedcrates/better-command/src/handler.rsdiffbeforeafterboth
--- a/crates/better-command/src/handler.rs
+++ b/crates/better-command/src/handler.rs
@@ -1,7 +1,9 @@
 //! Collection of handlers, which transform program-specific stdout format to tracing
 
-use std::collections::HashMap;
-use std::sync::{Arc, Mutex};
+use std::{
+	collections::HashMap,
+	sync::{Arc, Mutex},
+};
 
 use once_cell::sync::Lazy;
 use regex::Regex;
addedcrates/fleet-shared/Cargo.tomldiffbeforeafterboth
--- /dev/null
+++ b/crates/fleet-shared/Cargo.toml
@@ -0,0 +1,10 @@
+[package]
+name = "fleet-shared"
+edition = "2021"
+version.workspace = true
+
+[dependencies]
+base64 = "0.22.1"
+serde = "1.0.202"
+unicode_categories = "0.1.1"
+z85 = "3.0.5"
addedcrates/fleet-shared/src/lib.rsdiffbeforeafterboth
--- /dev/null
+++ b/crates/fleet-shared/src/lib.rs
@@ -0,0 +1,156 @@
+use std::{
+	fmt::{self, Display},
+	str::FromStr,
+};
+
+use base64::engine::{general_purpose::STANDARD_NO_PAD, Engine};
+use serde::{de::Error, Deserialize, Deserializer, Serialize};
+use unicode_categories::UnicodeCategories;
+
+#[derive(Debug, PartialEq, Clone)]
+pub struct SecretData {
+	pub data: Vec<u8>,
+	pub encrypted: bool,
+}
+
+const BASE64_ENCODED_PREFIX: &str = "<BASE64-ENCODED>\n";
+const Z85_ENCODED_PREFIX: &str = "<Z85-ENCODED>\n";
+// Multiline text in Nix can only end with \n, which is not cool for actual single-line strings.
+const PLAINTEXT_NEWLINE_PREFIX: &str = "<PLAINTEXT-NL>\n";
+const PLAINTEXT_PREFIX: &str = "<PLAINTEXT>";
+
+const SECRET_PREFIX: &str = "<ENCRYPTED>";
+
+impl<'de> Deserialize<'de> for SecretData {
+	fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+	where
+		D: Deserializer<'de>,
+	{
+		let string = String::deserialize(deserializer)?;
+		string.parse().map_err(D::Error::custom)
+	}
+}
+
+impl Serialize for SecretData {
+	fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+	where
+		S: serde::Serializer,
+	{
+		self.to_string().serialize(serializer)
+	}
+}
+
+impl FromStr for SecretData {
+	type Err = String;
+
+	fn from_str(string: &str) -> Result<Self, Self::Err> {
+		let (encrypted, string) = if let Some(unprefixed) = string.strip_prefix(SECRET_PREFIX) {
+			(true, unprefixed)
+		} else {
+			(false, string)
+		};
+		let data = if let Some(unprefixed) = string.strip_prefix(BASE64_ENCODED_PREFIX) {
+			STANDARD_NO_PAD
+				.decode(unprefixed.replace(|v| matches!(v, '\n' | '\t' | ' '), ""))
+				.map_err(|e| format!("base64-encoded failed: {e}"))?
+		} else if let Some(unprefixed) = string.strip_prefix(Z85_ENCODED_PREFIX) {
+			z85::decode(unprefixed.replace(|v| matches!(v, '\n' | '\t' | ' '), ""))
+				.map_err(|e| format!("z85-encoded failed: {e}"))?
+		} else if let Some(unprefixed) = string.strip_prefix(PLAINTEXT_NEWLINE_PREFIX) {
+			unprefixed.as_bytes().to_owned()
+		} else if let Some(unprefixed) = string.strip_prefix(PLAINTEXT_PREFIX) {
+			unprefixed.as_bytes().to_owned()
+		} else {
+			let secret_prefix = format!("{SECRET_PREFIX}{Z85_ENCODED_PREFIX}");
+			return Err(format!(
+				"unknown secret encoding. If you're migrating from old version of fleet, prefix public secret fields with {PLAINTEXT_PREFIX:?}, and encrypted data with {secret_prefix:?}: {string}"
+			));
+		};
+		Ok(Self { data, encrypted })
+	}
+}
+
+impl Display for SecretData {
+	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+		let mut readable = std::str::from_utf8(&self.data).ok();
+		if self.encrypted {
+			write!(f, "{SECRET_PREFIX}")?;
+			// Always base64-encode encrypted fields.
+			readable = None;
+		}
+		if Some(false) == readable.map(is_printable) {
+			readable = None
+		};
+		// TODO: Check if text is readable, and has no unprintable characters?..
+		if let Some(plaintext) = readable {
+			if plaintext.ends_with('\n') {
+				write!(f, "{PLAINTEXT_NEWLINE_PREFIX}")?;
+			} else {
+				write!(f, "{PLAINTEXT_PREFIX}")?;
+			}
+			write!(f, "{plaintext}")?;
+		} else {
+			write!(f, "{BASE64_ENCODED_PREFIX}")?;
+			let encoded = STANDARD_NO_PAD.encode(&self.data);
+			for ele in encoded.as_bytes().chunks(64) {
+				let chunk = std::str::from_utf8(ele).expect(
+					"any slice of base64-encoded text is utf-8 compatible, as it is ascii-based",
+				);
+				writeln!(f, "{chunk}")?;
+			}
+		};
+		Ok(())
+	}
+}
+
+fn is_printable(text: &str) -> bool {
+	text.chars().all(|c| {
+		c.is_letter()
+			|| c.is_mark()
+			|| c.is_number()
+			|| c.is_punctuation()
+			|| c.is_separator()
+			|| c == '\n' || c == '\t'
+			// Complete base64 alphabet
+			|| c == '/' || c == '+'
+			|| c == '='
+	})
+}
+
+#[test]
+fn test() {
+	fn check_roundtrip(data: SecretData, expected: &str) {
+		let string = data.to_string();
+		assert_eq!(string, expected, "unexpected encoding");
+		let roundtrip: SecretData = string.parse().expect("roundtrip parse");
+		assert_eq!(data, roundtrip, "roundtrip didn't match");
+	}
+	check_roundtrip(
+		SecretData {
+			data: vec![1, 2, 3, 4, 5, 6],
+			encrypted: false,
+		},
+		"<BASE64-ENCODED>\nAQIDBAUG\n",
+	);
+	check_roundtrip(
+		SecretData {
+			data: vec![1, 2, 3, 4, 5, 6],
+			encrypted: true,
+		},
+		"<ENCRYPTED><BASE64-ENCODED>\nAQIDBAUG\n",
+	);
+	check_roundtrip(
+		SecretData {
+			data: "Привет, мир!\n".to_owned().into(),
+			encrypted: false,
+		},
+		"<PLAINTEXT-NL>\nПривет, мир!\n",
+	);
+	check_roundtrip(
+		SecretData {
+			data: "Привет, мир!".to_owned().into(),
+			encrypted: false,
+		},
+		"<PLAINTEXT>Привет, мир!",
+	);
+}
modifiedcrates/nixlike/src/lib.rsdiffbeforeafterboth
--- a/crates/nixlike/src/lib.rs
+++ b/crates/nixlike/src/lib.rs
@@ -38,6 +38,57 @@
 	Null,
 }
 
+fn count_spaces(l: &str) -> usize {
+	l.chars().take_while(|&c| c == ' ').count()
+}
+fn is_significant(l: &str) -> bool {
+	count_spaces(l) != l.len()
+}
+
+fn dedent(l: &str, by: usize) -> &str {
+	assert!(
+		l[0..by.min(l.len())].chars().all(|c| c == ' '),
+		"dedent calculation is wrong"
+	);
+	&l[by.min(l.len())..]
+}
+
+fn process_multiline(lines: Vec<&str>) -> String {
+	// Even when parsing '''', there is single "line" between those '' delimiters.
+	// unwrap_or is for case where there is no significant lines
+	let dedent_by = lines
+		.iter()
+		.copied()
+		.filter(|c| is_significant(c))
+		.map(count_spaces)
+		.min()
+		.unwrap_or(0);
+
+	let mut out = String::new();
+
+	let mut had_first = false;
+	for (i, line) in lines.into_iter().enumerate() {
+		// Newline after '' is ignored, if there is no text.
+		if i == 0 && !is_significant(line) {
+			continue;
+		}
+		if had_first {
+			out.push('\n');
+		}
+		had_first = true;
+		// ''' is hard escape
+		for (i, part) in dedent(line, dedent_by).split("'''").enumerate() {
+			if i != 0 {
+				out.push_str(r#"""""#);
+			}
+			// This is the only replacements done by nixlike writer, no need to support more.
+			out.push_str(&part.replace("''${", "${").replace("''\\t", "\t"));
+		}
+	}
+
+	out
+}
+
 peg::parser! {
 pub grammar nixlike() for str {
 	rule number() -> i64
@@ -50,8 +101,17 @@
 		/ "\\r" { "\r" }
 		/ "\\$" { "$" }
 		/ c:$([_]) { c }
-	rule string() -> String
+	rule string() -> String = singleline_string() / multiline_string();
+	rule singleline_string() -> String
 		= quiet! { "\"" v:(!"\"" c:string_char() {c})* "\"" { v.into_iter().collect() } } / expected!("<string>")
+	pub rule multiline_string() -> String
+		= "''"
+		// First line may also contain text, and whitespace for it is counted, but if it is empty - then it is'nt counted as full line...
+		// This logic is complicated, see `parse_multiline` test.
+		lines:$(("'''" / !"''" [_])*) "''"
+		{
+			process_multiline(lines.split('\n').collect())
+		}
 	rule boolean() -> bool
 		= quiet! { "true" {true}
 		/ "false" {false} } / expected!("<boolean>")
@@ -135,3 +195,12 @@
 	let (_, out) = alejandra::format::in_memory("".to_owned(), value.to_owned());
 	out
 }
+
+#[test]
+fn parse_multiline() {
+	assert_eq!(nixlike::multiline_string("''\n''").expect("parse"), "");
+	assert_eq!(nixlike::multiline_string("''\n\n''").expect("parse"), "\n");
+	assert_eq!(nixlike::multiline_string("''t\n''").expect("parse"), "t\n");
+	assert_eq!(nixlike::multiline_string("''''").expect("parse"), "");
+	assert_eq!(nixlike::multiline_string("''    ''").expect("parse"), "");
+}
modifiedcrates/nixlike/src/to_string.rsdiffbeforeafterboth
--- a/crates/nixlike/src/to_string.rs
+++ b/crates/nixlike/src/to_string.rs
@@ -38,7 +38,28 @@
 }
 
 pub fn write_nix_str(str: &str, out: &mut String) {
-	out.push_str(&escape_string(str))
+	if str.ends_with('\n') {
+		out.push_str("''");
+		for ele in str.split('\n') {
+			out.push('\n');
+			out.push_str(
+				&ele
+					// '' is escaped with '
+					.replace("''", "'''")
+					// ${ is escaped wth ''
+					.replace("${", "''${")
+					// \t is not counted as whitespace for dedent
+					// to avoid confusion, it is printed literally.
+					//
+					// ...Escaped \t literal should be prefixed with '' for... Idk, this logic is complicated.
+					.replace('\t', "''\\t"),
+			);
+		}
+		// Final newline is assumed due to str.ends_with condition
+		out.push_str("''");
+	} else {
+		out.push_str(&escape_string(str))
+	}
 }
 
 fn write_nix_buf(value: &Value, out: &mut String) {
modifiedcrates/remowt-fs/src/lib.rsdiffbeforeafterboth
--- a/crates/remowt-fs/src/lib.rs
+++ b/crates/remowt-fs/src/lib.rs
@@ -1,2 +1 @@
 trait RemowtFS {}
-
modifiedmodules/fleet/secrets.nixdiffbeforeafterboth
--- a/modules/fleet/secrets.nix
+++ b/modules/fleet/secrets.nix
@@ -7,14 +7,8 @@
 with lib;
 with fleetLib; let
   sharedSecret = with types; ({config, ...}: {
+    freeformType = types.lazyAttrsOf unspecified;
     options = {
-      managed = mkOption {
-        type = bool;
-        description = ''
-          Is this secret managed by configuration (I.e will work with reencrypt/etc), or it is configured by user
-        '';
-      };
-
       expectedOwners = mkOption {
         type = nullOr (listOf str);
         description = ''
@@ -71,23 +65,10 @@
         '';
         default = [];
       };
-      # TODO: Make secret generator generate arbitrary number of secret/public parts?
-      # Make it generate a folder, where all files except suffixed by .enc are public, and the rest are secret?
-      # How should modules refer to those files then?
-      public = mkOption {
-        type = nullOr str;
-        description = "Secret public data. Imported from fleet.nix";
-        default = null;
-      };
-      secret = mkOption {
-        type = nullOr str;
-        description = "Encrypted secret data. Imported from fleet.nix";
-        default = null;
-        internal = true;
-      };
     };
   });
   hostSecret = with types; {
+    freeformType = types.lazyAttrsOf unspecified;
     options = {
       createdAt = mkOption {
         type = nullOr str;
@@ -97,21 +78,15 @@
         type = nullOr str;
         default = null;
       };
-      public = mkOption {
-        type = nullOr str;
-        description = "Secret public data. Imported from fleet.nix";
-        default = null;
-      };
-      secret = mkOption {
-        type = nullOr str;
-        description = "Encrypted secret data. Imported from fleet.nix";
-        default = null;
-        internal = true;
-      };
     };
   };
 in {
   options = with types; {
+    version = mkOption {
+      type = str;
+      default = "";
+      internal = true;
+    };
     sharedSecrets = mkOption {
       type = attrsOf (submodule sharedSecret);
       default = {};
@@ -134,18 +109,20 @@
       config.sharedSecrets;
     hosts = hostsToAttrs (host: {
       nixosModules = let
-        cleanupSecret = secretName: v: {
-          inherit (v) public secret;
-          shared = true;
-        };
+        # processPart
+        processSecret = v:
+          (removeAttrs v ["createdAt" "expiresAt" "expectedOwners" "owners" "regenerateOnOwnerAdded" "regenerateOnOwnerRemoved"])
+          // {
+            shared = true;
+          };
       in [
         {
           secrets =
             (
-              mapAttrs cleanupSecret
+              mapAttrs (_: processSecret)
               (filterAttrs (_: v: builtins.elem host v.owners) config.sharedSecrets)
             )
-            // (mapAttrs cleanupSecret (config.hostSecrets.${host} or {}));
+            // (mapAttrs (_: processSecret) (config.hostSecrets.${host} or {}));
         }
       ];
     });
modifiednixos/secrets.nixdiffbeforeafterboth
--- a/nixos/secrets.nix
+++ b/nixos/secrets.nix
@@ -1,39 +1,72 @@
-{ lib, config, pkgs, ... }:
-
-with lib;
+{
+  lib,
+  config,
+  pkgs,
+  ...
+}:
+with lib; let
+  inherit (lib.strings) hasPrefix stripPrefix;
+  plaintextPrefix = "<PLAINTEXT>";
+  plaintextNewlinePrefix = "<PLAINTEXT-NL>";
 
-let
   sysConfig = config;
-  secretType = types.submodule ({ config, ... }: {
-    config = let secretName = config._module.args.name; in {
-      stableSecretPath = mkOptionDefault "/run/secrets/secret-stable-${secretName}";
-      secretPath = mkOptionDefault "/run/secrets/secret-${config.secretHash}-${secretName}";
-      secretHash = mkOptionDefault (if config.secret != null then (builtins.hashString "sha1" config.secret) else throw "secret is not defined for secret ${secretName}");
-
-      stablePublicPath = mkOptionDefault "/run/secrets/public-stable-${secretName}";
-      publicPath = mkOptionDefault "/run/secrets/public-${config.publicHash}-${secretName}";
-      publicHash = mkOptionDefault (if config.public != null then (builtins.hashString "sha1" config.public) else throw "public is not defined for secret ${secretName}");
-    };
+  secretPartType = secretName:
+    types.submodule ({config, ...}: {
+      options = with types; {
+        raw = mkOption {
+          description = "Secret in fleet-specific undocumented format, do not use. Import from fleet.nix";
+          internal = true;
+        };
+        hash = mkOption {
+          type = str;
+          description = "Hash of secret in encoded format";
+        };
+        path = mkOption {
+          type = str;
+          description = "Path to secret part, incorporating data hash (thus it will be updated on secret change)";
+        };
+        stablePath = mkOption {
+          type = str;
+          description = "Path to secret part, incorporating data hash (thus it will be updated on secret change)";
+        };
+        data = mkOption {
+          type = str;
+          description = "Secret public data (only available for plaintext)";
+        };
+      };
+      config = let
+        partName = config._module.args.name;
+      in {
+        hash = mkOptionDefault (builtins.hashString "sha1" config.raw);
+        data = mkOptionDefault (
+          if hasPrefix plaintextPrefix config.raw
+          then stripPrefix plaintextPrefix config.raw
+          else if hasPrefix plaintextNewlinePrefix config.raw
+          then stripPrefix plaintextNewlinePrefix config.raw
+          else throw "secret.part.data attribute only works for public plaintext secret parts, got ${config.raw}"
+        );
+        path = mkOptionDefault "/run/secrets/${secretName}/${config.hash}-${partName}";
+        stablePath = mkOptionDefault "/run/secrets/${secretName}/${partName}";
+      };
+    });
+  secretType = types.submodule ({config, ...}: let
+    secretName = config._module.args.name;
+  in {
+    freeformType = types.lazyAttrsOf (secretPartType secretName);
     options = with types; {
       shared = mkOption {
         description = "Is this secret owned by this machine, or propagated from shared secrets";
         default = false;
       };
-
-      generator = mkOption {
+      expectedOwners = mkOption {
         type = nullOr unspecified;
-        description = "Derivation to evaluate for secret generation";
         default = null;
+        internal = true;
       };
 
-      public = mkOption {
-        type = nullOr str;
-        description = "Secret public data";
-        default = null;
-      };
-      secret = mkOption {
-        type = nullOr str;
-        description = "Encrypted secret data";
+      generator = mkOption {
+        type = nullOr unspecified;
+        description = "Derivation to evaluate for secret generation";
         default = null;
       };
       mode = mkOption {
@@ -50,64 +83,43 @@
         type = str;
         description = "Group of the secret";
         default = sysConfig.users.users.${config.owner}.group;
-      };
-
-      secretHash = mkOption {
-        type = str;
-        description = "Hash of .secret field";
-      };
-      publicHash = mkOption {
-        type = str;
-        description = "Hash of .public field";
-      };
-
-      stableSecretPath = mkOption {
-        type = str;
-        description = ''
-          Use this, if target process supports re-reading of secret from disk,
-          and doesn't needs to be restarted when secret is updated in file
-        '';
-      };
-      secretPath = mkOption {
-        type = str;
-        description = "Path to decrypted secret, suffixed with contents hash";
-      };
-
-      stablePublicPath = mkOption {
-        type = str;
-        description = ''
-          Use this, if target process supports re-reading of secret from disk,
-          and doesn't needs to be restarted when secret is updated in file
-        '';
-      };
-      publicPath = mkOption {
-        type = str;
-        description = "Path to the public part of secret";
       };
     };
   });
+  processPart = part: {
+    inherit (part) raw path stablePath;
+  };
+  processSecret = secret:
+    {
+      inherit (secret) group mode owner;
+    }
+    // (mapAttrs (_: processPart) (removeAttrs secret [
+      "shared"
+      "generator"
+      "mode"
+      "group"
+      "owner"
+
+      # FIXME: Some of those removed attributes shouldn't be here, but there is some error in passing shared secrets from fleet to nixos.
+      "expectedOwners"
+    ]));
   secretsFile = pkgs.writeTextFile {
     name = "secrets.json";
-    text = builtins.toJSON (mapAttrs (_: value: rec {
-      inherit (value) group mode owner secret public;
-      publicPath = if public != null then value.publicPath else "/missingno";
-      stablePublicPath = if public != null then value.stablePublicPath else "/missingno";
-      secretPath = if secret != null then value.secretPath else "/missingno";
-      stableSecretPath = if secret != null then value.stableSecretPath else "/missingno";
-    }) config.secrets);
+    text =
+      builtins.toJSON (mapAttrs (_: processSecret)
+        config.secrets);
   };
-in
-{
+in {
   options = {
     secrets = mkOption {
       type = types.attrsOf secretType;
-      default = { };
+      default = {};
       description = "Host-local secrets";
     };
   };
   config = {
-    environment.systemPackages = with pkgs; [pkgs.fleet-install-secrets];
-    system.activationScripts.decryptSecrets = stringAfter [ "users" "groups" "specialfs" ] ''
+    environment.systemPackages = [pkgs.fleet-install-secrets];
+    system.activationScripts.decryptSecrets = stringAfter ["users" "groups" "specialfs"] ''
       1>&2 echo "setting up secrets"
       ${pkgs.fleet-install-secrets}/bin/fleet-install-secrets install ${secretsFile}
     '';
modifiedrustfmt.tomldiffbeforeafterboth
--- a/rustfmt.toml
+++ b/rustfmt.toml
@@ -1 +1,3 @@
 hard_tabs = true
+imports_granularity = "Crate"
+group_imports = "StdExternalCrate"