diff options
| author | SunskyXH <SunskyXH@gmail.com> | 2026-06-01 23:24:08 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-06-01 17:24:08 +0300 |
| commit | 250ae921628b7bbf68f33ac3f43ac90bdac10f24 (patch) | |
| tree | da6612a07094cfc7f3875f349fa3a94e409e0ddb /src | |
| parent | 043968dd97e8a864f4473f1651e9df9ecd6ed99d (diff) | |
feat: keep remote bridge alive with an ssh config keepalive fallback (#355)
Diffstat (limited to 'src')
| -rw-r--r-- | src/config/model.rs | 17 | ||||
| -rw-r--r-- | src/main.rs | 10 | ||||
| -rw-r--r-- | src/remote.rs | 172 |
3 files changed, 196 insertions, 3 deletions
diff --git a/src/config/model.rs b/src/config/model.rs index c312acfa..dd247d83 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -184,6 +184,7 @@ pub struct Config { pub worktrees: WorktreesConfig, pub advanced: AdvancedConfig, pub experimental: ExperimentalConfig, + pub remote: RemoteConfig, } #[derive(Debug)] @@ -388,6 +389,22 @@ pub struct AdvancedConfig { pub scrollback_limit_bytes: usize, } +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct RemoteConfig { + /// Add a keepalive fallback under the user's ssh config for the `--remote` + /// bridge. Set false to run plain ssh unchanged. Default: true. + pub manage_ssh_config: bool, +} + +impl Default for RemoteConfig { + fn default() -> Self { + Self { + manage_ssh_config: true, + } + } +} + #[derive(Debug, Default, Deserialize)] #[serde(default)] pub struct ExperimentalConfig { diff --git a/src/main.rs b/src/main.rs index b7b11b16..02dabda6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -248,6 +248,16 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # a Herdr server restart. Requires official integrations that report session refs. # resume_agents_on_restore = false +[remote] +# Whether herdr manages the ssh config used for the `herdr --remote` bridge. +# When true (default), herdr runs the bridge ssh through a generated config that +# includes your ~/.ssh/config first and adds ServerAliveInterval/ +# ServerAliveCountMax as a fallback (so any keepalive you set yourself still +# wins) to survive idle network/NAT timeouts. Set false to run plain ssh against +# your ssh config unchanged — this does not force keepalive off, it only stops +# herdr from adding its own. +# manage_ssh_config = true + [experimental] # Allow launching herdr from inside a herdr-managed pane. # allow_nested = false diff --git a/src/remote.rs b/src/remote.rs index 3cf1e43b..fbc062e1 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -169,11 +169,16 @@ pub(crate) fn run_remote(remote: RemoteLaunch) -> io::Result<()> { remote.live_handoff, )?; + let manage_ssh_config = crate::config::Config::load() + .config + .remote + .manage_ssh_config; let _bridge = SshStdioBridge::start( remote.target, prepared_remote.remote_herdr, local_socket.clone(), session_name, + manage_ssh_config, )?; run_client_process(&local_socket, &reattach_command, remote.keybindings) @@ -1273,6 +1278,7 @@ fn command_failed(context: &str, output: &Output) -> io::Error { struct SshStdioBridge { local_socket: PathBuf, + keepalive_ssh_config: Option<PathBuf>, should_stop: Arc<AtomicBool>, thread: Option<JoinHandle<()>>, } @@ -1283,14 +1289,26 @@ impl SshStdioBridge { remote_herdr: RemoteHerdr, local_socket: PathBuf, session_name: String, + manage_ssh_config: bool, ) -> io::Result<Self> { let _ = std::fs::remove_file(&local_socket); let listener = UnixListener::bind(&local_socket)?; crate::ipc::restrict_socket_permissions(&local_socket, BRIDGE_SOCKET_PERMISSION_MODE)?; listener.set_nonblocking(true)?; + let keepalive_ssh_config = if manage_ssh_config { + write_keepalive_ssh_config() + .inspect_err(|err| { + tracing::debug!(%err, "could not write ssh keepalive config; using plain ssh"); + }) + .ok() + } else { + None + }; + let should_stop = Arc::new(AtomicBool::new(false)); let thread_stop = Arc::clone(&should_stop); + let thread_ssh_config = keepalive_ssh_config.clone(); let thread = thread::spawn(move || { while !thread_stop.load(Ordering::Acquire) { match listener.accept() { @@ -1301,9 +1319,13 @@ impl SshStdioBridge { ); continue; } - if let Err(err) = - bridge_connection(stream, &target, &remote_herdr, &session_name) - { + if let Err(err) = bridge_connection( + stream, + &target, + &remote_herdr, + &session_name, + thread_ssh_config.as_deref(), + ) { eprintln!("herdr: remote bridge failed: {err}"); } } @@ -1320,6 +1342,7 @@ impl SshStdioBridge { Ok(Self { local_socket, + keepalive_ssh_config, should_stop, thread: Some(thread), }) @@ -1333,7 +1356,85 @@ impl Drop for SshStdioBridge { if let Some(thread) = self.thread.take() { let _ = thread.join(); } + // Remove the generated ssh config only after the bridge thread has + // joined, so it can never start a connection with a config path that + // was just deleted. + if let Some(dir) = self.keepalive_ssh_config.as_deref().and_then(Path::parent) { + let _ = std::fs::remove_dir_all(dir); + } + } +} + +/// Creates a fresh user-only (`0700`) directory under the temp dir for the +/// bridge's generated ssh config, returning its path. +/// +/// Using a private directory created with fail-if-exists semantics — rather +/// than a predictable file in the world-writable temp dir — stops a local user +/// from pre-planting a symlink or world-writable file that herdr would write +/// and `ssh -F` would then read. +fn private_ssh_config_dir() -> io::Result<PathBuf> { + use std::os::unix::fs::DirBuilderExt; + + let base = std::env::temp_dir(); + for attempt in 0..100 { + let dir = base.join(format!("herdr-ssh-{}-{attempt}", std::process::id())); + match fs::DirBuilder::new().mode(0o700).create(&dir) { + Ok(()) => return Ok(dir), + Err(err) if err.kind() == io::ErrorKind::AlreadyExists => continue, + Err(err) => return Err(err), + } + } + + Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "failed to create private herdr ssh config directory", + )) +} + +/// Quotes a path for an ssh_config `Include` so a path containing spaces (or +/// glob metacharacters) is treated as one literal token instead of being split +/// or expanded by ssh — otherwise the user's config might not be Included and +/// herdr's fallback would wrongly take effect. +fn ssh_config_quote(path: &str) -> String { + format!("\"{path}\"") +} + +/// Builds a temporary ssh config that keeps the bridge tunnel alive without +/// overriding the user's own settings, returning its path. +/// +/// The file `Include`s the user's real ssh config first, so ssh's +/// first-value-wins rule keeps any `ServerAlive*` the user set there (including +/// an explicit `0` to disable it); herdr's values apply only when the user has +/// none. +fn write_keepalive_ssh_config() -> io::Result<PathBuf> { + use std::os::unix::fs::OpenOptionsExt; + + let path = private_ssh_config_dir()?.join("config"); + + let mut contents = String::new(); + if let Some(home) = std::env::var_os("HOME") { + let user_config = PathBuf::from(home).join(".ssh").join("config"); + if user_config.is_file() { + contents.push_str(&format!( + "Include {}\n", + ssh_config_quote(&user_config.to_string_lossy()) + )); + } + } + if Path::new("/etc/ssh/ssh_config").is_file() { + contents.push_str("Include /etc/ssh/ssh_config\n"); } + contents.push_str("Host *\n"); + contents.push_str(" ServerAliveInterval 15\n"); + contents.push_str(" ServerAliveCountMax 4\n"); + + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(BRIDGE_SOCKET_PERMISSION_MODE) + .open(&path)?; + file.write_all(contents.as_bytes())?; + Ok(path) } fn bridge_connection( @@ -1341,8 +1442,13 @@ fn bridge_connection( target: &str, remote_herdr: &RemoteHerdr, session_name: &str, + keepalive_ssh_config: Option<&Path>, ) -> io::Result<()> { let mut command = Command::new("ssh"); + // Use the generated keepalive ssh config when present; otherwise plain ssh. + if let Some(ssh_config) = keepalive_ssh_config { + command.arg("-F").arg(ssh_config); + } command .arg("-T") .arg(target) @@ -1520,6 +1626,7 @@ mod tests { remote_herdr, socket.clone(), "default".to_string(), + false, ) .expect("start bridge listener"); @@ -1531,6 +1638,65 @@ mod tests { } #[test] + fn keepalive_ssh_config_includes_user_config_then_fallback() { + use std::os::unix::fs::PermissionsExt; + + let path = write_keepalive_ssh_config().expect("write keepalive config"); + let contents = std::fs::read_to_string(&path).expect("read keepalive config"); + + // herdr's fallback keepalive is present... + assert!( + contents.contains("Host *"), + "config should add a Host * fallback block: {contents}" + ); + assert!( + contents.contains("ServerAliveInterval 15"), + "config should set the keepalive interval: {contents}" + ); + assert!( + contents.contains("ServerAliveCountMax 4"), + "config should set the keepalive count: {contents}" + ); + // ...and any user config is Included (quoted) BEFORE it so first-value-wins + // keeps the user's own settings. + if let Some(home) = std::env::var_os("HOME") { + let user_config = PathBuf::from(home).join(".ssh").join("config"); + if user_config.is_file() { + let include = format!( + "Include {}", + ssh_config_quote(&user_config.to_string_lossy()) + ); + let include_at = contents.find(&include).expect("user config Included"); + let fallback_at = contents.find("Host *").expect("fallback present"); + assert!( + include_at < fallback_at, + "user config must be Included before herdr's fallback: {contents}" + ); + } + } + + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!( + mode, BRIDGE_SOCKET_PERMISSION_MODE, + "keepalive config must be user-only" + ); + // The config lives in a private 0700 dir, not a predictable temp path. + let dir = path.parent().expect("config has a parent dir"); + let dir_mode = std::fs::metadata(dir).unwrap().permissions().mode() & 0o777; + assert_eq!(dir_mode, 0o700, "ssh config dir must be user-only"); + + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn ssh_config_quote_wraps_path_with_spaces() { + assert_eq!( + ssh_config_quote("/home/a b/.ssh/config"), + "\"/home/a b/.ssh/config\"" + ); + } + + #[test] fn extract_remote_args_removes_space_form() { let args = vec