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
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"