difftreelog
feat add-shared --readd
in: trunk
1 file changed
cmds/fleet/src/cmds/secrets/mod.rsdiffbeforeafterboth1use crate::{2 fleetdata::{FleetSecret, FleetSharedSecret},3 host::Config,4};5use anyhow::{bail, ensure, Context, Result};6use chrono::Utc;7use clap::Parser;8use futures::{StreamExt, TryStreamExt};9use owo_colors::OwoColorize;10use std::{11 collections::HashSet,12 io::{self, Cursor, Read},13 path::PathBuf,14};15use tabled::{Table, Tabled};16use tokio::fs::read_to_string;17use tracing::{error, info, info_span, warn};1819#[derive(Parser)]20pub enum Secrets {21 /// Force load keys for all defined hosts22 ForceKeys,23 /// Add secret, data should be provided in stdin24 AddShared {25 /// Secret name26 name: String,27 /// Secret owners28 machines: Vec<String>,29 /// Override secret if already present30 #[clap(long)]31 force: bool,32 #[clap(long)]33 public: Option<String>,34 #[clap(long)]35 public_file: Option<PathBuf>,36 },37 /// Add secret, data should be provided in stdin38 Add {39 /// Secret name40 name: String,41 /// Secret owners42 machine: String,43 /// Override secret if already present44 #[clap(long)]45 force: bool,46 #[clap(long)]47 public: Option<String>,48 #[clap(long)]49 public_file: Option<PathBuf>,50 },51 /// Read secret from remote host, requires sudo on said host52 Read {53 name: String,54 machine: String,55 #[clap(long)]56 plaintext: bool,57 },58 UpdateShared {59 name: String,6061 #[clap(long)]62 machines: Option<Vec<String>>,6364 #[clap(long)]65 add_machines: Vec<String>,66 #[clap(long)]67 remove_machines: Vec<String>,6869 /// Which host should we use to decrypt70 #[clap(long)]71 prefer_identities: Vec<String>,72 },73 Regenerate {74 /// Which host should we use to decrypt, in case if reencryption is required, without75 /// regeneration76 #[clap(long)]77 prefer_identities: Vec<String>,78 },79 List {},80}8182impl Secrets {83 pub async fn run(self, config: &Config) -> Result<()> {84 match self {85 Secrets::ForceKeys => {86 for host in config.list_hosts().await? {87 if config.should_skip(&host.name) {88 continue;89 }90 config.key(&host.name).await?;91 }92 }93 Secrets::AddShared {94 machines,95 name,96 force,97 public,98 public_file,99 } => {100 let recipients = futures::stream::iter(machines.iter())101 .then(|m| config.recipient(m))102 .try_collect::<Vec<_>>()103 .await?;104105 let secret = {106 let mut input = vec![];107 io::stdin().read_to_end(&mut input)?;108109 if input.is_empty() {110 input111 } else {112 let mut encrypted = vec![];113 let recipients = recipients114 .iter()115 .cloned()116 .map(|r| Box::new(r) as Box<dyn age::Recipient + Send>)117 .collect();118 let mut encryptor = age::Encryptor::with_recipients(recipients)119 .expect("recipients provided")120 .wrap_output(&mut encrypted)?;121 io::copy(&mut Cursor::new(input), &mut encryptor)?;122 encryptor.finish()?;123 encrypted124 }125 };126127 if config.has_shared(&name) && !force {128 bail!("secret already defined");129 }130 config.replace_shared(131 name,132 FleetSharedSecret {133 owners: machines,134 secret: FleetSecret {135 created_at: Utc::now(),136 expires_at: None,137 secret,138 public: match (public, public_file) {139 (Some(v), None) => Some(v),140 (None, Some(v)) => Some(read_to_string(v).await?),141 (Some(_), Some(_)) => {142 bail!("only public or public_file should be set")143 }144 (None, None) => None,145 },146 },147 },148 );149 }150 Secrets::Add {151 machine,152 name,153 force,154 public,155 public_file,156 } => {157 let recipient = config.recipient(&machine).await?;158159 let secret = {160 let mut input = vec![];161 io::stdin().read_to_end(&mut input)?;162 if input.is_empty() {163 bail!("no data provided")164 }165166 let mut encrypted = vec![];167 let recipient = Box::new(recipient) as Box<dyn age::Recipient + Send>;168 let mut encryptor = age::Encryptor::with_recipients(vec![recipient])169 .expect("recipients provided")170 .wrap_output(&mut encrypted)?;171 io::copy(&mut Cursor::new(input), &mut encryptor)?;172 encryptor.finish()?;173 encrypted174 };175176 if config.has_secret(&machine, &name) && !force {177 bail!("secret already defined");178 }179 config.insert_secret(180 &machine,181 name,182 FleetSecret {183 created_at: Utc::now(),184 expires_at: None,185 secret,186 public: match (public, public_file) {187 (Some(v), None) => Some(v),188 (None, Some(v)) => Some(std::fs::read_to_string(v)?),189 (Some(_), Some(_)) => bail!("only public or public_file should be set"),190 (None, None) => None,191 },192 },193 );194 }195 // TODO: Instead of using sudo, decode secret on remote machine196 #[allow(clippy::await_holding_refcell_ref)]197 Secrets::Read {198 name,199 machine,200 plaintext,201 } => {202 let secret = config.host_secret(&machine, &name)?;203 if secret.secret.is_empty() {204 bail!("no secret {name}");205 }206 let data = config.decrypt_on_host(&machine, secret.secret).await?;207 if plaintext {208 let s = String::from_utf8(data).context("output is not utf8")?;209 print!("{s}");210 } else {211 println!("{}", z85::encode(&data));212 }213 }214 Secrets::UpdateShared {215 name,216 machines,217 mut add_machines,218 mut remove_machines,219 prefer_identities,220 } => {221 if machines.is_none() && add_machines.is_empty() && remove_machines.is_empty() {222 bail!("no operation");223 }224225 let mut secret = config.shared_secret(&name)?;226 if secret.secret.secret.is_empty() {227 bail!("no secret");228 }229230 let initial_machines = secret.owners.clone();231 let mut target_machines = secret.owners.clone();232 info!("Currently encrypted for {initial_machines:?}");233234 // ensure!(machines.is_some() || !add_machines.is_empty() || )235 if let Some(machines) = machines {236 ensure!(237 add_machines.is_empty() && remove_machines.is_empty(),238 "can't combine --machines and --add-machines/--remove-machines"239 );240 let target = initial_machines.iter().collect::<HashSet<_>>();241 let source = machines.iter().collect::<HashSet<_>>();242 for removed in target.difference(&source) {243 remove_machines.push((*removed).clone());244 }245 for added in source.difference(&target) {246 add_machines.push((*added).clone());247 }248 }249250 for machine in &remove_machines {251 let mut removed = false;252 while let Some(pos) = target_machines.iter().position(|m| m == machine) {253 target_machines.swap_remove(pos);254 removed = true;255 }256 if !removed {257 warn!("secret is not enabled for {machine}");258 }259 }260 for machine in &add_machines {261 if target_machines.iter().any(|m| m == machine) {262 warn!("secret is already added to {machine}");263 } else {264 target_machines.push(machine.to_owned());265 }266 }267 if !remove_machines.is_empty() {268 warn!("secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret");269 }270271 if target_machines.is_empty() {272 info!("no machines left for secret, removing it");273 config.remove_shared(&name);274 return Ok(());275 }276277 if target_machines == initial_machines {278 warn!("secret owners are already correct");279 return Ok(());280 }281282 let identity_holder = if !prefer_identities.is_empty() {283 prefer_identities284 .iter()285 .find(|i| initial_machines.iter().any(|s| s == *i))286 } else {287 secret.owners.first()288 };289 let Some(identity_holder) = identity_holder else {290 bail!("no available holder found");291 };292 let target_recipients = futures::stream::iter(&target_machines)293 .then(|m| async { config.key(m).await })294 .collect::<Vec<_>>()295 .await;296 let target_recipients =297 target_recipients.into_iter().collect::<Result<Vec<_>>>()?;298299 let encrypted = config300 .reencrypt_on_host(identity_holder, secret.secret.secret, target_recipients)301 .await?;302303 secret.owners = target_machines;304 secret.secret.secret = encrypted;305 config.replace_shared(name, secret);306 }307 Secrets::Regenerate { prefer_identities } => {308 {309 let expected_shared_set = config310 .list_configured_shared()311 .await?312 .into_iter()313 .collect::<HashSet<_>>();314 let shared_set = config.list_shared().into_iter().collect::<HashSet<_>>();315 for removed in expected_shared_set.difference(&shared_set) {316 error!("secret needs to be generated: {removed}")317 }318 }319 let mut to_remove = Vec::new();320 for name in &config.list_shared() {321 info!("updating secret: {name}");322 let mut data = config.shared_secret(name)?;323 let expected_owners: Vec<String> = config324 .config_field325 .get_json_deep(["sharedSecrets", name, "expectedOwners"])326 .await?;327 if expected_owners.is_empty() {328 warn!("secret was removed from fleet config: {name}, removing from data");329 to_remove.push(name.to_string());330 continue;331 }332 let set = data.owners.iter().collect::<HashSet<_>>();333 let expected_set = expected_owners.iter().collect::<HashSet<_>>();334 let should_remove = set.difference(&expected_set).next().is_some();335 if set != expected_set {336 let owner_dependent: bool = config337 .config_field338 .get_json_deep(["sharedSecrets", name, "ownerDependent"])339 .await?;340 if !owner_dependent {341 warn!("reencrypting secret '{name}' for new owner set");342 // TODO: force regeneration343 if should_remove {344 warn!("secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret");345 }346347 let identity_holder = if !prefer_identities.is_empty() {348 prefer_identities349 .iter()350 .find(|i| data.owners.iter().any(|s| s == *i))351 } else {352 data.owners.first()353 };354 let Some(identity_holder) = identity_holder else {355 bail!("no available holder found");356 };357358 let target_recipients = futures::stream::iter(&expected_owners)359 .then(|m| async { config.key(m).await })360 .collect::<Vec<_>>()361 .await;362 let target_recipients =363 target_recipients.into_iter().collect::<Result<Vec<_>>>()?;364365 let encrypted = config366 .reencrypt_on_host(367 identity_holder,368 data.secret.secret,369 target_recipients,370 )371 .await?;372373 data.secret.secret = encrypted;374 data.owners = expected_owners;375 config.replace_shared(name.to_owned(), data);376 } else {377 error!("secret '{name}' should be regenerated manually");378 }379 } else {380 info!("secret data is ok")381 }382 }383 for k in to_remove {384 config.remove_shared(&k);385 }386 }387 Secrets::List {} => {388 let _span = info_span!("loading secrets").entered();389 let configured = config.list_configured_shared().await?;390 #[derive(Tabled)]391 struct SecretDisplay {392 #[tabled(rename = "Name")]393 name: String,394 #[tabled(rename = "Owners")]395 owners: String,396 }397 let mut table = vec![];398 for name in configured.iter().cloned() {399 let config = config.clone();400 let expected_owners = config.shared_secret_expected_owners(&name).await?;401 let data = config.shared_secret(&name)?;402 let owners = data403 .owners404 .iter()405 .map(|o| {406 if expected_owners.contains(o) {407 o.green().to_string()408 } else {409 o.red().to_string()410 }411 })412 .collect::<Vec<_>>();413 table.push(SecretDisplay {414 owners: owners.join(", "),415 name,416 })417 }418 info!("loaded\n{}", Table::new(table).to_string())419 }420 }421 Ok(())422 }423}1use crate::{2 fleetdata::{FleetSecret, FleetSharedSecret},3 host::Config,4};5use anyhow::{bail, ensure, Context, Result};6use chrono::Utc;7use clap::Parser;8use futures::{StreamExt, TryStreamExt};9use owo_colors::OwoColorize;10use std::{11 collections::HashSet,12 io::{self, Cursor, Read},13 path::PathBuf,14};15use tabled::{Table, Tabled};16use tokio::fs::read_to_string;17use tracing::{error, info, info_span, warn};1819#[derive(Parser)]20pub enum Secrets {21 /// Force load keys for all defined hosts22 ForceKeys,23 /// Add secret, data should be provided in stdin24 AddShared {25 /// Secret name26 name: String,27 /// Secret owners28 machines: Vec<String>,29 /// Override secret if already present30 #[clap(long)]31 force: bool,32 #[clap(long)]33 public: Option<String>,34 #[clap(long)]35 public_file: Option<PathBuf>,3637 /// Secret with this name already exists, override its value while keeping the same owners.38 #[clap(long)]39 readd: bool,40 },41 /// Add secret, data should be provided in stdin42 Add {43 /// Secret name44 name: String,45 /// Secret owners46 machine: String,47 /// Override secret if already present48 #[clap(long)]49 force: bool,50 #[clap(long)]51 public: Option<String>,52 #[clap(long)]53 public_file: Option<PathBuf>,54 },55 /// Read secret from remote host, requires sudo on said host56 Read {57 name: String,58 machine: String,59 #[clap(long)]60 plaintext: bool,61 },62 UpdateShared {63 name: String,6465 #[clap(long)]66 machines: Option<Vec<String>>,6768 #[clap(long)]69 add_machines: Vec<String>,70 #[clap(long)]71 remove_machines: Vec<String>,7273 /// Which host should we use to decrypt74 #[clap(long)]75 prefer_identities: Vec<String>,76 },77 Regenerate {78 /// Which host should we use to decrypt, in case if reencryption is required, without79 /// regeneration80 #[clap(long)]81 prefer_identities: Vec<String>,82 },83 List {},84}8586impl Secrets {87 pub async fn run(self, config: &Config) -> Result<()> {88 match self {89 Secrets::ForceKeys => {90 for host in config.list_hosts().await? {91 if config.should_skip(&host.name) {92 continue;93 }94 config.key(&host.name).await?;95 }96 }97 Secrets::AddShared {98 mut machines,99 name,100 force,101 public,102 public_file,103 readd,104 } => {105 let exists = config.has_shared(&name);106 if exists && !force && !readd {107 bail!("secret already defined");108 }109 if readd {110 // Fixme: use clap to limit this usage111 ensure!(!force, "--force and --readd are not compatible");112 ensure!(exists, "secret doesn't exists");113 ensure!(114 machines.is_empty(),115 "you can't use machines argument for --readd"116 );117 let shared = config.shared_secret(&name)?;118 machines = shared.owners;119 }120121 let recipients = futures::stream::iter(machines.iter())122 .then(|m| config.recipient(m))123 .try_collect::<Vec<_>>()124 .await?;125126 let secret = {127 let mut input = vec![];128 io::stdin().read_to_end(&mut input)?;129130 if input.is_empty() {131 input132 } else {133 let mut encrypted = vec![];134 let recipients = recipients135 .iter()136 .cloned()137 .map(|r| Box::new(r) as Box<dyn age::Recipient + Send>)138 .collect();139 let mut encryptor = age::Encryptor::with_recipients(recipients)140 .expect("recipients provided")141 .wrap_output(&mut encrypted)?;142 io::copy(&mut Cursor::new(input), &mut encryptor)?;143 encryptor.finish()?;144 encrypted145 }146 };147 config.replace_shared(148 name,149 FleetSharedSecret {150 owners: machines,151 secret: FleetSecret {152 created_at: Utc::now(),153 expires_at: None,154 secret,155 public: match (public, public_file) {156 (Some(v), None) => Some(v),157 (None, Some(v)) => Some(read_to_string(v).await?),158 (Some(_), Some(_)) => {159 bail!("only public or public_file should be set")160 }161 (None, None) => None,162 },163 },164 },165 );166 }167 Secrets::Add {168 machine,169 name,170 force,171 public,172 public_file,173 } => {174 let recipient = config.recipient(&machine).await?;175176 let secret = {177 let mut input = vec![];178 io::stdin().read_to_end(&mut input)?;179 if input.is_empty() {180 bail!("no data provided")181 }182183 let mut encrypted = vec![];184 let recipient = Box::new(recipient) as Box<dyn age::Recipient + Send>;185 let mut encryptor = age::Encryptor::with_recipients(vec![recipient])186 .expect("recipients provided")187 .wrap_output(&mut encrypted)?;188 io::copy(&mut Cursor::new(input), &mut encryptor)?;189 encryptor.finish()?;190 encrypted191 };192193 if config.has_secret(&machine, &name) && !force {194 bail!("secret already defined");195 }196 config.insert_secret(197 &machine,198 name,199 FleetSecret {200 created_at: Utc::now(),201 expires_at: None,202 secret,203 public: match (public, public_file) {204 (Some(v), None) => Some(v),205 (None, Some(v)) => Some(std::fs::read_to_string(v)?),206 (Some(_), Some(_)) => bail!("only public or public_file should be set"),207 (None, None) => None,208 },209 },210 );211 }212 // TODO: Instead of using sudo, decode secret on remote machine213 #[allow(clippy::await_holding_refcell_ref)]214 Secrets::Read {215 name,216 machine,217 plaintext,218 } => {219 let secret = config.host_secret(&machine, &name)?;220 if secret.secret.is_empty() {221 bail!("no secret {name}");222 }223 let data = config.decrypt_on_host(&machine, secret.secret).await?;224 if plaintext {225 let s = String::from_utf8(data).context("output is not utf8")?;226 print!("{s}");227 } else {228 println!("{}", z85::encode(&data));229 }230 }231 Secrets::UpdateShared {232 name,233 machines,234 mut add_machines,235 mut remove_machines,236 prefer_identities,237 } => {238 if machines.is_none() && add_machines.is_empty() && remove_machines.is_empty() {239 bail!("no operation");240 }241242 let mut secret = config.shared_secret(&name)?;243 if secret.secret.secret.is_empty() {244 bail!("no secret");245 }246247 let initial_machines = secret.owners.clone();248 let mut target_machines = secret.owners.clone();249 info!("Currently encrypted for {initial_machines:?}");250251 // ensure!(machines.is_some() || !add_machines.is_empty() || )252 if let Some(machines) = machines {253 ensure!(254 add_machines.is_empty() && remove_machines.is_empty(),255 "can't combine --machines and --add-machines/--remove-machines"256 );257 let target = initial_machines.iter().collect::<HashSet<_>>();258 let source = machines.iter().collect::<HashSet<_>>();259 for removed in target.difference(&source) {260 remove_machines.push((*removed).clone());261 }262 for added in source.difference(&target) {263 add_machines.push((*added).clone());264 }265 }266267 for machine in &remove_machines {268 let mut removed = false;269 while let Some(pos) = target_machines.iter().position(|m| m == machine) {270 target_machines.swap_remove(pos);271 removed = true;272 }273 if !removed {274 warn!("secret is not enabled for {machine}");275 }276 }277 for machine in &add_machines {278 if target_machines.iter().any(|m| m == machine) {279 warn!("secret is already added to {machine}");280 } else {281 target_machines.push(machine.to_owned());282 }283 }284 if !remove_machines.is_empty() {285 warn!("secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret");286 }287288 if target_machines.is_empty() {289 info!("no machines left for secret, removing it");290 config.remove_shared(&name);291 return Ok(());292 }293294 if target_machines == initial_machines {295 warn!("secret owners are already correct");296 return Ok(());297 }298299 let identity_holder = if !prefer_identities.is_empty() {300 prefer_identities301 .iter()302 .find(|i| initial_machines.iter().any(|s| s == *i))303 } else {304 secret.owners.first()305 };306 let Some(identity_holder) = identity_holder else {307 bail!("no available holder found");308 };309 let target_recipients = futures::stream::iter(&target_machines)310 .then(|m| async { config.key(m).await })311 .collect::<Vec<_>>()312 .await;313 let target_recipients =314 target_recipients.into_iter().collect::<Result<Vec<_>>>()?;315316 let encrypted = config317 .reencrypt_on_host(identity_holder, secret.secret.secret, target_recipients)318 .await?;319320 secret.owners = target_machines;321 secret.secret.secret = encrypted;322 config.replace_shared(name, secret);323 }324 Secrets::Regenerate { prefer_identities } => {325 {326 let expected_shared_set = config327 .list_configured_shared()328 .await?329 .into_iter()330 .collect::<HashSet<_>>();331 let shared_set = config.list_shared().into_iter().collect::<HashSet<_>>();332 for removed in expected_shared_set.difference(&shared_set) {333 error!("secret needs to be generated: {removed}")334 }335 }336 let mut to_remove = Vec::new();337 for name in &config.list_shared() {338 info!("updating secret: {name}");339 let mut data = config.shared_secret(name)?;340 let expected_owners: Vec<String> = config341 .config_field342 .get_json_deep(["sharedSecrets", name, "expectedOwners"])343 .await?;344 if expected_owners.is_empty() {345 warn!("secret was removed from fleet config: {name}, removing from data");346 to_remove.push(name.to_string());347 continue;348 }349 let set = data.owners.iter().collect::<HashSet<_>>();350 let expected_set = expected_owners.iter().collect::<HashSet<_>>();351 let should_remove = set.difference(&expected_set).next().is_some();352 if set != expected_set {353 let owner_dependent: bool = config354 .config_field355 .get_json_deep(["sharedSecrets", name, "ownerDependent"])356 .await?;357 if !owner_dependent {358 warn!("reencrypting secret '{name}' for new owner set");359 // TODO: force regeneration360 if should_remove {361 warn!("secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret");362 }363364 let identity_holder = if !prefer_identities.is_empty() {365 prefer_identities366 .iter()367 .find(|i| data.owners.iter().any(|s| s == *i))368 } else {369 data.owners.first()370 };371 let Some(identity_holder) = identity_holder else {372 bail!("no available holder found");373 };374375 let target_recipients = futures::stream::iter(&expected_owners)376 .then(|m| async { config.key(m).await })377 .collect::<Vec<_>>()378 .await;379 let target_recipients =380 target_recipients.into_iter().collect::<Result<Vec<_>>>()?;381382 let encrypted = config383 .reencrypt_on_host(384 identity_holder,385 data.secret.secret,386 target_recipients,387 )388 .await?;389390 data.secret.secret = encrypted;391 data.owners = expected_owners;392 config.replace_shared(name.to_owned(), data);393 } else {394 error!("secret '{name}' should be regenerated manually");395 }396 } else {397 info!("secret data is ok")398 }399 }400 for k in to_remove {401 config.remove_shared(&k);402 }403 }404 Secrets::List {} => {405 let _span = info_span!("loading secrets").entered();406 let configured = config.list_configured_shared().await?;407 #[derive(Tabled)]408 struct SecretDisplay {409 #[tabled(rename = "Name")]410 name: String,411 #[tabled(rename = "Owners")]412 owners: String,413 }414 let mut table = vec![];415 for name in configured.iter().cloned() {416 let config = config.clone();417 let expected_owners = config.shared_secret_expected_owners(&name).await?;418 let data = config.shared_secret(&name)?;419 let owners = data420 .owners421 .iter()422 .map(|o| {423 if expected_owners.contains(o) {424 o.green().to_string()425 } else {426 o.red().to_string()427 }428 })429 .collect::<Vec<_>>();430 table.push(SecretDisplay {431 owners: owners.join(", "),432 name,433 })434 }435 info!("loaded\n{}", Table::new(table).to_string())436 }437 }438 Ok(())439 }440}