host_keys.rs 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  1. //! Auto-discover SSH host key fingerprints for every node in the config
  2. //! and rewrite `config.toml` with `host_key = "SHA256:..."` entries.
  3. use anyhow::{Context, Result};
  4. use std::fs;
  5. use crate::config::Config;
  6. use crate::ssh::fetch_host_key;
  7. /// Discover host keys and update the config file. When `cluster_filter`
  8. /// is `Some`, only that cluster is processed.
  9. pub async fn init_host_keys(
  10. config: &Config,
  11. cluster_filter: Option<&str>,
  12. config_path: &str,
  13. ) -> Result<()> {
  14. let updates = collect_updates(config, cluster_filter).await?;
  15. if updates.is_empty() {
  16. println!("(no nodes matched)");
  17. return Ok(());
  18. }
  19. println!("Discovered host keys:");
  20. for (name, fp) in &updates {
  21. println!(" {} -> {}", name, fp);
  22. }
  23. let content = fs::read_to_string(config_path)
  24. .with_context(|| format!("Failed to read {}", config_path))?;
  25. let new_content = update_host_keys_text(&content, &updates);
  26. if content == new_content {
  27. println!("\nNo changes needed in {}", config_path);
  28. } else {
  29. fs::write(config_path, &new_content)
  30. .with_context(|| format!("Failed to write {}", config_path))?;
  31. println!("\nUpdated {}", config_path);
  32. }
  33. Ok(())
  34. }
  35. /// Walk the config and return `(node_name, fingerprint)` for every node.
  36. async fn collect_updates(
  37. config: &Config,
  38. cluster_filter: Option<&str>,
  39. ) -> Result<Vec<(String, String)>> {
  40. let mut out = Vec::new();
  41. for cluster in &config.clusters {
  42. if let Some(name) = cluster_filter
  43. && cluster.name != name
  44. {
  45. continue;
  46. }
  47. for node in &cluster.nodes {
  48. match fetch_host_key(config, node).await {
  49. Ok(fp) => out.push((node.name.clone(), fp)),
  50. Err(e) => eprintln!("warning: failed to fetch key for {}: {}", node.name, e),
  51. }
  52. }
  53. }
  54. Ok(out)
  55. }
  56. /// Walk `content` line by line and rewrite any node definition whose
  57. /// `name = "..."` matches an entry in `updates`, setting or updating the
  58. /// `host_key` field.
  59. fn update_host_keys_text(content: &str, updates: &[(String, String)]) -> String {
  60. let mut lines: Vec<String> = content.lines().map(String::from).collect();
  61. for (name, fp) in updates {
  62. let marker = format!("name = \"{}\"", name);
  63. for line in &mut lines {
  64. if is_node_line(line) && line.contains(&marker) {
  65. *line = update_node_line(line, fp);
  66. }
  67. }
  68. }
  69. if content.ends_with('\n') {
  70. lines.join("\n") + "\n"
  71. } else {
  72. lines.join("\n")
  73. }
  74. }
  75. /// Heuristic: a node definition starts (after indent) with `{`.
  76. fn is_node_line(line: &str) -> bool {
  77. line.trim_start().starts_with('{')
  78. }
  79. /// Set or replace the `host_key = "..."` field in a single node line.
  80. ///
  81. /// If the field is already present, its value is swapped for `fp`.
  82. /// Otherwise the field is inserted before the trailing `}` with a comma
  83. /// if needed.
  84. fn update_node_line(line: &str, fp: &str) -> String {
  85. let marker = "host_key = \"";
  86. if let Some(start) = line.find(marker) {
  87. let value_start = start + marker.len();
  88. if let Some(rel_end) = line[value_start..].find('"') {
  89. let value_end = value_start + rel_end;
  90. let mut result = String::with_capacity(line.len());
  91. result.push_str(&line[..value_start]);
  92. result.push_str(fp);
  93. result.push_str(&line[value_end..]);
  94. return result;
  95. }
  96. }
  97. // No host_key yet — insert before the closing `}`.
  98. let close_idx = line.rfind('}').expect("node line must contain '}'");
  99. let before_close = &line[..close_idx];
  100. let after_close = &line[close_idx..];
  101. let trimmed = before_close.trim_end();
  102. let needs_comma = !trimmed.is_empty() && !trimmed.ends_with(',');
  103. // Original line either ends with `}` or `},`. Detect which so we can
  104. // keep the formatting consistent (space before `}` only when a comma
  105. // follows it).
  106. let has_trailing_comma = after_close.starts_with("},");
  107. let mut result = String::with_capacity(line.len() + fp.len() + 20);
  108. result.push_str(trimmed);
  109. if needs_comma {
  110. result.push(',');
  111. }
  112. result.push(' ');
  113. result.push_str(&format!("host_key = \"{}\"", fp));
  114. if has_trailing_comma {
  115. result.push(' ');
  116. }
  117. result.push('}');
  118. if has_trailing_comma {
  119. result.push(',');
  120. }
  121. result
  122. }
  123. #[cfg(test)]
  124. mod tests {
  125. use super::*;
  126. #[test]
  127. fn adds_host_key_when_missing() {
  128. let line =
  129. r#" { name = "dgx1.lan", ssh_ip = "10.0.0.1", ib_ip = "1.2.3.4", rank = 0 },"#;
  130. let result = update_node_line(line, "SHA256:abc");
  131. assert_eq!(
  132. result,
  133. r#" { name = "dgx1.lan", ssh_ip = "10.0.0.1", ib_ip = "1.2.3.4", rank = 0, host_key = "SHA256:abc" },"#
  134. );
  135. }
  136. #[test]
  137. fn replaces_host_key_value() {
  138. let line = r#" { name = "x", rank = 0, host_key = "OLD" },"#;
  139. let result = update_node_line(line, "NEW");
  140. assert_eq!(result, r#" { name = "x", rank = 0, host_key = "NEW" },"#);
  141. }
  142. #[test]
  143. fn update_host_keys_text_targets_only_named_lines() {
  144. let content = "\
  145. [[clusters]]
  146. name = \"c\"
  147. nodes = [
  148. { name = \"a\", rank = 0 },
  149. { name = \"b\", rank = 1 },
  150. ]
  151. ";
  152. let updates = vec![("a".to_string(), "SHA256:newA".to_string())];
  153. let result = update_host_keys_text(content, &updates);
  154. // a was updated to include host_key
  155. assert!(result.contains("name = \"a\", rank = 0, host_key = \"SHA256:newA\""));
  156. // b was NOT updated — original line preserved, no host_key added
  157. assert!(result.contains("name = \"b\", rank = 1 }"));
  158. assert!(!result.contains("name = \"b\", rank = 1, host_key"));
  159. }
  160. #[test]
  161. fn update_node_line_with_no_trailing_comma() {
  162. // Last field already ends with comma — no extra comma needed.
  163. let line = r#" { name = "x", rank = 0 }"#;
  164. let result = update_node_line(line, "FP");
  165. assert_eq!(result, r#" { name = "x", rank = 0, host_key = "FP"}"#);
  166. }
  167. }