host_keys.rs 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  1. //! Auto-discover SSH host key fingerprints for every node in the config
  2. //! and rewrite `config.toml` with `host_key = "SHA256:..."` entries.
  3. //!
  4. //! Uses `toml_edit` for round-trip-safe TOML manipulation so formatting
  5. //! and comments in the config file are preserved.
  6. use anyhow::{Context, Result};
  7. use std::fs;
  8. use crate::config::Config;
  9. use crate::ssh::fetch_host_key;
  10. /// Discover host keys and update the config file. When `cluster_filter`
  11. /// is `Some`, only that cluster is processed.
  12. pub async fn init_host_keys(
  13. config: &Config,
  14. cluster_filter: Option<&str>,
  15. config_path: &str,
  16. ) -> Result<()> {
  17. let updates = collect_updates(config, cluster_filter).await?;
  18. if updates.is_empty() {
  19. println!("{}", fluent_i18n::t!("host-key-no-match"));
  20. return Ok(());
  21. }
  22. println!("{}", fluent_i18n::t!("host-key-header"));
  23. for (name, ip, fp) in &updates {
  24. println!(
  25. " {}",
  26. fluent_i18n::t!("host-key-line", { "name" => name, "ip" => ip, "key" => fp })
  27. );
  28. }
  29. let content = fs::read_to_string(config_path)
  30. .with_context(|| format!("Failed to read {}", config_path))?;
  31. let new_content = update_host_keys_toml(&content, &updates)?;
  32. if content == new_content {
  33. println!("\nNo changes needed in {}", config_path);
  34. } else {
  35. fs::write(config_path, &new_content)
  36. .with_context(|| format!("Failed to write {}", config_path))?;
  37. println!("\nUpdated {}", config_path);
  38. }
  39. Ok(())
  40. }
  41. /// Walk the config and return `(node_name, fingerprint)` for every node.
  42. async fn collect_updates(
  43. config: &Config,
  44. cluster_filter: Option<&str>,
  45. ) -> Result<Vec<(String, String, String)>> {
  46. let mut out = Vec::new();
  47. for cluster in &config.clusters {
  48. if let Some(name) = cluster_filter
  49. && cluster.name != name
  50. {
  51. continue;
  52. }
  53. for node in &cluster.nodes {
  54. match fetch_host_key(config, node).await {
  55. Ok(fp) => out.push((node.name.clone(), node.ssh_ip.clone(), fp)),
  56. Err(e) => println!(
  57. "{}",
  58. fluent_i18n::t!("host-key-error", { "name" => &node.name, "ip" => &node.ssh_ip, "error" => &e.to_string() })
  59. ),
  60. }
  61. }
  62. }
  63. Ok(out)
  64. }
  65. /// Parse `content` as TOML, walk the `[[clusters]]` tables, find each
  66. /// node whose `name` matches an entry in `updates`, and set the
  67. /// `host_key` field on it. Preserves comments and formatting.
  68. fn update_host_keys_toml(content: &str, updates: &[(String, String, String)]) -> Result<String> {
  69. use toml_edit::{DocumentMut, Value};
  70. let mut doc = content
  71. .parse::<DocumentMut>()
  72. .with_context(|| "config is not valid TOML")?;
  73. let clusters = doc
  74. .get_mut("clusters")
  75. .and_then(|v| v.as_array_of_tables_mut());
  76. let Some(clusters) = clusters else {
  77. return Ok(content.to_string());
  78. };
  79. for cluster in clusters.iter_mut() {
  80. let Some(nodes) = cluster.get_mut("nodes") else {
  81. continue;
  82. };
  83. let Some(arr) = nodes.as_array_mut() else {
  84. continue;
  85. };
  86. for item in arr.iter_mut() {
  87. let Some(table) = item.as_inline_table_mut() else {
  88. continue;
  89. };
  90. let name_val = table.get("name").and_then(|v| v.as_str());
  91. let Some(name) = name_val else {
  92. continue;
  93. };
  94. if let Some((_n, _ip, fp)) = updates.iter().find(|(n, _, _)| n == name) {
  95. table.insert("host_key", Value::from(fp.as_str()));
  96. }
  97. }
  98. }
  99. Ok(doc.to_string())
  100. }
  101. #[cfg(test)]
  102. mod tests {
  103. use super::*;
  104. #[test]
  105. fn adds_host_key_when_missing() {
  106. let content = r#"
  107. [[clusters]]
  108. name = "c"
  109. nodes = [
  110. { name = "dgx1.lan", ssh_ip = "10.0.0.1", ib_ip = "1.2.3.4", rank = 0 },
  111. ]
  112. "#;
  113. let updates = vec![(
  114. "dgx1.lan".to_string(),
  115. "10.0.0.1".to_string(),
  116. "SHA256:abc".to_string(),
  117. )];
  118. let result = update_host_keys_toml(content, &updates).unwrap();
  119. assert!(result.contains(r#"host_key = "SHA256:abc""#));
  120. assert!(result.contains("dgx1.lan"));
  121. }
  122. #[test]
  123. fn replaces_host_key_value() {
  124. let content = r#"
  125. [[clusters]]
  126. name = "c"
  127. nodes = [
  128. { name = "x", rank = 0, host_key = "OLD" },
  129. ]
  130. "#;
  131. let updates = vec![("x".to_string(), "10.0.0.1".to_string(), "NEW".to_string())];
  132. let result = update_host_keys_toml(content, &updates).unwrap();
  133. assert!(result.contains(r#"host_key = "NEW""#));
  134. assert!(!result.contains(r#"host_key = "OLD""#));
  135. }
  136. #[test]
  137. fn only_updates_targeted_nodes() {
  138. let content = r#"
  139. [[clusters]]
  140. name = "c"
  141. nodes = [
  142. { name = "a", rank = 0 },
  143. { name = "b", rank = 1 },
  144. ]
  145. "#;
  146. let updates = vec![(
  147. "a".to_string(),
  148. "10.0.0.1".to_string(),
  149. "SHA256:newA".to_string(),
  150. )];
  151. let result = update_host_keys_toml(content, &updates).unwrap();
  152. assert!(
  153. result.contains(r#"host_key = "SHA256:newA""#),
  154. "result: {result}"
  155. );
  156. // b should NOT have host_key added
  157. assert!(result.contains(r#"name = "b""#), "result has b: {result}");
  158. assert!(
  159. !result.contains("host_key") || result.matches("host_key").count() == 1,
  160. "only 'a' should have host_key: {result}"
  161. );
  162. }
  163. #[test]
  164. fn updates_multiple_clusters() {
  165. let content = r#"
  166. [[clusters]]
  167. name = "c1"
  168. nodes = [
  169. { name = "n1", rank = 0 },
  170. ]
  171. [[clusters]]
  172. name = "c2"
  173. nodes = [
  174. { name = "n2", rank = 0 },
  175. ]
  176. "#;
  177. let updates = vec![
  178. ("n1".to_string(), "10.0.0.1".to_string(), "FP1".to_string()),
  179. ("n2".to_string(), "10.0.0.2".to_string(), "FP2".to_string()),
  180. ];
  181. let result = update_host_keys_toml(content, &updates).unwrap();
  182. assert!(
  183. result.contains(r#"host_key = "FP1""#),
  184. "result has FP1: {result}"
  185. );
  186. assert!(
  187. result.contains(r#"host_key = "FP2""#),
  188. "result has FP2: {result}"
  189. );
  190. }
  191. #[test]
  192. fn preserves_trailing_newline() {
  193. let content = "[[clusters]]\nname = \"c\"\nnodes = []\n";
  194. let result = update_host_keys_toml(content, &[]).unwrap();
  195. assert_eq!(result, content);
  196. }
  197. #[test]
  198. fn no_host_key_for_absent_update() {
  199. let content = r#"[[clusters]]
  200. name = "c"
  201. nodes = [{ name = "n1", rank = 0 }]
  202. "#;
  203. let result = update_host_keys_toml(content, &[]).unwrap();
  204. assert_eq!(result, content);
  205. }
  206. }