From a9d1ee0e91dc74f7f7c3ca0a518dde1f7160ee7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20NARCIN?= Date: Thu, 10 Sep 2026 15:24:28 +0200 Subject: [PATCH] fix: persist per-profile SSH authentication across synchronization --- CHANGELOG.md | 4 + .../src-tauri/src/commands.rs | 2 + crates/warpgatesh-cli/src/lib.rs | 2 +- crates/warpgatesh-cli/src/main.rs | 31 ++++- crates/warpgatesh-core/src/profiles.rs | 22 ++++ crates/warpgatesh-core/src/ssh_config.rs | 14 ++- .../warpgatesh-runtime/src/configuration.rs | 119 +++++++++++++++++- crates/warpgatesh-runtime/src/ipc.rs | 1 + crates/warpgatesh-runtime/src/storage.rs | 1 + crates/warpgatesh-runtime/src/sync.rs | 72 ++++++++++- docs/getting-started.md | 31 +++++ 11 files changed, 289 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13282c5..7eb2765 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ Toutes les évolutions notables de WarpgateSH sont documentées ici. +## Unreleased + +- Une préférence d’authentification SSH par profil permet de conserver la validation navigateur après chaque régénération des alias, y compris pour les nouvelles cibles. Elle se règle avec `warpgatesh profile ssh-auth in-browser` et survit au renouvellement du jeton ainsi qu’au réenrôlement du profil. Les profils existants conservent le mode automatique. + ## 0.1.15 — 2026-09-10 - Le menu de la barre système conserve la dernière synchronisation réussie et retire le compte à rebours de la suivante. diff --git a/apps/warpgatesh-companion/src-tauri/src/commands.rs b/apps/warpgatesh-companion/src-tauri/src/commands.rs index fdde03f..47265d6 100644 --- a/apps/warpgatesh-companion/src-tauri/src/commands.rs +++ b/apps/warpgatesh-companion/src-tauri/src/commands.rs @@ -296,6 +296,7 @@ pub async fn add_profile(request: ProfileRequest) -> Result<(), String> { warpgate_version: metadata.version, ssh_host, ssh_port, + ssh_authentication: warpgatesh_core::profiles::SshAuthentication::Auto, }, token: request.token.trim().to_owned(), known_hosts: host_keys.known_hosts, @@ -642,6 +643,7 @@ mod tests { warpgate_version: Some("0.27.1".to_owned()), ssh_host: "10.60.0.17".to_owned(), ssh_port: 2222, + ssh_authentication: warpgatesh_core::profiles::SshAuthentication::Auto, }) .expect("profile"); store.save_profiles(&catalog).expect("save profiles"); diff --git a/crates/warpgatesh-cli/src/lib.rs b/crates/warpgatesh-cli/src/lib.rs index 94c5e9d..8359f02 100644 --- a/crates/warpgatesh-cli/src/lib.rs +++ b/crates/warpgatesh-cli/src/lib.rs @@ -69,7 +69,7 @@ pub fn parse(arguments: impl IntoIterator) -> Result Add or replace a Warpgate profile\n profile list List configured profiles\n profile default Select the profile providing short aliases\n profile ssh-auth Select SSH authentication\n login Replace a personal API token\n ls List synchronized SSH targets\n sync Request an immediate synchronization\n status Show profile and snapshot status\n agent install Install and start the background agent\n agent status Show whether the background agent is running\n agent uninstall Stop and remove the background agent service\n doctor Diagnose the local installation\n diagnostics preview Preview local logs before exporting\n diagnostics export Create a sanitized ZIP archive in Downloads\n help Show this help\n"; #[must_use] pub fn openssh_arguments(alias: &str, ssh_arguments: &[String]) -> Vec { diff --git a/crates/warpgatesh-cli/src/main.rs b/crates/warpgatesh-cli/src/main.rs index 8fe9f73..b60db05 100644 --- a/crates/warpgatesh-cli/src/main.rs +++ b/crates/warpgatesh-cli/src/main.rs @@ -5,7 +5,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use warpgatesh_cli::{CliCommand, HELP, openssh_arguments, parse}; use warpgatesh_core::aliases::is_valid_profile_name; -use warpgatesh_core::profiles::Profile; +use warpgatesh_core::profiles::{Profile, SshAuthentication}; use warpgatesh_runtime::RuntimeError; use warpgatesh_runtime::agent_service; use warpgatesh_runtime::api::ApiClient; @@ -82,8 +82,9 @@ fn run_profile(arguments: &[String]) -> Result<(), RuntimeError> { [command, name, url] if command == "add" => add_profile(name, url), [command] if command == "list" => list_profiles(), [command, name] if command == "default" => set_default_profile(name), + [command, name, mode] if command == "ssh-auth" => set_ssh_authentication(name, mode), _ => Err(RuntimeError::InvalidInput( - "usage: warpgatesh profile add | profile list | profile default " + "usage: warpgatesh profile add | profile list | profile default | profile ssh-auth " .to_owned(), )), } @@ -159,6 +160,7 @@ fn add_profile(name: &str, url: &str) -> Result<(), RuntimeError> { warpgate_version: metadata.version, ssh_host, ssh_port, + ssh_authentication: warpgatesh_core::profiles::SshAuthentication::Auto, }, token, known_hosts: host_keys.known_hosts, @@ -210,13 +212,34 @@ fn list_profiles() -> Result<(), RuntimeError> { " " }; println!( - "{marker} {}\t{}\t{}", - profile.name, profile.username, profile.base_url + "{marker} {}\t{}\t{}\tssh-auth={}", + profile.name, + profile.username, + profile.base_url, + profile.ssh_authentication.cli_name() ); } Ok(()) } +fn set_ssh_authentication(name: &str, mode: &str) -> Result<(), RuntimeError> { + let authentication = match mode { + "auto" => SshAuthentication::Auto, + "in-browser" => SshAuthentication::InBrowser, + _ => { + return Err(RuntimeError::InvalidInput( + "SSH authentication must be 'auto' or 'in-browser'".to_owned(), + )); + } + }; + request_configuration_mutation(&ConfigurationMutation::SetSshAuthentication { + name: name.to_owned(), + authentication, + })?; + println!("SSH authentication for profile '{name}' set to {mode}; synchronization requested."); + Ok(()) +} + fn set_default_profile(name: &str) -> Result<(), RuntimeError> { let store = LocalStore::for_current_user()?; let catalog = store.load_profiles()?; diff --git a/crates/warpgatesh-core/src/profiles.rs b/crates/warpgatesh-core/src/profiles.rs index 81d13c5..09b491f 100644 --- a/crates/warpgatesh-core/src/profiles.rs +++ b/crates/warpgatesh-core/src/profiles.rs @@ -6,6 +6,25 @@ use crate::aliases::is_valid_profile_name; pub const PROFILE_SCHEMA_VERSION: u32 = 1; +/// Client-side SSH authentication preference, independent of the API token. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SshAuthentication { + #[default] + Auto, + InBrowser, +} + +impl SshAuthentication { + #[must_use] + pub const fn cli_name(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::InBrowser => "in-browser", + } + } +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct Profile { pub name: String, @@ -14,6 +33,8 @@ pub struct Profile { pub warpgate_version: Option, pub ssh_host: String, pub ssh_port: u16, + #[serde(default)] + pub ssh_authentication: SshAuthentication, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -141,6 +162,7 @@ mod tests { warpgate_version: Some("0.27.0".to_owned()), ssh_host: "ssh.warpgate.example".to_owned(), ssh_port: 2222, + ssh_authentication: crate::profiles::SshAuthentication::Auto, } } diff --git a/crates/warpgatesh-core/src/ssh_config.rs b/crates/warpgatesh-core/src/ssh_config.rs index 9c67ee5..cb8b5de 100644 --- a/crates/warpgatesh-core/src/ssh_config.rs +++ b/crates/warpgatesh-core/src/ssh_config.rs @@ -3,7 +3,7 @@ use std::fmt::{self, Write as _}; use std::hash::BuildHasher; use crate::aliases::{AliasError, Target, allocate_aliases}; -use crate::profiles::Profile; +use crate::profiles::{Profile, SshAuthentication}; pub const SSH_INCLUDE_LINE: &str = "Include ~/.ssh/warpgatesh/config"; @@ -87,6 +87,15 @@ pub fn render_profile( let known_hosts = format!("~/.ssh/warpgatesh/known_hosts/{}", profile.name); let mut output = format!("# Profile {}\n", profile.name); + let authentication = match profile.ssh_authentication { + SshAuthentication::Auto => { + " KbdInteractiveAuthentication yes\n PasswordAuthentication yes\n PubkeyAuthentication yes\n" + } + SshAuthentication::InBrowser => { + " PreferredAuthentications keyboard-interactive\n KbdInteractiveAuthentication yes\n PasswordAuthentication no\n PubkeyAuthentication no\n" + } + }; + for (target, aliases) in targets.iter().zip(aliases) { let mut host_aliases = Vec::with_capacity(2); if let Some(short) = aliases.short { @@ -102,7 +111,7 @@ pub fn render_profile( write!( output, - "\nHost {}\n HostName {host_name}\n Port {}\n User {user}\n UserKnownHostsFile {known_hosts}\n StrictHostKeyChecking yes\n KbdInteractiveAuthentication yes\n PasswordAuthentication yes\n PubkeyAuthentication yes\n", + "\nHost {}\n HostName {host_name}\n Port {}\n User {user}\n UserKnownHostsFile {known_hosts}\n StrictHostKeyChecking yes\n{authentication}", host_aliases.join(" "), profile.ssh_port, ) @@ -136,6 +145,7 @@ mod tests { warpgate_version: Some("0.27.0".to_owned()), ssh_host: "ssh.warpgate.example".to_owned(), ssh_port: 2222, + ssh_authentication: crate::profiles::SshAuthentication::Auto, } } diff --git a/crates/warpgatesh-runtime/src/configuration.rs b/crates/warpgatesh-runtime/src/configuration.rs index c0b5059..37fe393 100644 --- a/crates/warpgatesh-runtime/src/configuration.rs +++ b/crates/warpgatesh-runtime/src/configuration.rs @@ -1,7 +1,7 @@ use std::fs; use serde::{Deserialize, Serialize}; -use warpgatesh_core::profiles::Profile; +use warpgatesh_core::profiles::{Profile, SshAuthentication}; use crate::RuntimeError; use crate::keychain::TokenStore; @@ -22,6 +22,10 @@ pub enum ConfigurationMutation { username: String, warpgate_version: Option, }, + SetSshAuthentication { + name: String, + authentication: SshAuthentication, + }, RemoveProfile { name: String, }, @@ -77,6 +81,10 @@ impl<'a, T: TokenStore> LocalConfiguration<'a, T> { username, warpgate_version, } => self.renew_token(&name, &token, username, warpgate_version), + ConfigurationMutation::SetSshAuthentication { + name, + authentication, + } => self.set_ssh_authentication(&name, authentication), ConfigurationMutation::RemoveProfile { name } => self.remove_profile(&name), ConfigurationMutation::SavePreferences { preferences, @@ -87,7 +95,7 @@ impl<'a, T: TokenStore> LocalConfiguration<'a, T> { fn save_profile( &self, - profile: Profile, + mut profile: Profile, token: &str, known_hosts: &str, ) -> Result<(), RuntimeError> { @@ -98,6 +106,10 @@ impl<'a, T: TokenStore> LocalConfiguration<'a, T> { } let name = profile.name.clone(); let mut catalog = self.store.load_profiles()?; + // Re-enrollment refreshes credentials and metadata, not local preferences. + if let Some(existing) = catalog.find(&name) { + profile.ssh_authentication = existing.ssh_authentication; + } catalog.upsert(profile)?; self.tokens.set(&name, token.trim())?; save_host_keys(self.store.paths(), &name, known_hosts)?; @@ -132,6 +144,21 @@ impl<'a, T: TokenStore> LocalConfiguration<'a, T> { self.store.save_profiles(&catalog) } + fn set_ssh_authentication( + &self, + name: &str, + authentication: SshAuthentication, + ) -> Result<(), RuntimeError> { + let mut catalog = self.store.load_profiles()?; + let mut profile = catalog + .find(name) + .cloned() + .ok_or_else(|| RuntimeError::InvalidInput(format!("unknown profile '{name}'")))?; + profile.ssh_authentication = authentication; + catalog.upsert(profile)?; + self.store.save_profiles(&catalog) + } + fn remove_profile(&self, name: &str) -> Result<(), RuntimeError> { let mut catalog = self.store.load_profiles()?; if !catalog.remove(name) { @@ -220,6 +247,7 @@ mod tests { warpgate_version: Some("0.27.1".to_owned()), ssh_host: "ssh.warpgate.example".to_owned(), ssh_port: 2222, + ssh_authentication: warpgatesh_core::profiles::SshAuthentication::Auto, } } @@ -263,4 +291,91 @@ mod tests { .expect("remove profile"); assert!(store.load_profiles().expect("profiles").profiles.is_empty()); } + #[test] + fn retains_authentication_preference_on_token_renewal_and_reenrollment() { + let home = TempDir::new().expect("temporary home"); + let store = LocalStore::new(WarpgatePaths::for_home(home.path())); + let tokens = MemoryTokens::default(); + let configuration = LocalConfiguration::new(&store, &tokens); + let enroll = || ConfigurationMutation::SaveProfile { + profile: profile("lab"), + token: "secret".to_owned(), + known_hosts: "ssh.example ssh-ed25519 AAAA\n".to_owned(), + }; + configuration.apply(enroll()).expect("enroll"); + let mutation = ConfigurationMutation::from_json( + r#"{"type":"set_ssh_authentication","name":"lab","authentication":"in_browser"}"#, + ) + .expect("IPC mutation"); + configuration.apply(mutation).expect("set preference"); + configuration + .apply(ConfigurationMutation::RenewToken { + name: "lab".to_owned(), + token: "renewed".to_owned(), + username: "gregory".to_owned(), + warpgate_version: Some("0.28.0".to_owned()), + }) + .expect("renew token"); + assert_eq!( + store + .load_profiles() + .unwrap() + .find("lab") + .unwrap() + .ssh_authentication, + SshAuthentication::InBrowser + ); + configuration + .apply(enroll()) + .expect("re-enroll from older UI"); + assert_eq!( + store + .load_profiles() + .unwrap() + .find("lab") + .unwrap() + .ssh_authentication, + SshAuthentication::InBrowser + ); + configuration + .apply(ConfigurationMutation::SetSshAuthentication { + name: "lab".to_owned(), + authentication: SshAuthentication::Auto, + }) + .expect("restore automatic authentication"); + assert_eq!( + store + .load_profiles() + .unwrap() + .find("lab") + .unwrap() + .ssh_authentication, + SshAuthentication::Auto + ); + } + + #[test] + fn rejects_unknown_profile_without_creating_a_catalog() { + let home = TempDir::new().expect("temporary home"); + let store = LocalStore::new(WarpgatePaths::for_home(home.path())); + let tokens = MemoryTokens::default(); + assert!( + LocalConfiguration::new(&store, &tokens) + .apply(ConfigurationMutation::SetSshAuthentication { + name: "missing".to_owned(), + authentication: SshAuthentication::InBrowser, + }) + .is_err() + ); + assert!(!store.paths().profiles.exists()); + } + #[test] + fn legacy_profiles_keep_automatic_authentication() { + let legacy = r#"{"name":"lab","base_url":"https://warpgate.example/", + "username":"alice","ssh_host":"ssh.example","ssh_port":2222}"#; + let profile: Profile = serde_json::from_str(legacy).expect("legacy profile"); + assert_eq!(profile.ssh_authentication, SshAuthentication::Auto); + let invalid = legacy.replace("2222}", "2222,\"ssh_authentication\":\"typo\"}"); + assert!(serde_json::from_str::(&invalid).is_err()); + } } diff --git a/crates/warpgatesh-runtime/src/ipc.rs b/crates/warpgatesh-runtime/src/ipc.rs index ce228f8..cb9b861 100644 --- a/crates/warpgatesh-runtime/src/ipc.rs +++ b/crates/warpgatesh-runtime/src/ipc.rs @@ -230,6 +230,7 @@ mod tests { warpgate_version: None, ssh_host: "ssh.example".to_owned(), ssh_port: 2222, + ssh_authentication: warpgatesh_core::profiles::SshAuthentication::Auto, }, token: "secret".to_owned(), known_hosts: "ssh.example ssh-ed25519 AAAA\n".to_owned(), diff --git a/crates/warpgatesh-runtime/src/storage.rs b/crates/warpgatesh-runtime/src/storage.rs index cdca759..492df24 100644 --- a/crates/warpgatesh-runtime/src/storage.rs +++ b/crates/warpgatesh-runtime/src/storage.rs @@ -321,6 +321,7 @@ mod tests { warpgate_version: Some("0.27.0".to_owned()), ssh_host: "ssh.warpgate.example".to_owned(), ssh_port: 2222, + ssh_authentication: warpgatesh_core::profiles::SshAuthentication::Auto, }) .expect("valid profile"); diff --git a/crates/warpgatesh-runtime/src/sync.rs b/crates/warpgatesh-runtime/src/sync.rs index 05aa334..12531b8 100644 --- a/crates/warpgatesh-runtime/src/sync.rs +++ b/crates/warpgatesh-runtime/src/sync.rs @@ -197,11 +197,17 @@ mod tests { } fn mock_warpgate() -> (String, thread::JoinHandle<()>) { + mock_warpgate_targets( + r#"[{"id":"target-1","name":"db","kind":"Ssh"},{"id":"target-2","name":"web","kind":"Http"}]"#, + ) + } + + fn mock_warpgate_targets(targets: &'static str) -> (String, thread::JoinHandle<()>) { let listener = TcpListener::bind("127.0.0.1:0").expect("mock listener"); let address = listener.local_addr().expect("mock address"); let responses = [ r#"{"version":"0.27.0","username":"gregory","external_hosts":{"ssh":"ssh.example"},"ports":{"ssh":2222}}"#, - r#"[{"id":"target-1","name":"db","kind":"Ssh"},{"id":"target-2","name":"web","kind":"Http"}]"#, + targets, ]; let handle = thread::spawn(move || { for body in responses { @@ -234,6 +240,7 @@ mod tests { warpgate_version: None, ssh_host: "10.60.0.17".to_owned(), ssh_port: 22, + ssh_authentication: warpgatesh_core::profiles::SshAuthentication::Auto, }) .expect("profile"); store.save_profiles(&catalog).expect("save profiles"); @@ -264,4 +271,67 @@ mod tests { "db" ); } + #[test] + fn preserves_browser_authentication_across_regeneration() { + let home = TempDir::new().expect("temporary home"); + let paths = WarpgatePaths::for_home(home.path()); + let store = LocalStore::new(paths.clone()); + let tokens = MemoryTokens::default(); + tokens.set("lab", "secret").expect("test token"); + atomic_write(&paths.known_hosts_directory.join("lab"), b"pinned-key\n") + .expect("pinned key"); + + for round in 0..2 { + let (base_url, server) = mock_warpgate_targets(if round == 0 { + r#"[{"id":"target-1","name":"db","kind":"Ssh"}]"# + } else { + r#"[{"id":"target-1","name":"db","kind":"Ssh"},{"id":"target-2","name":"new-host","kind":"Ssh"}]"# + }); + let mut saved = if round == 0 { + serde_json::json!({ + "schema_version": 1, "default_profile": "lab", + "profiles": [{"name": "lab", "base_url": base_url, + "username": "pending", "warpgate_version": null, + "ssh_host": "ssh.example", "ssh_port": 2222, + "ssh_authentication": "in_browser"}] + }) + } else { + serde_json::from_slice(&fs::read(&paths.profiles).expect("profiles")) + .expect("profile JSON") + }; + saved["profiles"][0]["base_url"] = serde_json::json!(base_url); + atomic_write(&paths.profiles, &serde_json::to_vec(&saved).expect("JSON")) + .expect("persist catalog"); + synchronize_all(&store, &tokens).expect("synchronization"); + server.join().expect("mock server"); + let persisted: serde_json::Value = + serde_json::from_slice(&fs::read(&paths.profiles).expect("profiles")) + .expect("profile JSON"); + assert_eq!(persisted["profiles"][0]["ssh_authentication"], "in_browser"); + let rendered = fs::read_to_string(&paths.ssh_config).expect("managed config"); + assert!(rendered.contains("PreferredAuthentications keyboard-interactive\n")); + assert!(rendered.contains("PubkeyAuthentication no\n")); + assert!(!rendered.contains("PubkeyAuthentication yes\n")); + if round == 1 { + assert!(rendered.contains("Host new-host new-host.lab\n")); + assert_eq!(rendered.matches("PubkeyAuthentication no\n").count(), 2); + // Verify effective OpenSSH behavior, not only generated text. + for alias in ["db", "db.lab", "new-host", "new-host.lab"] { + let output = std::process::Command::new("/usr/bin/ssh") + .args(["-G", "-F"]) + .arg(&paths.ssh_config) + .arg(alias) + .output() + .expect("OpenSSH client"); + assert!(output.status.success()); + let effective = String::from_utf8(output.stdout).expect("SSH settings"); + assert!(effective.contains("preferredauthentications keyboard-interactive\n")); + assert!(effective.lines().any(|line| matches!( + line, + "pubkeyauthentication false" | "pubkeyauthentication no" + ))); + } + } + } + } } diff --git a/docs/getting-started.md b/docs/getting-started.md index a5edfa3..389a858 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -94,6 +94,37 @@ Only the default profile receives short aliases. Change it with: warpgatesh profile default production ``` +## Keep browser authentication consistent + +For a profile whose Warpgate account uses in-browser SSH approval: + +```sh +warpgatesh profile ssh-auth production in-browser +``` + +The background agent stores this preference in the profile and requests a new +synchronization. Every generated alias, including new targets, uses +`keyboard-interactive` without offering SSH keys or passwords. The preference +survives automatic synchronization, token renewal and re-enrollment from the +companion. `warpgatesh profile list` shows the selected mode. + +Existing profiles keep `auto`, which preserves the usual OpenSSH methods. To +restore it: + +```sh +warpgatesh profile ssh-auth production auto +``` + +Use `in-browser` only when the server account supports that method. This is a +client preference: it does not change server authentication requirements, the +approval duration, or the server's “all targets” button. A remembered approval +may still require confirmation after a change of source address or identity. + +The CLI and background agent must both include this feature. Older agents do +not support the preference and can discard it when saving profiles; upgrade the +complete application before configuring it. Personal SSH rules loaded earlier +still take precedence over generated aliases. + ## Renew an expired token Create a new personal token in Warpgate, then use the profile action in the