1use std::collections::{BTreeMap, BTreeSet};
20
21use anyhow::{Context, Result};
22use serde::{Deserialize, Serialize};
23
24use crate::config::Config;
25use crate::config_store::ConfigStoreResult;
26use crate::credential_store::{credential_ref, CredentialRef};
27
28#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
33pub struct ClusterFabricConfig {
34 #[serde(default, skip_serializing_if = "Vec::is_empty")]
36 pub clusters: Vec<Cluster>,
37 #[serde(default, skip_serializing_if = "Vec::is_empty")]
39 pub nodes: Vec<Node>,
40 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
47 pub credential_refs: BTreeMap<String, ClusterNodeCredentialRefs>,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub health_interval_secs: Option<u64>,
52}
53
54pub const DEFAULT_HEALTH_INTERVAL_SECS: u64 = 30;
56
57impl ClusterFabricConfig {
58 pub fn is_empty(&self) -> bool {
60 self.clusters.is_empty() && self.nodes.is_empty() && self.credential_refs.is_empty()
61 }
62
63 pub fn health_interval(&self) -> Option<std::time::Duration> {
66 match self.health_interval_secs {
67 Some(0) => None,
68 Some(s) => Some(std::time::Duration::from_secs(s)),
69 None => Some(std::time::Duration::from_secs(DEFAULT_HEALTH_INTERVAL_SECS)),
70 }
71 }
72
73 pub fn node(&self, id: &str) -> Option<&Node> {
75 self.nodes.iter().find(|n| n.id == id)
76 }
77
78 pub fn node_mut(&mut self, id: &str) -> Option<&mut Node> {
80 self.nodes.iter_mut().find(|n| n.id == id)
81 }
82
83 pub fn cluster(&self, name: &str) -> Option<&Cluster> {
85 self.clusters.iter().find(|c| c.name == name)
86 }
87
88 pub fn prune_orphaned_credential_refs(&mut self) {
92 let node_ids = self
93 .nodes
94 .iter()
95 .map(|node| node.id.as_str())
96 .collect::<std::collections::BTreeSet<_>>();
97 self.credential_refs
98 .retain(|node_id, _| node_ids.contains(node_id.as_str()));
99 }
100}
101
102#[derive(Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
104pub struct ClusterNodeCredentialRefs {
105 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub password_credential_ref: Option<CredentialRef>,
107 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
108 pub password_configured: bool,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub private_key_credential_ref: Option<CredentialRef>,
111 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
112 pub private_key_configured: bool,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub passphrase_credential_ref: Option<CredentialRef>,
115 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
116 pub passphrase_configured: bool,
117}
118
119impl std::fmt::Debug for ClusterNodeCredentialRefs {
120 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121 formatter
122 .debug_struct("ClusterNodeCredentialRefs")
123 .field(
124 "password_reference_present",
125 &self.password_credential_ref.is_some(),
126 )
127 .field("password_configured", &self.password_configured)
128 .field(
129 "private_key_reference_present",
130 &self.private_key_credential_ref.is_some(),
131 )
132 .field("private_key_configured", &self.private_key_configured)
133 .field(
134 "passphrase_reference_present",
135 &self.passphrase_credential_ref.is_some(),
136 )
137 .field("passphrase_configured", &self.passphrase_configured)
138 .finish()
139 }
140}
141
142impl ClusterNodeCredentialRefs {
143 pub fn references(&self) -> impl Iterator<Item = &CredentialRef> {
144 [
145 self.password_credential_ref.as_ref(),
146 self.private_key_credential_ref.as_ref(),
147 self.passphrase_credential_ref.as_ref(),
148 ]
149 .into_iter()
150 .flatten()
151 }
152
153 pub fn is_empty(&self) -> bool {
154 self.references().next().is_none()
155 && !self.password_configured
156 && !self.private_key_configured
157 && !self.passphrase_configured
158 }
159}
160
161#[derive(Clone, PartialEq, Eq)]
167pub enum ClusterCredentialAction {
168 Keep,
169 Replace(String),
170 Clear,
171}
172
173#[derive(Clone, PartialEq, Eq)]
178pub struct ClusterNodeCredentialIntents {
179 pub password: ClusterCredentialAction,
180 pub private_key: ClusterCredentialAction,
181 pub passphrase: ClusterCredentialAction,
182}
183
184impl ClusterNodeCredentialIntents {
185 pub fn clear_all() -> Self {
186 Self {
187 password: ClusterCredentialAction::Clear,
188 private_key: ClusterCredentialAction::Clear,
189 passphrase: ClusterCredentialAction::Clear,
190 }
191 }
192}
193
194pub fn cluster_password_credential_ref(node_id: &str) -> ConfigStoreResult<CredentialRef> {
195 credential_ref("cluster", node_id, "password")
196}
197
198pub fn cluster_private_key_credential_ref(node_id: &str) -> ConfigStoreResult<CredentialRef> {
199 credential_ref("cluster", node_id, "private_key")
200}
201
202pub fn cluster_passphrase_credential_ref(node_id: &str) -> ConfigStoreResult<CredentialRef> {
203 credential_ref("cluster", node_id, "passphrase")
204}
205
206#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
209pub struct Cluster {
210 pub name: String,
211 #[serde(default, skip_serializing_if = "Option::is_none")]
212 pub description: Option<String>,
213 #[serde(default, skip_serializing_if = "Vec::is_empty")]
214 pub node_ids: Vec<String>,
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
220pub struct Node {
221 pub id: String,
223 pub label: String,
225 pub placement: NodePlacement,
227 #[serde(default)]
229 pub trust_level: TrustLevel,
230 #[serde(default)]
232 pub deploy: DeployProfile,
233 #[serde(default, skip_serializing_if = "Option::is_none")]
235 pub state: Option<NodeState>,
236 #[serde(default = "default_true")]
238 pub enabled: bool,
239}
240
241fn default_true() -> bool {
242 true
243}
244
245#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
247#[serde(tag = "type", rename_all = "snake_case")]
248pub enum NodePlacement {
249 #[default]
251 Local,
252 Ssh(SshTarget),
254}
255
256#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
258#[serde(rename_all = "snake_case")]
259pub enum TrustLevel {
260 #[default]
262 Trusted,
263 Untrusted,
265}
266
267#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
269pub struct SshTarget {
270 pub host: String,
271 #[serde(default = "default_ssh_port")]
272 pub port: u16,
273 pub username: String,
274 pub auth: SshAuth,
275 #[serde(default, skip_serializing_if = "Option::is_none")]
277 pub host_key_fingerprint: Option<String>,
278}
279
280fn default_ssh_port() -> u16 {
281 22
282}
283
284#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
288#[serde(tag = "method", rename_all = "snake_case")]
289pub enum SshAuth {
290 SystemSshConfig,
294 Password {
296 #[serde(default, skip_serializing_if = "String::is_empty")]
298 password: String,
299 #[serde(default, skip_serializing_if = "Option::is_none")]
301 password_encrypted: Option<String>,
302 },
303 PrivateKey {
306 #[serde(default, skip_serializing_if = "String::is_empty")]
308 private_key: String,
309 #[serde(default, skip_serializing_if = "Option::is_none")]
311 private_key_encrypted: Option<String>,
312 #[serde(default, skip_serializing_if = "Option::is_none")]
314 private_key_path: Option<String>,
315 #[serde(default, skip_serializing_if = "String::is_empty")]
317 passphrase: String,
318 #[serde(default, skip_serializing_if = "Option::is_none")]
320 passphrase_encrypted: Option<String>,
321 },
322}
323
324impl std::fmt::Debug for SshAuth {
325 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
326 match self {
327 Self::SystemSshConfig => formatter.write_str("SystemSshConfig"),
328 Self::Password {
329 password,
330 password_encrypted,
331 } => formatter
332 .debug_struct("Password")
333 .field("password_configured", &!password.is_empty())
334 .field(
335 "legacy_password_ciphertext_present",
336 &password_encrypted.is_some(),
337 )
338 .finish(),
339 Self::PrivateKey {
340 private_key,
341 private_key_encrypted,
342 private_key_path,
343 passphrase,
344 passphrase_encrypted,
345 } => formatter
346 .debug_struct("PrivateKey")
347 .field("inline_private_key_configured", &!private_key.is_empty())
348 .field(
349 "legacy_private_key_ciphertext_present",
350 &private_key_encrypted.is_some(),
351 )
352 .field("private_key_path", private_key_path)
353 .field("passphrase_configured", &!passphrase.is_empty())
354 .field(
355 "legacy_passphrase_ciphertext_present",
356 &passphrase_encrypted.is_some(),
357 )
358 .finish(),
359 }
360 }
361}
362
363#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
365pub struct DeployProfile {
366 #[serde(default, skip_serializing_if = "Option::is_none")]
368 pub artifact_path: Option<String>,
369 #[serde(default, skip_serializing_if = "Option::is_none")]
371 pub artifact_sha256: Option<String>,
372 #[serde(default, skip_serializing_if = "Option::is_none")]
374 pub remote_dir: Option<String>,
375 #[serde(default, skip_serializing_if = "Option::is_none")]
377 pub default_role: Option<String>,
378 #[serde(default, skip_serializing_if = "Option::is_none")]
380 pub model: Option<String>,
381 #[serde(default, skip_serializing_if = "Option::is_none")]
383 pub workspace: Option<String>,
384 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
387 pub auto_recover: bool,
388}
389
390#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
392pub struct NodeState {
393 pub status: NodeStatus,
394 #[serde(default, skip_serializing_if = "Option::is_none")]
396 pub worker_id: Option<String>,
397 #[serde(default, skip_serializing_if = "Option::is_none")]
399 pub token_env: Option<String>,
400 #[serde(default, skip_serializing_if = "Option::is_none")]
401 pub remote_pid: Option<u32>,
402 #[serde(default, skip_serializing_if = "Option::is_none")]
403 pub log_path: Option<String>,
404 #[serde(default, skip_serializing_if = "Option::is_none")]
406 pub deployed_at: Option<String>,
407 #[serde(default, skip_serializing_if = "Option::is_none")]
408 pub last_health: Option<String>,
409 #[serde(default, skip_serializing_if = "Option::is_none")]
410 pub last_error: Option<String>,
411}
412
413#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
415#[serde(rename_all = "snake_case")]
416pub enum NodeStatus {
417 #[default]
418 NotDeployed,
419 Deploying,
420 Running,
421 Unreachable,
422 Stopped,
423 Failed,
424}
425
426impl Config {
429 pub fn hydrate_cluster_credentials_from_store(
436 &mut self,
437 data_dir: &std::path::Path,
438 ) -> ConfigStoreResult<()> {
439 if !self
443 .cluster_fabric
444 .credential_refs
445 .values()
446 .any(|metadata| metadata.references().next().is_some())
447 {
448 return self.hydrate_cluster_credentials(None);
449 }
450
451 let store = crate::CredentialStore::open(data_dir);
452 let (snapshot, _, _) = store.snapshot_with_health()?;
455 self.hydrate_cluster_credentials(Some(&snapshot))
456 }
457
458 pub(crate) fn hydrate_cluster_credentials_from_snapshot(
462 &mut self,
463 credentials: &crate::credential_store::CredentialDocumentLkg,
464 ) -> ConfigStoreResult<()> {
465 self.hydrate_cluster_credentials(Some(credentials))
466 }
467
468 fn hydrate_cluster_credentials(
469 &mut self,
470 credentials: Option<&crate::credential_store::CredentialDocumentLkg>,
471 ) -> ConfigStoreResult<()> {
472 #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
473 enum Field {
474 Password,
475 PrivateKey,
476 Passphrase,
477 }
478 let mut prior_plaintext = BTreeMap::<(usize, Field), String>::new();
479 let legacy_ciphertext_present =
480 self.cluster_fabric
481 .nodes
482 .iter()
483 .enumerate()
484 .any(|(index, node)| {
485 let NodePlacement::Ssh(target) = &node.placement else {
486 return false;
487 };
488 match &target.auth {
489 SshAuth::SystemSshConfig => false,
490 SshAuth::Password {
491 password,
492 password_encrypted,
493 } => {
494 if !password.trim().is_empty() {
495 prior_plaintext.insert((index, Field::Password), password.clone());
496 }
497 password_encrypted
498 .as_deref()
499 .is_some_and(|value| !value.trim().is_empty())
500 }
501 SshAuth::PrivateKey {
502 private_key,
503 private_key_encrypted,
504 passphrase,
505 passphrase_encrypted,
506 ..
507 } => {
508 if !private_key.trim().is_empty() {
509 prior_plaintext
510 .insert((index, Field::PrivateKey), private_key.clone());
511 }
512 if !passphrase.trim().is_empty() {
513 prior_plaintext
514 .insert((index, Field::Passphrase), passphrase.clone());
515 }
516 private_key_encrypted
517 .as_deref()
518 .is_some_and(|value| !value.trim().is_empty())
519 || passphrase_encrypted
520 .as_deref()
521 .is_some_and(|value| !value.trim().is_empty())
522 }
523 }
524 });
525 self.clear_cluster_runtime_credentials();
526 if legacy_ciphertext_present {
527 return Err(crate::ConfigStoreError::Validation(
528 "legacy cluster credential appeared after migration".to_string(),
529 ));
530 }
531
532 let mut node_ids = BTreeSet::new();
533 for node in &self.cluster_fabric.nodes {
534 if node.id.trim().is_empty() {
535 return Err(crate::ConfigStoreError::Validation(
536 "cluster node id is empty".to_string(),
537 ));
538 }
539 if !node_ids.insert(node.id.as_str()) {
540 return Err(crate::ConfigStoreError::Validation(
541 "cluster node ids must be unique".to_string(),
542 ));
543 }
544 }
545 for node_id in self.cluster_fabric.credential_refs.keys() {
546 if !node_ids.contains(node_id.as_str()) {
547 return Err(crate::ConfigStoreError::Validation(
548 "cluster credential metadata references an unknown node".to_string(),
549 ));
550 }
551 }
552
553 let mut requested = Vec::<(usize, Field, CredentialRef)>::new();
554 let mut seen_refs = BTreeSet::new();
555 let consumer_counts = crate::credential_store::config_credential_ref_counts(self)?;
556 for (index, node) in self.cluster_fabric.nodes.iter().enumerate() {
557 let metadata = self.cluster_fabric.credential_refs.get(&node.id);
558 let empty = ClusterNodeCredentialRefs::default();
559 let metadata = metadata.unwrap_or(&empty);
560 let mut validate = |field: Field,
561 reference: Option<&CredentialRef>,
562 configured: bool,
563 canonical: CredentialRef|
564 -> ConfigStoreResult<()> {
565 if configured != reference.is_some() {
566 return Err(crate::ConfigStoreError::Validation(
567 "cluster credential configured metadata is inconsistent".to_string(),
568 ));
569 }
570 let Some(reference) = reference else {
571 return Ok(());
572 };
573 if reference != &canonical {
574 return Err(crate::ConfigStoreError::Validation(
575 "cluster credential reference is not canonical".to_string(),
576 ));
577 }
578 if consumer_counts.get(reference).copied().unwrap_or(0) != 1
582 || !seen_refs.insert(reference.clone())
583 {
584 return Err(crate::ConfigStoreError::Validation(
585 "cluster credential reference is shared by another config consumer"
586 .to_string(),
587 ));
588 }
589 requested.push((index, field, reference.clone()));
590 Ok(())
591 };
592
593 match &node.placement {
594 NodePlacement::Local => {
595 if !metadata.is_empty() {
596 return Err(crate::ConfigStoreError::Validation(
597 "local cluster node carries SSH credential metadata".to_string(),
598 ));
599 }
600 }
601 NodePlacement::Ssh(target) => match &target.auth {
602 SshAuth::SystemSshConfig => {
603 if !metadata.is_empty() {
604 return Err(crate::ConfigStoreError::Validation(
605 "system SSH node carries stored credential metadata".to_string(),
606 ));
607 }
608 }
609 SshAuth::Password { .. } => {
610 if metadata.private_key_credential_ref.is_some()
611 || metadata.private_key_configured
612 || metadata.passphrase_credential_ref.is_some()
613 || metadata.passphrase_configured
614 {
615 return Err(crate::ConfigStoreError::Validation(
616 "cluster credential metadata does not match password auth"
617 .to_string(),
618 ));
619 }
620 validate(
621 Field::Password,
622 metadata.password_credential_ref.as_ref(),
623 metadata.password_configured,
624 cluster_password_credential_ref(&node.id)?,
625 )?;
626 }
627 SshAuth::PrivateKey { .. } => {
628 if metadata.password_credential_ref.is_some()
629 || metadata.password_configured
630 {
631 return Err(crate::ConfigStoreError::Validation(
632 "cluster credential metadata does not match private-key auth"
633 .to_string(),
634 ));
635 }
636 validate(
637 Field::PrivateKey,
638 metadata.private_key_credential_ref.as_ref(),
639 metadata.private_key_configured,
640 cluster_private_key_credential_ref(&node.id)?,
641 )?;
642 validate(
643 Field::Passphrase,
644 metadata.passphrase_credential_ref.as_ref(),
645 metadata.passphrase_configured,
646 cluster_passphrase_credential_ref(&node.id)?,
647 )?;
648 }
649 },
650 }
651 }
652
653 let mut resolved = Vec::with_capacity(requested.len());
654 for (index, field, reference) in requested {
655 let credentials = credentials.ok_or_else(|| {
656 crate::ConfigStoreError::Validation(
657 "referenced cluster credential is unavailable".to_string(),
658 )
659 })?;
660 let secret = credentials.resolve(&reference)?.ok_or_else(|| {
661 crate::ConfigStoreError::Validation(
662 "referenced cluster credential is unavailable".to_string(),
663 )
664 })?;
665 let secret = secret.expose().to_string();
666 if let Some(previous) = prior_plaintext.remove(&(index, field)) {
667 if previous != secret {
668 return Err(crate::ConfigStoreError::Validation(
669 "legacy cluster credential appeared after migration".to_string(),
670 ));
671 }
672 }
673 resolved.push((index, field, secret));
674 }
675 if !prior_plaintext.is_empty() {
676 return Err(crate::ConfigStoreError::Validation(
677 "legacy cluster credential appeared after migration".to_string(),
678 ));
679 }
680 for (index, field, secret) in resolved {
681 let NodePlacement::Ssh(target) = &mut self.cluster_fabric.nodes[index].placement else {
682 unreachable!("validated SSH credential target")
683 };
684 match (&mut target.auth, field) {
685 (SshAuth::Password { password, .. }, Field::Password) => *password = secret,
686 (SshAuth::PrivateKey { private_key, .. }, Field::PrivateKey) => {
687 *private_key = secret
688 }
689 (SshAuth::PrivateKey { passphrase, .. }, Field::Passphrase) => *passphrase = secret,
690 _ => unreachable!("validated cluster credential field"),
691 }
692 }
693 Ok(())
694 }
695
696 pub fn clear_cluster_runtime_credentials(&mut self) {
699 for node in &mut self.cluster_fabric.nodes {
700 let NodePlacement::Ssh(target) = &mut node.placement else {
701 continue;
702 };
703 match &mut target.auth {
704 SshAuth::SystemSshConfig => {}
705 SshAuth::Password {
706 password,
707 password_encrypted,
708 } => {
709 password.clear();
710 *password_encrypted = None;
711 }
712 SshAuth::PrivateKey {
713 private_key,
714 private_key_encrypted,
715 passphrase,
716 passphrase_encrypted,
717 ..
718 } => {
719 private_key.clear();
720 *private_key_encrypted = None;
721 passphrase.clear();
722 *passphrase_encrypted = None;
723 }
724 }
725 }
726 }
727
728 pub fn hydrate_cluster_fabric_from_encrypted(&mut self) {
734 let credential_refs = self.cluster_fabric.credential_refs.clone();
735 for node in &mut self.cluster_fabric.nodes {
736 let metadata = credential_refs.get(&node.id);
737 let NodePlacement::Ssh(target) = &mut node.placement else {
738 continue;
739 };
740 match &mut target.auth {
741 SshAuth::SystemSshConfig => {}
742 SshAuth::Password {
743 password,
744 password_encrypted,
745 } => {
746 if metadata.is_some_and(|value| value.password_credential_ref.is_some()) {
747 *password_encrypted = None;
748 continue;
749 }
750 hydrate_field(
751 password,
752 password_encrypted.as_deref(),
753 &node.id,
754 "password",
755 );
756 }
757 SshAuth::PrivateKey {
758 private_key,
759 private_key_encrypted,
760 passphrase,
761 passphrase_encrypted,
762 ..
763 } => {
764 if metadata.is_some_and(|value| value.private_key_credential_ref.is_some()) {
765 *private_key_encrypted = None;
766 } else {
767 hydrate_field(
768 private_key,
769 private_key_encrypted.as_deref(),
770 &node.id,
771 "private_key",
772 );
773 }
774 if metadata.is_some_and(|value| value.passphrase_credential_ref.is_some()) {
775 *passphrase_encrypted = None;
776 } else {
777 hydrate_field(
778 passphrase,
779 passphrase_encrypted.as_deref(),
780 &node.id,
781 "passphrase",
782 );
783 }
784 }
785 }
786 }
787 }
788
789 pub fn refresh_cluster_fabric_encrypted(&mut self) -> Result<()> {
796 let credential_refs = self.cluster_fabric.credential_refs.clone();
797 for node in &mut self.cluster_fabric.nodes {
798 let node_id = node.id.clone();
799 let metadata = credential_refs.get(&node_id);
800 let NodePlacement::Ssh(target) = &mut node.placement else {
801 continue;
802 };
803 match &mut target.auth {
804 SshAuth::SystemSshConfig => {}
805 SshAuth::Password {
806 password,
807 password_encrypted,
808 } => {
809 if metadata.is_some_and(|value| value.password_credential_ref.is_some()) {
810 *password_encrypted = None;
811 } else {
812 refresh_field(password, password_encrypted, &node_id, "password")?;
813 }
814 }
815 SshAuth::PrivateKey {
816 private_key,
817 private_key_encrypted,
818 passphrase,
819 passphrase_encrypted,
820 ..
821 } => {
822 if metadata.is_some_and(|value| value.private_key_credential_ref.is_some()) {
823 *private_key_encrypted = None;
824 } else {
825 refresh_field(private_key, private_key_encrypted, &node_id, "private_key")?;
826 }
827 if metadata.is_some_and(|value| value.passphrase_credential_ref.is_some()) {
828 *passphrase_encrypted = None;
829 } else {
830 refresh_field(passphrase, passphrase_encrypted, &node_id, "passphrase")?;
831 }
832 }
833 }
834 }
835 Ok(())
836 }
837
838 pub fn sanitize_cluster_fabric_for_disk(&mut self) {
840 for node in &mut self.cluster_fabric.nodes {
841 let NodePlacement::Ssh(target) = &mut node.placement else {
842 continue;
843 };
844 match &mut target.auth {
845 SshAuth::SystemSshConfig => {}
846 SshAuth::Password { password, .. } => password.clear(),
847 SshAuth::PrivateKey {
848 private_key,
849 passphrase,
850 ..
851 } => {
852 private_key.clear();
853 passphrase.clear();
854 }
855 }
856 }
857 }
858}
859
860fn hydrate_field(plaintext: &mut String, encrypted: Option<&str>, node_id: &str, what: &str) {
862 if !plaintext.trim().is_empty() {
863 return;
864 }
865 let Some(encrypted) = encrypted else {
866 return;
867 };
868 match crate::encryption::decrypt(encrypted) {
869 Ok(value) => *plaintext = value,
870 Err(e) => tracing::warn!("Failed to decrypt node '{node_id}' {what}: {e}"),
871 }
872}
873
874fn refresh_field(
877 plaintext: &str,
878 encrypted: &mut Option<String>,
879 node_id: &str,
880 what: &str,
881) -> Result<()> {
882 if plaintext.trim().is_empty() {
883 return Ok(());
884 }
885 *encrypted = Some(
886 crate::encryption::encrypt(plaintext)
887 .with_context(|| format!("Failed to encrypt node '{node_id}' {what}"))?,
888 );
889 Ok(())
890}
891
892#[cfg(test)]
893mod tests {
894 use super::*;
895
896 fn ssh_node(id: &str, auth: SshAuth) -> Node {
897 Node {
898 id: id.to_string(),
899 label: id.to_string(),
900 placement: NodePlacement::Ssh(SshTarget {
901 host: "10.0.0.1".to_string(),
902 port: 22,
903 username: "deploy".to_string(),
904 auth,
905 host_key_fingerprint: None,
906 }),
907 trust_level: TrustLevel::Trusted,
908 deploy: DeployProfile::default(),
909 state: None,
910 enabled: true,
911 }
912 }
913
914 #[test]
915 fn empty_fabric_is_skipped_on_serialize() {
916 let cfg = ClusterFabricConfig::default();
917 assert!(cfg.is_empty());
918 }
919
920 #[test]
921 fn credential_metadata_round_trips_without_secret_material() {
922 let mut fabric = ClusterFabricConfig::default();
923 fabric.credential_refs.insert(
924 "node/with unsafe id".to_string(),
925 ClusterNodeCredentialRefs {
926 password_credential_ref: Some(
927 cluster_password_credential_ref("node/with unsafe id").unwrap(),
928 ),
929 password_configured: true,
930 private_key_credential_ref: Some(
931 cluster_private_key_credential_ref("node/with unsafe id").unwrap(),
932 ),
933 private_key_configured: true,
934 passphrase_credential_ref: Some(
935 cluster_passphrase_credential_ref("node/with unsafe id").unwrap(),
936 ),
937 passphrase_configured: true,
938 },
939 );
940
941 let encoded = serde_json::to_string(&fabric).unwrap();
942 assert!(encoded.contains("password_credential_ref"));
943 assert!(encoded.contains("private_key_credential_ref"));
944 assert!(encoded.contains("passphrase_credential_ref"));
945 assert!(!encoded.contains("password_encrypted"));
946 assert!(!encoded.contains("private_key_encrypted"));
947 assert!(!encoded.contains("passphrase_encrypted"));
948 let decoded: ClusterFabricConfig = serde_json::from_str(&encoded).unwrap();
949 assert_eq!(decoded, fabric);
950 }
951
952 #[test]
953 fn canonical_cluster_refs_are_node_scoped_and_injective() {
954 let slash = cluster_password_credential_ref("node/a").unwrap();
955 let dot = cluster_password_credential_ref("node.a").unwrap();
956 assert_ne!(slash, dot);
957 assert!(slash.as_str().starts_with("cluster."));
958 assert!(slash.as_str().ends_with(".password"));
959 assert_ne!(
960 cluster_private_key_credential_ref("node/a").unwrap(),
961 cluster_passphrase_credential_ref("node/a").unwrap()
962 );
963 }
964
965 #[test]
966 fn pruning_nodes_drops_orphaned_credential_metadata() {
967 let mut fabric = ClusterFabricConfig::default();
968 fabric
969 .nodes
970 .push(ssh_node("kept", SshAuth::SystemSshConfig));
971 for id in ["kept", "deleted"] {
972 fabric.credential_refs.insert(
973 id.to_string(),
974 ClusterNodeCredentialRefs {
975 password_credential_ref: Some(cluster_password_credential_ref(id).unwrap()),
976 password_configured: true,
977 ..ClusterNodeCredentialRefs::default()
978 },
979 );
980 }
981
982 fabric.prune_orphaned_credential_refs();
983 assert!(fabric.credential_refs.contains_key("kept"));
984 assert!(!fabric.credential_refs.contains_key("deleted"));
985 }
986
987 #[test]
988 fn password_secret_round_trips_through_encrypt_sanitize_hydrate() {
989 let mut config = Config::default();
990 config.cluster_fabric.nodes.push(ssh_node(
991 "n1",
992 SshAuth::Password {
993 password: "hunter2".to_string(),
994 password_encrypted: None,
995 },
996 ));
997
998 config.refresh_cluster_fabric_encrypted().unwrap();
1000 config.sanitize_cluster_fabric_for_disk();
1001
1002 let NodePlacement::Ssh(t) = &config.cluster_fabric.nodes[0].placement else {
1004 panic!("expected ssh");
1005 };
1006 let SshAuth::Password {
1007 password,
1008 password_encrypted,
1009 } = &t.auth
1010 else {
1011 panic!("expected password auth");
1012 };
1013 assert!(password.is_empty(), "plaintext must be cleared for disk");
1014 assert!(password_encrypted.is_some(), "ciphertext must be stored");
1015
1016 config.hydrate_cluster_fabric_from_encrypted();
1018 let NodePlacement::Ssh(t) = &config.cluster_fabric.nodes[0].placement else {
1019 panic!("expected ssh");
1020 };
1021 let SshAuth::Password { password, .. } = &t.auth else {
1022 panic!("expected password auth");
1023 };
1024 assert_eq!(password, "hunter2", "plaintext restored on hydrate");
1025 }
1026
1027 #[test]
1028 fn private_key_and_passphrase_round_trip() {
1029 let mut config = Config::default();
1030 config.cluster_fabric.nodes.push(ssh_node(
1031 "n2",
1032 SshAuth::PrivateKey {
1033 private_key: "-----BEGIN KEY-----xyz".to_string(),
1034 private_key_encrypted: None,
1035 private_key_path: None,
1036 passphrase: "pp".to_string(),
1037 passphrase_encrypted: None,
1038 },
1039 ));
1040
1041 config.refresh_cluster_fabric_encrypted().unwrap();
1042 config.sanitize_cluster_fabric_for_disk();
1043 config.hydrate_cluster_fabric_from_encrypted();
1044
1045 let NodePlacement::Ssh(t) = &config.cluster_fabric.nodes[0].placement else {
1046 panic!("expected ssh");
1047 };
1048 let SshAuth::PrivateKey {
1049 private_key,
1050 passphrase,
1051 ..
1052 } = &t.auth
1053 else {
1054 panic!("expected private key auth");
1055 };
1056 assert_eq!(private_key, "-----BEGIN KEY-----xyz");
1057 assert_eq!(passphrase, "pp");
1058 }
1059
1060 #[test]
1061 fn empty_plaintext_keeps_existing_ciphertext_on_refresh() {
1062 let mut config = Config::default();
1065 config.cluster_fabric.nodes.push(ssh_node(
1066 "n3",
1067 SshAuth::Password {
1068 password: "secret".to_string(),
1069 password_encrypted: None,
1070 },
1071 ));
1072 config.refresh_cluster_fabric_encrypted().unwrap();
1073 config.sanitize_cluster_fabric_for_disk();
1074
1075 config.refresh_cluster_fabric_encrypted().unwrap();
1077 config.hydrate_cluster_fabric_from_encrypted();
1078
1079 let NodePlacement::Ssh(t) = &config.cluster_fabric.nodes[0].placement else {
1080 panic!("expected ssh");
1081 };
1082 let SshAuth::Password { password, .. } = &t.auth else {
1083 panic!("expected password auth");
1084 };
1085 assert_eq!(
1086 password, "secret",
1087 "ciphertext preserved across empty refresh"
1088 );
1089 }
1090
1091 #[test]
1092 fn local_node_has_no_secrets_to_touch() {
1093 let mut config = Config::default();
1094 config.cluster_fabric.nodes.push(Node {
1095 id: "local".to_string(),
1096 label: "local".to_string(),
1097 placement: NodePlacement::Local,
1098 trust_level: TrustLevel::Trusted,
1099 deploy: DeployProfile::default(),
1100 state: None,
1101 enabled: true,
1102 });
1103 config.refresh_cluster_fabric_encrypted().unwrap();
1105 config.sanitize_cluster_fabric_for_disk();
1106 config.hydrate_cluster_fabric_from_encrypted();
1107 assert_eq!(config.cluster_fabric.nodes.len(), 1);
1108 }
1109
1110 #[test]
1111 fn isolated_cluster_credentials_hydrate_only_from_canonical_refs() {
1112 let _key = crate::encryption::set_test_encryption_key([0xb1; 32]);
1113 let dir = tempfile::tempdir().unwrap();
1114 let password_ref = cluster_password_credential_ref("password-node").unwrap();
1115 let key_ref = cluster_private_key_credential_ref("key-node").unwrap();
1116 let passphrase_ref = cluster_passphrase_credential_ref("key-node").unwrap();
1117 let store = crate::CredentialStore::open(dir.path());
1118 store
1119 .replace(
1120 password_ref.clone(),
1121 "password-secret",
1122 crate::CredentialSource::User,
1123 0,
1124 )
1125 .unwrap();
1126 store
1127 .replace(
1128 key_ref.clone(),
1129 "private-key-secret",
1130 crate::CredentialSource::User,
1131 1,
1132 )
1133 .unwrap();
1134 store
1135 .replace(
1136 passphrase_ref.clone(),
1137 "passphrase-secret",
1138 crate::CredentialSource::User,
1139 2,
1140 )
1141 .unwrap();
1142
1143 let mut config = Config::default();
1144 config.cluster_fabric.nodes.push(ssh_node(
1145 "password-node",
1146 SshAuth::Password {
1147 password: String::new(),
1148 password_encrypted: None,
1149 },
1150 ));
1151 config.cluster_fabric.nodes.push(ssh_node(
1152 "key-node",
1153 SshAuth::PrivateKey {
1154 private_key: String::new(),
1155 private_key_encrypted: None,
1156 private_key_path: None,
1157 passphrase: String::new(),
1158 passphrase_encrypted: None,
1159 },
1160 ));
1161 config.cluster_fabric.credential_refs.insert(
1162 "password-node".to_string(),
1163 ClusterNodeCredentialRefs {
1164 password_credential_ref: Some(password_ref),
1165 password_configured: true,
1166 ..ClusterNodeCredentialRefs::default()
1167 },
1168 );
1169 config.cluster_fabric.credential_refs.insert(
1170 "key-node".to_string(),
1171 ClusterNodeCredentialRefs {
1172 private_key_credential_ref: Some(key_ref),
1173 private_key_configured: true,
1174 passphrase_credential_ref: Some(passphrase_ref),
1175 passphrase_configured: true,
1176 ..ClusterNodeCredentialRefs::default()
1177 },
1178 );
1179
1180 config
1181 .hydrate_cluster_credentials_from_store(dir.path())
1182 .unwrap();
1183 let NodePlacement::Ssh(password_target) = &config.cluster_fabric.nodes[0].placement else {
1184 panic!("expected SSH node")
1185 };
1186 let SshAuth::Password { password, .. } = &password_target.auth else {
1187 panic!("expected password auth")
1188 };
1189 assert_eq!(password, "password-secret");
1190 let NodePlacement::Ssh(key_target) = &config.cluster_fabric.nodes[1].placement else {
1191 panic!("expected SSH node")
1192 };
1193 let SshAuth::PrivateKey {
1194 private_key,
1195 passphrase,
1196 ..
1197 } = &key_target.auth
1198 else {
1199 panic!("expected private-key auth")
1200 };
1201 assert_eq!(private_key, "private-key-secret");
1202 assert_eq!(passphrase, "passphrase-secret");
1203
1204 config
1205 .hydrate_cluster_credentials_from_store(dir.path())
1206 .unwrap();
1207 config.refresh_cluster_fabric_encrypted().unwrap();
1208 config.sanitize_cluster_fabric_for_disk();
1209 let durable = serde_json::to_string(&config).unwrap();
1210 for forbidden in [
1211 "password-secret",
1212 "private-key-secret",
1213 "passphrase-secret",
1214 "password_encrypted",
1215 "private_key_encrypted",
1216 "passphrase_encrypted",
1217 ] {
1218 assert!(!durable.contains(forbidden), "persisted {forbidden}");
1219 }
1220 }
1221
1222 #[test]
1223 fn recursive_config_debug_redacts_all_cluster_credential_forms() {
1224 let _key = crate::encryption::set_test_encryption_key([0xc7; 32]);
1225 let dir = tempfile::tempdir().unwrap();
1226 let password_ref = cluster_password_credential_ref("password-debug").unwrap();
1227 let key_ref = cluster_private_key_credential_ref("key-debug").unwrap();
1228 let passphrase_ref = cluster_passphrase_credential_ref("key-debug").unwrap();
1229 let store = crate::CredentialStore::open(dir.path());
1230 for (revision, (reference, value)) in [
1231 (&password_ref, "debug-password-plaintext"),
1232 (&key_ref, "debug-private-key-plaintext"),
1233 (&passphrase_ref, "debug-passphrase-plaintext"),
1234 ]
1235 .into_iter()
1236 .enumerate()
1237 {
1238 store
1239 .replace(
1240 reference.clone(),
1241 value,
1242 crate::CredentialSource::User,
1243 revision as u64,
1244 )
1245 .unwrap();
1246 }
1247
1248 let mut config = Config::default();
1249 config.cluster_fabric.nodes.push(ssh_node(
1250 "password-debug",
1251 SshAuth::Password {
1252 password: String::new(),
1253 password_encrypted: None,
1254 },
1255 ));
1256 config.cluster_fabric.nodes.push(ssh_node(
1257 "key-debug",
1258 SshAuth::PrivateKey {
1259 private_key: String::new(),
1260 private_key_encrypted: None,
1261 private_key_path: Some("/safe/on-host/key".to_string()),
1262 passphrase: String::new(),
1263 passphrase_encrypted: None,
1264 },
1265 ));
1266 config.cluster_fabric.credential_refs.insert(
1267 "password-debug".to_string(),
1268 ClusterNodeCredentialRefs {
1269 password_credential_ref: Some(password_ref.clone()),
1270 password_configured: true,
1271 ..ClusterNodeCredentialRefs::default()
1272 },
1273 );
1274 config.cluster_fabric.credential_refs.insert(
1275 "key-debug".to_string(),
1276 ClusterNodeCredentialRefs {
1277 private_key_credential_ref: Some(key_ref.clone()),
1278 private_key_configured: true,
1279 passphrase_credential_ref: Some(passphrase_ref.clone()),
1280 passphrase_configured: true,
1281 ..ClusterNodeCredentialRefs::default()
1282 },
1283 );
1284 config
1285 .hydrate_cluster_credentials_from_store(dir.path())
1286 .unwrap();
1287
1288 let NodePlacement::Ssh(password_target) = &mut config.cluster_fabric.nodes[0].placement
1289 else {
1290 panic!("expected password SSH node")
1291 };
1292 let SshAuth::Password {
1293 password_encrypted, ..
1294 } = &mut password_target.auth
1295 else {
1296 panic!("expected password auth")
1297 };
1298 *password_encrypted = Some("debug-password-ciphertext".to_string());
1299
1300 let NodePlacement::Ssh(key_target) = &mut config.cluster_fabric.nodes[1].placement else {
1301 panic!("expected private-key SSH node")
1302 };
1303 let SshAuth::PrivateKey {
1304 private_key_encrypted,
1305 passphrase_encrypted,
1306 ..
1307 } = &mut key_target.auth
1308 else {
1309 panic!("expected private-key auth")
1310 };
1311 *private_key_encrypted = Some("debug-private-key-ciphertext".to_string());
1312 *passphrase_encrypted = Some("********debug-mask-sentinel".to_string());
1313
1314 let recursive_debug = format!("{config:?}");
1315 let values_debug = format!("{:?}", config.section_values());
1316 for forbidden in [
1317 "debug-password-plaintext",
1318 "debug-private-key-plaintext",
1319 "debug-passphrase-plaintext",
1320 "debug-password-ciphertext",
1321 "debug-private-key-ciphertext",
1322 "********debug-mask-sentinel",
1323 password_ref.as_str(),
1324 key_ref.as_str(),
1325 passphrase_ref.as_str(),
1326 ] {
1327 assert!(
1328 !recursive_debug.contains(forbidden),
1329 "Config Debug leaked {forbidden}"
1330 );
1331 assert!(
1332 !values_debug.contains(forbidden),
1333 "ConfigValues Debug leaked {forbidden}"
1334 );
1335 }
1336 assert!(recursive_debug.contains("password_configured: true"));
1337 assert!(recursive_debug.contains("inline_private_key_configured: true"));
1338 assert!(recursive_debug.contains("passphrase_configured: true"));
1339 assert!(recursive_debug.contains("legacy_password_ciphertext_present: true"));
1340 assert!(recursive_debug.contains("legacy_private_key_ciphertext_present: true"));
1341 assert!(recursive_debug.contains("legacy_passphrase_ciphertext_present: true"));
1342 }
1343
1344 #[test]
1345 fn cluster_without_credential_refs_does_not_read_the_credential_document() {
1346 let dir = tempfile::tempdir().unwrap();
1347 std::fs::write(dir.path().join("credentials.json"), b"{invalid").unwrap();
1348 let mut config = Config::default();
1349 config
1350 .cluster_fabric
1351 .nodes
1352 .push(ssh_node("system-node", SshAuth::SystemSshConfig));
1353
1354 config
1355 .hydrate_cluster_credentials_from_store(dir.path())
1356 .unwrap();
1357 }
1358
1359 #[test]
1360 fn cluster_credential_snapshot_preserves_transaction_readiness_checks() {
1361 let dir = tempfile::tempdir().unwrap();
1362 std::fs::write(
1363 dir.path().join("config-credential-migration.json"),
1364 b"{invalid",
1365 )
1366 .unwrap();
1367 let reference = cluster_password_credential_ref("node").unwrap();
1368 let mut config = Config::default();
1369 config.cluster_fabric.nodes.push(ssh_node(
1370 "node",
1371 SshAuth::Password {
1372 password: String::new(),
1373 password_encrypted: None,
1374 },
1375 ));
1376 config.cluster_fabric.credential_refs.insert(
1377 "node".to_string(),
1378 ClusterNodeCredentialRefs {
1379 password_credential_ref: Some(reference),
1380 password_configured: true,
1381 ..ClusterNodeCredentialRefs::default()
1382 },
1383 );
1384
1385 let error = config
1386 .hydrate_cluster_credentials_from_store(dir.path())
1387 .unwrap_err();
1388 assert!(error.to_string().contains("migration is pending"));
1389 }
1390
1391 #[test]
1392 fn unavailable_or_shared_cluster_ref_fails_closed() {
1393 let dir = tempfile::tempdir().unwrap();
1394 let reference = cluster_password_credential_ref("node").unwrap();
1395 let mut config = Config::default();
1396 config.cluster_fabric.nodes.push(ssh_node(
1397 "node",
1398 SshAuth::Password {
1399 password: String::new(),
1400 password_encrypted: None,
1401 },
1402 ));
1403 config.cluster_fabric.credential_refs.insert(
1404 "node".to_string(),
1405 ClusterNodeCredentialRefs {
1406 password_credential_ref: Some(reference.clone()),
1407 password_configured: true,
1408 ..ClusterNodeCredentialRefs::default()
1409 },
1410 );
1411
1412 let error = config
1413 .hydrate_cluster_credentials_from_store(dir.path())
1414 .unwrap_err();
1415 assert!(error.to_string().contains("unavailable"));
1416
1417 config.notifications.ntfy.credential_ref = Some(reference);
1418 config.notifications.ntfy.configured = true;
1419 let error = config
1420 .hydrate_cluster_credentials_from_store(dir.path())
1421 .unwrap_err();
1422 assert!(error.to_string().contains("shared"));
1423 }
1424
1425 #[test]
1426 fn late_legacy_cluster_secret_is_rejected_and_cleared() {
1427 let dir = tempfile::tempdir().unwrap();
1428 let ciphertext = crate::encryption::encrypt("late-secret").unwrap();
1429 let mut config = Config::default();
1430 config.cluster_fabric.nodes.push(ssh_node(
1431 "node",
1432 SshAuth::Password {
1433 password: "late-plaintext".to_string(),
1434 password_encrypted: Some(ciphertext),
1435 },
1436 ));
1437
1438 let error = config
1439 .hydrate_cluster_credentials_from_store(dir.path())
1440 .unwrap_err();
1441 assert!(error.to_string().contains("appeared after migration"));
1442 let NodePlacement::Ssh(target) = &config.cluster_fabric.nodes[0].placement else {
1443 panic!("expected SSH node")
1444 };
1445 let SshAuth::Password {
1446 password,
1447 password_encrypted,
1448 } = &target.auth
1449 else {
1450 panic!("expected password auth")
1451 };
1452 assert!(password.is_empty());
1453 assert!(password_encrypted.is_none());
1454 }
1455}