git.delta.rocks / jrsonnet / refs/commits / a59c7c970515

difftreelog

refactor rework fleet.nix secret bookkeeping

Yaroslav Bolyukin2024-05-21parent: #2c5a4bd.patch.diff
in: trunk

17 files changed

modifiedCargo.lockdiffbeforeafterboth
70 "aes",70 "aes",
71 "aes-gcm",71 "aes-gcm",
72 "age-core",72 "age-core",
73 "base64",73 "base64 0.21.7",
74 "bcrypt-pbkdf",74 "bcrypt-pbkdf",
75 "bech32",75 "bech32",
76 "cbc",76 "cbc",
102source = "registry+https://github.com/rust-lang/crates.io-index"102source = "registry+https://github.com/rust-lang/crates.io-index"
103checksum = "a5f11899bc2bbddd135edbc30c36b1924fa59d0746bb45beb5933fafe3fe509b"103checksum = "a5f11899bc2bbddd135edbc30c36b1924fa59d0746bb45beb5933fafe3fe509b"
104dependencies = [104dependencies = [
105 "base64",105 "base64 0.21.7",
106 "chacha20poly1305",106 "chacha20poly1305",
107 "cookie-factory",107 "cookie-factory",
108 "hkdf",108 "hkdf",
252source = "registry+https://github.com/rust-lang/crates.io-index"252source = "registry+https://github.com/rust-lang/crates.io-index"
253checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"253checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
254
255[[package]]
256name = "base64"
257version = "0.22.1"
258source = "registry+https://github.com/rust-lang/crates.io-index"
259checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
254260
255[[package]]261[[package]]
256name = "base64ct"262name = "base64ct"
534dependencies = [540dependencies = [
535 "bitflags",541 "bitflags",
536 "crossterm_winapi",542 "crossterm_winapi",
543 "filedescriptor",
537 "libc",544 "libc",
538 "mio",545 "mio",
539 "parking_lot",546 "parking_lot",
694source = "registry+https://github.com/rust-lang/crates.io-index"701source = "registry+https://github.com/rust-lang/crates.io-index"
695checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"702checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
703
704[[package]]
705name = "filedescriptor"
706version = "0.8.2"
707source = "registry+https://github.com/rust-lang/crates.io-index"
708checksum = "7199d965852c3bac31f779ef99cbb4537f80e952e2d6aa0ffeb30cce00f4f46e"
709dependencies = [
710 "libc",
711 "thiserror",
712 "winapi",
713]
696714
697[[package]]715[[package]]
698name = "find-crate"716name = "find-crate"
712 "age-core",730 "age-core",
713 "anyhow",731 "anyhow",
714 "async-trait",732 "async-trait",
715 "base64",733 "base64 0.22.1",
716 "better-command",734 "better-command",
717 "chrono",735 "chrono",
718 "clap",736 "clap",
719 "crossterm",737 "crossterm",
738 "fleet-shared",
720 "futures",739 "futures",
721 "hostname",740 "hostname",
722 "human-repr",741 "human-repr",
741 "tracing-indicatif",760 "tracing-indicatif",
742 "tracing-subscriber",761 "tracing-subscriber",
743 "unindent",762 "unindent",
744 "z85",
745]763]
746764
747[[package]]765[[package]]
751 "age",769 "age",
752 "anyhow",770 "anyhow",
753 "clap",771 "clap",
772 "fleet-shared",
754 "nix",773 "nix",
755 "serde",774 "serde",
756 "serde_json",775 "serde_json",
757 "tempfile",776 "tempfile",
758 "tracing",777 "tracing",
759 "tracing-subscriber",778 "tracing-subscriber",
760 "z85",
761]779]
780
781[[package]]
782name = "fleet-shared"
783version = "0.1.0"
784dependencies = [
785 "base64 0.22.1",
786 "serde",
787 "unicode_categories",
788 "z85",
789]
762790
763[[package]]791[[package]]
764name = "fluent"792name = "fluent"
1824source = "registry+https://github.com/rust-lang/crates.io-index"1852source = "registry+https://github.com/rust-lang/crates.io-index"
1825checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94"1853checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94"
1826dependencies = [1854dependencies = [
1827 "base64",1855 "base64 0.21.7",
1828 "bitflags",1856 "bitflags",
1829 "serde",1857 "serde",
1830 "serde_derive",1858 "serde_derive",
20132041
2014[[package]]2042[[package]]
2015name = "serde"2043name = "serde"
2016version = "1.0.201"2044version = "1.0.202"
2017source = "registry+https://github.com/rust-lang/crates.io-index"2045source = "registry+https://github.com/rust-lang/crates.io-index"
2018checksum = "780f1cebed1629e4753a1a38a3c72d30b97ec044f0aef68cb26650a3c5cf363c"2046checksum = "226b61a0d411b2ba5ff6d7f73a476ac4f8bb900373459cd00fab8512828ba395"
2019dependencies = [2047dependencies = [
2020 "serde_derive",2048 "serde_derive",
2021]2049]
20312059
2032[[package]]2060[[package]]
2033name = "serde_derive"2061name = "serde_derive"
2034version = "1.0.201"2062version = "1.0.202"
2035source = "registry+https://github.com/rust-lang/crates.io-index"2063source = "registry+https://github.com/rust-lang/crates.io-index"
2036checksum = "c5e405930b9796f1c00bee880d03fc7e0bb4b9a11afc776885ffe84320da2865"2064checksum = "6048858004bcff69094cd972ed40a32500f153bd3be9f716b2eed2e8217c4838"
2037dependencies = [2065dependencies = [
2038 "proc-macro2",2066 "proc-macro2",
2039 "quote",2067 "quote",
2570source = "registry+https://github.com/rust-lang/crates.io-index"2598source = "registry+https://github.com/rust-lang/crates.io-index"
2571checksum = "68f5e5f3158ecfd4b8ff6fe086db7c8467a2dfdac97fe420f2b7c4aa97af66d6"2599checksum = "68f5e5f3158ecfd4b8ff6fe086db7c8467a2dfdac97fe420f2b7c4aa97af66d6"
2600
2601[[package]]
2602name = "unicode_categories"
2603version = "0.1.1"
2604source = "registry+https://github.com/rust-lang/crates.io-index"
2605checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
25722606
2573[[package]]2607[[package]]
2574name = "unindent"2608name = "unindent"
modifiedCargo.tomldiffbeforeafterboth
9bifrostlink = "0.1.0"9bifrostlink = "0.1.0"
10uuid = { version = "1.7.0", features = ["v4"] }10uuid = { version = "1.7.0", features = ["v4"] }
11tokio = { version = "1.36.0", features = ["fs", "rt", "macros", "sync", "time", "rt-multi-thread"] }11tokio = { version = "1.36.0", features = ["fs", "rt", "macros", "sync", "time", "rt-multi-thread"] }
12fleet-shared = { path = "./crates/fleet-shared" }
1213
13
modifiedcmds/fleet/Cargo.tomldiffbeforeafterboth
19age-core = "0.10"19age-core = "0.10"
20peg = "0.8"20peg = "0.8"
21age = { version = "0.10", features = ["ssh", "armor"] }21age = { version = "0.10", features = ["ssh", "armor"] }
22base64 = "0.21"22base64 = "0.22.1"
23chrono = { version = "0.4", features = ["serde"] }23chrono = { version = "0.4", features = ["serde"] }
24z85 = "3.0"24# Using fixed version for rust on stable nixos branches.
25clap = { version = ">=4.4, <4.5", features = ["derive", "env", "wrap_help", "unicode"] }25clap = { version = ">=4.4, <4.5", features = [
26 "derive",
27 "env",
28 "wrap_help",
29 "unicode",
30] }
26tracing = "0.1"31tracing = "0.1"
27tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }32tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
40unindent = "0.2"45unindent = "0.2"
41regex = "1.10"46regex = "1.10"
42openssh = "0.10"47openssh = "0.10"
43crossterm = "0.27"48crossterm = { version = "0.27.0", features = ["use-dev-tty"] }
49fleet-shared.workspace = true
4450
45tracing-indicatif = { version = "0.3", optional = true }51tracing-indicatif = { version = "0.3", optional = true }
46human-repr = { version = "1.1", optional = true }52human-repr = { version = "1.1", optional = true }
47indicatif = { version = "0.17", optional = true }53indicatif = { version = "0.17", optional = true }
4854
49[features]55[features]
50# Not quite stable56# Not quite stable
51indicatif = ["tracing-indicatif", "dep:indicatif", "human-repr", "better-command/indicatif"]57indicatif = [
58 "tracing-indicatif",
59 "dep:indicatif",
60 "human-repr",
61 "better-command/indicatif",
62]
5263
modifiedcmds/fleet/src/cmds/secrets/mod.rsdiffbeforeafterboth
1use crate::{1use std::{
2 better_nix_eval::Field,2 collections::{BTreeMap, BTreeSet, HashSet},
3 fleetdata::{FleetSecret, FleetSharedSecret, SecretData},3 ffi::OsString,
4 io::{self, stdin, stdout, Read, Write},
4 host::Config,5 path::PathBuf,
5 nix_go, nix_go_json,
6};6};
7
7use anyhow::{anyhow, bail, ensure, Context, Result};8use anyhow::{anyhow, bail, ensure, Context, Result};
8use chrono::{DateTime, Utc};9use chrono::{DateTime, Utc};
9use clap::{error::ErrorKind, Parser};10use clap::Parser;
10use crossterm::{terminal, tty::IsTty};11use crossterm::{terminal, tty::IsTty};
12use fleet_shared::SecretData;
11use itertools::Itertools;13use itertools::Itertools;
12use owo_colors::OwoColorize;14use owo_colors::OwoColorize;
13use serde::Deserialize;15use 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};16use tabled::{Table, Tabled};
21use tempfile::NamedTempFile;17use tokio::{fs::read, process::Command};
22use tokio::{fs::read_to_string, process::Command};
23use tracing::{error, info, info_span, warn, Instrument};18use tracing::{error, info, info_span, warn, Instrument};
2419
20use crate::{
21 better_nix_eval::Field,
22 fleetdata::{encrypt_secret_data, FleetSecret, FleetSecretPart, FleetSharedSecret},
23 host::Config,
24 nix_go, nix_go_json,
25};
26
25#[derive(Parser)]27#[derive(Parser)]
26pub enum Secret {28pub enum Secret {
27 /// Force load host keys for all defined hosts29 /// Force load host keys for all defined hosts
38 /// Secret public part40 /// Secret public part
39 #[clap(long)]41 #[clap(long)]
40 public: Option<String>,42 public: Option<String>,
43 /// How to name public secret part
44 #[clap(long, default_value = "public")]
45 public_name: String,
41 /// Load public part from specified file46 /// Load public part from specified file
42 #[clap(long)]47 #[clap(long)]
43 public_file: Option<PathBuf>,48 public_file: Option<PathBuf>,
49 /// Secret with this name already exists, override its value while keeping the same owners.54 /// Secret with this name already exists, override its value while keeping the same owners.
50 #[clap(long)]55 #[clap(long)]
51 re_add: bool,56 re_add: bool,
57
58 #[clap(default_value = "secret")]
59 part_name: String,
52 },60 },
53 /// Add secret, data should be provided in stdin61 /// Add secret, data should be provided in stdin
54 Add {62 Add {
59 /// Override secret if already present67 /// Override secret if already present
60 #[clap(long)]68 #[clap(long)]
61 force: bool,69 force: bool,
70 /// Secret public part
62 #[clap(long)]71 #[clap(long)]
63 public: Option<String>,72 public: Option<String>,
73 /// How to name public secret part
74 #[clap(long, default_value = "public")]
75 public_name: String,
76 /// Load public part from specified file
64 #[clap(long)]77 #[clap(long)]
65 public_file: Option<PathBuf>,78 public_file: Option<PathBuf>,
79
80 #[clap(default_value = "secret")]
81 part_name: String,
66 },82 },
67 /// Read secret from remote host, requires sudo on said host83 /// Read secret from remote host, requires sudo on said host
68 Read {84 Read {
69 name: String,85 name: String,
70 machine: String,86 machine: String,
87
71 #[clap(long)]88 #[clap(default_value = "secret")]
72 plaintext: bool,89 part_name: String,
73 },90 },
74 ReadPublic {
75 name: String,
76 machine: String,
77 },
78 UpdateShared {91 UpdateShared {
79 name: String,92 name: String,
8093
89 /// Which host should we use to decrypt102 /// Which host should we use to decrypt
90 #[clap(long)]103 #[clap(long)]
91 prefer_identities: Vec<String>,104 prefer_identities: Vec<String>,
105
106 #[clap(default_value = "secret")]
107 part_name: String,
92 },108 },
93 Regenerate {109 Regenerate {
94 /// Which host should we use to decrypt, in case if reencryption is required, without110 /// Which host should we use to decrypt, in case if reencryption is required, without
97 prefer_identities: Vec<String>,113 prefer_identities: Vec<String>,
98 },114 },
99 List {},115 List {},
116 Edit {
117 name: String,
118 machine: String,
119
120 #[clap(default_value = "secret")]
121 part: String,
122
123 #[clap(long)]
124 add: bool,
125 },
100}126}
101127
102#[tracing::instrument(skip(config, secret, field, prefer_identities))]128#[tracing::instrument(skip(config, secret, field, prefer_identities))]
144 bail!("no available holder found");170 bail!("no available holder found");
145 };171 };
146172
147 if let Some(data) = secret.secret.secret {173 for (part_name, part) in secret.secret.parts.iter_mut() {
174 let _span = info_span!("part reencryption", part_name);
175 if !part.raw.encrypted {
176 continue;
177 }
148 let host = config.host(identity_holder).await?;178 let host = config.host(identity_holder).await?;
149 let encrypted = host.reencrypt(data, updated_set.to_vec()).await?;179 let encrypted = host
180 .reencrypt(part.raw.clone(), updated_set.to_vec())
181 .await?;
150 secret.secret.secret = Some(encrypted);182 part.raw = encrypted;
151 }183 }
152184
153 secret.owners = updated_set.to_vec();185 secret.owners = updated_set.to_vec();
232 ensure!(marker == "SUCCESS", "generation not succeeded");264 ensure!(marker == "SUCCESS", "generation not succeeded");
233 }265 }
234266
235 let public = host.read_file_text(format!("{out}/public")).await.ok();267 let mut parts = BTreeMap::new();
268 for part in host.read_dir(&out).await? {
269 if part == "created_at" || part == "expired_at" || part == "marker" {
270 continue;
236 let secret = host.read_file_bin(format!("{out}/secret")).await.ok();271 }
272 let contents: SecretData = host
273 .read_file_text(format!("{out}/{part}"))
274 .await?
275 .parse()
237 if let Some(secret) = &secret {276 .map_err(|e| anyhow!("failed to decode secret {out:?} part {part:?}: {e}"))?;
238 ensure!(
239 age::Decryptor::new(Cursor::new(&secret)).is_ok(),277 parts.insert(part.to_owned(), FleetSecretPart { raw: contents });
240 "builder produced non-encrypted value as secret, this is highly insecure, and not allowed."
241 );
242 }278 }
243279
244 let created_at = host.read_file_value(format!("{out}/created_at")).await?;280 let created_at = host.read_file_value(format!("{out}/created_at")).await?;
247 Ok(FleetSecret {283 Ok(FleetSecret {
248 created_at,284 created_at,
249 expires_at,285 expires_at,
250 public,286 parts,
251 secret: secret.map(SecretData),
252 })287 })
253}288}
254async fn generate(289async fn generate(
310async fn parse_public(345async fn parse_public(
311 public: Option<String>,346 public: Option<String>,
312 public_file: Option<PathBuf>,347 public_file: Option<PathBuf>,
313) -> Result<Option<String>> {348) -> Result<Option<SecretData>> {
314 Ok(match (public, public_file) {349 Ok(match (public, public_file) {
315 (Some(v), None) => Some(v),350 (Some(v), None) => Some(SecretData {
351 data: v.into(),
316 (None, Some(v)) => Some(read_to_string(v).await?),352 encrypted: false,
353 }),
354 (None, Some(v)) => Some(SecretData {
355 data: read(v).await?,
356 encrypted: false,
357 }),
317 (Some(_), Some(_)) => {358 (Some(_), Some(_)) => {
318 bail!("only public or public_file should be set")359 bail!("only public or public_file should be set")
319 }360 }
320 (None, None) => None,361 (None, None) => None,
321 })362 })
322}363}
323364
365async fn parse_secret() -> Result<Option<Vec<u8>>> {
366 let mut input = vec![];
367 io::stdin().read_to_end(&mut input)?;
368 if input.is_empty() {
369 Ok(None)
370 } else {
371 Ok(Some(input))
372 }
373}
374
324fn parse_machines(375fn parse_machines(
325 initial: Vec<String>,376 initial: Vec<String>,
326 machines: Option<Vec<String>>,377 machines: Option<Vec<String>>,
391 name,442 name,
392 force,443 force,
393 public,444 public,
445 public_name,
394 public_file,446 public_file,
395 expires_at,447 expires_at,
396 re_add,448 re_add,
449 part_name,
397 } => {450 } => {
398 // TODO: Forbid updating secrets with set expectedOwners (= not user-managed).451 // TODO: Forbid updating secrets with set expectedOwners (= not user-managed).
399452
415468
416 let recipients = config.recipients(machines.clone()).await?;469 let recipients = config.recipients(machines.clone()).await?;
417470
418 let secret = {471 let mut parts = BTreeMap::new();
419 let mut input = vec![];
420 io::stdin().read_to_end(&mut input)?;
421472
422 if input.is_empty() {473 let mut input = vec![];
423 None474 io::stdin().read_to_end(&mut input)?;
475
424 } else {476 if !input.is_empty() {
425 Some(477 let encrypted = encrypt_secret_data(recipients, input)
426 SecretData::encrypt(recipients, input)
427 .ok_or_else(|| anyhow!("no recipients provided"))?,478 .ok_or_else(|| anyhow!("no recipients provided"))?;
428 )479 parts.insert(part_name, FleetSecretPart { raw: encrypted });
429 }480 }
430 };481
431 let public = parse_public(public, public_file).await?;482 if let Some(public) = parse_public(public, public_file).await? {
483 parts.insert(public_name, FleetSecretPart { raw: public });
484 }
485
432 config.replace_shared(486 config.replace_shared(
433 name,487 name,
436 secret: FleetSecret {490 secret: FleetSecret {
437 created_at: Utc::now(),491 created_at: Utc::now(),
438 expires_at,492 expires_at,
439 secret,493 parts,
440 public,
441 },494 },
442 },495 },
443 );496 );
447 name,500 name,
448 force,501 force,
449 public,502 public,
503 public_name,
450 public_file,504 public_file,
505 part_name,
451 } => {506 } => {
452 let recipient = config.recipient(&machine).await?;507 if config.has_secret(&machine, &name) && !force {
508 bail!("secret already defined");
509 }
453510
454 let secret = {511 let mut parts = BTreeMap::new();
455 let mut input = vec![];
456 io::stdin().read_to_end(&mut input)?;
457 if input.is_empty() {
458 bail!("no data provided")
459 }
460512
461 Some(SecretData::encrypt(vec![recipient], input).expect("recipient provided"))513 if let Some(secret) = parse_secret().await? {
514 let recipient = config.recipient(&machine).await?;
515 let encrypted =
516 encrypt_secret_data(vec![recipient], secret).expect("recipient provided");
517 parts.insert(part_name, FleetSecretPart { raw: encrypted });
518 }
519
520 if let Some(public) = parse_public(public, public_file).await? {
521 parts.insert(public_name, FleetSecretPart { raw: public });
462 };522 };
463523
464 if config.has_secret(&machine, &name) && !force {
465 bail!("secret already defined");
466 }
467 let public = parse_public(public, public_file).await?;
468
469 config.insert_secret(524 config.insert_secret(
470 &machine,525 &machine,
471 name,526 name,
472 FleetSecret {527 FleetSecret {
473 created_at: Utc::now(),528 created_at: Utc::now(),
474 expires_at: None,529 expires_at: None,
475 secret,530 parts,
476 public,
477 },531 },
478 );532 );
479 }533 }
480 #[allow(clippy::await_holding_refcell_ref)]534 #[allow(clippy::await_holding_refcell_ref)]
481 Secret::Read {535 Secret::Read {
482 name,536 name,
483 machine,537 machine,
484 plaintext,538 part_name,
485 } => {539 } => {
486 let secret = config.host_secret(&machine, &name)?;540 let secret = config.host_secret(&machine, &name)?;
487 let Some(secret) = secret.secret else {541 let Some(secret) = secret.parts.get(&part_name) else {
488 bail!("no secret {name}");542 bail!("no part {part_name} in secret {name}");
489 };543 };
490 let host = config.host(&machine).await?;544 let data = if secret.raw.encrypted {
491 let data = host.decrypt(secret).await?;545 let host = config.host(&machine).await?;
492 if plaintext {546 host.decrypt(secret.raw.clone()).await?
493 let s = String::from_utf8(data).context("output is not utf8")?;
494 print!("{s}");
495 } else {547 } else {
496 println!("{}", z85::encode(&data));548 secret.raw.data.clone()
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 };549 };
550
504 print!("{public}");551 stdout().write_all(&data)?;
505 }552 }
506 Secret::UpdateShared {553 Secret::UpdateShared {
507 name,554 name,
508 machines,555 machines,
509 add_machines,556 add_machines,
510 remove_machines,557 remove_machines,
511 prefer_identities,558 prefer_identities,
559 part_name,
512 } => {560 } => {
513 // TODO: Forbid updating secrets with set expectedOwners (= not user-managed).561 // TODO: Forbid updating secrets with set expectedOwners (= not user-managed).
514562
515 let secret = config.shared_secret(&name)?;563 let secret = config.shared_secret(&name)?;
516 if secret.secret.secret.is_none() {564 if secret.secret.parts.get(&part_name).is_none() {
517 bail!("no secret");565 bail!("no secret");
518 }566 }
519567
572 }620 }
573 }621 }
574 for host in config.list_hosts().await? {622 for host in config.list_hosts().await? {
623 if config.should_skip(&host.name) {
624 continue;
625 }
626
575 let _span = info_span!("host", host = host.name).entered();627 let _span = info_span!("host", host = host.name).entered();
576 let expected_set = host628 let expected_set = host
577 .list_configured_secrets()629 .list_configured_secrets()
664 }716 }
665 info!("loaded\n{}", Table::new(table).to_string())717 info!("loaded\n{}", Table::new(table).to_string())
666 }718 }
719 Secret::Edit {
720 name,
721 machine,
722 part,
723 add,
724 } => {
725 let secret = config.host_secret(&machine, &name)?;
726 if let Some(data) = secret.parts.get(&part) {
727 let host = config.host(&machine).await?;
728 let secret = host.decrypt(data.raw.clone()).await?;
729 String::from_utf8(secret).context("secret is not utf8")?
730 } else if add {
731 String::new()
732 } else {
733 bail!("part {part} not found in secret {name}. Did you mean to `--add` it?");
734 };
735 }
667 }736 }
668 Ok(())737 Ok(())
669 }738 }
739}
740
741async fn edit_temp_file(
742 builder: tempfile::Builder<'_, '_>,
743 r: Vec<u8>,
744 header: &str,
745 comment: &str,
746) -> Result<(Vec<u8>, Option<String>), anyhow::Error> {
747 if !stdin().is_tty() {
748 // TODO: Also try to open /dev/tty directly?
749 bail!("stdin is not tty, can't open editor");
750 }
751
752 use std::fmt::Write;
753 let mut file = builder.tempfile()?;
754
755 let mut full_header = String::new();
756 let mut had = false;
757 for line in header.trim_end().lines() {
758 had = true;
759 writeln!(&mut full_header, "{comment}{line}")?;
760 }
761 if had {
762 writeln!(&mut full_header, "{}", comment.trim_end())?;
763 }
764 writeln!(
765 &mut full_header,
766 "{comment}Do not touch this header! It will be removed automatically"
767 )?;
768
769 file.write_all(full_header.as_bytes())?;
770 file.write_all(&r)?;
771
772 let abs_path = file.into_temp_path();
773 let editor = std::env::var_os("VISUAL")
774 .or_else(|| std::env::var_os("EDITOR"))
775 .unwrap_or_else(|| "vi".into());
776 let editor_args = shlex::bytes::split(editor.as_encoded_bytes())
777 .ok_or_else(|| anyhow!("EDITOR env var has wrong syntax"))?;
778 let editor_args = editor_args
779 .into_iter()
780 .map(|v| {
781 // Only ASCII subsequences are replaced
782 unsafe { OsString::from_encoded_bytes_unchecked(v) }
783 })
784 .collect_vec();
785 let Some((editor, args)) = editor_args.split_first() else {
786 bail!("EDITOR env var has no command");
787 };
788 let mut command = Command::new(editor);
789 command.args(args);
790
791 let path_arg = abs_path.canonicalize()?;
792
793 // TODO: Save full state, using tcget/_getmode/_setmode
794 let was_raw = terminal::is_raw_mode_enabled()?;
795 terminal::enable_raw_mode()?;
796
797 let status = command.arg(path_arg).status().await;
798
799 if !was_raw {
800 terminal::disable_raw_mode()?;
801 }
802
803 let success = match status {
804 Ok(s) => s.success(),
805 Err(e) if e.kind() == io::ErrorKind::NotFound => {
806 bail!("editor not found")
807 }
808 Err(e) => bail!("editor spawn error: {e}"),
809 };
810
811 let mut file = std::fs::read(&abs_path).context("read editor output")?;
812 let Some(v) = file.strip_prefix(full_header.as_bytes()) else {
813 todo!();
814 };
815 todo!();
816
817 // Ok((success, abs_path))
670}818}
671819
modifiedcmds/fleet/src/fleetdata.rsdiffbeforeafterboth
1use age::Recipient;1use std::{
2 collections::BTreeMap,
3 io::{self, Cursor},
4};
5
2use anyhow::Result;6use age::Recipient;
3use chrono::{DateTime, Utc};7use chrono::{DateTime, Utc};
4use itertools::Itertools;8use fleet_shared::SecretData;
5use nixlike::format_nix;9use itertools::Itertools;
6use serde::{Deserialize, Deserializer, Serialize, Serializer};10use serde::{de::Error, Deserialize, Serialize};
7use std::{
8 collections::BTreeMap,
9 io::{self, Cursor},
10};
11use tempfile::TempDir;
12use tokio::{
13 fs::{self, File},
14 io::AsyncWriteExt,
15 process::Command,
16};
1711
18#[derive(Serialize, Deserialize, Default)]12#[derive(Serialize, Deserialize, Default)]
19#[serde(rename_all = "camelCase")]13#[serde(rename_all = "camelCase")]
23 pub encryption_key: String,17 pub encryption_key: String,
24}18}
19
20const VERSION: &str = "0.1.0";
21pub struct FleetDataVersion;
22impl Serialize for FleetDataVersion {
23 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
24 where
25 S: serde::Serializer,
26 {
27 VERSION.serialize(serializer)
28 }
29}
30impl<'de> Deserialize<'de> for FleetDataVersion {
31 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
32 where
33 D: serde::Deserializer<'de>,
34 {
35 let version = String::deserialize(deserializer)?;
36 if version != VERSION {
37 return Err(D::Error::custom(format!(
38 "fleet.nix data version mismatch, expected {VERSION}, got {version}.\nFollow the docs for migration instruction"
39 )));
40 }
41 Ok(Self)
42 }
43}
2544
26#[derive(Serialize, Deserialize)]45#[derive(Serialize, Deserialize)]
27#[serde(rename_all = "camelCase")]46#[serde(rename_all = "camelCase")]
28pub struct FleetData {47pub struct FleetData {
48 pub version: FleetDataVersion,
49
29 #[serde(default)]50 #[serde(default)]
30 pub hosts: BTreeMap<String, HostData>,51 pub hosts: BTreeMap<String, HostData>,
45 pub secret: FleetSecret,66 pub secret: FleetSecret,
46}67}
4768
48#[derive(Serialize, Deserialize, Clone)]
49pub struct SecretData(
50 #[serde(
51 default,
52 skip_serializing_if = "Vec::is_empty",
53 serialize_with = "as_z85",
54 deserialize_with = "from_z85"
55 )]
56 pub Vec<u8>,
57);
58impl SecretData {
59 /// Returns None if recipients.is_empty()69/// Returns None if recipients.is_empty()
60 pub fn encrypt(70pub fn encrypt_secret_data(
61 recipients: impl IntoIterator<Item = impl Recipient + Send + 'static>,71 recipients: impl IntoIterator<Item = impl Recipient + Send + 'static>,
62 data: Vec<u8>,72 data: Vec<u8>,
63 ) -> Option<Self> {73) -> Option<SecretData> {
64 let mut encrypted = vec![];74 let mut encrypted = vec![];
65 let recipients = recipients75 let recipients = recipients
66 .into_iter()76 .into_iter()
71 .expect("in memory write");81 .expect("in memory write");
72 io::copy(&mut Cursor::new(data), &mut encryptor).expect("in memory copy");82 io::copy(&mut Cursor::new(data), &mut encryptor).expect("in memory copy");
73 encryptor.finish().expect("in memory flush");83 encryptor.finish().expect("in memory flush");
74 Some(Self(encrypted))84 Some(SecretData {
85 data: encrypted,
86 encrypted: true,
87 })
75 }88}
76 pub fn encode_z85(&self) -> String {89
77 z85::encode(&self.0)90#[derive(Serialize, Deserialize, Clone)]
78 }91pub struct FleetSecretPart {
79 pub fn decode_z85(v: &str) -> Result<Self> {92 pub raw: SecretData,
80 let v = z85::decode(v)?;93}
81 Ok(Self(v))
82 }
83}
8494
85#[derive(Serialize, Deserialize, Clone)]95#[derive(Serialize, Deserialize, Clone)]
86#[serde(rename_all = "camelCase")]96#[serde(rename_all = "camelCase")]
92 #[serde(skip_serializing_if = "Option::is_none", alias = "expire_at")]102 #[serde(skip_serializing_if = "Option::is_none", alias = "expire_at")]
93 pub expires_at: Option<DateTime<Utc>>,103 pub expires_at: Option<DateTime<Utc>>,
104
94 #[serde(skip_serializing_if = "Option::is_none")]105 #[serde(flatten)]
95 pub public: Option<String>,106 pub parts: BTreeMap<String, FleetSecretPart>,
96 #[serde(skip_serializing_if = "Option::is_none")]
97 pub secret: Option<SecretData>,
98}107}
99
100fn as_z85<S>(key: &[u8], serializer: S) -> Result<S::Ok, S::Error>
101where
102 S: Serializer,
103{
104 serializer.serialize_str(&z85::encode(key))
105}
106
107fn from_z85<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
108where
109 D: Deserializer<'de>,
110{
111 use serde::de::Error;
112 String::deserialize(deserializer)
113 .and_then(|string| z85::decode(string).map_err(|err| Error::custom(err.to_string())))
114}
115
116/// Isn't used yet
117#[allow(dead_code)]
118pub async fn dummy_flake() -> Result<TempDir> {
119 let data_str = fs::read_to_string("fleet.nix").await?;
120
121 let mut cmd = Command::new("nix");
122 cmd.arg("flake").arg("metadata").arg("--json");
123
124 let flake_dir = tempfile::tempdir()?;
125 let mut flake_nix = flake_dir.path().to_path_buf();
126 flake_nix.push("flake.nix");
127 // flake_dir
128
129 File::create(&flake_nix)
130 .await?
131 .write_all(
132 format_nix(&format!(
133 "
134 {{
135 outputs = {{self, ...}}: {{
136 data = {data_str};
137 }};
138 }}
139 "
140 ))
141 .as_bytes(),
142 )
143 .await?;
144
145 // std::thread::sleep(Duration::MAX);
146 // flake_dir.close()
147 // FIXME
148 dbg!(&flake_nix);
149 Ok(flake_dir)
150}
151108
modifiedcmds/fleet/src/host.rsdiffbeforeafterboth
9 sync::{Arc, Mutex, MutexGuard, OnceLock},9 sync::{Arc, Mutex, MutexGuard, OnceLock},
10};10};
1111
12use anyhow::{anyhow, bail, Context, Result};12use anyhow::{anyhow, bail, ensure, Context, Result};
13use clap::{ArgGroup, Parser};13use clap::{ArgGroup, Parser};
14use fleet_shared::SecretData;
14use openssh::SessionBuilder;15use openssh::SessionBuilder;
15use serde::de::DeserializeOwned;16use serde::de::DeserializeOwned;
16use tempfile::NamedTempFile;17use tempfile::NamedTempFile;
1718
18use crate::{19use crate::{
19 better_nix_eval::{Field, NixSessionPool},20 better_nix_eval::{Field, NixSessionPool},
20 command::MyCommand,21 command::MyCommand,
21 fleetdata::{FleetData, FleetSecret, FleetSharedSecret, SecretData},22 fleetdata::{FleetData, FleetSecret, FleetSharedSecret},
22 nix_go, nix_go_json,23 nix_go, nix_go_json,
23};24};
2425
89 cmd.arg(path);90 cmd.arg(path);
90 cmd.run_string().await91 cmd.run_string().await
91 }92 }
93 pub async fn read_dir(&self, path: impl AsRef<OsStr>) -> Result<Vec<String>> {
94 let mut cmd = self.cmd("ls").await?;
95 cmd.arg(path);
96 let out = cmd.run_string().await?;
97 let mut lines = out.split('\n');
98 if let Some(last) = lines.next_back() {
99 ensure!(last == "", "output of ls should end with newline");
100 }
101 Ok(lines.map(ToOwned::to_owned).collect())
102 }
92 #[allow(dead_code)]103 #[allow(dead_code)]
93 pub async fn read_file_json<D: DeserializeOwned>(&self, path: impl AsRef<OsStr>) -> Result<D> {104 pub async fn read_file_json<D: DeserializeOwned>(&self, path: impl AsRef<OsStr>) -> Result<D> {
94 let text = self.read_file_text(path).await?;105 let text = self.read_file_text(path).await?;
111 }122 }
112123
113 pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {124 pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {
125 ensure!(data.encrypted, "secret is not encrypted");
114 let mut cmd = self.cmd("fleet-install-secrets").await?;126 let mut cmd = self.cmd("fleet-install-secrets").await?;
115 cmd.arg("decrypt").eqarg("--secret", data.encode_z85());127 cmd.arg("decrypt").eqarg("--secret", data.to_string());
116 let encoded = cmd128 let encoded = cmd
117 .sudo()129 .sudo()
118 .run_string()130 .run_string()
119 .await131 .await
120 .context("failed to call remote host for decrypt")?;132 .context("failed to call remote host for decrypt")?;
121 z85::decode(encoded.trim_end()).context("bad encoded data? outdated host?")133 let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;
134 ensure!(!data.encrypted, "didn't decrypted secret");
135 Ok(data.data)
122 }136 }
123 pub async fn reencrypt(&self, data: SecretData, targets: Vec<String>) -> Result<SecretData> {137 pub async fn reencrypt(&self, data: SecretData, targets: Vec<String>) -> Result<SecretData> {
138 ensure!(data.encrypted, "secret is not encrypted");
124 let mut cmd = self.cmd("fleet-install-secrets").await?;139 let mut cmd = self.cmd("fleet-install-secrets").await?;
125 cmd.arg("reencrypt").eqarg("--secret", data.encode_z85());140 cmd.arg("reencrypt").eqarg("--secret", data.to_string());
126 for target in targets {141 for target in targets {
127 let key = self.config.key(&target).await?;142 let key = self.config.key(&target).await?;
128 cmd.eqarg("--targets", key);143 cmd.eqarg("--targets", key);
132 .run_string()147 .run_string()
133 .await148 .await
134 .context("failed to call remote host for decrypt")?;149 .context("failed to call remote host for decrypt")?;
135 SecretData::decode_z85(encoded.trim_end()).context("bad encoded data? outdated host?")150 let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;
151 ensure!(!data.encrypted, "didn't decrypted secret");
152 Ok(data)
136 }153 }
137 /// Returns path for futureproofing, as path might change i.e on conversion to CA154 /// Returns path for futureproofing, as path might change i.e on conversion to CA
138 pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {155 pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {
324 }341 }
325342
326 pub fn save(&self) -> Result<()> {343 pub fn save(&self) -> Result<()> {
327 let mut tempfile = NamedTempFile::new_in(self.directory.clone())?;344 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.")?;
328 let data = nixlike::serialize(&self.data() as &FleetData)?;345 let data = nixlike::serialize(&self.data() as &FleetData)?;
329 tempfile.write_all(346 tempfile.write_all(
330 format!(347 format!(
modifiedcmds/install-secrets/Cargo.tomldiffbeforeafterboth
20 "unicode",20 "unicode",
21] }21] }
22tempfile = "3.10.0"22tempfile = "3.10.0"
23z85 = "3.0.5"23fleet-shared.workspace = true
2424
modifiedcmds/install-secrets/src/main.rsdiffbeforeafterboth
1use age::{ssh::Identity as SshIdentity, ssh::Recipient as SshRecipient, Decryptor};1use std::{
2 collections::{BTreeMap, HashMap},
3 fs::{self, File},
4 io::{self, Cursor, Read, Write},
5 iter,
6 os::unix::prelude::PermissionsExt,
7 path::{Path, PathBuf},
8 str::{from_utf8, FromStr},
9};
10
2use age::{Encryptor, Identity, Recipient};11use age::{
12 ssh::{Identity as SshIdentity, Recipient as SshRecipient},
13 Decryptor, Encryptor, Identity, Recipient,
14};
3use anyhow::{anyhow, bail, Context, Result};15use anyhow::{anyhow, bail, ensure, Context, Result};
4use clap::Parser;16use clap::Parser;
5use nix::sys::stat::Mode;17use fleet_shared::SecretData;
6use nix::unistd::{chown, Group, User};18use nix::unistd::{chown, Group, User};
7use serde::{Deserialize, Deserializer};19use serde::Deserialize;
8use std::fmt::{self, Display};
9use std::fs::{self, File};
10use std::io::{self, Cursor, Read, Write};
11use std::iter;
12use std::os::unix::prelude::PermissionsExt;
13use std::path::Path;
14use std::str::{from_utf8, FromStr};
15use std::{collections::HashMap, path::PathBuf};
16use tracing::{error, info, info_span, warn};20use tracing::{error, info_span};
17use tracing_subscriber::filter::LevelFilter;21use tracing_subscriber::{filter::LevelFilter, EnvFilter};
18use tracing_subscriber::EnvFilter;
19
20#[derive(Clone, Debug)]
21struct SecretWrapper(Vec<u8>);
22impl Display for SecretWrapper {
23 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
24 let encoded = z85::encode(&self.0);
25 write!(f, "{encoded}")
26 }
27}
28impl FromStr for SecretWrapper {
29 type Err = z85::DecodeError;
30
31 fn from_str(s: &str) -> Result<Self, Self::Err> {
32 z85::decode(s).map(Self)
33 }
34}
35impl<'de> Deserialize<'de> for SecretWrapper {
36 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
37 where
38 D: Deserializer<'de>,
39 {
40 let v = String::deserialize(deserializer)?;
41 let de = z85::decode(v).map_err(|err| serde::de::Error::custom(err.to_string()))?;
42 Ok(Self(de))
43 }
44}
4522
46#[derive(Parser)]23#[derive(Parser)]
47#[clap(author)]24#[clap(author)]
48enum Opts {25enum Opts {
49 /// Install secrets from json specification26 /// Install secrets from json specification
50 Install { data: PathBuf },27 Install { data: PathBuf },
51 /// Reencrypt secret using host key, outputting in z85 encoded string28 /// Reencrypt secret using host key, outputting in fleet encoded string
52 Reencrypt {29 Reencrypt {
53 #[clap(long)]30 #[clap(long)]
54 secret: SecretWrapper,31 secret: SecretData,
55 #[clap(long)]32 #[clap(long)]
56 targets: Vec<String>,33 targets: Vec<String>,
57 },34 },
58 /// Decrypt secret using host key, outputting in z85 encoded string35 /// Decrypt secret using host key, outputting in fleet encoded string
59 Decrypt {36 Decrypt {
60 #[clap(long)]37 #[clap(long)]
61 secret: SecretWrapper,38 secret: SecretData,
62 /// Shoult decoded output be printed as plaintext, instead of z85?39 /// Shoult decoded output be printed as plaintext, instead of z85?
63 #[clap(long)]40 #[clap(long)]
64 plaintext: bool,41 plaintext: bool,
65 },42 },
66}43}
44
45#[derive(Deserialize)]
46#[serde(rename_all = "camelCase")]
47struct Part {
48 raw: SecretData,
49 path: PathBuf,
50 stable_path: PathBuf,
51}
6752
68#[derive(Deserialize)]53#[derive(Deserialize)]
69#[serde(rename_all = "camelCase")]54#[serde(rename_all = "camelCase")]
72 mode: String,57 mode: String,
73 owner: String,58 owner: String,
74
75 secret: Option<SecretWrapper>,59 root_path: Option<PathBuf>,
60
61 #[serde(flatten)]
76 public: Option<String>,62 parts: BTreeMap<String, Part>,
77
78 public_path: PathBuf,
79 stable_public_path: PathBuf,
80
81 secret_path: PathBuf,
82 stable_secret_path: PathBuf,
83}63}
8464
85type Data = HashMap<String, DataItem>;65type Data = HashMap<String, DataItem>;
8666
87fn decrypt(input: &SecretWrapper, identity: &dyn Identity) -> Result<Vec<u8>> {67fn decrypt(input: &SecretData, identity: &dyn Identity) -> Result<Vec<u8>> {
68 ensure!(input.encrypted, "passed data is not encrypted!");
88 let mut input = Cursor::new(&input.0);69 let mut input = Cursor::new(&input.data);
89 let decryptor = Decryptor::new(&mut input).context("failed to init decryptor")?;70 let decryptor = Decryptor::new(&mut input).context("failed to init decryptor")?;
90 let decryptor = match decryptor {71 let decryptor = match decryptor {
91 Decryptor::Recipients(r) => r,72 Decryptor::Recipients(r) => r,
101 .context("failed to decrypt")?;82 .context("failed to decrypt")?;
102 Ok(decrypted)83 Ok(decrypted)
103}84}
104fn encrypt(input: &[u8], targets: Vec<String>) -> Result<SecretWrapper> {85fn encrypt(input: &[u8], targets: Vec<String>) -> Result<SecretData> {
105 let recipients = targets86 let recipients = targets
106 .into_iter()87 .into_iter()
107 .map(|t| {88 .map(|t| {
119 .expect("constructor should not fail");100 .expect("constructor should not fail");
120 io::copy(&mut Cursor::new(input), &mut encryptor).expect("copy should not fail");101 io::copy(&mut Cursor::new(input), &mut encryptor).expect("copy should not fail");
121 encryptor.finish().context("failed to finish encryption")?;102 encryptor.finish().context("failed to finish encryption")?;
122 Ok(SecretWrapper(encrypted))103 Ok(SecretData {
104 data: encrypted,
105 encrypted: true,
106 })
123}107}
124108
125fn init_secret(identity: &age::ssh::Identity, value: DataItem) -> Result<()> {109fn init_part(identity: &dyn Identity, item: &DataItem, value: &Part) -> Result<()> {
126 if let Some(public) = &value.public {
127 let mut hashed = File::create(&value.public_path)?;
128 let stable_dir = value.stable_public_path.parent().expect("not root");110 let stable_dir = value.stable_path.parent().expect("not root");
111
112 // Right now stable & non-stable data are both located in this dir.
113 std::fs::create_dir_all(stable_dir)?;
114
129 let mut stable_temp =115 let mut stable_temp =
130 tempfile::NamedTempFile::new_in(stable_dir).context("failed to create tempfile")?;116 tempfile::NamedTempFile::new_in(stable_dir).context("failed to create tempfile")?;
117 let mut hashed = File::create(&value.path)?;
118
119 let private = value.raw.encrypted;
120 let data = if private {
121 decrypt(&value.raw, identity)?
122 } else {
123 value.raw.data.to_owned()
124 };
125
131 hashed.write_all(public.as_bytes())?;126 hashed.write_all(&data)?;
127 hashed.flush()?;
132 stable_temp.write_all(public.as_bytes())?;128 stable_temp.write_all(&data)?;
133 stable_temp.flush()?;129 stable_temp.flush()?;
130
131 let mode = if private {
134 fs::set_permissions(stable_temp.path(), fs::Permissions::from_mode(0o444))132 fs::Permissions::from_mode(
135 .context("perm")?;133 u32::from_str_radix(&item.mode, 8).context("failed to parse mode as octal")?,
134 )
135 } else {
136 fs::Permissions::from_mode(0o444)
137 };
136 fs::set_permissions(&value.public_path, fs::Permissions::from_mode(0o444))138 fs::set_permissions(stable_temp.path(), mode.clone()).context("stable temp mode")?;
137 .context("perm")?;
138
139 stable_temp
140 .persist(value.stable_public_path)
141 .context("failed to persist")?;
142 }
143 if value.secret.is_none() {
144 info!("no secret data found");
145 return Ok(());
146 }
147 let secret = value.secret.as_ref().unwrap();
148
149 let mode = Mode::from_bits(139 fs::set_permissions(&value.path, mode).context("hashed mode")?;
150 u32::from_str_radix(&value.mode, 8).context("failed to parse mode as octal")?,140
151 )141 // Files are initially owned by root, thus making set mode first inaccessible to user, and then
152 .context("failed to parse mode")?;142 // altering user/group.
143 if private {
153 let user = User::from_name(&value.owner)144 let user = User::from_name(&item.owner)
154 .context("failed to get user")?145 .context("failed to get user")?
155 .ok_or_else(|| anyhow!("user not found"))?;146 .ok_or_else(|| anyhow!("user not found"))?;
156 let group = Group::from_name(&value.group)147 let group = Group::from_name(&item.group)
157 .context("failed to get group")?148 .context("failed to get group")?
158 .ok_or_else(|| anyhow!("group not found"))?;149 .ok_or_else(|| anyhow!("group not found"))?;
159150
160 let stable_dir = value.stable_secret_path.parent().expect("not root");
161 let mut stable_temp =
162 tempfile::NamedTempFile::new_in(stable_dir).context("failed to create tempfile")?;
163 let mut hashed = File::create(&value.secret_path)?;
164
165 // File is owned by root, and only root can modify it
166 let decrypted = decrypt(secret, identity)?;
167 if decrypted.is_empty() {
168 warn!("secret is decoded as empty, something is broken?");
169 }
170
171 io::copy(&mut Cursor::new(&decrypted), &mut stable_temp)
172 .context("failed to write decrypted file")?;
173 io::copy(&mut Cursor::new(decrypted), &mut hashed).context("failed to write decrypted file")?;
174
175 // Make file owned by specified user and group, then change mode
176 chown(stable_temp.path(), Some(user.uid), Some(group.gid))151 chown(stable_temp.path(), Some(user.uid), Some(group.gid))
177 .context("failed to apply user/group")?;152 .context("failed to apply user/group")?;
178 chown(&value.secret_path, Some(user.uid), Some(group.gid))153 chown(&value.path, Some(user.uid), Some(group.gid))
179 .context("failed to apply user/group")?;154 .context("failed to apply user/group")?;
180 fs::set_permissions(stable_temp.path(), fs::Permissions::from_mode(mode.bits())).unwrap();155 }
181 fs::set_permissions(&value.secret_path, fs::Permissions::from_mode(mode.bits())).unwrap();156
182 stable_temp157 stable_temp
183 .persist(value.stable_secret_path)158 .persist(&value.stable_path)
184 .context("failed to persist")?;159 .context("stable persist")?;
185
186 Ok(())160 Ok(())
187}161}
162
163fn init_secret(identity: &age::ssh::Identity, value: &DataItem) -> Result<()> {
164 if let Some(root_path) = &value.root_path {
165 if !fs::metadata(root_path).map(|m| m.is_dir()).unwrap_or(false) {
166 fs::create_dir(root_path).context("failed to create secret directory")?;
167 }
168 }
169 for (part_id, part) in value.parts.iter() {
170 let _span = info_span!("part", part_id = part_id);
171 if let Err(e) = init_part(identity, value, part) {
172 error!("failed to init part {part_id}: {e}");
173 }
174 }
175
176 Ok(())
177}
188178
189fn host_identity() -> anyhow::Result<SshIdentity> {179fn host_identity() -> anyhow::Result<SshIdentity> {
190 let identity = SshIdentity::from_buffer(180 let identity = SshIdentity::from_buffer(
214 let mut failed = false;204 let mut failed = false;
215 for (name, value) in data {205 for (name, value) in data {
216 let _span = info_span!("init", name = name);206 let _span = info_span!("init", name = name);
217 if let Err(e) = init_secret(&identity, value) {207 if let Err(e) = init_secret(&identity, &value) {
218 error!("{e}");208 error!("secret failed to initialize: {e}");
219 failed = true;209 failed = true;
220 }210 }
221 }211 }
257 let s = String::from_utf8(decrypted).context("output is not utf8")?;247 let s = String::from_utf8(decrypted).context("output is not utf8")?;
258 print!("{s}");248 print!("{s}");
259 } else {249 } else {
260 println!("{}", SecretWrapper(decrypted));250 println!(
251 "{}",
252 SecretData {
253 data: decrypted,
254 encrypted: false
255 }
256 );
261 }257 }
262 Ok(())258 Ok(())
modifiedcrates/better-command/src/handler.rsdiffbeforeafterboth
1//! Collection of handlers, which transform program-specific stdout format to tracing1//! Collection of handlers, which transform program-specific stdout format to tracing
22
3use std::collections::HashMap;
4use std::sync::{Arc, Mutex};3use std::{
4 collections::HashMap,
5 sync::{Arc, Mutex},
6};
57
6use once_cell::sync::Lazy;8use once_cell::sync::Lazy;
7use regex::Regex;9use regex::Regex;
addedcrates/fleet-shared/Cargo.tomldiffbeforeafterboth

no changes

addedcrates/fleet-shared/src/lib.rsdiffbeforeafterboth

no changes

modifiedcrates/nixlike/src/lib.rsdiffbeforeafterboth
38 Null,38 Null,
39}39}
40
41fn count_spaces(l: &str) -> usize {
42 l.chars().take_while(|&c| c == ' ').count()
43}
44fn is_significant(l: &str) -> bool {
45 count_spaces(l) != l.len()
46}
47
48fn dedent(l: &str, by: usize) -> &str {
49 assert!(
50 l[0..by.min(l.len())].chars().all(|c| c == ' '),
51 "dedent calculation is wrong"
52 );
53 &l[by.min(l.len())..]
54}
55
56fn process_multiline(lines: Vec<&str>) -> String {
57 // Even when parsing '''', there is single "line" between those '' delimiters.
58 // unwrap_or is for case where there is no significant lines
59 let dedent_by = lines
60 .iter()
61 .copied()
62 .filter(|c| is_significant(c))
63 .map(count_spaces)
64 .min()
65 .unwrap_or(0);
66
67 let mut out = String::new();
68
69 let mut had_first = false;
70 for (i, line) in lines.into_iter().enumerate() {
71 // Newline after '' is ignored, if there is no text.
72 if i == 0 && !is_significant(line) {
73 continue;
74 }
75 if had_first {
76 out.push('\n');
77 }
78 had_first = true;
79 // ''' is hard escape
80 for (i, part) in dedent(line, dedent_by).split("'''").enumerate() {
81 if i != 0 {
82 out.push_str(r#"""""#);
83 }
84 // This is the only replacements done by nixlike writer, no need to support more.
85 out.push_str(&part.replace("''${", "${").replace("''\\t", "\t"));
86 }
87 }
88
89 out
90}
4091
41peg::parser! {92peg::parser! {
42pub grammar nixlike() for str {93pub grammar nixlike() for str {
50 / "\\r" { "\r" }101 / "\\r" { "\r" }
51 / "\\$" { "$" }102 / "\\$" { "$" }
52 / c:$([_]) { c }103 / c:$([_]) { c }
53 rule string() -> String104 rule string() -> String = singleline_string() / multiline_string();
105 rule singleline_string() -> String
54 = quiet! { "\"" v:(!"\"" c:string_char() {c})* "\"" { v.into_iter().collect() } } / expected!("<string>")106 = quiet! { "\"" v:(!"\"" c:string_char() {c})* "\"" { v.into_iter().collect() } } / expected!("<string>")
107 pub rule multiline_string() -> String
108 = "''"
109 // 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...
110 // This logic is complicated, see `parse_multiline` test.
111 lines:$(("'''" / !"''" [_])*) "''"
112 {
113 process_multiline(lines.split('\n').collect())
114 }
55 rule boolean() -> bool115 rule boolean() -> bool
56 = quiet! { "true" {true}116 = quiet! { "true" {true}
57 / "false" {false} } / expected!("<boolean>")117 / "false" {false} } / expected!("<boolean>")
136 out196 out
137}197}
198
199#[test]
200fn parse_multiline() {
201 assert_eq!(nixlike::multiline_string("''\n''").expect("parse"), "");
202 assert_eq!(nixlike::multiline_string("''\n\n''").expect("parse"), "\n");
203 assert_eq!(nixlike::multiline_string("''t\n''").expect("parse"), "t\n");
204 assert_eq!(nixlike::multiline_string("''''").expect("parse"), "");
205 assert_eq!(nixlike::multiline_string("'' ''").expect("parse"), "");
206}
138207
modifiedcrates/nixlike/src/to_string.rsdiffbeforeafterboth
38}38}
3939
40pub fn write_nix_str(str: &str, out: &mut String) {40pub fn write_nix_str(str: &str, out: &mut String) {
41 if str.ends_with('\n') {
42 out.push_str("''");
43 for ele in str.split('\n') {
44 out.push('\n');
45 out.push_str(
46 &ele
47 // '' is escaped with '
48 .replace("''", "'''")
49 // ${ is escaped wth ''
50 .replace("${", "''${")
51 // \t is not counted as whitespace for dedent
52 // to avoid confusion, it is printed literally.
53 //
54 // ...Escaped \t literal should be prefixed with '' for... Idk, this logic is complicated.
55 .replace('\t', "''\\t"),
56 );
57 }
58 // Final newline is assumed due to str.ends_with condition
59 out.push_str("''");
60 } else {
41 out.push_str(&escape_string(str))61 out.push_str(&escape_string(str))
42}62 }
63}
4364
44fn write_nix_buf(value: &Value, out: &mut String) {65fn write_nix_buf(value: &Value, out: &mut String) {
modifiedcrates/remowt-fs/src/lib.rsdiffbeforeafterboth

no syntactic changes

modifiedmodules/fleet/secrets.nixdiffbeforeafterboth
7with lib;7with lib;
8with fleetLib; let8with fleetLib; let
9 sharedSecret = with types; ({config, ...}: {9 sharedSecret = with types; ({config, ...}: {
10 options = {
11 managed = mkOption {10 freeformType = types.lazyAttrsOf unspecified;
12 type = bool;11 options = {
13 description = ''
14 Is this secret managed by configuration (I.e will work with reencrypt/etc), or it is configured by user
15 '';
16 };
17
18 expectedOwners = mkOption {12 expectedOwners = mkOption {
19 type = nullOr (listOf str);13 type = nullOr (listOf str);
71 '';65 '';
72 default = [];66 default = [];
73 };67 };
74 # TODO: Make secret generator generate arbitrary number of secret/public parts?
75 # Make it generate a folder, where all files except suffixed by .enc are public, and the rest are secret?
76 # How should modules refer to those files then?
77 public = mkOption {
78 type = nullOr str;
79 description = "Secret public data. Imported from fleet.nix";
80 default = null;
81 };
82 secret = mkOption {
83 type = nullOr str;
84 description = "Encrypted secret data. Imported from fleet.nix";
85 default = null;
86 internal = true;
87 };
88 };68 };
89 });69 });
90 hostSecret = with types; {70 hostSecret = with types; {
71 freeformType = types.lazyAttrsOf unspecified;
91 options = {72 options = {
92 createdAt = mkOption {73 createdAt = mkOption {
93 type = nullOr str;74 type = nullOr str;
97 type = nullOr str;78 type = nullOr str;
98 default = null;79 default = null;
99 };80 };
100 public = mkOption {
101 type = nullOr str;
102 description = "Secret public data. Imported from fleet.nix";
103 default = null;
104 };
105 secret = mkOption {
106 type = nullOr str;
107 description = "Encrypted secret data. Imported from fleet.nix";
108 default = null;
109 internal = true;
110 };
111 };81 };
112 };82 };
113in {83in {
114 options = with types; {84 options = with types; {
85 version = mkOption {
86 type = str;
87 default = "";
88 internal = true;
89 };
115 sharedSecrets = mkOption {90 sharedSecrets = mkOption {
116 type = attrsOf (submodule sharedSecret);91 type = attrsOf (submodule sharedSecret);
117 default = {};92 default = {};
134 config.sharedSecrets;109 config.sharedSecrets;
135 hosts = hostsToAttrs (host: {110 hosts = hostsToAttrs (host: {
136 nixosModules = let111 nixosModules = let
112 # processPart
137 cleanupSecret = secretName: v: {113 processSecret = v:
138 inherit (v) public secret;114 (removeAttrs v ["createdAt" "expiresAt" "expectedOwners" "owners" "regenerateOnOwnerAdded" "regenerateOnOwnerRemoved"])
115 // {
139 shared = true;116 shared = true;
140 };117 };
141 in [118 in [
142 {119 {
143 secrets =120 secrets =
144 (121 (
145 mapAttrs cleanupSecret122 mapAttrs (_: processSecret)
146 (filterAttrs (_: v: builtins.elem host v.owners) config.sharedSecrets)123 (filterAttrs (_: v: builtins.elem host v.owners) config.sharedSecrets)
147 )124 )
148 // (mapAttrs cleanupSecret (config.hostSecrets.${host} or {}));125 // (mapAttrs (_: processSecret) (config.hostSecrets.${host} or {}));
149 }126 }
150 ];127 ];
151 });128 });
modifiednixos/secrets.nixdiffbeforeafterboth
5 ...
6}:
3with lib;7with lib; let
48 inherit (lib.strings) hasPrefix stripPrefix;
5let9 plaintextPrefix = "<PLAINTEXT>";
10 plaintextNewlinePrefix = "<PLAINTEXT-NL>";
11
6 sysConfig = config;12 sysConfig = config;
13 secretPartType = secretName:
14 types.submodule ({config, ...}: {
15 options = with types; {
16 raw = mkOption {
17 description = "Secret in fleet-specific undocumented format, do not use. Import from fleet.nix";
18 internal = true;
19 };
20 hash = mkOption {
21 type = str;
22 description = "Hash of secret in encoded format";
23 };
24 path = mkOption {
25 type = str;
26 description = "Path to secret part, incorporating data hash (thus it will be updated on secret change)";
27 };
28 stablePath = mkOption {
29 type = str;
30 description = "Path to secret part, incorporating data hash (thus it will be updated on secret change)";
31 };
32 data = mkOption {
33 type = str;
34 description = "Secret public data (only available for plaintext)";
35 };
36 };
37 config = let
38 partName = config._module.args.name;
39 in {
40 hash = mkOptionDefault (builtins.hashString "sha1" config.raw);
41 data = mkOptionDefault (
42 if hasPrefix plaintextPrefix config.raw
43 then stripPrefix plaintextPrefix config.raw
44 else if hasPrefix plaintextNewlinePrefix config.raw
45 then stripPrefix plaintextNewlinePrefix config.raw
46 else throw "secret.part.data attribute only works for public plaintext secret parts, got ${config.raw}"
47 );
48 path = mkOptionDefault "/run/secrets/${secretName}/${config.hash}-${partName}";
49 stablePath = mkOptionDefault "/run/secrets/${secretName}/${partName}";
50 };
51 });
7 secretType = types.submodule ({ config, ... }: {52 secretType = types.submodule ({config, ...}: let
8 config = let secretName = config._module.args.name; in {53 secretName = config._module.args.name;
9 stableSecretPath = mkOptionDefault "/run/secrets/secret-stable-${secretName}";54 in {
10 secretPath = mkOptionDefault "/run/secrets/secret-${config.secretHash}-${secretName}";
11 secretHash = mkOptionDefault (if config.secret != null then (builtins.hashString "sha1" config.secret) else throw "secret is not defined for secret ${secretName}");
12
13 stablePublicPath = mkOptionDefault "/run/secrets/public-stable-${secretName}";
14 publicPath = mkOptionDefault "/run/secrets/public-${config.publicHash}-${secretName}";
15 publicHash = mkOptionDefault (if config.public != null then (builtins.hashString "sha1" config.public) else throw "public is not defined for secret ${secretName}");
16 };55 freeformType = types.lazyAttrsOf (secretPartType secretName);
17 options = with types; {56 options = with types; {
18 shared = mkOption {57 shared = mkOption {
19 description = "Is this secret owned by this machine, or propagated from shared secrets";58 description = "Is this secret owned by this machine, or propagated from shared secrets";
20 default = false;59 default = false;
21 };60 };
22
23 generator = mkOption {61 expectedOwners = mkOption {
24 type = nullOr unspecified;62 type = nullOr unspecified;
25 description = "Derivation to evaluate for secret generation";
26 default = null;63 default = null;
64 internal = true;
27 };65 };
2866
29 public = mkOption {
30 type = nullOr str;
31 description = "Secret public data";
32 default = null;
33 };
34 secret = mkOption {67 generator = mkOption {
35 type = nullOr str;68 type = nullOr unspecified;
36 description = "Encrypted secret data";69 description = "Derivation to evaluate for secret generation";
37 default = null;70 default = null;
38 };71 };
39 mode = mkOption {72 mode = mkOption {
52 default = sysConfig.users.users.${config.owner}.group;85 default = sysConfig.users.users.${config.owner}.group;
53 };86 };
54
55 secretHash = mkOption {
56 type = str;
57 description = "Hash of .secret field";
58 };
59 publicHash = mkOption {
60 type = str;
61 description = "Hash of .public field";
62 };
63
64 stableSecretPath = mkOption {
65 type = str;
66 description = ''
67 Use this, if target process supports re-reading of secret from disk,
68 and doesn't needs to be restarted when secret is updated in file
69 '';
70 };
71 secretPath = mkOption {
72 type = str;
73 description = "Path to decrypted secret, suffixed with contents hash";
74 };
75
76 stablePublicPath = mkOption {
77 type = str;
78 description = ''
79 Use this, if target process supports re-reading of secret from disk,
80 and doesn't needs to be restarted when secret is updated in file
81 '';
82 };
83 publicPath = mkOption {
84 type = str;
85 description = "Path to the public part of secret";
86 };
87 };87 };
88 });88 });
89 processPart = part: {
90 inherit (part) raw path stablePath;
91 };
92 processSecret = secret:
93 {
94 inherit (secret) group mode owner;
95 }
96 // (mapAttrs (_: processPart) (removeAttrs secret [
97 "shared"
98 "generator"
99 "mode"
100 "group"
101 "owner"
102
103 # FIXME: Some of those removed attributes shouldn't be here, but there is some error in passing shared secrets from fleet to nixos.
104 "expectedOwners"
105 ]));
89 secretsFile = pkgs.writeTextFile {106 secretsFile = pkgs.writeTextFile {
90 name = "secrets.json";107 name = "secrets.json";
91 text = builtins.toJSON (mapAttrs (_: value: rec {108 text =
92 inherit (value) group mode owner secret public;
93 publicPath = if public != null then value.publicPath else "/missingno";
94 stablePublicPath = if public != null then value.stablePublicPath else "/missingno";
95 secretPath = if secret != null then value.secretPath else "/missingno";
96 stableSecretPath = if secret != null then value.stableSecretPath else "/missingno";
97 }) config.secrets);109 builtins.toJSON (mapAttrs (_: processSecret)
110 config.secrets);
98 };111 };
99in112in {
106 };118 };
107 };119 };
108 config = {120 config = {
109 environment.systemPackages = with pkgs; [pkgs.fleet-install-secrets];121 environment.systemPackages = [pkgs.fleet-install-secrets];
110 system.activationScripts.decryptSecrets = stringAfter [ "users" "groups" "specialfs" ] ''122 system.activationScripts.decryptSecrets = stringAfter ["users" "groups" "specialfs"] ''
111 1>&2 echo "setting up secrets"123 1>&2 echo "setting up secrets"
112 ${pkgs.fleet-install-secrets}/bin/fleet-install-secrets install ${secretsFile}124 ${pkgs.fleet-install-secrets}/bin/fleet-install-secrets install ${secretsFile}
modifiedrustfmt.tomldiffbeforeafterboth
1hard_tabs = true1hard_tabs = true
2imports_granularity = "Crate"
3group_imports = "StdExternalCrate"
24