agentmux_srv/backend/remote/
ssh_config_edit.rs1use std::path::{Path, PathBuf};
19
20pub const BACKUP_NAME: &str = "config.agentmux-backup";
22
23#[derive(Debug, Clone, Default, PartialEq, Eq)]
25pub struct NewHost {
26 pub alias: String,
27 pub hostname: String,
28 pub user: String,
29 pub port: String,
30 pub identity_file: String,
31 pub proxy_jump: String,
32}
33
34fn word(field: &str, value: &str) -> Result<String, String> {
37 let v = value.trim();
38 if v.chars().any(|c| c.is_whitespace() || c.is_control() || c == '"' || c == '\'' || c == '\\') {
39 return Err(format!("{field} can't contain spaces, quotes or backslashes"));
40 }
41 if v.starts_with('#') || v.starts_with('-') {
42 return Err(format!("{field} can't start with {:?}", &v[..1]));
43 }
44 Ok(v.to_string())
45}
46
47pub fn host_block(host: &NewHost) -> Result<String, String> {
50 let alias = word("The alias", &host.alias)?;
51 if alias.is_empty() {
52 return Err("a remote needs an alias".into());
53 }
54 if !super::ssh_config::is_plain_name(&alias) {
55 return Err(format!("{alias:?} can't be an alias: use a plain name, without * ? or !"));
56 }
57 let mut block = format!("Host {alias}\n");
58 fn line(block: &mut String, keyword: &str, field: &str, value: &str) -> Result<(), String> {
59 let v = word(field, value)?;
60 if !v.is_empty() {
61 block.push_str(&format!(" {keyword} {v}\n"));
62 }
63 Ok(())
64 }
65 line(&mut block, "HostName", "The host name", &host.hostname)?;
66 line(&mut block, "User", "The user", &host.user)?;
67 let port = host.port.trim();
68 if !port.is_empty() && port.parse::<u16>().map_or(true, |p| p == 0) {
69 return Err(format!("{port:?} is not a port (1 to 65535)"));
70 }
71 line(&mut block, "Port", "The port", port)?;
72 let identity = host.identity_file.trim();
75 if identity.chars().any(|c| c.is_control() || c == '"') {
76 return Err("the identity file's path can't contain quotes".into());
77 }
78 if identity.ends_with('\\') {
81 return Err("the identity file's path can't end in a backslash".into());
82 }
83 if !identity.is_empty() {
84 if identity.contains(char::is_whitespace) {
85 block.push_str(&format!(" IdentityFile \"{identity}\"\n"));
86 } else {
87 block.push_str(&format!(" IdentityFile {identity}\n"));
88 }
89 }
90 line(&mut block, "ProxyJump", "The jump host", &host.proxy_jump)?;
91 Ok(block)
92}
93
94fn all_host_words(path: &Path, ssh_dir: &Path, depth: usize, out: &mut Vec<String>) {
97 if depth > super::ssh_config::MAX_INCLUDE_DEPTH {
98 return;
99 }
100 let Ok(bytes) = std::fs::read(path) else {
101 return;
102 };
103 for line in String::from_utf8_lossy(&bytes).lines() {
104 let Some((keyword, args)) = super::ssh_config::split_line(line) else {
105 continue;
106 };
107 if keyword.eq_ignore_ascii_case("host") {
108 out.extend(args);
109 } else if keyword.eq_ignore_ascii_case("include") {
110 for pattern in args {
111 for file in super::ssh_config::expand_include(&pattern, ssh_dir) {
112 all_host_words(&file, ssh_dir, depth + 1, out);
113 }
114 }
115 }
116 }
117}
118
119pub fn add_host_in(config: &Path, ssh_dir: &Path, host: &NewHost) -> Result<String, String> {
122 let block = host_block(host)?;
123 let alias = host.alias.trim();
124 let mut names = Vec::new();
125 all_host_words(config, ssh_dir, 0, &mut names);
126 if names.iter().any(|n| n == alias) {
127 return Err(format!("your ssh config already has a host {alias:?}"));
128 }
129 std::fs::create_dir_all(ssh_dir).map_err(|e| format!("could not create {}: {e}", ssh_dir.display()))?;
130 let existing = match std::fs::read(config) {
133 Ok(bytes) => Some(bytes),
134 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
135 Err(e) => return Err(format!("could not read {} ({e}); nothing was changed", config.display())),
136 };
137 let backup = ssh_dir.join(BACKUP_NAME);
138 if let Some(bytes) = &existing {
139 if !backup.exists() {
140 std::fs::write(&backup, bytes).map_err(|e| format!("could not keep a copy of your ssh config: {e}"))?;
141 owner_only(&backup);
142 }
143 }
144 let mut add = String::new();
146 if let Some(bytes) = existing.as_deref().filter(|b| !b.is_empty()) {
147 if !bytes.ends_with(b"\n") {
148 add.push('\n');
149 }
150 add.push('\n');
151 }
152 add.push_str("# Added by AgentMux (Connectors > Remotes)\n");
153 add.push_str(&block);
154 use std::io::Write;
155 std::fs::OpenOptions::new()
156 .append(true)
157 .create_new(existing.is_none())
158 .open(config)
159 .and_then(|mut f| f.write_all(add.as_bytes()))
160 .map_err(|e| format!("could not write {}: {e}", config.display()))?;
161 if existing.is_none() {
162 owner_only(config);
163 }
164 Ok(block)
165}
166
167fn owner_only(_path: &Path) {
170 #[cfg(unix)]
171 {
172 use std::os::unix::fs::PermissionsExt;
173 let _ = std::fs::set_permissions(_path, std::fs::Permissions::from_mode(0o600));
174 }
175}
176
177pub fn locate_in(config: &Path, ssh_dir: &Path, alias: &str) -> Option<(PathBuf, usize)> {
180 fn find(path: &Path, ssh_dir: &Path, alias: &str, depth: usize) -> Option<(PathBuf, usize)> {
181 if depth > super::ssh_config::MAX_INCLUDE_DEPTH {
182 return None;
183 }
184 let bytes = std::fs::read(path).ok()?;
185 for (i, line) in String::from_utf8_lossy(&bytes).lines().enumerate() {
186 let Some((keyword, args)) = super::ssh_config::split_line(line) else {
187 continue;
188 };
189 if keyword.eq_ignore_ascii_case("host") && args.iter().any(|a| a == alias) {
190 return Some((path.to_path_buf(), i + 1));
191 }
192 if keyword.eq_ignore_ascii_case("include") {
193 for pattern in args {
194 for file in super::ssh_config::expand_include(&pattern, ssh_dir) {
195 if let Some(found) = find(&file, ssh_dir, alias, depth + 1) {
196 return Some(found);
197 }
198 }
199 }
200 }
201 }
202 None
203 }
204 find(config, ssh_dir, alias.trim(), 0)
205}
206
207pub fn user_paths() -> Option<(PathBuf, PathBuf)> {
209 let ssh_dir = dirs::home_dir()?.join(".ssh");
210 Some((ssh_dir.join("config"), ssh_dir))
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216
217 fn host(alias: &str) -> NewHost {
218 NewHost { alias: alias.into(), hostname: "10.0.0.5".into(), user: "asaf".into(), ..Default::default() }
219 }
220
221 #[test]
222 fn a_block_has_only_the_given_settings() {
223 let full = NewHost {
224 alias: "db1".into(),
225 hostname: "db1.internal".into(),
226 user: "admin".into(),
227 port: "2222".into(),
228 identity_file: "~/.ssh/my key".into(),
229 proxy_jump: "bastion".into(),
230 };
231 assert_eq!(
232 host_block(&full).unwrap(),
233 "Host db1\n HostName db1.internal\n User admin\n Port 2222\n IdentityFile \"~/.ssh/my key\"\n ProxyJump bastion\n"
234 );
235 assert_eq!(host_block(&NewHost { alias: "box".into(), ..Default::default() }).unwrap(), "Host box\n");
236 }
237
238 #[test]
239 fn a_field_cant_start_a_directive_of_its_own() {
240 for bad in [
241 NewHost { hostname: "x\n ProxyCommand calc".into(), ..host("a") },
242 NewHost { user: "a b".into(), ..host("a") },
243 NewHost { proxy_jump: "-oProxyCommand=calc".into(), ..host("a") },
244 NewHost { identity_file: "k\"\nProxyCommand x".into(), ..host("a") },
245 NewHost { identity_file: r"C:\Users\John Doe\.ssh\".into(), ..host("a") },
246 NewHost { port: "0".into(), ..host("a") },
247 NewHost { port: "ssh".into(), ..host("a") },
248 host(""),
249 host("*"),
250 host("web*"),
251 ] {
252 assert!(host_block(&bad).is_err(), "{bad:?}");
253 }
254 }
255
256 #[test]
257 fn adding_appends_keeps_a_copy_and_refuses_a_name_the_file_has() {
258 let dir = tempfile::tempdir().unwrap();
259 let config = dir.path().join("config");
260 std::fs::write(&config, "Host *\n ServerAliveInterval 30\nInclude extra\n").unwrap();
261 std::fs::write(dir.path().join("extra"), "Host nas\n").unwrap();
262 add_host_in(&config, dir.path(), &host("db1")).unwrap();
263 let text = std::fs::read_to_string(&config).unwrap();
264 assert!(text.starts_with("Host *\n"), "the old content is untouched: {text}");
265 assert!(text.ends_with("# Added by AgentMux (Connectors > Remotes)\nHost db1\n HostName 10.0.0.5\n User asaf\n"), "{text}");
266 let backup = std::fs::read_to_string(dir.path().join(BACKUP_NAME)).unwrap();
267 assert_eq!(backup, "Host *\n ServerAliveInterval 30\nInclude extra\n");
268 assert!(add_host_in(&config, dir.path(), &host("db1")).is_err());
270 assert!(add_host_in(&config, dir.path(), &host("nas")).is_err());
271 add_host_in(&config, dir.path(), &host("web")).unwrap();
273 assert_eq!(std::fs::read_to_string(dir.path().join(BACKUP_NAME)).unwrap(), backup);
274 assert_eq!(locate_in(&config, dir.path(), "web").map(|(_, l)| l), Some(text.lines().count() + 3));
275 assert_eq!(locate_in(&config, dir.path(), "nas"), Some((dir.path().join("extra"), 1)));
276 assert_eq!(locate_in(&config, dir.path(), "nope"), None);
277 }
278
279 #[test]
282 fn a_config_that_isnt_utf8_is_kept_byte_for_byte() {
283 let dir = tempfile::tempdir().unwrap();
284 let config = dir.path().join("config");
285 let original: &[u8] = b"# caf\xe9\nHost nas\n";
286 std::fs::write(&config, original).unwrap();
287 assert!(add_host_in(&config, dir.path(), &host("nas")).is_err(), "nas is still seen");
288 add_host_in(&config, dir.path(), &host("box")).unwrap();
289 let after = std::fs::read(&config).unwrap();
290 assert!(after.starts_with(original), "{:?}", String::from_utf8_lossy(&after));
291 assert!(after.ends_with(b"Host box\n HostName 10.0.0.5\n User asaf\n"));
292 assert_eq!(std::fs::read(dir.path().join(BACKUP_NAME)).unwrap(), original);
293 }
294
295 #[test]
297 fn an_unreadable_config_is_left_alone() {
298 let dir = tempfile::tempdir().unwrap();
299 let config = dir.path().join("config");
300 std::fs::create_dir(&config).unwrap();
301 let err = add_host_in(&config, dir.path(), &host("box")).unwrap_err();
302 assert!(err.contains("nothing was changed"), "{err}");
303 assert!(config.is_dir());
304 assert!(!dir.path().join(BACKUP_NAME).exists());
305 }
306
307 #[test]
308 fn a_missing_config_is_created() {
309 let dir = tempfile::tempdir().unwrap();
310 let ssh_dir = dir.path().join(".ssh");
311 let config = ssh_dir.join("config");
312 add_host_in(&config, &ssh_dir, &host("box")).unwrap();
313 assert_eq!(
314 std::fs::read_to_string(&config).unwrap(),
315 "# Added by AgentMux (Connectors > Remotes)\nHost box\n HostName 10.0.0.5\n User asaf\n"
316 );
317 assert!(!ssh_dir.join(BACKUP_NAME).exists(), "nothing to keep a copy of");
318 #[cfg(unix)]
319 {
320 use std::os::unix::fs::PermissionsExt;
321 assert_eq!(std::fs::metadata(&config).unwrap().permissions().mode() & 0o777, 0o600);
322 }
323 }
324}