Skip to main content

agentmux_srv/backend/remote/
ssh_config_edit.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Adding a host to the user's `~/.ssh/config` from Remotes, and finding a
5//! host's `Host` line there (SPEC_REMOTES_PANE_2026_10_05.md ยง4.4).
6//!
7//! The file stays the one place hosts are defined, so every tool sees a host
8//! added here. AgentMux only ever appends a new `Host` block at the end (an
9//! `Include` and earlier `Host *` defaults still apply to it as before) and
10//! never rewrites an existing entry. It refuses an alias the file already
11//! names, keeps a copy of the file before its first write, and creates the
12//! file owner-only when it is missing.
13//!
14//! Every value is checked before it is written: one word, no control
15//! characters, no quotes, so a field can't end its line and start a directive
16//! of its own (a `ProxyCommand`, say).
17
18use std::path::{Path, PathBuf};
19
20/// The copy kept of the config before AgentMux's first write.
21pub const BACKUP_NAME: &str = "config.agentmux-backup";
22
23/// What the Add remote form gives.
24#[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
34/// One word for an ssh config line: no whitespace, quotes, `#` at the start,
35/// or control characters.
36fn 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
47/// The `Host` block for `host`, checked: an alias (required, a plain name)
48/// and the optional settings, each on its own line.
49pub 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    // A path with spaces is quoted; anything else that could break the line
73    // is refused.
74    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    // In a quoted value a trailing `\` would escape the closing quote and
79    // leave the whole config unparseable; a key file never ends in one.
80    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
94/// Every name any `Host` line in the config (and what it includes) gives,
95/// patterns included.
96fn 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
119/// Append `host` to the config at `config` (Includes relative to `ssh_dir`).
120/// Returns the block written.
121pub 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    // As bytes, so anything in the file survives exactly. Only a missing file
131    // counts as no config: a file that can't be read is left alone.
132    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    // Appended, never rewritten: what is there stays byte for byte.
145    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
167/// Owner read and write only, as ssh wants its config (Unix; on Windows a new
168/// file under the user's profile is already the user's).
169fn 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
177/// The file and 1-based line of the `Host` line naming `alias`, following
178/// Includes.
179pub 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
207/// The user's ssh dir and config.
208pub 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        // Again: refused, whether the name is in the file or an Include.
269        assert!(add_host_in(&config, dir.path(), &host("db1")).is_err());
270        assert!(add_host_in(&config, dir.path(), &host("nas")).is_err());
271        // A second add keeps the first copy.
272        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    /// Bytes that aren't UTF-8 (a Latin-1 comment) survive exactly, and the
280    /// copy has them too.
281    #[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    /// A config that exists but can't be read is left alone.
296    #[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}