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
--- a/cmds/fleet/src/cmds/secrets/mod.rs
+++ b/cmds/fleet/src/cmds/secrets/mod.rs
@@ -1,27 +1,29 @@
-use crate::{
-	better_nix_eval::Field,
-	fleetdata::{FleetSecret, FleetSharedSecret, SecretData},
-	host::Config,
-	nix_go, nix_go_json,
+use std::{
+	collections::{BTreeMap, BTreeSet, HashSet},
+	ffi::OsString,
+	io::{self, stdin, stdout, Read, Write},
+	path::PathBuf,
 };
+
 use anyhow::{anyhow, bail, ensure, Context, Result};
 use chrono::{DateTime, Utc};
-use clap::{error::ErrorKind, Parser};
+use clap::Parser;
 use crossterm::{terminal, tty::IsTty};
+use fleet_shared::SecretData;
 use itertools::Itertools;
 use owo_colors::OwoColorize;
 use serde::Deserialize;
-use std::{
-	collections::{BTreeSet, HashSet},
-	ffi::OsString,
-	io::{self, stdin, Cursor, Read, Write},
-	path::PathBuf,
-};
 use tabled::{Table, Tabled};
-use tempfile::NamedTempFile;
-use tokio::{fs::read_to_string, process::Command};
+use tokio::{fs::read, process::Command};
 use tracing::{error, info, info_span, warn, Instrument};
 
+use crate::{
+	better_nix_eval::Field,
+	fleetdata::{encrypt_secret_data, FleetSecret, FleetSecretPart, FleetSharedSecret},
+	host::Config,
+	nix_go, nix_go_json,
+};
+
 #[derive(Parser)]
 pub enum Secret {
 	/// Force load host keys for all defined hosts
@@ -38,6 +40,9 @@
 		/// Secret public part
 		#[clap(long)]
 		public: Option<String>,
+		/// How to name public secret part
+		#[clap(long, default_value = "public")]
+		public_name: String,
 		/// Load public part from specified file
 		#[clap(long)]
 		public_file: Option<PathBuf>,
@@ -49,6 +54,9 @@
 		/// Secret with this name already exists, override its value while keeping the same owners.
 		#[clap(long)]
 		re_add: bool,
+
+		#[clap(default_value = "secret")]
+		part_name: String,
 	},
 	/// Add secret, data should be provided in stdin
 	Add {
@@ -59,22 +67,27 @@
 		/// Override secret if already present
 		#[clap(long)]
 		force: bool,
+		/// Secret public part
 		#[clap(long)]
 		public: Option<String>,
+		/// How to name public secret part
+		#[clap(long, default_value = "public")]
+		public_name: String,
+		/// Load public part from specified file
 		#[clap(long)]
 		public_file: Option<PathBuf>,
+
+		#[clap(default_value = "secret")]
+		part_name: String,
 	},
 	/// Read secret from remote host, requires sudo on said host
 	Read {
 		name: String,
 		machine: String,
-		#[clap(long)]
-		plaintext: bool,
+
+		#[clap(default_value = "secret")]
+		part_name: String,
 	},
-	ReadPublic {
-		name: String,
-		machine: String,
-	},
 	UpdateShared {
 		name: String,
 
@@ -89,6 +102,9 @@
 		/// Which host should we use to decrypt
 		#[clap(long)]
 		prefer_identities: Vec<String>,
+
+		#[clap(default_value = "secret")]
+		part_name: String,
 	},
 	Regenerate {
 		/// Which host should we use to decrypt, in case if reencryption is required, without
@@ -97,6 +113,16 @@
 		prefer_identities: Vec<String>,
 	},
 	List {},
+	Edit {
+		name: String,
+		machine: String,
+
+		#[clap(default_value = "secret")]
+		part: String,
+
+		#[clap(long)]
+		add: bool,
+	},
 }
 
 #[tracing::instrument(skip(config, secret, field, prefer_identities))]
@@ -144,10 +170,16 @@
 			bail!("no available holder found");
 		};
 
-		if let Some(data) = secret.secret.secret {
+		for (part_name, part) in secret.secret.parts.iter_mut() {
+			let _span = info_span!("part reencryption", part_name);
+			if !part.raw.encrypted {
+				continue;
+			}
 			let host = config.host(identity_holder).await?;
-			let encrypted = host.reencrypt(data, updated_set.to_vec()).await?;
-			secret.secret.secret = Some(encrypted);
+			let encrypted = host
+				.reencrypt(part.raw.clone(), updated_set.to_vec())
+				.await?;
+			part.raw = encrypted;
 		}
 
 		secret.owners = updated_set.to_vec();
@@ -232,13 +264,17 @@
 		ensure!(marker == "SUCCESS", "generation not succeeded");
 	}
 
-	let public = host.read_file_text(format!("{out}/public")).await.ok();
-	let secret = host.read_file_bin(format!("{out}/secret")).await.ok();
-	if let Some(secret) = &secret {
-		ensure!(
-			age::Decryptor::new(Cursor::new(&secret)).is_ok(),
-			"builder produced non-encrypted value as secret, this is highly insecure, and not allowed."
-		);
+	let mut parts = BTreeMap::new();
+	for part in host.read_dir(&out).await? {
+		if part == "created_at" || part == "expired_at" || part == "marker" {
+			continue;
+		}
+		let contents: SecretData = host
+			.read_file_text(format!("{out}/{part}"))
+			.await?
+			.parse()
+			.map_err(|e| anyhow!("failed to decode secret {out:?} part {part:?}: {e}"))?;
+		parts.insert(part.to_owned(), FleetSecretPart { raw: contents });
 	}
 
 	let created_at = host.read_file_value(format!("{out}/created_at")).await?;
@@ -247,8 +283,7 @@
 	Ok(FleetSecret {
 		created_at,
 		expires_at,
-		public,
-		secret: secret.map(SecretData),
+		parts,
 	})
 }
 async fn generate(
@@ -310,10 +345,16 @@
 async fn parse_public(
 	public: Option<String>,
 	public_file: Option<PathBuf>,
-) -> Result<Option<String>> {
+) -> Result<Option<SecretData>> {
 	Ok(match (public, public_file) {
-		(Some(v), None) => Some(v),
-		(None, Some(v)) => Some(read_to_string(v).await?),
+		(Some(v), None) => Some(SecretData {
+			data: v.into(),
+			encrypted: false,
+		}),
+		(None, Some(v)) => Some(SecretData {
+			data: read(v).await?,
+			encrypted: false,
+		}),
 		(Some(_), Some(_)) => {
 			bail!("only public or public_file should be set")
 		}
@@ -321,6 +362,16 @@
 	})
 }
 
+async fn parse_secret() -> Result<Option<Vec<u8>>> {
+	let mut input = vec![];
+	io::stdin().read_to_end(&mut input)?;
+	if input.is_empty() {
+		Ok(None)
+	} else {
+		Ok(Some(input))
+	}
+}
+
 fn parse_machines(
 	initial: Vec<String>,
 	machines: Option<Vec<String>>,
@@ -391,9 +442,11 @@
 				name,
 				force,
 				public,
+				public_name,
 				public_file,
 				expires_at,
 				re_add,
+				part_name,
 			} => {
 				// TODO: Forbid updating secrets with set expectedOwners (= not user-managed).
 
@@ -415,20 +468,21 @@
 
 				let recipients = config.recipients(machines.clone()).await?;
 
-				let secret = {
-					let mut input = vec![];
-					io::stdin().read_to_end(&mut input)?;
+				let mut parts = BTreeMap::new();
 
-					if input.is_empty() {
-						None
-					} else {
-						Some(
-							SecretData::encrypt(recipients, input)
-								.ok_or_else(|| anyhow!("no recipients provided"))?,
-						)
-					}
-				};
-				let public = parse_public(public, public_file).await?;
+				let mut input = vec![];
+				io::stdin().read_to_end(&mut input)?;
+
+				if !input.is_empty() {
+					let encrypted = encrypt_secret_data(recipients, input)
+						.ok_or_else(|| anyhow!("no recipients provided"))?;
+					parts.insert(part_name, FleetSecretPart { raw: encrypted });
+				}
+
+				if let Some(public) = parse_public(public, public_file).await? {
+					parts.insert(public_name, FleetSecretPart { raw: public });
+				}
+
 				config.replace_shared(
 					name,
 					FleetSharedSecret {
@@ -436,8 +490,7 @@
 						secret: FleetSecret {
 							created_at: Utc::now(),
 							expires_at,
-							secret,
-							public,
+							parts,
 						},
 					},
 				);
@@ -447,33 +500,34 @@
 				name,
 				force,
 				public,
+				public_name,
 				public_file,
+				part_name,
 			} => {
-				let recipient = config.recipient(&machine).await?;
+				if config.has_secret(&machine, &name) && !force {
+					bail!("secret already defined");
+				}
 
-				let secret = {
-					let mut input = vec![];
-					io::stdin().read_to_end(&mut input)?;
-					if input.is_empty() {
-						bail!("no data provided")
-					}
+				let mut parts = BTreeMap::new();
 
-					Some(SecretData::encrypt(vec![recipient], input).expect("recipient provided"))
-				};
-
-				if config.has_secret(&machine, &name) && !force {
-					bail!("secret already defined");
+				if let Some(secret) = parse_secret().await? {
+					let recipient = config.recipient(&machine).await?;
+					let encrypted =
+						encrypt_secret_data(vec![recipient], secret).expect("recipient provided");
+					parts.insert(part_name, FleetSecretPart { raw: encrypted });
 				}
-				let public = parse_public(public, public_file).await?;
 
+				if let Some(public) = parse_public(public, public_file).await? {
+					parts.insert(public_name, FleetSecretPart { raw: public });
+				};
+
 				config.insert_secret(
 					&machine,
 					name,
 					FleetSecret {
 						created_at: Utc::now(),
 						expires_at: None,
-						secret,
-						public,
+						parts,
 					},
 				);
 			}
@@ -481,27 +535,20 @@
 			Secret::Read {
 				name,
 				machine,
-				plaintext,
+				part_name,
 			} => {
 				let secret = config.host_secret(&machine, &name)?;
-				let Some(secret) = secret.secret else {
-					bail!("no secret {name}");
+				let Some(secret) = secret.parts.get(&part_name) else {
+					bail!("no part {part_name} in secret {name}");
 				};
-				let host = config.host(&machine).await?;
-				let data = host.decrypt(secret).await?;
-				if plaintext {
-					let s = String::from_utf8(data).context("output is not utf8")?;
-					print!("{s}");
+				let data = if secret.raw.encrypted {
+					let host = config.host(&machine).await?;
+					host.decrypt(secret.raw.clone()).await?
 				} else {
-					println!("{}", z85::encode(&data));
-				}
-			}
-			Secret::ReadPublic { name, machine } => {
-				let secret = config.host_secret(&machine, &name)?;
-				let Some(public) = secret.public else {
-					bail!("no secret {name}");
+					secret.raw.data.clone()
 				};
-				print!("{public}");
+
+				stdout().write_all(&data)?;
 			}
 			Secret::UpdateShared {
 				name,
@@ -509,11 +556,12 @@
 				add_machines,
 				remove_machines,
 				prefer_identities,
+				part_name,
 			} => {
 				// TODO: Forbid updating secrets with set expectedOwners (= not user-managed).
 
 				let secret = config.shared_secret(&name)?;
-				if secret.secret.secret.is_none() {
+				if secret.secret.parts.get(&part_name).is_none() {
 					bail!("no secret");
 				}
 
@@ -572,6 +620,10 @@
 					}
 				}
 				for host in config.list_hosts().await? {
+					if config.should_skip(&host.name) {
+						continue;
+					}
+
 					let _span = info_span!("host", host = host.name).entered();
 					let expected_set = host
 						.list_configured_secrets()
@@ -664,7 +716,103 @@
 				}
 				info!("loaded\n{}", Table::new(table).to_string())
 			}
+			Secret::Edit {
+				name,
+				machine,
+				part,
+				add,
+			} => {
+				let secret = config.host_secret(&machine, &name)?;
+				if let Some(data) = secret.parts.get(&part) {
+					let host = config.host(&machine).await?;
+					let secret = host.decrypt(data.raw.clone()).await?;
+					String::from_utf8(secret).context("secret is not utf8")?
+				} else if add {
+					String::new()
+				} else {
+					bail!("part {part} not found in secret {name}. Did you mean to `--add` it?");
+				};
+			}
 		}
 		Ok(())
 	}
 }
+
+async fn edit_temp_file(
+	builder: tempfile::Builder<'_, '_>,
+	r: Vec<u8>,
+	header: &str,
+	comment: &str,
+) -> Result<(Vec<u8>, Option<String>), anyhow::Error> {
+	if !stdin().is_tty() {
+		// TODO: Also try to open /dev/tty directly?
+		bail!("stdin is not tty, can't open editor");
+	}
+
+	use std::fmt::Write;
+	let mut file = builder.tempfile()?;
+
+	let mut full_header = String::new();
+	let mut had = false;
+	for line in header.trim_end().lines() {
+		had = true;
+		writeln!(&mut full_header, "{comment}{line}")?;
+	}
+	if had {
+		writeln!(&mut full_header, "{}", comment.trim_end())?;
+	}
+	writeln!(
+		&mut full_header,
+		"{comment}Do not touch this header! It will be removed automatically"
+	)?;
+
+	file.write_all(full_header.as_bytes())?;
+	file.write_all(&r)?;
+
+	let abs_path = file.into_temp_path();
+	let editor = std::env::var_os("VISUAL")
+		.or_else(|| std::env::var_os("EDITOR"))
+		.unwrap_or_else(|| "vi".into());
+	let editor_args = shlex::bytes::split(editor.as_encoded_bytes())
+		.ok_or_else(|| anyhow!("EDITOR env var has wrong syntax"))?;
+	let editor_args = editor_args
+		.into_iter()
+		.map(|v| {
+			// Only ASCII subsequences are replaced
+			unsafe { OsString::from_encoded_bytes_unchecked(v) }
+		})
+		.collect_vec();
+	let Some((editor, args)) = editor_args.split_first() else {
+		bail!("EDITOR env var has no command");
+	};
+	let mut command = Command::new(editor);
+	command.args(args);
+
+	let path_arg = abs_path.canonicalize()?;
+
+	// TODO: Save full state, using tcget/_getmode/_setmode
+	let was_raw = terminal::is_raw_mode_enabled()?;
+	terminal::enable_raw_mode()?;
+
+	let status = command.arg(path_arg).status().await;
+
+	if !was_raw {
+		terminal::disable_raw_mode()?;
+	}
+
+	let success = match status {
+		Ok(s) => s.success(),
+		Err(e) if e.kind() == io::ErrorKind::NotFound => {
+			bail!("editor not found")
+		}
+		Err(e) => bail!("editor spawn error: {e}"),
+	};
+
+	let mut file = std::fs::read(&abs_path).context("read editor output")?;
+	let Some(v) = file.strip_prefix(full_header.as_bytes()) else {
+		todo!();
+	};
+	todo!();
+
+	// Ok((success, abs_path))
+}
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
after · cmds/install-secrets/src/main.rs
1use std::{2	collections::{BTreeMap, HashMap},3	fs::{self, File},4	io::{self, Cursor, Read, Write},5	iter,6	os::unix::prelude::PermissionsExt,7	path::{Path, PathBuf},8	str::{from_utf8, FromStr},9};1011use age::{12	ssh::{Identity as SshIdentity, Recipient as SshRecipient},13	Decryptor, Encryptor, Identity, Recipient,14};15use anyhow::{anyhow, bail, ensure, Context, Result};16use clap::Parser;17use fleet_shared::SecretData;18use nix::unistd::{chown, Group, User};19use serde::Deserialize;20use tracing::{error, info_span};21use tracing_subscriber::{filter::LevelFilter, EnvFilter};2223#[derive(Parser)]24#[clap(author)]25enum Opts {26	/// Install secrets from json specification27	Install { data: PathBuf },28	/// Reencrypt secret using host key, outputting in fleet encoded string29	Reencrypt {30		#[clap(long)]31		secret: SecretData,32		#[clap(long)]33		targets: Vec<String>,34	},35	/// Decrypt secret using host key, outputting in fleet encoded string36	Decrypt {37		#[clap(long)]38		secret: SecretData,39		/// Shoult decoded output be printed as plaintext, instead of z85?40		#[clap(long)]41		plaintext: bool,42	},43}4445#[derive(Deserialize)]46#[serde(rename_all = "camelCase")]47struct Part {48	raw: SecretData,49	path: PathBuf,50	stable_path: PathBuf,51}5253#[derive(Deserialize)]54#[serde(rename_all = "camelCase")]55struct DataItem {56	group: String,57	mode: String,58	owner: String,59	root_path: Option<PathBuf>,6061	#[serde(flatten)]62	parts: BTreeMap<String, Part>,63}6465type Data = HashMap<String, DataItem>;6667fn decrypt(input: &SecretData, identity: &dyn Identity) -> Result<Vec<u8>> {68	ensure!(input.encrypted, "passed data is not encrypted!");69	let mut input = Cursor::new(&input.data);70	let decryptor = Decryptor::new(&mut input).context("failed to init decryptor")?;71	let decryptor = match decryptor {72		Decryptor::Recipients(r) => r,73		Decryptor::Passphrase(_) => bail!("should be recipients"),74	};75	let mut decryptor = decryptor76		.decrypt(iter::once(identity as &dyn age::Identity))77		.context("failed to decrypt, wrong key?")?;7879	let mut decrypted = Vec::new();80	decryptor81		.read_to_end(&mut decrypted)82		.context("failed to decrypt")?;83	Ok(decrypted)84}85fn encrypt(input: &[u8], targets: Vec<String>) -> Result<SecretData> {86	let recipients = targets87		.into_iter()88		.map(|t| {89			SshRecipient::from_str(&t).map_err(|e| anyhow!("failed to parse recipient: {e:?}"))90		})91		.collect::<Result<Vec<SshRecipient>>>()?;92	let recipients = recipients93		.into_iter()94		.map(|v| Box::new(v) as Box<dyn Recipient + Send>)95		.collect::<Vec<_>>();96	let mut encrypted = vec![];97	let mut encryptor = Encryptor::with_recipients(recipients)98		.expect("recipients provided")99		.wrap_output(&mut encrypted)100		.expect("constructor should not fail");101	io::copy(&mut Cursor::new(input), &mut encryptor).expect("copy should not fail");102	encryptor.finish().context("failed to finish encryption")?;103	Ok(SecretData {104		data: encrypted,105		encrypted: true,106	})107}108109fn init_part(identity: &dyn Identity, item: &DataItem, value: &Part) -> Result<()> {110	let stable_dir = value.stable_path.parent().expect("not root");111112	// Right now stable & non-stable data are both located in this dir.113	std::fs::create_dir_all(stable_dir)?;114115	let mut stable_temp =116		tempfile::NamedTempFile::new_in(stable_dir).context("failed to create tempfile")?;117	let mut hashed = File::create(&value.path)?;118119	let private = value.raw.encrypted;120	let data = if private {121		decrypt(&value.raw, identity)?122	} else {123		value.raw.data.to_owned()124	};125126	hashed.write_all(&data)?;127	hashed.flush()?;128	stable_temp.write_all(&data)?;129	stable_temp.flush()?;130131	let mode = if private {132		fs::Permissions::from_mode(133			u32::from_str_radix(&item.mode, 8).context("failed to parse mode as octal")?,134		)135	} else {136		fs::Permissions::from_mode(0o444)137	};138	fs::set_permissions(stable_temp.path(), mode.clone()).context("stable temp mode")?;139	fs::set_permissions(&value.path, mode).context("hashed mode")?;140141	// Files are initially owned by root, thus making set mode first inaccessible to user, and then142	// altering user/group.143	if private {144		let user = User::from_name(&item.owner)145			.context("failed to get user")?146			.ok_or_else(|| anyhow!("user not found"))?;147		let group = Group::from_name(&item.group)148			.context("failed to get group")?149			.ok_or_else(|| anyhow!("group not found"))?;150151		chown(stable_temp.path(), Some(user.uid), Some(group.gid))152			.context("failed to apply user/group")?;153		chown(&value.path, Some(user.uid), Some(group.gid))154			.context("failed to apply user/group")?;155	}156157	stable_temp158		.persist(&value.stable_path)159		.context("stable persist")?;160	Ok(())161}162163fn init_secret(identity: &age::ssh::Identity, value: &DataItem) -> Result<()> {164	if let Some(root_path) = &value.root_path {165		if !fs::metadata(root_path).map(|m| m.is_dir()).unwrap_or(false) {166			fs::create_dir(root_path).context("failed to create secret directory")?;167		}168	}169	for (part_id, part) in value.parts.iter() {170		let _span = info_span!("part", part_id = part_id);171		if let Err(e) = init_part(identity, value, part) {172			error!("failed to init part {part_id}: {e}");173		}174	}175176	Ok(())177}178179fn host_identity() -> anyhow::Result<SshIdentity> {180	let identity = SshIdentity::from_buffer(181		&mut Cursor::new(182			fs::read("/etc/ssh/ssh_host_ed25519_key").context("failed to read host private key")?,183		),184		None,185	)186	.context("failed to parse identity")?;187	Ok(identity)188}189190fn install(data: &Path) -> anyhow::Result<()> {191	let data = fs::read(data).context("failed to read secrets data")?;192	let data_str = from_utf8(&data).context("failed to read data to string")?;193	let data: Data = serde_json::from_str(data_str).context("failed to parse data")?;194195	if !fs::metadata("/run/secrets")196		.map(|m| m.is_dir())197		.unwrap_or(false)198	{199		fs::create_dir("/run/secrets").context("failed to create secrets directory")?;200	}201202	let identity = host_identity()?;203204	let mut failed = false;205	for (name, value) in data {206		let _span = info_span!("init", name = name);207		if let Err(e) = init_secret(&identity, &value) {208			error!("secret failed to initialize: {e}");209			failed = true;210		}211	}212	if failed {213		bail!("one or more secrets failed");214	}215216	Ok(())217}218219fn main() -> anyhow::Result<()> {220	tracing_subscriber::fmt()221		.with_env_filter(222			EnvFilter::builder()223				.with_default_directive(LevelFilter::INFO.into())224				.from_env_lossy(),225		)226		.without_time()227		.with_target(false)228		.init();229230	let opts = Opts::parse();231232	match opts {233		Opts::Install { data } => install(&data),234		Opts::Reencrypt { secret, targets } => {235			let identity = host_identity()?;236			let decrypted = decrypt(&secret, &identity).context("during decryption")?;237			let encrypted = encrypt(&decrypted, targets).context("during re-encryption")?;238239			println!("{encrypted}");240			Ok(())241		}242		Opts::Decrypt { secret, plaintext } => {243			let identity = host_identity()?;244			let decrypted = decrypt(&secret, &identity).context("during decryption")?;245246			if plaintext {247				let s = String::from_utf8(decrypted).context("output is not utf8")?;248				print!("{s}");249			} else {250				println!(251					"{}",252					SecretData {253						data: decrypted,254						encrypted: false255					}256				);257			}258			Ok(())259		}260	}261}
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"