Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <profil> 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.
Expand Down
2 changes: 2 additions & 0 deletions apps/warpgatesh-companion/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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");
Expand Down
2 changes: 1 addition & 1 deletion crates/warpgatesh-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ pub fn parse(arguments: impl IntoIterator<Item = String>) -> Result<CliCommand,

pub const HELP: &str = "WarpgateSH — unofficial community client for Warpgate\n\n\
Usage:\n warpgatesh <target> [-- <ssh arguments>]\n warpgatesh <command> [arguments]\n\n\
Commands:\n profile add <name> <url> Add or replace a Warpgate profile\n profile list List configured profiles\n profile default <name> Select the profile providing short aliases\n login <profile> 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";
Commands:\n profile add <name> <url> Add or replace a Warpgate profile\n profile list List configured profiles\n profile default <name> Select the profile providing short aliases\n profile ssh-auth <name> <auto|in-browser> Select SSH authentication\n login <profile> 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<String> {
Expand Down
31 changes: 27 additions & 4 deletions crates/warpgatesh-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 <name> <url> | profile list | profile default <name>"
"usage: warpgatesh profile add <name> <url> | profile list | profile default <name> | profile ssh-auth <name> <auto|in-browser>"
.to_owned(),
)),
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()?;
Expand Down
22 changes: 22 additions & 0 deletions crates/warpgatesh-core/src/profiles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -14,6 +33,8 @@ pub struct Profile {
pub warpgate_version: Option<String>,
pub ssh_host: String,
pub ssh_port: u16,
#[serde(default)]
pub ssh_authentication: SshAuthentication,
}

#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
Expand Down Expand Up @@ -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,
}
}

Expand Down
14 changes: 12 additions & 2 deletions crates/warpgatesh-core/src/ssh_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -87,6 +87,15 @@ pub fn render_profile<S: BuildHasher>(
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 {
Expand All @@ -102,7 +111,7 @@ pub fn render_profile<S: BuildHasher>(

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,
)
Expand Down Expand Up @@ -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,
}
}

Expand Down
119 changes: 117 additions & 2 deletions crates/warpgatesh-runtime/src/configuration.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -22,6 +22,10 @@ pub enum ConfigurationMutation {
username: String,
warpgate_version: Option<String>,
},
SetSshAuthentication {
name: String,
authentication: SshAuthentication,
},
RemoveProfile {
name: String,
},
Expand Down Expand Up @@ -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,
Expand All @@ -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> {
Expand All @@ -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)?;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -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::<Profile>(&invalid).is_err());
}
}
1 change: 1 addition & 0 deletions crates/warpgatesh-runtime/src/ipc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
1 change: 1 addition & 0 deletions crates/warpgatesh-runtime/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
Loading