diff --git a/CHANGELOG.md b/CHANGELOG.md index bd80b2c..a110e26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ Toutes les évolutions notables de WarpgateSH sont documentées ici. +## 0.1.11 — 2026-08-19 + +### Synchronisation + +- L’agent suit désormais les changements d’adresse ou de port SSH annoncés par Warpgate, sans demander de recréer le profil lorsque la clé d’hôte épinglée reste identique. +- Les profils et les alias SSH enregistrent le nouvel endpoint après une synchronisation réussie. + +### Interface + +- La page des profils permet de régénérer immédiatement les alias SSH. +- La suppression d’un profil utilise maintenant une confirmation intégrée, plus claire que la boîte de dialogue système. + +### Correctifs CLI + +- `warpgatesh -- ` conserve correctement les options OpenSSH avant la destination et place la commande distante après celle-ci. +- `warpgatesh agent install` retrouve correctement l’agent inclus dans l’application lorsque la CLI est appelée depuis son lien `/usr/local/bin/warpgatesh`. + +### Documentation et diagnostics + +- Les guides publics couvrent désormais l’installation, la première connexion, le dépannage, la désinstallation et la contribution. +- Cette publication inclut également les diagnostics locaux et l’export expurgé préparés pour v0.1.10, qui était restée en brouillon et n’avait pas été proposée aux utilisateurs. + ## 0.1.10 — 2026-08-13 ### Nouveauté diff --git a/Cargo.lock b/Cargo.lock index 6332d2b..33c4f40 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1107,7 +1107,7 @@ dependencies = [ [[package]] name = "warpgatesh-agent" -version = "0.1.10" +version = "0.1.11" dependencies = [ "serde_json", "warpgatesh-core", @@ -1116,23 +1116,24 @@ dependencies = [ [[package]] name = "warpgatesh-cli" -version = "0.1.10" +version = "0.1.11" dependencies = [ "rpassword", + "tempfile", "warpgatesh-core", "warpgatesh-runtime", ] [[package]] name = "warpgatesh-core" -version = "0.1.10" +version = "0.1.11" dependencies = [ "serde", ] [[package]] name = "warpgatesh-runtime" -version = "0.1.10" +version = "0.1.11" dependencies = [ "chrono", "glob", diff --git a/Cargo.toml b/Cargo.toml index 93513e1..652a42e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ resolver = "3" [workspace.package] -version = "0.1.10" +version = "0.1.11" edition = "2024" rust-version = "1.85" license = "Apache-2.0" diff --git a/apps/warpgatesh-companion/package-lock.json b/apps/warpgatesh-companion/package-lock.json index 3bea027..1728aed 100644 --- a/apps/warpgatesh-companion/package-lock.json +++ b/apps/warpgatesh-companion/package-lock.json @@ -1,12 +1,12 @@ { "name": "warpgatesh-companion", - "version": "0.1.10", + "version": "0.1.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "warpgatesh-companion", - "version": "0.1.10", + "version": "0.1.11", "dependencies": { "@tauri-apps/api": "^2.11.1", "react": "^19.2.0", diff --git a/apps/warpgatesh-companion/package.json b/apps/warpgatesh-companion/package.json index e796e9c..a7a5b72 100644 --- a/apps/warpgatesh-companion/package.json +++ b/apps/warpgatesh-companion/package.json @@ -1,7 +1,7 @@ { "name": "warpgatesh-companion", "private": true, - "version": "0.1.10", + "version": "0.1.11", "type": "module", "scripts": { "dev": "vite", diff --git a/apps/warpgatesh-companion/src-tauri/Cargo.lock b/apps/warpgatesh-companion/src-tauri/Cargo.lock index 8bfaf16..9602103 100644 --- a/apps/warpgatesh-companion/src-tauri/Cargo.lock +++ b/apps/warpgatesh-companion/src-tauri/Cargo.lock @@ -4514,7 +4514,7 @@ dependencies = [ [[package]] name = "warpgatesh-companion" -version = "0.1.10" +version = "0.1.11" dependencies = [ "semver", "serde", @@ -4531,14 +4531,14 @@ dependencies = [ [[package]] name = "warpgatesh-core" -version = "0.1.10" +version = "0.1.11" dependencies = [ "serde", ] [[package]] name = "warpgatesh-runtime" -version = "0.1.10" +version = "0.1.11" dependencies = [ "chrono", "glob", diff --git a/apps/warpgatesh-companion/src-tauri/Cargo.toml b/apps/warpgatesh-companion/src-tauri/Cargo.toml index cbbb763..5994ef0 100644 --- a/apps/warpgatesh-companion/src-tauri/Cargo.toml +++ b/apps/warpgatesh-companion/src-tauri/Cargo.toml @@ -3,7 +3,7 @@ [package] name = "warpgatesh-companion" description = "Optional desktop companion for WarpgateSH" -version = "0.1.10" +version = "0.1.11" edition = "2024" rust-version = "1.85" license = "Apache-2.0" diff --git a/apps/warpgatesh-companion/src-tauri/tauri.conf.json b/apps/warpgatesh-companion/src-tauri/tauri.conf.json index dfead29..568ce3d 100644 --- a/apps/warpgatesh-companion/src-tauri/tauri.conf.json +++ b/apps/warpgatesh-companion/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "WarpgateSH", - "version": "0.1.10", + "version": "0.1.11", "identifier": "dev.warpgatesh.companion", "build": { "beforeDevCommand": "npm run prepare:sidecars && npm run dev", diff --git a/apps/warpgatesh-companion/src/App.tsx b/apps/warpgatesh-companion/src/App.tsx index e034c99..6747539 100644 --- a/apps/warpgatesh-companion/src/App.tsx +++ b/apps/warpgatesh-companion/src/App.tsx @@ -208,6 +208,7 @@ function ProfileCard({ onRemove: (name: string) => Promise; }) { const [renewing, setRenewing] = useState(false); + const [confirmingRemoval, setConfirmingRemoval] = useState(false); const [token, setToken] = useState(""); return ( @@ -245,10 +246,21 @@ function ProfileCard({ ) : ( -
- - -
+ <> +
+ + +
+ {confirmingRemoval ? ( +
+

Supprimer ce profil et régénérer les alias SSH sans lui ?

+
+ + +
+
+ ) : null} + )} ); @@ -257,12 +269,16 @@ function ProfileCard({ function ProfilesView({ profiles, busy, + synchronizing, onChanged, + onSynchronize, runAction, }: { profiles: CompanionProfile[]; busy: boolean; + synchronizing: boolean; onChanged: () => Promise; + onSynchronize: () => void; runAction: (action: () => Promise, success: string) => Promise; }) { const [adding, setAdding] = useState(false); @@ -297,7 +313,6 @@ function ProfilesView({ } async function remove(name: string) { - if (!window.confirm(`Supprimer le profil « ${name} » et ses alias SSH ?`)) return; await runAction(async () => { await removeProfile(name); await onChanged(); @@ -308,7 +323,12 @@ function ProfilesView({

Instances

Profils Warpgate

- +
+ + +
{adding ? ( @@ -853,7 +873,7 @@ export default function App() {
{state === null ?

Lecture de l’état local…

: null} {state && view === "access" ? void handleSync()} onOpen={(alias) => void handleOpen(alias)} onNavigate={setView} /> : null} - {state && view === "profiles" ? : null} + {state && view === "profiles" ? void handleSync()} runAction={runAction} /> : null} {state && view === "preferences" ? : null}
diff --git a/apps/warpgatesh-companion/src/styles.css b/apps/warpgatesh-companion/src/styles.css index f2f2fdd..91d057e 100644 --- a/apps/warpgatesh-companion/src/styles.css +++ b/apps/warpgatesh-companion/src/styles.css @@ -632,6 +632,13 @@ main { margin-bottom: 18px; } +.page-heading__actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + .button-primary, .button-secondary { min-height: 40px; @@ -830,6 +837,28 @@ main { gap: 10px; } +.inline-confirm { + display: grid; + gap: 10px; + padding: 12px; + border: 1px solid rgb(211 93 71 / 35%); + border-radius: 8px; + background: var(--danger-soft); +} + +.inline-confirm p { + margin: 0; + color: var(--ink); + font-size: 0.76rem; + line-height: 1.45; +} + +.button-danger { + border-color: rgb(211 93 71 / 55%); + color: var(--danger); + background: var(--danger-soft); +} + .button-row { justify-content: flex-end; gap: 8px; diff --git a/crates/warpgatesh-agent/src/main.rs b/crates/warpgatesh-agent/src/main.rs index a283f59..0902916 100644 --- a/crates/warpgatesh-agent/src/main.rs +++ b/crates/warpgatesh-agent/src/main.rs @@ -4,11 +4,12 @@ use std::process::ExitCode; use serde_json::json; use warpgatesh_core::schedule::SyncSchedule; use warpgatesh_runtime::RuntimeError; +use warpgatesh_runtime::api::ApiClient; use warpgatesh_runtime::configuration::{ConfigurationMutation, LocalConfiguration}; use warpgatesh_runtime::diagnostics::DiagnosticLogger; use warpgatesh_runtime::ipc::MUTATION_PREFIX; -use warpgatesh_runtime::keychain::SystemKeychain; -use warpgatesh_runtime::ssh::verify_host_keys; +use warpgatesh_runtime::keychain::{SystemKeychain, TokenStore}; +use warpgatesh_runtime::ssh::{save_host_keys, verify_host_keys}; use warpgatesh_runtime::storage::{ AGENT_STATUS_SCHEMA_VERSION, AgentErrorKind, AgentStatus, LocalStore, }; @@ -111,15 +112,21 @@ fn synchronize_and_record(store: &LocalStore) -> Result Result { + let tokens = SystemKeychain; for profile in store.load_profiles()?.profiles { - verify_host_keys( + let token = tokens.get(&profile.name)?; + let metadata = ApiClient::new(&profile.base_url)?.validate(&token)?; + let presented = verify_host_keys( store.paths(), &profile.name, - &profile.ssh_host, - profile.ssh_port, + &metadata.ssh_host, + metadata.ssh_port, )?; + if profile.ssh_host != metadata.ssh_host || profile.ssh_port != metadata.ssh_port { + save_host_keys(store.paths(), &profile.name, &presented.known_hosts)?; + } } - synchronize_all(store, &SystemKeychain) + synchronize_all(store, &tokens) } fn error_kind(error: &RuntimeError) -> AgentErrorKind { diff --git a/crates/warpgatesh-cli/Cargo.toml b/crates/warpgatesh-cli/Cargo.toml index 171834c..a2e5be2 100644 --- a/crates/warpgatesh-cli/Cargo.toml +++ b/crates/warpgatesh-cli/Cargo.toml @@ -16,5 +16,8 @@ rpassword.workspace = true warpgatesh-core = { path = "../warpgatesh-core" } warpgatesh-runtime = { path = "../warpgatesh-runtime" } +[dev-dependencies] +tempfile = "3.27.0" + [lints] workspace = true diff --git a/crates/warpgatesh-cli/src/lib.rs b/crates/warpgatesh-cli/src/lib.rs index 733e73e..2c54edc 100644 --- a/crates/warpgatesh-cli/src/lib.rs +++ b/crates/warpgatesh-cli/src/lib.rs @@ -74,11 +74,60 @@ Commands:\n profile add Add or replace a Warpgate profile\n prof #[must_use] pub fn openssh_arguments(alias: &str, ssh_arguments: &[String]) -> Vec { let mut arguments = Vec::with_capacity(ssh_arguments.len() + 1); - arguments.extend_from_slice(ssh_arguments); + let destination_index = openssh_destination_index(ssh_arguments); + arguments.extend_from_slice(&ssh_arguments[..destination_index]); arguments.push(alias.to_owned()); + arguments.extend_from_slice(&ssh_arguments[destination_index..]); arguments } +fn openssh_destination_index(arguments: &[String]) -> usize { + let mut index = 0; + while index < arguments.len() { + let argument = &arguments[index]; + if argument == "--" { + return (index + 1).min(arguments.len()); + } + if !argument.starts_with('-') || argument == "-" { + return index; + } + + index += 1; + if option_requires_separate_value(argument) && index < arguments.len() { + index += 1; + } + } + arguments.len() +} + +fn option_requires_separate_value(argument: &str) -> bool { + argument.len() == 2 + && matches!( + argument.as_bytes()[1], + b'B' | b'b' + | b'c' + | b'D' + | b'E' + | b'e' + | b'F' + | b'I' + | b'i' + | b'J' + | b'L' + | b'l' + | b'm' + | b'O' + | b'o' + | b'P' + | b'p' + | b'Q' + | b'R' + | b'S' + | b'W' + | b'w' + ) +} + #[cfg(test)] mod tests { use super::*; @@ -127,4 +176,30 @@ mod tests { args(&["-L", "8080:localhost:80", "dmz-nextcloud-01"]) ); } + + #[test] + fn puts_a_remote_command_after_the_destination() { + assert_eq!( + openssh_arguments("dmz-nextcloud-01", &args(&["true"])), + args(&["dmz-nextcloud-01", "true"]) + ); + } + + #[test] + fn separates_openssh_options_from_the_remote_command() { + assert_eq!( + openssh_arguments( + "dmz-nextcloud-01", + &args(&["-o", "BatchMode=yes", "-p2222", "printf", "connected",]), + ), + args(&[ + "-o", + "BatchMode=yes", + "-p2222", + "dmz-nextcloud-01", + "printf", + "connected", + ]) + ); + } } diff --git a/crates/warpgatesh-cli/src/main.rs b/crates/warpgatesh-cli/src/main.rs index 8275c87..ed0a0c5 100644 --- a/crates/warpgatesh-cli/src/main.rs +++ b/crates/warpgatesh-cli/src/main.rs @@ -440,11 +440,7 @@ fn agent_is_running(store: &LocalStore) -> bool { fn agent_executable() -> Result { let current = std::env::current_exe()?; - let sibling = current - .parent() - .unwrap_or_else(|| Path::new(".")) - .join("warpgatesh-agent"); - if sibling.is_file() { + if let Some(sibling) = sibling_agent_executable(¤t) { return Ok(sibling); } @@ -460,6 +456,12 @@ fn agent_executable() -> Result { )) } +fn sibling_agent_executable(current: &Path) -> Option { + let resolved = std::fs::canonicalize(current).unwrap_or_else(|_| current.to_path_buf()); + let sibling = resolved.parent()?.join("warpgatesh-agent"); + sibling.is_file().then_some(sibling) +} + #[cfg(not(target_os = "macos"))] fn run_agent_once() -> Result<(), RuntimeError> { let executable = agent_executable()?; @@ -595,6 +597,11 @@ fn execute_ssh(_alias: &str, _ssh_arguments: &[String]) -> ExitCode { #[cfg(test)] mod tests { + #[cfg(unix)] + use std::fs; + #[cfg(unix)] + use std::os::unix::fs::symlink; + use super::*; #[test] @@ -612,4 +619,24 @@ mod tests { 2222 )); } + + #[cfg(unix)] + #[test] + fn finds_the_bundled_agent_when_the_cli_is_called_through_a_symlink() { + let directory = tempfile::TempDir::new().expect("temporary directory"); + let bundle = directory.path().join("WarpgateSH.app/Contents/MacOS"); + fs::create_dir_all(&bundle).expect("bundle directory"); + let cli = bundle.join("warpgatesh"); + let agent = bundle.join("warpgatesh-agent"); + fs::write(&cli, []).expect("CLI executable"); + fs::write(&agent, []).expect("agent executable"); + + let link = directory.path().join("warpgatesh"); + symlink(&cli, &link).expect("CLI symlink"); + + assert_eq!( + sibling_agent_executable(&link), + Some(fs::canonicalize(agent).expect("canonical agent path")) + ); + } } diff --git a/crates/warpgatesh-runtime/src/ssh.rs b/crates/warpgatesh-runtime/src/ssh.rs index 2c26402..147adee 100644 --- a/crates/warpgatesh-runtime/src/ssh.rs +++ b/crates/warpgatesh-runtime/src/ssh.rs @@ -121,7 +121,7 @@ pub fn verify_host_keys( profile: &str, host: &str, port: u16, -) -> Result<(), RuntimeError> { +) -> Result { let pinned = fs::read_to_string(paths.known_hosts_directory.join(profile))?; let presented = scan_host_keys(host, port)?; if key_material(&pinned) != key_material(&presented.known_hosts) { @@ -129,7 +129,7 @@ pub fn verify_host_keys( "SSH host keys changed for profile '{profile}' at {host}:{port}; review and add the profile again before synchronizing" ))); } - Ok(()) + Ok(presented) } fn key_material(known_hosts: &str) -> BTreeSet<(&str, &str)> { diff --git a/crates/warpgatesh-runtime/src/sync.rs b/crates/warpgatesh-runtime/src/sync.rs index 9223f2f..05aa334 100644 --- a/crates/warpgatesh-runtime/src/sync.rs +++ b/crates/warpgatesh-runtime/src/sync.rs @@ -77,6 +77,8 @@ pub fn synchronize_all( profile.username = metadata.username; profile.warpgate_version = metadata.version; + profile.ssh_host = metadata.ssh_host; + profile.ssh_port = metadata.ssh_port; let is_default = default_profile.as_deref() == Some(profile.name.as_str()); rendered.push_str(&render_profile( @@ -127,6 +129,7 @@ pub fn synchronize_all( atomic_write(store.paths().ssh_config.as_path(), rendered.as_bytes())?; store.save_snapshot(&snapshot)?; + store.save_profiles(&catalog)?; Ok(SyncReport { profile_count: catalog.profiles.len(), target_count: snapshot.targets.len(), @@ -249,10 +252,12 @@ mod tests { .expect("saved profiles") .profiles .remove(0); - assert_eq!(saved_profile.ssh_host, "10.60.0.17"); - assert_eq!(saved_profile.ssh_port, 22); + assert_eq!(saved_profile.ssh_host, "ssh.example"); + assert_eq!(saved_profile.ssh_port, 2222); let config = fs::read_to_string(paths.ssh_config).expect("managed config"); assert!(config.contains("Host db db.lab")); + assert!(config.contains("HostName \"ssh.example\"")); + assert!(config.contains("Port 2222")); assert!(!config.contains("Host web")); assert_eq!( store.load_snapshot().expect("snapshot").unwrap().targets[0].name,