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.rsdiffbeforeafterboth--- a/cmds/fleet/src/cmds/secrets/mod.rs
+++ b/cmds/fleet/src/cmds/secrets/mod.rs
@@ -1,27 +1,29 @@
-use crate::{
- better_nix_eval::Field,
- fleetdata::{FleetSecret, FleetSharedSecret, SecretData},
- host::Config,
- nix_go, nix_go_json,
+use std::{
+ collections::{BTreeMap, BTreeSet, HashSet},
+ ffi::OsString,
+ io::{self, stdin, stdout, Read, Write},
+ path::PathBuf,
};
+
use anyhow::{anyhow, bail, ensure, Context, Result};
use chrono::{DateTime, Utc};
-use clap::{error::ErrorKind, Parser};
+use clap::Parser;
use crossterm::{terminal, tty::IsTty};
+use fleet_shared::SecretData;
use itertools::Itertools;
use owo_colors::OwoColorize;
use serde::Deserialize;
-use std::{
- collections::{BTreeSet, HashSet},
- ffi::OsString,
- io::{self, stdin, Cursor, Read, Write},
- path::PathBuf,
-};
use tabled::{Table, Tabled};
-use tempfile::NamedTempFile;
-use tokio::{fs::read_to_string, process::Command};
+use tokio::{fs::read, process::Command};
use tracing::{error, info, info_span, warn, Instrument};
+use crate::{
+ better_nix_eval::Field,
+ fleetdata::{encrypt_secret_data, FleetSecret, FleetSecretPart, FleetSharedSecret},
+ host::Config,
+ nix_go, nix_go_json,
+};
+
#[derive(Parser)]
pub enum Secret {
/// Force load host keys for all defined hosts
@@ -38,6 +40,9 @@
/// Secret public part
#[clap(long)]
public: Option<String>,
+ /// How to name public secret part
+ #[clap(long, default_value = "public")]
+ public_name: String,
/// Load public part from specified file
#[clap(long)]
public_file: Option<PathBuf>,
@@ -49,6 +54,9 @@
/// Secret with this name already exists, override its value while keeping the same owners.
#[clap(long)]
re_add: bool,
+
+ #[clap(default_value = "secret")]
+ part_name: String,
},
/// Add secret, data should be provided in stdin
Add {
@@ -59,22 +67,27 @@
/// Override secret if already present
#[clap(long)]
force: bool,
+ /// Secret public part
#[clap(long)]
public: Option<String>,
+ /// How to name public secret part
+ #[clap(long, default_value = "public")]
+ public_name: String,
+ /// Load public part from specified file
#[clap(long)]
public_file: Option<PathBuf>,
+
+ #[clap(default_value = "secret")]
+ part_name: String,
},
/// Read secret from remote host, requires sudo on said host
Read {
name: String,
machine: String,
- #[clap(long)]
- plaintext: bool,
+
+ #[clap(default_value = "secret")]
+ part_name: String,
},
- ReadPublic {
- name: String,
- machine: String,
- },
UpdateShared {
name: String,
@@ -89,6 +102,9 @@
/// Which host should we use to decrypt
#[clap(long)]
prefer_identities: Vec<String>,
+
+ #[clap(default_value = "secret")]
+ part_name: String,
},
Regenerate {
/// Which host should we use to decrypt, in case if reencryption is required, without
@@ -97,6 +113,16 @@
prefer_identities: Vec<String>,
},
List {},
+ Edit {
+ name: String,
+ machine: String,
+
+ #[clap(default_value = "secret")]
+ part: String,
+
+ #[clap(long)]
+ add: bool,
+ },
}
#[tracing::instrument(skip(config, secret, field, prefer_identities))]
@@ -144,10 +170,16 @@
bail!("no available holder found");
};
- if let Some(data) = secret.secret.secret {
+ for (part_name, part) in secret.secret.parts.iter_mut() {
+ let _span = info_span!("part reencryption", part_name);
+ if !part.raw.encrypted {
+ continue;
+ }
let host = config.host(identity_holder).await?;
- let encrypted = host.reencrypt(data, updated_set.to_vec()).await?;
- secret.secret.secret = Some(encrypted);
+ let encrypted = host
+ .reencrypt(part.raw.clone(), updated_set.to_vec())
+ .await?;
+ part.raw = encrypted;
}
secret.owners = updated_set.to_vec();
@@ -232,13 +264,17 @@
ensure!(marker == "SUCCESS", "generation not succeeded");
}
- let public = host.read_file_text(format!("{out}/public")).await.ok();
- let secret = host.read_file_bin(format!("{out}/secret")).await.ok();
- if let Some(secret) = &secret {
- ensure!(
- age::Decryptor::new(Cursor::new(&secret)).is_ok(),
- "builder produced non-encrypted value as secret, this is highly insecure, and not allowed."
- );
+ let mut parts = BTreeMap::new();
+ for part in host.read_dir(&out).await? {
+ if part == "created_at" || part == "expired_at" || part == "marker" {
+ continue;
+ }
+ let contents: SecretData = host
+ .read_file_text(format!("{out}/{part}"))
+ .await?
+ .parse()
+ .map_err(|e| anyhow!("failed to decode secret {out:?} part {part:?}: {e}"))?;
+ parts.insert(part.to_owned(), FleetSecretPart { raw: contents });
}
let created_at = host.read_file_value(format!("{out}/created_at")).await?;
@@ -247,8 +283,7 @@
Ok(FleetSecret {
created_at,
expires_at,
- public,
- secret: secret.map(SecretData),
+ parts,
})
}
async fn generate(
@@ -310,10 +345,16 @@
async fn parse_public(
public: Option<String>,
public_file: Option<PathBuf>,
-) -> Result<Option<String>> {
+) -> Result<Option<SecretData>> {
Ok(match (public, public_file) {
- (Some(v), None) => Some(v),
- (None, Some(v)) => Some(read_to_string(v).await?),
+ (Some(v), None) => Some(SecretData {
+ data: v.into(),
+ encrypted: false,
+ }),
+ (None, Some(v)) => Some(SecretData {
+ data: read(v).await?,
+ encrypted: false,
+ }),
(Some(_), Some(_)) => {
bail!("only public or public_file should be set")
}
@@ -321,6 +362,16 @@
})
}
+async fn parse_secret() -> Result<Option<Vec<u8>>> {
+ let mut input = vec![];
+ io::stdin().read_to_end(&mut input)?;
+ if input.is_empty() {
+ Ok(None)
+ } else {
+ Ok(Some(input))
+ }
+}
+
fn parse_machines(
initial: Vec<String>,
machines: Option<Vec<String>>,
@@ -391,9 +442,11 @@
name,
force,
public,
+ public_name,
public_file,
expires_at,
re_add,
+ part_name,
} => {
// TODO: Forbid updating secrets with set expectedOwners (= not user-managed).
@@ -415,20 +468,21 @@
let recipients = config.recipients(machines.clone()).await?;
- let secret = {
- let mut input = vec![];
- io::stdin().read_to_end(&mut input)?;
+ let mut parts = BTreeMap::new();
- if input.is_empty() {
- None
- } else {
- Some(
- SecretData::encrypt(recipients, input)
- .ok_or_else(|| anyhow!("no recipients provided"))?,
- )
- }
- };
- let public = parse_public(public, public_file).await?;
+ let mut input = vec![];
+ io::stdin().read_to_end(&mut input)?;
+
+ if !input.is_empty() {
+ let encrypted = encrypt_secret_data(recipients, input)
+ .ok_or_else(|| anyhow!("no recipients provided"))?;
+ parts.insert(part_name, FleetSecretPart { raw: encrypted });
+ }
+
+ if let Some(public) = parse_public(public, public_file).await? {
+ parts.insert(public_name, FleetSecretPart { raw: public });
+ }
+
config.replace_shared(
name,
FleetSharedSecret {
@@ -436,8 +490,7 @@
secret: FleetSecret {
created_at: Utc::now(),
expires_at,
- secret,
- public,
+ parts,
},
},
);
@@ -447,33 +500,34 @@
name,
force,
public,
+ public_name,
public_file,
+ part_name,
} => {
- let recipient = config.recipient(&machine).await?;
+ if config.has_secret(&machine, &name) && !force {
+ bail!("secret already defined");
+ }
- let secret = {
- let mut input = vec![];
- io::stdin().read_to_end(&mut input)?;
- if input.is_empty() {
- bail!("no data provided")
- }
+ let mut parts = BTreeMap::new();
- Some(SecretData::encrypt(vec![recipient], input).expect("recipient provided"))
- };
-
- if config.has_secret(&machine, &name) && !force {
- bail!("secret already defined");
+ if let Some(secret) = parse_secret().await? {
+ let recipient = config.recipient(&machine).await?;
+ let encrypted =
+ encrypt_secret_data(vec![recipient], secret).expect("recipient provided");
+ parts.insert(part_name, FleetSecretPart { raw: encrypted });
}
- let public = parse_public(public, public_file).await?;
+ if let Some(public) = parse_public(public, public_file).await? {
+ parts.insert(public_name, FleetSecretPart { raw: public });
+ };
+
config.insert_secret(
&machine,
name,
FleetSecret {
created_at: Utc::now(),
expires_at: None,
- secret,
- public,
+ parts,
},
);
}
@@ -481,27 +535,20 @@
Secret::Read {
name,
machine,
- plaintext,
+ part_name,
} => {
let secret = config.host_secret(&machine, &name)?;
- let Some(secret) = secret.secret else {
- bail!("no secret {name}");
+ let Some(secret) = secret.parts.get(&part_name) else {
+ bail!("no part {part_name} in secret {name}");
};
- let host = config.host(&machine).await?;
- let data = host.decrypt(secret).await?;
- if plaintext {
- let s = String::from_utf8(data).context("output is not utf8")?;
- print!("{s}");
+ let data = if secret.raw.encrypted {
+ let host = config.host(&machine).await?;
+ host.decrypt(secret.raw.clone()).await?
} else {
- println!("{}", z85::encode(&data));
- }
- }
- Secret::ReadPublic { name, machine } => {
- let secret = config.host_secret(&machine, &name)?;
- let Some(public) = secret.public else {
- bail!("no secret {name}");
+ secret.raw.data.clone()
};
- print!("{public}");
+
+ stdout().write_all(&data)?;
}
Secret::UpdateShared {
name,
@@ -509,11 +556,12 @@
add_machines,
remove_machines,
prefer_identities,
+ part_name,
} => {
// TODO: Forbid updating secrets with set expectedOwners (= not user-managed).
let secret = config.shared_secret(&name)?;
- if secret.secret.secret.is_none() {
+ if secret.secret.parts.get(&part_name).is_none() {
bail!("no secret");
}
@@ -572,6 +620,10 @@
}
}
for host in config.list_hosts().await? {
+ if config.should_skip(&host.name) {
+ continue;
+ }
+
let _span = info_span!("host", host = host.name).entered();
let expected_set = host
.list_configured_secrets()
@@ -664,7 +716,103 @@
}
info!("loaded\n{}", Table::new(table).to_string())
}
+ Secret::Edit {
+ name,
+ machine,
+ part,
+ add,
+ } => {
+ let secret = config.host_secret(&machine, &name)?;
+ if let Some(data) = secret.parts.get(&part) {
+ let host = config.host(&machine).await?;
+ let secret = host.decrypt(data.raw.clone()).await?;
+ String::from_utf8(secret).context("secret is not utf8")?
+ } else if add {
+ String::new()
+ } else {
+ bail!("part {part} not found in secret {name}. Did you mean to `--add` it?");
+ };
+ }
}
Ok(())
}
}
+
+async fn edit_temp_file(
+ builder: tempfile::Builder<'_, '_>,
+ r: Vec<u8>,
+ header: &str,
+ comment: &str,
+) -> Result<(Vec<u8>, Option<String>), anyhow::Error> {
+ if !stdin().is_tty() {
+ // TODO: Also try to open /dev/tty directly?
+ bail!("stdin is not tty, can't open editor");
+ }
+
+ use std::fmt::Write;
+ let mut file = builder.tempfile()?;
+
+ let mut full_header = String::new();
+ let mut had = false;
+ for line in header.trim_end().lines() {
+ had = true;
+ writeln!(&mut full_header, "{comment}{line}")?;
+ }
+ if had {
+ writeln!(&mut full_header, "{}", comment.trim_end())?;
+ }
+ writeln!(
+ &mut full_header,
+ "{comment}Do not touch this header! It will be removed automatically"
+ )?;
+
+ file.write_all(full_header.as_bytes())?;
+ file.write_all(&r)?;
+
+ let abs_path = file.into_temp_path();
+ let editor = std::env::var_os("VISUAL")
+ .or_else(|| std::env::var_os("EDITOR"))
+ .unwrap_or_else(|| "vi".into());
+ let editor_args = shlex::bytes::split(editor.as_encoded_bytes())
+ .ok_or_else(|| anyhow!("EDITOR env var has wrong syntax"))?;
+ let editor_args = editor_args
+ .into_iter()
+ .map(|v| {
+ // Only ASCII subsequences are replaced
+ unsafe { OsString::from_encoded_bytes_unchecked(v) }
+ })
+ .collect_vec();
+ let Some((editor, args)) = editor_args.split_first() else {
+ bail!("EDITOR env var has no command");
+ };
+ let mut command = Command::new(editor);
+ command.args(args);
+
+ let path_arg = abs_path.canonicalize()?;
+
+ // TODO: Save full state, using tcget/_getmode/_setmode
+ let was_raw = terminal::is_raw_mode_enabled()?;
+ terminal::enable_raw_mode()?;
+
+ let status = command.arg(path_arg).status().await;
+
+ if !was_raw {
+ terminal::disable_raw_mode()?;
+ }
+
+ let success = match status {
+ Ok(s) => s.success(),
+ Err(e) if e.kind() == io::ErrorKind::NotFound => {
+ bail!("editor not found")
+ }
+ Err(e) => bail!("editor spawn error: {e}"),
+ };
+
+ let mut file = std::fs::read(&abs_path).context("read editor output")?;
+ let Some(v) = file.strip_prefix(full_header.as_bytes()) else {
+ todo!();
+ };
+ todo!();
+
+ // Ok((success, abs_path))
+}
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.rsdiffbeforeafterboth1use std::{2 env::current_dir,3 ffi::{OsStr, OsString},4 fmt::Display,5 io::Write,6 ops::Deref,7 path::PathBuf,8 str::FromStr,9 sync::{Arc, Mutex, MutexGuard, OnceLock},10};1112use anyhow::{anyhow, bail, Context, Result};13use clap::{ArgGroup, Parser};14use openssh::SessionBuilder;15use serde::de::DeserializeOwned;16use tempfile::NamedTempFile;1718use crate::{19 better_nix_eval::{Field, NixSessionPool},20 command::MyCommand,21 fleetdata::{FleetData, FleetSecret, FleetSharedSecret, SecretData},22 nix_go, nix_go_json,23};2425pub struct FleetConfigInternals {26 pub local_system: String,27 pub directory: PathBuf,28 pub opts: FleetOpts,29 pub data: Mutex<FleetData>,30 pub nix_args: Vec<OsString>,31 /// fleet_config.config32 pub config_field: Field,33 /// fleet_config.unchecked.config34 pub config_unchecked_field: Field,3536 /// import nixpkgs {system = local};37 pub default_pkgs: Field,38}3940#[derive(Clone)]41pub struct Config(Arc<FleetConfigInternals>);4243impl Deref for Config {44 type Target = FleetConfigInternals;4546 fn deref(&self) -> &Self::Target {47 &self.048 }49}5051pub struct ConfigHost {52 config: Config,53 pub name: String,54 pub local: bool,55 pub session: OnceLock<Arc<openssh::Session>>,5657 pub nixos_config: Option<Field>,58}59impl ConfigHost {60 async fn open_session(&self) -> Result<Arc<openssh::Session>> {61 assert!(!self.local, "do not open ssh connection to local session");62 // FIXME: TOCTOU63 if let Some(session) = &self.session.get() {64 return Ok((*session).clone());65 };66 let session = SessionBuilder::default();6768 let session = session69 .connect(&self.name)70 .await71 .map_err(|e| anyhow!("ssh error while connecting to {}: {e}", self.name))?;72 let session = Arc::new(session);73 self.session.set(session.clone()).expect("TOCTOU happened");74 Ok(session)75 }76 pub async fn mktemp_dir(&self) -> Result<String> {77 let mut cmd = self.cmd("mktemp").await?;78 cmd.arg("-d");79 let path = cmd.run_string().await?;80 Ok(path.trim_end().to_owned())81 }82 pub async fn read_file_bin(&self, path: impl AsRef<OsStr>) -> Result<Vec<u8>> {83 let mut cmd = self.cmd("cat").await?;84 cmd.arg(path);85 cmd.run_bytes().await86 }87 pub async fn read_file_text(&self, path: impl AsRef<OsStr>) -> Result<String> {88 let mut cmd = self.cmd("cat").await?;89 cmd.arg(path);90 cmd.run_string().await91 }92 #[allow(dead_code)]93 pub async fn read_file_json<D: DeserializeOwned>(&self, path: impl AsRef<OsStr>) -> Result<D> {94 let text = self.read_file_text(path).await?;95 Ok(serde_json::from_str(&text)?)96 }97 pub async fn read_file_value<D: FromStr>(&self, path: impl AsRef<OsStr>) -> Result<D>98 where99 <D as FromStr>::Err: Display,100 {101 let text = self.read_file_text(path).await?;102 D::from_str(&text).map_err(|e| anyhow!("failed to parse value: {e}"))103 }104 pub async fn cmd(&self, cmd: impl AsRef<OsStr>) -> Result<MyCommand> {105 if self.local {106 Ok(MyCommand::new(cmd))107 } else {108 let session = self.open_session().await?;109 Ok(MyCommand::new_on(cmd, session))110 }111 }112113 pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {114 let mut cmd = self.cmd("fleet-install-secrets").await?;115 cmd.arg("decrypt").eqarg("--secret", data.encode_z85());116 let encoded = cmd117 .sudo()118 .run_string()119 .await120 .context("failed to call remote host for decrypt")?;121 z85::decode(encoded.trim_end()).context("bad encoded data? outdated host?")122 }123 pub async fn reencrypt(&self, data: SecretData, targets: Vec<String>) -> Result<SecretData> {124 let mut cmd = self.cmd("fleet-install-secrets").await?;125 cmd.arg("reencrypt").eqarg("--secret", data.encode_z85());126 for target in targets {127 let key = self.config.key(&target).await?;128 cmd.eqarg("--targets", key);129 }130 let encoded = cmd131 .sudo()132 .run_string()133 .await134 .context("failed to call remote host for decrypt")?;135 SecretData::decode_z85(encoded.trim_end()).context("bad encoded data? outdated host?")136 }137 /// Returns path for futureproofing, as path might change i.e on conversion to CA138 pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {139 if self.local {140 // Path is located locally, thus already trusted.141 return Ok(path.to_owned());142 }143 let mut nix = MyCommand::new("nix");144 nix.arg("copy")145 .arg("--substitute-on-destination")146 .comparg("--to", format!("ssh-ng://{}", self.name))147 .arg(path);148 nix.run_nix().await.context("nix copy")?;149 Ok(path.to_owned())150 }151 pub async fn systemctl_stop(&self, name: &str) -> Result<()> {152 let mut cmd = self.cmd("systemctl").await?;153 cmd.arg("stop").arg(name);154 cmd.sudo().run().await155 }156 pub async fn systemctl_start(&self, name: &str) -> Result<()> {157 let mut cmd = self.cmd("systemctl").await?;158 cmd.arg("start").arg(name);159 cmd.sudo().run().await160 }161162 pub async fn rm_file(&self, path: impl AsRef<OsStr>, sudo: bool) -> Result<()> {163 let mut cmd = self.cmd("rm").await?;164 cmd.arg("-f").arg(path);165 if sudo {166 cmd = cmd.sudo()167 }168 cmd.run().await169 }170171 pub async fn list_configured_secrets(&self) -> Result<Vec<String>> {172 let Some(nixos) = &self.nixos_config else {173 return Ok(vec![]);174 };175 let secrets = nix_go!(nixos.secrets);176 let mut out = Vec::new();177 for name in secrets.list_fields().await? {178 let secret = nix_go!(secrets[{ name }]);179 let is_shared: bool = nix_go_json!(secret.shared);180 if is_shared {181 continue;182 }183 out.push(name);184 }185 Ok(out)186 }187 pub async fn secret_field(&self, name: &str) -> Result<Field> {188 let Some(nixos) = &self.nixos_config else {189 bail!("host is virtual and has no secrets");190 };191 Ok(nix_go!(nixos.secrets[{ name }]))192 }193194 /// Packages for this host, resolved with nixpkgs overlays195 pub async fn pkgs(&self) -> Result<Field> {196 let Some(nixos) = &self.nixos_config else {197 return Ok(self.config.default_pkgs.clone());198 };199 Ok(nix_go!(nixos.nixpkgs.resolvedPkgs))200 }201}202203impl Config {204 pub fn should_skip(&self, host: &str) -> bool {205 if !self.opts.skip.is_empty() {206 self.opts.skip.iter().any(|h| h as &str == host)207 } else if !self.opts.only.is_empty() {208 !self.opts.only.iter().any(|h| h as &str == host)209 } else {210 false211 }212 }213 pub fn is_local(&self, host: &str) -> bool {214 self.opts.localhost.as_ref().map(|s| s as &str) == Some(host)215 }216217 pub fn local_host(&self) -> ConfigHost {218 ConfigHost {219 config: self.clone(),220 name: "<virtual localhost>".to_owned(),221 local: true,222 session: OnceLock::new(),223 nixos_config: None,224 }225 }226227 pub async fn host(&self, name: &str) -> Result<ConfigHost> {228 let config = &self.config_unchecked_field;229 let nixos_config = nix_go!(config.hosts[{ name }].nixosSystem.config);230 Ok(ConfigHost {231 config: self.clone(),232 name: name.to_owned(),233 local: self.is_local(name),234 session: OnceLock::new(),235 nixos_config: Some(nixos_config),236 })237 }238 pub async fn list_hosts(&self) -> Result<Vec<ConfigHost>> {239 let config = &self.config_unchecked_field;240 let names = nix_go!(config.hosts).list_fields().await?;241 let mut out = vec![];242 for name in names {243 out.push(self.host(&name).await?);244 }245 Ok(out)246 }247 pub async fn system_config(&self, host: &str) -> Result<Field> {248 let fleet_field = &self.config_unchecked_field;249 Ok(nix_go!(fleet_field.hosts[{ host }].nixosSystem.config))250 }251252 pub(super) fn data(&self) -> MutexGuard<FleetData> {253 self.data.lock().unwrap()254 }255 pub(super) fn data_mut(&self) -> MutexGuard<FleetData> {256 self.data.lock().unwrap()257 }258 /// Shared secrets configured in fleet.nix or in flake259 pub async fn list_configured_shared(&self) -> Result<Vec<String>> {260 let config_field = &self.config_unchecked_field;261 nix_go!(config_field.sharedSecrets).list_fields().await262 }263 /// Shared secrets configured in fleet.nix264 pub fn list_shared(&self) -> Vec<String> {265 let data = self.data();266 data.shared_secrets.keys().cloned().collect()267 }268 pub fn has_shared(&self, name: &str) -> bool {269 let data = self.data();270 data.shared_secrets.contains_key(name)271 }272 pub fn replace_shared(&self, name: String, shared: FleetSharedSecret) {273 let mut data = self.data_mut();274 data.shared_secrets.insert(name.to_owned(), shared);275 }276 pub fn remove_shared(&self, secret: &str) {277 let mut data = self.data_mut();278 data.shared_secrets.remove(secret);279 }280281 pub fn list_secrets(&self, host: &str) -> Vec<String> {282 let data = self.data();283 let Some(secrets) = data.host_secrets.get(host) else {284 return Vec::new();285 };286 secrets.keys().cloned().collect()287 }288289 pub fn has_secret(&self, host: &str, secret: &str) -> bool {290 let data = self.data();291 let Some(host_secrets) = data.host_secrets.get(host) else {292 return false;293 };294 host_secrets.contains_key(secret)295 }296 pub fn insert_secret(&self, host: &str, secret: String, value: FleetSecret) {297 let mut data = self.data_mut();298 let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();299 host_secrets.insert(secret, value);300 }301302 pub fn host_secret(&self, host: &str, secret: &str) -> Result<FleetSecret> {303 let data = self.data();304 let Some(host_secrets) = data.host_secrets.get(host) else {305 bail!("no secrets for machine {host}");306 };307 let Some(secret) = host_secrets.get(secret) else {308 bail!("machine {host} has no secret {secret}");309 };310 Ok(secret.clone())311 }312 pub fn shared_secret(&self, secret: &str) -> Result<FleetSharedSecret> {313 let data = self.data();314 let Some(secret) = data.shared_secrets.get(secret) else {315 bail!("no shared secret {secret}");316 };317 Ok(secret.clone())318 }319 pub async fn shared_secret_expected_owners(&self, secret: &str) -> Result<Vec<String>> {320 let config_field = &self.config_unchecked_field;321 Ok(nix_go_json!(322 config_field.sharedSecrets[{ secret }].expectedOwners323 ))324 }325326 pub fn save(&self) -> Result<()> {327 let mut tempfile = NamedTempFile::new_in(self.directory.clone())?;328 let data = nixlike::serialize(&self.data() as &FleetData)?;329 tempfile.write_all(330 format!(331 "# This file contains fleet state and shouldn't be edited by hand\n\n{}\n\n# vim: ts=2 et nowrap\n",332 data333 )334 .as_bytes(),335 )?;336 let mut fleet_data_path = self.directory.clone();337 fleet_data_path.push("fleet.nix");338 tempfile.persist(fleet_data_path)?;339 Ok(())340 }341}342343#[derive(Parser, Clone)]344#[clap(group = ArgGroup::new("target_hosts"))]345pub struct FleetOpts {346 /// All hosts except those would be skipped347 #[clap(long, number_of_values = 1, group = "target_hosts")]348 only: Vec<String>,349350 /// Hosts to skip351 #[clap(long, number_of_values = 1, group = "target_hosts")]352 skip: Vec<String>,353354 /// Host, which should be threaten as current machine355 #[clap(long)]356 pub localhost: Option<String>,357358 /// Override detected system for host, to perform builds via359 /// binfmt-declared qemu instead of trying to crosscompile360 #[clap(long, default_value = "detect")]361 pub local_system: String,362}363364impl FleetOpts {365 pub async fn build(mut self, nix_args: Vec<OsString>) -> Result<Config> {366 if self.localhost.is_none() {367 self.localhost368 .replace(hostname::get().unwrap().to_str().unwrap().to_owned());369 }370 let directory = current_dir()?;371372 let pool = NixSessionPool::new(directory.as_os_str().to_owned(), nix_args.clone()).await?;373 let root_field = pool.get().await?;374375 let builtins_field = Field::field(root_field.clone(), "builtins").await?;376 if self.local_system == "detect" {377 self.local_system = nix_go_json!(builtins_field.currentSystem);378 }379 let local_system = self.local_system.clone();380381 let fleet_root = Field::field(root_field, "fleetConfigurations").await?;382 let fleet_field = nix_go!(fleet_root.default);383384 let config_field = nix_go!(fleet_field.config);385 let config_unchecked_field = nix_go!(fleet_field.unchecked.config);386387 let import = nix_go!(builtins_field.import);388 let overlays = nix_go!(config_unchecked_field.overlays);389 let nixpkgs = nix_go!(fleet_field.nixpkgs | import);390391 let default_pkgs = nix_go!(nixpkgs(Obj {392 overlays,393 system: { self.local_system.clone() },394 }));395396 let mut fleet_data_path = directory.clone();397 fleet_data_path.push("fleet.nix");398 let bytes = std::fs::read_to_string(fleet_data_path)?;399 let data = nixlike::parse_str(&bytes)?;400401 Ok(Config(Arc::new(FleetConfigInternals {402 opts: self,403 directory,404 data,405 local_system,406 nix_args,407 config_field,408 config_unchecked_field,409 default_pkgs,410 })))411 }412}1use std::{2 env::current_dir,3 ffi::{OsStr, OsString},4 fmt::Display,5 io::Write,6 ops::Deref,7 path::PathBuf,8 str::FromStr,9 sync::{Arc, Mutex, MutexGuard, OnceLock},10};1112use anyhow::{anyhow, bail, ensure, Context, Result};13use clap::{ArgGroup, Parser};14use fleet_shared::SecretData;15use openssh::SessionBuilder;16use serde::de::DeserializeOwned;17use tempfile::NamedTempFile;1819use crate::{20 better_nix_eval::{Field, NixSessionPool},21 command::MyCommand,22 fleetdata::{FleetData, FleetSecret, FleetSharedSecret},23 nix_go, nix_go_json,24};2526pub struct FleetConfigInternals {27 pub local_system: String,28 pub directory: PathBuf,29 pub opts: FleetOpts,30 pub data: Mutex<FleetData>,31 pub nix_args: Vec<OsString>,32 /// fleet_config.config33 pub config_field: Field,34 /// fleet_config.unchecked.config35 pub config_unchecked_field: Field,3637 /// import nixpkgs {system = local};38 pub default_pkgs: Field,39}4041#[derive(Clone)]42pub struct Config(Arc<FleetConfigInternals>);4344impl Deref for Config {45 type Target = FleetConfigInternals;4647 fn deref(&self) -> &Self::Target {48 &self.049 }50}5152pub struct ConfigHost {53 config: Config,54 pub name: String,55 pub local: bool,56 pub session: OnceLock<Arc<openssh::Session>>,5758 pub nixos_config: Option<Field>,59}60impl ConfigHost {61 async fn open_session(&self) -> Result<Arc<openssh::Session>> {62 assert!(!self.local, "do not open ssh connection to local session");63 // FIXME: TOCTOU64 if let Some(session) = &self.session.get() {65 return Ok((*session).clone());66 };67 let session = SessionBuilder::default();6869 let session = session70 .connect(&self.name)71 .await72 .map_err(|e| anyhow!("ssh error while connecting to {}: {e}", self.name))?;73 let session = Arc::new(session);74 self.session.set(session.clone()).expect("TOCTOU happened");75 Ok(session)76 }77 pub async fn mktemp_dir(&self) -> Result<String> {78 let mut cmd = self.cmd("mktemp").await?;79 cmd.arg("-d");80 let path = cmd.run_string().await?;81 Ok(path.trim_end().to_owned())82 }83 pub async fn read_file_bin(&self, path: impl AsRef<OsStr>) -> Result<Vec<u8>> {84 let mut cmd = self.cmd("cat").await?;85 cmd.arg(path);86 cmd.run_bytes().await87 }88 pub async fn read_file_text(&self, path: impl AsRef<OsStr>) -> Result<String> {89 let mut cmd = self.cmd("cat").await?;90 cmd.arg(path);91 cmd.run_string().await92 }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 }103 #[allow(dead_code)]104 pub async fn read_file_json<D: DeserializeOwned>(&self, path: impl AsRef<OsStr>) -> Result<D> {105 let text = self.read_file_text(path).await?;106 Ok(serde_json::from_str(&text)?)107 }108 pub async fn read_file_value<D: FromStr>(&self, path: impl AsRef<OsStr>) -> Result<D>109 where110 <D as FromStr>::Err: Display,111 {112 let text = self.read_file_text(path).await?;113 D::from_str(&text).map_err(|e| anyhow!("failed to parse value: {e}"))114 }115 pub async fn cmd(&self, cmd: impl AsRef<OsStr>) -> Result<MyCommand> {116 if self.local {117 Ok(MyCommand::new(cmd))118 } else {119 let session = self.open_session().await?;120 Ok(MyCommand::new_on(cmd, session))121 }122 }123124 pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {125 ensure!(data.encrypted, "secret is not encrypted");126 let mut cmd = self.cmd("fleet-install-secrets").await?;127 cmd.arg("decrypt").eqarg("--secret", data.to_string());128 let encoded = cmd129 .sudo()130 .run_string()131 .await132 .context("failed to call remote host for decrypt")?;133 let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;134 ensure!(!data.encrypted, "didn't decrypted secret");135 Ok(data.data)136 }137 pub async fn reencrypt(&self, data: SecretData, targets: Vec<String>) -> Result<SecretData> {138 ensure!(data.encrypted, "secret is not encrypted");139 let mut cmd = self.cmd("fleet-install-secrets").await?;140 cmd.arg("reencrypt").eqarg("--secret", data.to_string());141 for target in targets {142 let key = self.config.key(&target).await?;143 cmd.eqarg("--targets", key);144 }145 let encoded = cmd146 .sudo()147 .run_string()148 .await149 .context("failed to call remote host for decrypt")?;150 let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;151 ensure!(!data.encrypted, "didn't decrypted secret");152 Ok(data)153 }154 /// Returns path for futureproofing, as path might change i.e on conversion to CA155 pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {156 if self.local {157 // Path is located locally, thus already trusted.158 return Ok(path.to_owned());159 }160 let mut nix = MyCommand::new("nix");161 nix.arg("copy")162 .arg("--substitute-on-destination")163 .comparg("--to", format!("ssh-ng://{}", self.name))164 .arg(path);165 nix.run_nix().await.context("nix copy")?;166 Ok(path.to_owned())167 }168 pub async fn systemctl_stop(&self, name: &str) -> Result<()> {169 let mut cmd = self.cmd("systemctl").await?;170 cmd.arg("stop").arg(name);171 cmd.sudo().run().await172 }173 pub async fn systemctl_start(&self, name: &str) -> Result<()> {174 let mut cmd = self.cmd("systemctl").await?;175 cmd.arg("start").arg(name);176 cmd.sudo().run().await177 }178179 pub async fn rm_file(&self, path: impl AsRef<OsStr>, sudo: bool) -> Result<()> {180 let mut cmd = self.cmd("rm").await?;181 cmd.arg("-f").arg(path);182 if sudo {183 cmd = cmd.sudo()184 }185 cmd.run().await186 }187188 pub async fn list_configured_secrets(&self) -> Result<Vec<String>> {189 let Some(nixos) = &self.nixos_config else {190 return Ok(vec![]);191 };192 let secrets = nix_go!(nixos.secrets);193 let mut out = Vec::new();194 for name in secrets.list_fields().await? {195 let secret = nix_go!(secrets[{ name }]);196 let is_shared: bool = nix_go_json!(secret.shared);197 if is_shared {198 continue;199 }200 out.push(name);201 }202 Ok(out)203 }204 pub async fn secret_field(&self, name: &str) -> Result<Field> {205 let Some(nixos) = &self.nixos_config else {206 bail!("host is virtual and has no secrets");207 };208 Ok(nix_go!(nixos.secrets[{ name }]))209 }210211 /// Packages for this host, resolved with nixpkgs overlays212 pub async fn pkgs(&self) -> Result<Field> {213 let Some(nixos) = &self.nixos_config else {214 return Ok(self.config.default_pkgs.clone());215 };216 Ok(nix_go!(nixos.nixpkgs.resolvedPkgs))217 }218}219220impl Config {221 pub fn should_skip(&self, host: &str) -> bool {222 if !self.opts.skip.is_empty() {223 self.opts.skip.iter().any(|h| h as &str == host)224 } else if !self.opts.only.is_empty() {225 !self.opts.only.iter().any(|h| h as &str == host)226 } else {227 false228 }229 }230 pub fn is_local(&self, host: &str) -> bool {231 self.opts.localhost.as_ref().map(|s| s as &str) == Some(host)232 }233234 pub fn local_host(&self) -> ConfigHost {235 ConfigHost {236 config: self.clone(),237 name: "<virtual localhost>".to_owned(),238 local: true,239 session: OnceLock::new(),240 nixos_config: None,241 }242 }243244 pub async fn host(&self, name: &str) -> Result<ConfigHost> {245 let config = &self.config_unchecked_field;246 let nixos_config = nix_go!(config.hosts[{ name }].nixosSystem.config);247 Ok(ConfigHost {248 config: self.clone(),249 name: name.to_owned(),250 local: self.is_local(name),251 session: OnceLock::new(),252 nixos_config: Some(nixos_config),253 })254 }255 pub async fn list_hosts(&self) -> Result<Vec<ConfigHost>> {256 let config = &self.config_unchecked_field;257 let names = nix_go!(config.hosts).list_fields().await?;258 let mut out = vec![];259 for name in names {260 out.push(self.host(&name).await?);261 }262 Ok(out)263 }264 pub async fn system_config(&self, host: &str) -> Result<Field> {265 let fleet_field = &self.config_unchecked_field;266 Ok(nix_go!(fleet_field.hosts[{ host }].nixosSystem.config))267 }268269 pub(super) fn data(&self) -> MutexGuard<FleetData> {270 self.data.lock().unwrap()271 }272 pub(super) fn data_mut(&self) -> MutexGuard<FleetData> {273 self.data.lock().unwrap()274 }275 /// Shared secrets configured in fleet.nix or in flake276 pub async fn list_configured_shared(&self) -> Result<Vec<String>> {277 let config_field = &self.config_unchecked_field;278 nix_go!(config_field.sharedSecrets).list_fields().await279 }280 /// Shared secrets configured in fleet.nix281 pub fn list_shared(&self) -> Vec<String> {282 let data = self.data();283 data.shared_secrets.keys().cloned().collect()284 }285 pub fn has_shared(&self, name: &str) -> bool {286 let data = self.data();287 data.shared_secrets.contains_key(name)288 }289 pub fn replace_shared(&self, name: String, shared: FleetSharedSecret) {290 let mut data = self.data_mut();291 data.shared_secrets.insert(name.to_owned(), shared);292 }293 pub fn remove_shared(&self, secret: &str) {294 let mut data = self.data_mut();295 data.shared_secrets.remove(secret);296 }297298 pub fn list_secrets(&self, host: &str) -> Vec<String> {299 let data = self.data();300 let Some(secrets) = data.host_secrets.get(host) else {301 return Vec::new();302 };303 secrets.keys().cloned().collect()304 }305306 pub fn has_secret(&self, host: &str, secret: &str) -> bool {307 let data = self.data();308 let Some(host_secrets) = data.host_secrets.get(host) else {309 return false;310 };311 host_secrets.contains_key(secret)312 }313 pub fn insert_secret(&self, host: &str, secret: String, value: FleetSecret) {314 let mut data = self.data_mut();315 let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();316 host_secrets.insert(secret, value);317 }318319 pub fn host_secret(&self, host: &str, secret: &str) -> Result<FleetSecret> {320 let data = self.data();321 let Some(host_secrets) = data.host_secrets.get(host) else {322 bail!("no secrets for machine {host}");323 };324 let Some(secret) = host_secrets.get(secret) else {325 bail!("machine {host} has no secret {secret}");326 };327 Ok(secret.clone())328 }329 pub fn shared_secret(&self, secret: &str) -> Result<FleetSharedSecret> {330 let data = self.data();331 let Some(secret) = data.shared_secrets.get(secret) else {332 bail!("no shared secret {secret}");333 };334 Ok(secret.clone())335 }336 pub async fn shared_secret_expected_owners(&self, secret: &str) -> Result<Vec<String>> {337 let config_field = &self.config_unchecked_field;338 Ok(nix_go_json!(339 config_field.sharedSecrets[{ secret }].expectedOwners340 ))341 }342343 pub fn save(&self) -> Result<()> {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.")?;345 let data = nixlike::serialize(&self.data() as &FleetData)?;346 tempfile.write_all(347 format!(348 "# This file contains fleet state and shouldn't be edited by hand\n\n{}\n\n# vim: ts=2 et nowrap\n",349 data350 )351 .as_bytes(),352 )?;353 let mut fleet_data_path = self.directory.clone();354 fleet_data_path.push("fleet.nix");355 tempfile.persist(fleet_data_path)?;356 Ok(())357 }358}359360#[derive(Parser, Clone)]361#[clap(group = ArgGroup::new("target_hosts"))]362pub struct FleetOpts {363 /// All hosts except those would be skipped364 #[clap(long, number_of_values = 1, group = "target_hosts")]365 only: Vec<String>,366367 /// Hosts to skip368 #[clap(long, number_of_values = 1, group = "target_hosts")]369 skip: Vec<String>,370371 /// Host, which should be threaten as current machine372 #[clap(long)]373 pub localhost: Option<String>,374375 /// Override detected system for host, to perform builds via376 /// binfmt-declared qemu instead of trying to crosscompile377 #[clap(long, default_value = "detect")]378 pub local_system: String,379}380381impl FleetOpts {382 pub async fn build(mut self, nix_args: Vec<OsString>) -> Result<Config> {383 if self.localhost.is_none() {384 self.localhost385 .replace(hostname::get().unwrap().to_str().unwrap().to_owned());386 }387 let directory = current_dir()?;388389 let pool = NixSessionPool::new(directory.as_os_str().to_owned(), nix_args.clone()).await?;390 let root_field = pool.get().await?;391392 let builtins_field = Field::field(root_field.clone(), "builtins").await?;393 if self.local_system == "detect" {394 self.local_system = nix_go_json!(builtins_field.currentSystem);395 }396 let local_system = self.local_system.clone();397398 let fleet_root = Field::field(root_field, "fleetConfigurations").await?;399 let fleet_field = nix_go!(fleet_root.default);400401 let config_field = nix_go!(fleet_field.config);402 let config_unchecked_field = nix_go!(fleet_field.unchecked.config);403404 let import = nix_go!(builtins_field.import);405 let overlays = nix_go!(config_unchecked_field.overlays);406 let nixpkgs = nix_go!(fleet_field.nixpkgs | import);407408 let default_pkgs = nix_go!(nixpkgs(Obj {409 overlays,410 system: { self.local_system.clone() },411 }));412413 let mut fleet_data_path = directory.clone();414 fleet_data_path.push("fleet.nix");415 let bytes = std::fs::read_to_string(fleet_data_path)?;416 let data = nixlike::parse_str(&bytes)?;417418 Ok(Config(Arc::new(FleetConfigInternals {419 opts: self,420 directory,421 data,422 local_system,423 nix_args,424 config_field,425 config_unchecked_field,426 default_pkgs,427 })))428 }429}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"