git.delta.rocks / fleet / refs/commits / 992c7b63e98f

difftreelog

feat postgres secret generator

xmvqpnskYaroslav Bolyukin2026-05-20parent: #7fa1963.patch.diff

10 files changed

modifiedCargo.lockdiffbeforeafterboth
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1045,7 +1045,10 @@
  "ed25519-dalek",
  "fleet-shared",
  "hex",
+ "hmac",
+ "pbkdf2",
  "rand 0.10.1",
+ "sha2",
  "x25519-dalek",
 ]
 
modifiedCargo.tomldiffbeforeafterboth
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -31,6 +31,7 @@
 futures = "0.3.31"
 futures-util = { version = "0.3.31", features = ["sink"] }
 hex = "0.4.3"
+hmac = "0.12"
 hostname = "0.4.1"
 human-repr = "1.1"
 hyper = "1.8.1"
@@ -40,11 +41,12 @@
 linked-hash-map = "0.5.6"
 nix = { version = "0.31.2", features = ["fs", "user"] }
 nom = "8.0.0"
+openssh = "0.11.5"
 opentelemetry = "0.31.0"
+opentelemetry-appender-tracing = "0.31.1"
 opentelemetry-otlp = { version = "0.31.0", features = ["grpc-tonic", "gzip-tonic", "http-json", "reqwest-rustls"] }
 opentelemetry_sdk = "0.31.0"
-opentelemetry-appender-tracing = "0.31.1"
-openssh = "0.11.5"
+pbkdf2 = "0.12"
 peg = "0.8.5"
 pkg-config = "0.3.30"
 rand = "0.10.0"
@@ -52,6 +54,7 @@
 serde = { version = "1.0", features = ["derive"] }
 serde-transcode = "1.1.1"
 serde_json = "1.0"
+sha2 = "0.10"
 shlex = "1.3"
 tabled = "0.20.0"
 tempfile = "3.20"
modifiedcmds/fleet/Cargo.tomldiffbeforeafterboth
--- a/cmds/fleet/Cargo.toml
+++ b/cmds/fleet/Cargo.toml
@@ -44,12 +44,12 @@
 indicatif = { workspace = true, optional = true }
 nom.workspace = true
 opentelemetry.workspace = true
+opentelemetry-appender-tracing.workspace = true
+opentelemetry-exporter-env.workspace = true
 opentelemetry_sdk.workspace = true
 thiserror.workspace = true
 tracing-indicatif = { workspace = true, optional = true }
 tracing-opentelemetry.workspace = true
-opentelemetry-exporter-env.workspace = true
-opentelemetry-appender-tracing.workspace = true
 
 [features]
 default = ["indicatif"]
modifiedcmds/generator-helper/Cargo.tomldiffbeforeafterboth
--- a/cmds/generator-helper/Cargo.toml
+++ b/cmds/generator-helper/Cargo.toml
@@ -13,5 +13,8 @@
 base64.workspace = true
 ed25519-dalek.workspace = true
 hex.workspace = true
+hmac.workspace = true
+pbkdf2.workspace = true
 rand.workspace = true
+sha2.workspace = true
 x25519-dalek.workspace = true
modifiedcmds/generator-helper/src/main.rsdiffbeforeafterboth
before · cmds/generator-helper/src/main.rs
1use std::{2	env,3	fs::{File, OpenOptions},4	io::{self, Read, Write, copy, stdin, stdout},5	str::FromStr,6};78use age::{9	Encryptor, Recipient,10	ssh::{ParseRecipientKeyError, Recipient as SshRecipient},11};12use anyhow::{Context, Result, anyhow, bail, ensure};13use clap::{Parser, ValueEnum};14use ed25519_dalek::SecretKey;15use fleet_shared::SecretData;16use rand::{17	Rng as _,18	distr::{Alphanumeric, Distribution, SampleString, Uniform},19	rng,20};2122fn write_output_file(out: &str) -> Result<File> {23	let file = OpenOptions::new()24		.create_new(true)25		.write(true)26		.open(out)27		.with_context(|| format!("failed to open output {out:?}"))?;28	Ok(file)29}30fn write_public(out: &str, mut input: impl Read, encoding: OutputEncoding) -> Result<()> {31	let mut output = write_output_file(out)?;3233	let mut data = Vec::new();34	copy(&mut input, &mut wrap_encoder(&mut data, encoding))?;3536	output.write_all(37		SecretData {38			data,39			encrypted: false,40		}41		.to_string()42		.as_bytes(),43	)?;44	Ok(())45}46fn write_private(47	identities: &Identities,48	out: &str,49	mut input: impl Read,50	encoding: OutputEncoding,51) -> Result<()> {52	let mut output = write_output_file(out)?;53	let encryptor = make_encryptor(identities)?;5455	let mut data = Vec::new();56	{57		let mut encrypted_writer = encryptor.wrap_output(&mut data)?;58		copy(59			&mut input,60			&mut wrap_encoder(&mut encrypted_writer, encoding),61		)?;62		encrypted_writer.finish()?;63	};6465	output.write_all(66		SecretData {67			data,68			encrypted: true,69		}70		.to_string()71		.as_bytes(),72	)?;73	Ok(())74}7576type Identities = Vec<SshRecipient>;77fn load_identities() -> Result<Identities> {78	let list = env::var("GENERATOR_HELPER_IDENTITIES");79	let list = match list {80		Ok(v) => v,81		Err(env::VarError::NotPresent) => {82			bail!(83				"gh is only intended to be used from secret generator scripts, but if you really want to use it somewhere else - set GENERATOR_HELPER_IDENTITIES to list of newline-delimited ssh identities"84			);85		}86		Err(e) => bail!("somehow, identities list is not utf-8: {e}"),87	};88	let list = list.trim();89	ensure!(!list.is_empty(), "no identities passed, can't encrypt data");90	list.lines()91		.map(age::ssh::Recipient::from_str)92		.collect::<Result<Identities, ParseRecipientKeyError>>()93		.map_err(|e| anyhow!("parse recipients: {e:?}"))94}95fn make_encryptor(r: &Identities) -> Result<Encryptor> {96	Ok(97		Encryptor::with_recipients(r.iter().map(|v| v as &dyn Recipient))98			.expect("list is not empty"),99	)100}101fn wrap_encoder<'t>(w: impl Write + 't, encoding: OutputEncoding) -> impl Write + 't {102	fn coerce<'t>(w: impl Write + 't) -> Box<dyn Write + 't> {103		Box::new(w)104	}105	match encoding {106		OutputEncoding::Raw => coerce(w),107		OutputEncoding::Base64 => {108			use base64::{engine::general_purpose::STANDARD, write::EncoderWriter};109110			let writer = EncoderWriter::new(w, &STANDARD);111			coerce(writer)112		}113		OutputEncoding::Hex => {114			struct HexWriter<W>(W);115			impl<W> Write for HexWriter<W>116			where117				W: Write,118			{119				fn write(&mut self, buf: &[u8]) -> io::Result<usize> {120					let encoded = hex::encode(buf);121					self.0.write_all(encoded.as_bytes())?;122					Ok(buf.len())123				}124125				fn flush(&mut self) -> io::Result<()> {126					self.0.flush()127				}128			}129			coerce(HexWriter(w))130		}131	}132}133134#[derive(Clone, Copy, ValueEnum, Default)]135enum OutputEncoding {136	/// Do not encode data, store as is.137	#[default]138	Raw,139	/// Encode as base64 (with padding).140	Base64,141	/// Encode as hex (without leading 0x)142	Hex,143}144145#[derive(Parser)]146enum Generate {147	/// Generate public, private keys without wrapping, in standard ed25519 schema148	/// (64 bytes private (due to merge with private), 32 bytes public)149	Ed25519 {150		#[arg(long, short = 'p')]151		public: String,152		#[arg(long, short = 's')]153		private: String,154		/// Private key should be just the private key (32 bytes), not standard private+public.155		#[arg(long)]156		no_embed_public: bool,157		#[arg(long, short = 'e', value_enum, default_value_t)]158		encoding: OutputEncoding,159	},160	/// Generate public, private keys without wrapping, in standard x25519 schema161	/// (32 bytes private, 32 bytes public)162	X25519 {163		#[arg(long, short = 'p')]164		public: String,165		#[arg(long, short = 's')]166		private: String,167		#[arg(long, short = 'e', value_enum, default_value_t)]168		encoding: OutputEncoding,169	},170	Password {171		#[arg(long, short = 'o')]172		output: String,173		#[arg(long)]174		size: usize,175		#[arg(long, short = 'n')]176		no_symbols: bool,177		#[arg(long, short = 'e', value_enum, default_value_t)]178		encoding: OutputEncoding,179	},180	Bytes {181		#[arg(long, short = 'o')]182		output: String,183		#[arg(long, short = 'c')]184		count: usize,185		/// Ensure there is no NULs in bytestring.186		#[arg(long)]187		no_nuls: bool,188		#[arg(long, short = 'e', value_enum, default_value_t)]189		encoding: OutputEncoding,190	},191}192193#[derive(Parser)]194enum Opts {195	/// Encode public part from stdin.196	Public {197		#[arg(long, short = 'o')]198		output: String,199		#[arg(long, short = 'e', value_enum, default_value_t)]200		encoding: OutputEncoding,201	},202	/// Encrypt private part from stdin.203	Private {204		#[arg(long, short = 'o')]205		output: String,206		#[arg(long, short = 'e', value_enum, default_value_t)]207		encoding: OutputEncoding,208	},209	/// Sometimes you also need to reencode secret, this command allows you to get raw data from210	/// secret encoded using `gh public`... I would like if I knew a better design for some sort of211	/// such thing. Ideally there should be no need to decode secrets back, but garage wants both212	/// raw pubkey and raw secret key, yet also requires node id which is hex-reencoded public key.213	Decode {214		#[arg(long, short = 'i')]215		input: String,216	},217	/// Generate keys in well-known schemas.218	///219	/// Note that this command is only intended to be used in fleet secret generator,220	/// otherwise you should ensure noone is able to read generated files, they don't have any mode set by default.221	///222	/// Fleet also doesn't zeroize memory/assumes good OsRng/makes other assumptions, which makes it only suitable to223	/// be used in nix sandbox.224	#[command(subcommand)]225	Generate(Generate),226}227228fn main() -> Result<()> {229	let opts = Opts::parse();230	// Assumed to be secure, seeded from secure OsRng+reseeded.231	let mut rng = rng();232233	match opts {234		Opts::Public { output, encoding } => {235			write_public(&output, stdin(), encoding)?;236		}237		Opts::Private { output, encoding } => {238			let recipients = load_identities()?;239			write_private(&recipients, &output, stdin(), encoding)?;240		}241		Opts::Generate(generate) => {242			match generate {243				Generate::Ed25519 {244					public,245					private,246					no_embed_public,247					encoding,248				} => {249					use ed25519_dalek::SigningKey;250251					let recipients = load_identities()?;252					let mut secret = SecretKey::default();253					rng.fill_bytes(&mut secret);254					// TODO: Use SigningKey::generate after https://github.com/dalek-cryptography/curve25519-dalek/pull/762255					let key = SigningKey::from_bytes(&secret).to_keypair_bytes();256					write_public(&public, &key[32..], encoding)?;257					write_private(258						&recipients,259						&private,260						&key[..{ if no_embed_public { 32 } else { 64 } }],261						encoding,262					)?;263				}264				Generate::X25519 {265					public,266					private,267					encoding,268				} => {269					use x25519_dalek::{PublicKey, StaticSecret};270271					let recipients = load_identities()?;272					// TODO: Use random_from_rng after https://github.com/dalek-cryptography/curve25519-dalek/pull/762273					let key = StaticSecret::random();274					let public_key: PublicKey = (&key).into();275					write_public(&public, public_key.as_bytes().as_slice(), encoding)?;276					write_private(&recipients, &private, key.as_bytes().as_slice(), encoding)?;277				}278				Generate::Password {279					size,280					no_symbols,281					output,282					encoding,283				} => {284					ensure!(285						size >= 6,286						"misconfiguration? password is shorter than 6 chars"287					);288					let recipients = load_identities()?;289					let out = if no_symbols {290						Alphanumeric.sample_string(&mut rng, size)291					} else {292						// Alphabet of Alphanumberic + symbols293						const GEN_ASCII_SYMBOLS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~";294						let uniform =295							Uniform::new(0, GEN_ASCII_SYMBOLS.len()).expect("range is valid");296						(0..size)297							.map(|_| uniform.sample(&mut rng))298							.map(|i| GEN_ASCII_SYMBOLS[i] as char)299							.collect::<String>()300					};301					write_private(&recipients, &output, out.as_bytes(), encoding)?;302				}303				Generate::Bytes {304					output,305					count,306					no_nuls,307					encoding,308				} => {309					ensure!(310						count >= 6,311						"misconfiguration? bytestring is shorter than 6 chars"312					);313					let recipients = load_identities()?;314					let mut bytes = vec![0u8; count];315					if no_nuls {316						let rand = Uniform::new_inclusive(0x1u8, 0xffu8)317							.expect("range is valid")318							.sample_iter(&mut rng);319						for (byte, rand) in bytes.iter_mut().zip(rand) {320							*byte = rand;321						}322					} else {323						rng.fill_bytes(&mut bytes);324					};325					write_private(&recipients, &output, bytes.as_slice(), encoding)?;326				}327			}328		}329		Opts::Decode { input } => {330			let mut data = Vec::new();331			File::open(input)?.read_to_end(&mut data)?;332			let data = String::from_utf8(data).context(333				"encoded data is always utf-8, you are trying to use decode the wrong way.",334			)?;335			let data =336				SecretData::from_str(&data).map_err(|e| anyhow!("failed to decode data: {e}"))?;337			ensure!(338				!data.encrypted,339				"you can not decrypt secret data, only decode public."340			);341			stdout().write_all(&data.data)?;342		}343	}344	Ok(())345}
modifiedcrates/nix-eval/src/lib.rsdiffbeforeafterboth
--- a/crates/nix-eval/src/lib.rs
+++ b/crates/nix-eval/src/lib.rs
@@ -26,9 +26,8 @@
 	clear_err, copy_value, err_NIX_ERR_KEY, err_NIX_ERR_NIX_ERROR, err_NIX_ERR_OVERFLOW,
 	err_NIX_ERR_UNKNOWN, err_code, err_info_msg, err_msg, eval_state_build,
 	eval_state_builder_load, eval_state_builder_new, eval_state_builder_set_eval_setting,
-	expr_eval_from_string, fetchers_settings,
-	fetchers_settings_free, fetchers_settings_new, flake_lock, flake_lock_flags,
-	flake_lock_flags_free, flake_lock_flags_new, flake_reference,
+	expr_eval_from_string, fetchers_settings, fetchers_settings_free, fetchers_settings_new,
+	flake_lock, flake_lock_flags, flake_lock_flags_free, flake_lock_flags_new, flake_reference,
 	flake_reference_and_fragment_from_string, flake_reference_parse_flags,
 	flake_reference_parse_flags_free, flake_reference_parse_flags_new,
 	flake_reference_parse_flags_set_base_directory, flake_settings, flake_settings_free,
@@ -323,7 +322,9 @@
 thread_local! {
 	static THREAD_STATE: RefCell<ThreadState> = RefCell::new(ThreadState::new().expect("thread state init shouldn't fail"));
 }
-pub(crate) fn with_default_context<T>(f: impl FnOnce(*mut c_context, *mut c_eval_state) -> T) -> Result<T> {
+pub(crate) fn with_default_context<T>(
+	f: impl FnOnce(*mut c_context, *mut c_eval_state) -> T,
+) -> Result<T> {
 	let global = &GLOBAL_STATE.state;
 	let (ctx, state) = THREAD_STATE.with_borrow_mut(|w| (w.ctx.0, global.0));
 	let mut ctx = NixContext(ctx);
@@ -439,7 +440,11 @@
 	}
 }
 
-pub(crate) unsafe extern "C" fn copy_nix_str(start: *const c_char, n: c_uint, user_data: *mut c_void) {
+pub(crate) unsafe extern "C" fn copy_nix_str(
+	start: *const c_char,
+	n: c_uint,
+	user_data: *mut c_void,
+) {
 	let s = unsafe { slice::from_raw_parts(start.cast::<u8>(), n as usize) };
 	let s = std::str::from_utf8(s).expect("c string has invalid utf-8");
 	unsafe { *user_data.cast::<String>() = s.to_owned() };
@@ -1128,8 +1133,8 @@
 	let test_result: String = nix_go_json!(builtins.uppercaseSuffix2("test")("suffix"));
 	assert_eq!(test_result, "TESTsuffix");
 
-	let drv_path = nix_go!(attrs.packages["x86_64-linux"]["fleet-install-secrets"].drvPath)
-		.to_string()?;
+	let drv_path =
+		nix_go!(attrs.packages["x86_64-linux"]["fleet-install-secrets"].drvPath).to_string()?;
 	let graph = drv::DrvGraph::resolve(&drv_path)?;
 	eprintln!(
 		"fleet-install-secrets dependency graph: {} nodes",
modifiedcrates/nix-eval/src/logging.rsdiffbeforeafterboth
--- a/crates/nix-eval/src/logging.rs
+++ b/crates/nix-eval/src/logging.rs
@@ -351,12 +351,15 @@
 			if let Some(entry) = drv_graph.get_mut(dep_path) {
 				entry.refcount += 1;
 			} else {
-				drv_graph.insert(dep_path.clone(), DrvGraphEntry {
-					name: dep_node.name.clone(),
-					parent: Some(path.clone()),
-					span: None,
-					refcount: 1,
-				});
+				drv_graph.insert(
+					dep_path.clone(),
+					DrvGraphEntry {
+						name: dep_node.name.clone(),
+						parent: Some(path.clone()),
+						span: None,
+						refcount: 1,
+					},
+				);
 			}
 			paths.push(dep_path.clone());
 			queue.push_back(dep_path.clone());
modifiedcrates/opentelemetry-exporter-env/src/lib.rsdiffbeforeafterboth
--- a/crates/opentelemetry-exporter-env/src/lib.rs
+++ b/crates/opentelemetry-exporter-env/src/lib.rs
@@ -11,10 +11,7 @@
 #[derive(thiserror::Error, Debug)]
 pub enum Error {
 	#[error("environment variable {env} contains invalid UTF-8: {value:?}")]
-	InvalidUtf8 {
-		env: &'static str,
-		value: OsString,
-	},
+	InvalidUtf8 { env: &'static str, value: OsString },
 	#[error("environment variable {env}={value:?}: {error}")]
 	EnvParse {
 		env: &'static str,
@@ -103,13 +100,30 @@
 #[cfg_attr(feature = "clap", derive(clap::Parser))]
 pub struct SignalExporterSettings {
 	/// Traces exporter to be used.
-	#[cfg_attr(feature = "clap", arg(long = "otel-traces-exporter", env = "OTEL_TRACES_EXPORTER", value_enum))]
+	#[cfg_attr(
+		feature = "clap",
+		arg(
+			long = "otel-traces-exporter",
+			env = "OTEL_TRACES_EXPORTER",
+			value_enum
+		)
+	)]
 	pub traces: Option<ExporterKind>,
 	/// Metrics exporter to be used.
-	#[cfg_attr(feature = "clap", arg(long = "otel-metrics-exporter", env = "OTEL_METRICS_EXPORTER", value_enum))]
+	#[cfg_attr(
+		feature = "clap",
+		arg(
+			long = "otel-metrics-exporter",
+			env = "OTEL_METRICS_EXPORTER",
+			value_enum
+		)
+	)]
 	pub metrics: Option<ExporterKind>,
 	/// Logs exporter to be used.
-	#[cfg_attr(feature = "clap", arg(long = "otel-logs-exporter", env = "OTEL_LOGS_EXPORTER", value_enum))]
+	#[cfg_attr(
+		feature = "clap",
+		arg(long = "otel-logs-exporter", env = "OTEL_LOGS_EXPORTER", value_enum)
+	)]
 	pub logs: Option<ExporterKind>,
 }
 
modifiedcrates/opentelemetry-exporter-env/src/otlp.rsdiffbeforeafterboth
--- a/crates/opentelemetry-exporter-env/src/otlp.rs
+++ b/crates/opentelemetry-exporter-env/src/otlp.rs
@@ -55,15 +55,13 @@
 				}
 				builder.build()
 			}
-			OtlpProtocol::HttpProtobuf | OtlpProtocol::HttpJson => {
-				<$exporter>::builder()
-					.with_http()
-					.with_endpoint(&s.endpoint)
-					.with_headers(to_hashmap(s.headers.as_deref()))
-					.with_protocol(s.protocol.into())
-					.with_timeout(s.timeout)
-					.build()
-			}
+			OtlpProtocol::HttpProtobuf | OtlpProtocol::HttpJson => <$exporter>::builder()
+				.with_http()
+				.with_endpoint(&s.endpoint)
+				.with_headers(to_hashmap(s.headers.as_deref()))
+				.with_protocol(s.protocol.into())
+				.with_timeout(s.timeout)
+				.build(),
 		}
 	}};
 }
modifiedlib/default.nixdiffbeforeafterboth
--- a/lib/default.nix
+++ b/lib/default.nix
@@ -89,6 +89,44 @@
       );
 
     /**
+      Generate a random password suitable for PostgreSQL role authentication.
+
+      Options:
+        size: generated password length in ascii characters (bytes).
+        iterations: PBKDF2 iteration count (PG default: 4096).
+        noSymbols: by default, character set includes various special characters ($ , ! + * : ~), and might
+                   not be accepted in some contexts, this option switches charset to just [A-Za-z0-9].
+
+      Output:
+        secret: encrypted password.
+        hash: SCRAM-SHA-256 hash of the password to be used by postgres.
+    */
+    # It is not possible to extract it into a scram-sha-256 generic generator because there is no estabilished format
+    # for it, and mongodb for example encodes it differently.
+    mkPostgresPassword =
+      {
+        size ? 32,
+        iterations ? 4096,
+        noSymbols ? false,
+      }:
+      (
+        { mkSecretGenerator }:
+        mkSecretGenerator {
+          script = ''
+            mkdir $out
+            gh generate postgres-password \
+              -s $out/secret \
+              -H $out/hash \
+              --size ${toString size} \
+              --iterations ${toString iterations} \
+              ${optionalString noSymbols "--no-symbols"}
+          '';
+          parts.secret.encrypted = true;
+          parts.hash.encrypted = false;
+        }
+      );
+
+    /**
       Generate a random ed25519 keypair
 
       Options:
@@ -205,8 +243,7 @@
       mkAskFile {
         inherit part;
         header = builtins.concatStringsSep "\n" (
-          (map (l: "# ${l}") (lib.splitString "\n" header))
-          ++ (map (v: "${v}=") variables)
+          (map (l: "# ${l}") (lib.splitString "\n" header)) ++ (map (v: "${v}=") variables)
         );
       };
 
@@ -306,6 +343,7 @@
 
   inherit (secrets)
     mkPassword
+    mkPostgresPassword
     mkEd25519
     mkX25519
     mkRsa