difftreelog
refactor rework fleet.nix secret bookkeeping
in: trunk
17 files changed
Cargo.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"
Cargo.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" }
cmds/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",
+]
cmds/fleet/src/cmds/secrets/mod.rsdiffbeforeafterboth1use crate::{2 better_nix_eval::Field,3 fleetdata::{FleetSecret, FleetSharedSecret, SecretData},4 host::Config,5 nix_go, nix_go_json,6};7use anyhow::{anyhow, bail, ensure, Context, Result};8use chrono::{DateTime, Utc};9use clap::{error::ErrorKind, Parser};10use crossterm::{terminal, tty::IsTty};11use itertools::Itertools;12use owo_colors::OwoColorize;13use serde::Deserialize;14use std::{15 collections::{BTreeSet, HashSet},16 ffi::OsString,17 io::{self, stdin, Cursor, Read, Write},18 path::PathBuf,19};20use tabled::{Table, Tabled};21use tempfile::NamedTempFile;22use tokio::{fs::read_to_string, process::Command};23use tracing::{error, info, info_span, warn, Instrument};2425#[derive(Parser)]26pub enum Secret {27 /// Force load host keys for all defined hosts28 ForceKeys,29 /// Add secret, data should be provided in stdin30 AddShared {31 /// Secret name32 name: String,33 /// Secret owners34 machines: Vec<String>,35 /// Override secret if already present36 #[clap(long)]37 force: bool,38 /// Secret public part39 #[clap(long)]40 public: Option<String>,41 /// Load public part from specified file42 #[clap(long)]43 public_file: Option<PathBuf>,4445 /// Create a notification on secret expiration46 #[clap(long)]47 expires_at: Option<DateTime<Utc>>,4849 /// Secret with this name already exists, override its value while keeping the same owners.50 #[clap(long)]51 re_add: bool,52 },53 /// Add secret, data should be provided in stdin54 Add {55 /// Secret name56 name: String,57 /// Secret owners58 machine: String,59 /// Override secret if already present60 #[clap(long)]61 force: bool,62 #[clap(long)]63 public: Option<String>,64 #[clap(long)]65 public_file: Option<PathBuf>,66 },67 /// Read secret from remote host, requires sudo on said host68 Read {69 name: String,70 machine: String,71 #[clap(long)]72 plaintext: bool,73 },74 ReadPublic {75 name: String,76 machine: String,77 },78 UpdateShared {79 name: String,8081 #[clap(long)]82 machines: Option<Vec<String>>,8384 #[clap(long)]85 add_machines: Vec<String>,86 #[clap(long)]87 remove_machines: Vec<String>,8889 /// Which host should we use to decrypt90 #[clap(long)]91 prefer_identities: Vec<String>,92 },93 Regenerate {94 /// Which host should we use to decrypt, in case if reencryption is required, without95 /// regeneration96 #[clap(long)]97 prefer_identities: Vec<String>,98 },99 List {},100}101102#[tracing::instrument(skip(config, secret, field, prefer_identities))]103async fn update_owner_set(104 secret_name: &str,105 config: &Config,106 mut secret: FleetSharedSecret,107 field: Field,108 updated_set: &[String],109 prefer_identities: &[String],110) -> Result<FleetSharedSecret> {111 let original_set = secret.owners.clone();112113 let set = original_set.iter().collect::<BTreeSet<_>>();114 let expected_set = updated_set.iter().collect::<BTreeSet<_>>();115116 if set == expected_set {117 info!("no need to update owner list, it is already correct");118 return Ok(secret);119 }120121 let should_regenerate = if set.difference(&expected_set).next().is_some() {122 // TODO: Remove this warning for revokable secrets.123 warn!("host was removed from secret owners, but until this host rebuild, the secret will still be stored on it.");124 nix_go_json!(field.regenerateOnOwnerRemoved)125 } else if expected_set.difference(&set).next().is_some() {126 nix_go_json!(field.regenerateOnOwnerAdded)127 } else {128 false129 };130131 if should_regenerate {132 info!("secret is owner-dependent, will regenerate");133 let generated = generate_shared(config, secret_name, field, updated_set.to_vec()).await?;134 Ok(generated)135 } else {136 let identity_holder = if !prefer_identities.is_empty() {137 prefer_identities138 .iter()139 .find(|i| original_set.iter().any(|s| s == *i))140 } else {141 secret.owners.first()142 };143 let Some(identity_holder) = identity_holder else {144 bail!("no available holder found");145 };146147 if let Some(data) = secret.secret.secret {148 let host = config.host(identity_holder).await?;149 let encrypted = host.reencrypt(data, updated_set.to_vec()).await?;150 secret.secret.secret = Some(encrypted);151 }152153 secret.owners = updated_set.to_vec();154 Ok(secret)155 }156}157158#[derive(Deserialize)]159#[serde(rename_all = "camelCase")]160enum GeneratorKind {161 Impure,162 Pure,163}164165async fn generate_pure(166 _config: &Config,167 _display_name: &str,168 _secret: Field,169 _default_generator: Field,170 _owners: &[String],171) -> Result<FleetSecret> {172 bail!("pure generators are broken for now")173}174async fn generate_impure(175 config: &Config,176 _display_name: &str,177 secret: Field,178 default_generator: Field,179 owners: &[String],180) -> Result<FleetSecret> {181 let generator = nix_go!(secret.generator);182 let on: Option<String> = nix_go_json!(default_generator.impureOn);183184 let host = if let Some(on) = &on {185 config.host(on).await?186 } else {187 config.local_host()188 };189 let on_pkgs = host.pkgs().await?;190 let call_package = nix_go!(on_pkgs.callPackage);191 let mk_encrypt_secret = nix_go!(on_pkgs.mkEncryptSecret);192193 let mut recipients = Vec::new();194 for owner in owners {195 let key = config.key(owner).await?;196 recipients.push(key);197 }198 let encrypt = nix_go!(mk_encrypt_secret(Obj {199 recipients: { recipients },200 }));201202 let generator = nix_go!(call_package(generator)(Obj {203 encrypt,204 // rustfmt_please_newline205 }));206207 let generator = generator.build().await?;208 let generator = generator209 .get("out")210 .ok_or_else(|| anyhow!("missing generateImpure out"))?;211 let generator = host.remote_derivation(generator).await?;212213 let out_parent = host.mktemp_dir().await?;214 let out = format!("{out_parent}/out");215216 let mut gen = host.cmd(generator).await?;217 gen.env("out", &out);218 if on.is_none() {219 // This path is local, thus we can feed `OsString` directly to env var... But I don't think that's necessary to handle.220 let project_path: String = config221 .directory222 .clone()223 .into_os_string()224 .into_string()225 .map_err(|s| anyhow!("fleet project path is not utf-8: {s:?}"))?;226 gen.env("FLEET_PROJECT", project_path);227 }228 gen.run().await.context("impure generator")?;229230 {231 let marker = host.read_file_text(format!("{out}/marker")).await?;232 ensure!(marker == "SUCCESS", "generation not succeeded");233 }234235 let public = host.read_file_text(format!("{out}/public")).await.ok();236 let secret = host.read_file_bin(format!("{out}/secret")).await.ok();237 if let Some(secret) = &secret {238 ensure!(239 age::Decryptor::new(Cursor::new(&secret)).is_ok(),240 "builder produced non-encrypted value as secret, this is highly insecure, and not allowed."241 );242 }243244 let created_at = host.read_file_value(format!("{out}/created_at")).await?;245 let expires_at = host.read_file_value(format!("{out}/expires_at")).await.ok();246247 Ok(FleetSecret {248 created_at,249 expires_at,250 public,251 secret: secret.map(SecretData),252 })253}254async fn generate(255 config: &Config,256 display_name: &str,257 secret: Field,258 owners: &[String],259) -> Result<FleetSecret> {260 let generator = nix_go!(secret.generator);261 // Can't properly check on nix module system level262 {263 let gen_ty = generator.type_of().await?;264 if gen_ty == "null" {265 bail!("secret has no generator defined, can't automatically generate it.");266 }267 if gen_ty != "lambda" {268 bail!("generator should be lambda, got {gen_ty}");269 }270 }271 let default_pkgs = &config.default_pkgs;272 let default_call_package = nix_go!(default_pkgs.callPackage);273 // Generators provide additional information in passthru, to access274 // passthru we should call generator, but information about where this generator is supposed to build275 // is located in passthru... Thus evaluating generator on host.276 //277 // Maybe it is also possible to do some magic with __functor?278 //279 // I don't want to make modules always responsible for additional secret data anyway,280 // so it should be in derivation, and not in the secret data itself.281 let default_generator = nix_go!(default_call_package(generator)(Obj {282 encrypt: { "exit 1" },283 // rustfmt_please_newline284 }));285286 let kind: GeneratorKind = nix_go_json!(default_generator.generatorKind);287288 match kind {289 GeneratorKind::Impure => {290 generate_impure(config, display_name, secret, default_generator, owners).await291 }292 GeneratorKind::Pure => {293 generate_pure(config, display_name, secret, default_generator, owners).await294 }295 }296}297async fn generate_shared(298 config: &Config,299 display_name: &str,300 secret: Field,301 expected_owners: Vec<String>,302) -> Result<FleetSharedSecret> {303 // let owners: Vec<String> = nix_go_json!(secret.expectedOwners);304 Ok(FleetSharedSecret {305 secret: generate(config, display_name, secret, &expected_owners).await?,306 owners: expected_owners,307 })308}309310async fn parse_public(311 public: Option<String>,312 public_file: Option<PathBuf>,313) -> Result<Option<String>> {314 Ok(match (public, public_file) {315 (Some(v), None) => Some(v),316 (None, Some(v)) => Some(read_to_string(v).await?),317 (Some(_), Some(_)) => {318 bail!("only public or public_file should be set")319 }320 (None, None) => None,321 })322}323324fn parse_machines(325 initial: Vec<String>,326 machines: Option<Vec<String>>,327 mut add_machines: Vec<String>,328 mut remove_machines: Vec<String>,329) -> Result<Vec<String>> {330 if machines.is_none() && add_machines.is_empty() && remove_machines.is_empty() {331 bail!("no operation");332 }333334 let initial_machines = initial.clone();335 let mut target_machines = initial;336 info!("Currently encrypted for {initial_machines:?}");337338 // ensure!(machines.is_some() || !add_machines.is_empty() || )339 if let Some(machines) = machines {340 ensure!(341 add_machines.is_empty() && remove_machines.is_empty(),342 "can't combine --machines and --add-machines/--remove-machines"343 );344 let target = initial_machines.iter().collect::<HashSet<_>>();345 let source = machines.iter().collect::<HashSet<_>>();346 for removed in target.difference(&source) {347 remove_machines.push((*removed).clone());348 }349 for added in source.difference(&target) {350 add_machines.push((*added).clone());351 }352 }353354 for machine in &remove_machines {355 let mut removed = false;356 while let Some(pos) = target_machines.iter().position(|m| m == machine) {357 target_machines.swap_remove(pos);358 removed = true;359 }360 if !removed {361 warn!("secret is not enabled for {machine}");362 }363 }364 for machine in &add_machines {365 if target_machines.iter().any(|m| m == machine) {366 warn!("secret is already added to {machine}");367 } else {368 target_machines.push(machine.to_owned());369 }370 }371 if !remove_machines.is_empty() {372 // TODO: maybe force secret regeneration?373 // Not that useful without revokation.374 warn!("secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret");375 }376 Ok(target_machines)377}378impl Secret {379 pub async fn run(self, config: &Config) -> Result<()> {380 match self {381 Secret::ForceKeys => {382 for host in config.list_hosts().await? {383 if config.should_skip(&host.name) {384 continue;385 }386 config.key(&host.name).await?;387 }388 }389 Secret::AddShared {390 mut machines,391 name,392 force,393 public,394 public_file,395 expires_at,396 re_add,397 } => {398 // TODO: Forbid updating secrets with set expectedOwners (= not user-managed).399400 let exists = config.has_shared(&name);401 if exists && !force && !re_add {402 bail!("secret already defined");403 }404 if re_add {405 // Fixme: use clap to limit this usage406 ensure!(!force, "--force and --readd are not compatible");407 ensure!(exists, "secret doesn't exists");408 ensure!(409 machines.is_empty(),410 "you can't use machines argument for --readd"411 );412 let shared = config.shared_secret(&name)?;413 machines = shared.owners;414 }415416 let recipients = config.recipients(machines.clone()).await?;417418 let secret = {419 let mut input = vec![];420 io::stdin().read_to_end(&mut input)?;421422 if input.is_empty() {423 None424 } else {425 Some(426 SecretData::encrypt(recipients, input)427 .ok_or_else(|| anyhow!("no recipients provided"))?,428 )429 }430 };431 let public = parse_public(public, public_file).await?;432 config.replace_shared(433 name,434 FleetSharedSecret {435 owners: machines,436 secret: FleetSecret {437 created_at: Utc::now(),438 expires_at,439 secret,440 public,441 },442 },443 );444 }445 Secret::Add {446 machine,447 name,448 force,449 public,450 public_file,451 } => {452 let recipient = config.recipient(&machine).await?;453454 let secret = {455 let mut input = vec![];456 io::stdin().read_to_end(&mut input)?;457 if input.is_empty() {458 bail!("no data provided")459 }460461 Some(SecretData::encrypt(vec![recipient], input).expect("recipient provided"))462 };463464 if config.has_secret(&machine, &name) && !force {465 bail!("secret already defined");466 }467 let public = parse_public(public, public_file).await?;468469 config.insert_secret(470 &machine,471 name,472 FleetSecret {473 created_at: Utc::now(),474 expires_at: None,475 secret,476 public,477 },478 );479 }480 #[allow(clippy::await_holding_refcell_ref)]481 Secret::Read {482 name,483 machine,484 plaintext,485 } => {486 let secret = config.host_secret(&machine, &name)?;487 let Some(secret) = secret.secret else {488 bail!("no secret {name}");489 };490 let host = config.host(&machine).await?;491 let data = host.decrypt(secret).await?;492 if plaintext {493 let s = String::from_utf8(data).context("output is not utf8")?;494 print!("{s}");495 } else {496 println!("{}", z85::encode(&data));497 }498 }499 Secret::ReadPublic { name, machine } => {500 let secret = config.host_secret(&machine, &name)?;501 let Some(public) = secret.public else {502 bail!("no secret {name}");503 };504 print!("{public}");505 }506 Secret::UpdateShared {507 name,508 machines,509 add_machines,510 remove_machines,511 prefer_identities,512 } => {513 // TODO: Forbid updating secrets with set expectedOwners (= not user-managed).514515 let secret = config.shared_secret(&name)?;516 if secret.secret.secret.is_none() {517 bail!("no secret");518 }519520 let initial_machines = secret.owners.clone();521 let target_machines = parse_machines(522 initial_machines.clone(),523 machines,524 add_machines,525 remove_machines,526 )?;527528 if target_machines.is_empty() {529 info!("no machines left for secret, removing it");530 config.remove_shared(&name);531 return Ok(());532 }533534 let config_field = &config.config_unchecked_field;535 let field = nix_go!(config_field.sharedSecrets[{ name }]);536537 let updated = update_owner_set(538 &name,539 config,540 secret,541 field,542 &target_machines,543 &prefer_identities,544 )545 .await?;546 config.replace_shared(name, updated);547 }548 Secret::Regenerate { prefer_identities } => {549 info!("checking for secrets to regenerate");550 {551 let _span = info_span!("shared").entered();552 let expected_shared_set = config553 .list_configured_shared()554 .await?555 .into_iter()556 .collect::<HashSet<_>>();557 let shared_set = config.list_shared().into_iter().collect::<HashSet<_>>();558 for missing in expected_shared_set.difference(&shared_set) {559 let config_field = &config.config_unchecked_field;560 let secret = nix_go!(config_field.sharedSecrets[{ missing }]);561 let expected_owners: Option<Vec<String>> =562 nix_go_json!(secret.expectedOwners);563 let Some(expected_owners) = expected_owners else {564 // TODO: Might still need to regenerate565 continue;566 };567 info!("generating secret: {missing}");568 let shared = generate_shared(config, missing, secret, expected_owners)569 .in_current_span()570 .await?;571 config.replace_shared(missing.to_string(), shared)572 }573 }574 for host in config.list_hosts().await? {575 let _span = info_span!("host", host = host.name).entered();576 let expected_set = host577 .list_configured_secrets()578 .in_current_span()579 .await?580 .into_iter()581 .collect::<HashSet<_>>();582 let stored_set = config583 .list_secrets(&host.name)584 .into_iter()585 .collect::<HashSet<_>>();586 for missing in expected_set.difference(&stored_set) {587 info!("generating secret: {missing}");588 let secret = host.secret_field(missing).in_current_span().await?;589 let generated =590 match generate(config, missing, secret, &[host.name.clone()])591 .in_current_span()592 .await593 {594 Ok(v) => v,595 Err(e) => {596 error!("{e:?}");597 continue;598 }599 };600 config.insert_secret(&host.name, missing.to_string(), generated)601 }602 }603 let mut to_remove = Vec::new();604 for name in &config.list_shared() {605 info!("updating secret: {name}");606 let data = config.shared_secret(name)?;607 let config_field = &config.config_unchecked_field;608 let expected_owners: Vec<String> =609 nix_go_json!(config_field.sharedSecrets[{ name }].expectedOwners);610 if expected_owners.is_empty() {611 warn!("secret was removed from fleet config: {name}, removing from data");612 to_remove.push(name.to_string());613 continue;614 }615616 let secret = nix_go!(config_field.sharedSecrets[{ name }]);617 config.replace_shared(618 name.to_owned(),619 update_owner_set(620 name,621 config,622 data,623 secret,624 &expected_owners,625 &prefer_identities,626 )627 .await?,628 );629 }630 for k in to_remove {631 config.remove_shared(&k);632 }633 }634 Secret::List {} => {635 let _span = info_span!("loading secrets").entered();636 let configured = config.list_configured_shared().await?;637 #[derive(Tabled)]638 struct SecretDisplay {639 #[tabled(rename = "Name")]640 name: String,641 #[tabled(rename = "Owners")]642 owners: String,643 }644 let mut table = vec![];645 for name in configured.iter().cloned() {646 let config = config.clone();647 let expected_owners = config.shared_secret_expected_owners(&name).await?;648 let data = config.shared_secret(&name)?;649 let owners = data650 .owners651 .iter()652 .map(|o| {653 if expected_owners.contains(o) {654 o.green().to_string()655 } else {656 o.red().to_string()657 }658 })659 .collect::<Vec<_>>();660 table.push(SecretDisplay {661 owners: owners.join(", "),662 name,663 })664 }665 info!("loaded\n{}", Table::new(table).to_string())666 }667 }668 Ok(())669 }670}cmds/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>,
}
cmds/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!(
cmds/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
cmds/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(())
}
crates/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;
crates/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"
crates/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>Привет, мир!",
+ );
+}
crates/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"), "");
+}
crates/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) {
crates/remowt-fs/src/lib.rsdiffbeforeafterboth--- a/crates/remowt-fs/src/lib.rs
+++ b/crates/remowt-fs/src/lib.rs
@@ -1,2 +1 @@
trait RemowtFS {}
-
modules/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 {}));
}
];
});
nixos/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}
'';
rustfmt.tomldiffbeforeafterboth--- a/rustfmt.toml
+++ b/rustfmt.toml
@@ -1 +1,3 @@
hard_tabs = true
+imports_granularity = "Crate"
+group_imports = "StdExternalCrate"