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

difftreelog

feat postgres secret generator

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

10 files changed

modifiedCargo.lockdiffbeforeafterboth
before · Cargo.lock
471 packageslockfile v4
after · Cargo.lock
471 packageslockfile v4
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
--- a/cmds/generator-helper/src/main.rs
+++ b/cmds/generator-helper/src/main.rs
@@ -10,14 +10,30 @@
 	ssh::{ParseRecipientKeyError, Recipient as SshRecipient},
 };
 use anyhow::{Context, Result, anyhow, bail, ensure};
+use base64::{Engine as _, engine::general_purpose::STANDARD, write::EncoderWriter};
 use clap::{Parser, ValueEnum};
 use ed25519_dalek::SecretKey;
 use fleet_shared::SecretData;
+use hmac::Mac as _;
 use rand::{
 	Rng as _,
 	distr::{Alphanumeric, Distribution, SampleString, Uniform},
 	rng,
 };
+use sha2::Digest as _;
+
+fn gen_password(rng: &mut impl rand::Rng, size: usize, no_symbols: bool) -> String {
+	if no_symbols {
+		Alphanumeric.sample_string(rng, size)
+	} else {
+		const GEN_ASCII_SYMBOLS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~";
+		let uniform = Uniform::new(0, GEN_ASCII_SYMBOLS.len()).expect("range is valid");
+		(0..size)
+			.map(|_| uniform.sample(rng))
+			.map(|i| GEN_ASCII_SYMBOLS[i] as char)
+			.collect::<String>()
+	}
+}
 
 fn write_output_file(out: &str) -> Result<File> {
 	let file = OpenOptions::new()
@@ -105,8 +121,6 @@
 	match encoding {
 		OutputEncoding::Raw => coerce(w),
 		OutputEncoding::Base64 => {
-			use base64::{engine::general_purpose::STANDARD, write::EncoderWriter};
-
 			let writer = EncoderWriter::new(w, &STANDARD);
 			coerce(writer)
 		}
@@ -177,6 +191,18 @@
 		#[arg(long, short = 'e', value_enum, default_value_t)]
 		encoding: OutputEncoding,
 	},
+	PostgresPassword {
+		#[arg(long, short = 's')]
+		secret: String,
+		#[arg(long, short = 'H')]
+		hash: String,
+		#[arg(long, default_value_t = 24)]
+		size: usize,
+		#[arg(long, default_value_t = 4096)]
+		iterations: u32,
+		#[arg(long, short = 'n')]
+		no_symbols: bool,
+	},
 	Bytes {
 		#[arg(long, short = 'o')]
 		output: String,
@@ -286,20 +312,62 @@
 						"misconfiguration? password is shorter than 6 chars"
 					);
 					let recipients = load_identities()?;
-					let out = if no_symbols {
-						Alphanumeric.sample_string(&mut rng, size)
-					} else {
-						// Alphabet of Alphanumberic + symbols
-						const GEN_ASCII_SYMBOLS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~";
-						let uniform =
-							Uniform::new(0, GEN_ASCII_SYMBOLS.len()).expect("range is valid");
-						(0..size)
-							.map(|_| uniform.sample(&mut rng))
-							.map(|i| GEN_ASCII_SYMBOLS[i] as char)
-							.collect::<String>()
-					};
+					let out = gen_password(&mut rng, size, no_symbols);
 					write_private(&recipients, &output, out.as_bytes(), encoding)?;
 				}
+				Generate::PostgresPassword {
+					secret,
+					hash,
+					size,
+					iterations,
+					no_symbols,
+				} => {
+					ensure!(
+						size >= 6,
+						"misconfiguration? password is shorter than 6 chars"
+					);
+					let recipients = load_identities()?;
+					let password = gen_password(&mut rng, size, no_symbols);
+
+					let mut salt = [0u8; 16];
+					rng.fill_bytes(&mut salt);
+					let salted = pbkdf2::pbkdf2_hmac_array::<sha2::Sha256, 32>(
+						password.as_bytes(),
+						&salt,
+						iterations,
+					);
+
+					type HmacSha256 = hmac::Hmac<sha2::Sha256>;
+					let mut mac = <HmacSha256 as hmac::Mac>::new_from_slice(&salted)
+						.expect("HMAC accepts any key length");
+					mac.update(b"Client Key");
+					let client_key = mac.finalize().into_bytes();
+
+					let mut hasher = sha2::Sha256::new();
+					hasher.update(client_key);
+					let stored_key = hasher.finalize();
+
+					let mut mac = <HmacSha256 as hmac::Mac>::new_from_slice(&salted)
+						.expect("HMAC accepts any key length");
+					mac.update(b"Server Key");
+					let server_key = mac.finalize().into_bytes();
+
+					let hash_str = format!(
+						"SCRAM-SHA-256${}:{}${}:{}",
+						iterations,
+						STANDARD.encode(salt),
+						STANDARD.encode(stored_key),
+						STANDARD.encode(server_key),
+					);
+
+					write_private(
+						&recipients,
+						&secret,
+						password.as_bytes(),
+						OutputEncoding::Raw,
+					)?;
+					write_public(&hash, hash_str.as_bytes(), OutputEncoding::Raw)?;
+				}
 				Generate::Bytes {
 					output,
 					count,
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