diff --git a/CHANGELOG.md b/CHANGELOG.md index c423ea2..e87844a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ Toutes les évolutions notables de WarpgateSH sont documentées ici. +## Non publié + +- Les changements de bannière SSH dans les commentaires de `ssh-keyscan` ne sont plus signalés comme une rotation des clés hôtes. Les modifications du matériel des clés restent refusées. +- Des tests de contrat couvrent les API utilisateur Warpgate 0.27.5, 0.28.6 et 0.29.1, ainsi que la conservation de l’état local en cas de réponse invalide ou d’erreur HTTP. + ## 0.1.16 — 2026-09-10 - 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. diff --git a/crates/warpgatesh-runtime/src/ssh.rs b/crates/warpgatesh-runtime/src/ssh.rs index 147adee..79add5a 100644 --- a/crates/warpgatesh-runtime/src/ssh.rs +++ b/crates/warpgatesh-runtime/src/ssh.rs @@ -135,6 +135,7 @@ pub fn verify_host_keys( fn key_material(known_hosts: &str) -> BTreeSet<(&str, &str)> { known_hosts .lines() + .filter(|line| !line.trim_start().starts_with('#')) .filter_map(|line| { let mut fields = line.split_whitespace(); let _hosts = fields.next()?; @@ -333,6 +334,29 @@ mod tests { assert!(!uninstall_managed_include(&paths).expect("second uninstall")); } + #[test] + fn ignores_scan_banner_changes_when_comparing_pinned_keys() { + let pinned = "# gateway:2222 SSH-2.0-russh_0.62.5\n\ + gateway ssh-ed25519 AAAA\n\ + gateway ssh-rsa BBBB\n"; + let presented = " # gateway:2222 SSH-2.0-russh_0.63.3\n\n\ + gateway ssh-rsa BBBB\n\ + gateway ssh-ed25519 AAAA\n"; + + assert_eq!(key_material(pinned), key_material(presented)); + assert_eq!(key_material(pinned).len(), 2); + } + + #[test] + fn still_detects_changed_keys_when_scan_banners_match() { + let pinned = "# gateway:2222 SSH-2.0-russh_0.63.3\n\ + gateway ssh-ed25519 AAAA\n"; + let presented = "# gateway:2222 SSH-2.0-russh_0.63.3\n\ + gateway ssh-ed25519 CHANGED\n"; + + assert_ne!(key_material(pinned), key_material(presented)); + } + #[test] fn compares_host_keys_without_depending_on_host_labels_or_order() { let pinned = "host-a ssh-ed25519 AAAA\nhost-a ssh-rsa BBBB\n"; diff --git a/crates/warpgatesh-runtime/tests/fixtures/README.md b/crates/warpgatesh-runtime/tests/fixtures/README.md new file mode 100644 index 0000000..af963ea --- /dev/null +++ b/crates/warpgatesh-runtime/tests/fixtures/README.md @@ -0,0 +1,26 @@ +# Warpgate user API fixtures + +These are synthetic responses, not captured production data. Hostnames use +`example.test`, usernames and UUIDs are invented, and no fixture contains a +credential. Test requests use a synthetic token. + +Each `info.json` contains the required fields of the upstream `Info` schema +plus the authenticated metadata consumed by WarpgateSH. Each `targets.json` +includes all seven `TargetKind` values, required `TargetSnapshot` fields and +one synthetic target group. Optional fields not needed by these tests are +omitted. The 0.29.1 info fixture deliberately omits the removed +`minimize_password_login` field and includes the new MFA fields. + +Source: `warpgate-web/src/gateway/lib/openapi-schema.json` in the official +[Warpgate repository](https://github.com/warp-tech/warpgate), reviewed 2026-10-02. + +| Version | Immutable source commit | +| --- | --- | +| 0.27.5 | [a28faaa4f99e6a2a7bbb4db359b18b539536f78b](https://github.com/warp-tech/warpgate/blob/a28faaa4f99e6a2a7bbb4db359b18b539536f78b/warpgate-web/src/gateway/lib/openapi-schema.json) | +| 0.28.6 | [525c7caf2219d5f5e3913b5732e4cbad5d15cd34](https://github.com/warp-tech/warpgate/blob/525c7caf2219d5f5e3913b5732e4cbad5d15cd34/warpgate-web/src/gateway/lib/openapi-schema.json) | +| 0.29.1 | [54f93c807be2c161a94c0df764242849125161a8](https://github.com/warp-tech/warpgate/blob/54f93c807be2c161a94c0df764242849125161a8/warpgate-web/src/gateway/lib/openapi-schema.json) | + +When adding a version, check the actual HTTP route mounting and token security +scheme too. Verify fixture required fields, types, enum values and target UUIDs +against its schema; do not infer future API shapes or label synthetic fixtures +as live-server validation. See the [compatibility review](../../../../docs/warpgate-api-compatibility.md). diff --git a/crates/warpgatesh-runtime/tests/fixtures/warpgate-0.27.5/info.json b/crates/warpgatesh-runtime/tests/fixtures/warpgate-0.27.5/info.json new file mode 100644 index 0000000..e6b0b58 --- /dev/null +++ b/crates/warpgatesh-runtime/tests/fixtures/warpgate-0.27.5/info.json @@ -0,0 +1,27 @@ +{ + "ports": { + "ssh": 2222, + "http": 443 + }, + "password_login_mode": "Enabled", + "minimize_password_login": false, + "authorized_via_ticket": false, + "authorized_via_sso_with_single_logout": false, + "own_credential_management_allowed": true, + "ticket_self_service_enabled": false, + "ticket_require_description": false, + "ticket_request_show_all_targets": false, + "target_click_action": "Connect", + "web_clients_enabled": false, + "has_ldap": false, + "should_prompt_analytics": false, + "banner": "", + "show_session_menu": true, + "version": "0.27.5", + "username": "alice", + "external_host": "gateway.example.test", + "external_hosts": { + "ssh": "ssh.example.test", + "http": "gateway.example.test" + } +} diff --git a/crates/warpgatesh-runtime/tests/fixtures/warpgate-0.27.5/targets.json b/crates/warpgatesh-runtime/tests/fixtures/warpgate-0.27.5/targets.json new file mode 100644 index 0000000..6d8805e --- /dev/null +++ b/crates/warpgatesh-runtime/tests/fixtures/warpgate-0.27.5/targets.json @@ -0,0 +1,49 @@ +[ + { + "id": "00000000-0000-4000-8000-000000000001", + "name": "http", + "description": "Synthetic Http target", + "kind": "Http" + }, + { + "id": "00000000-0000-4000-8000-000000000002", + "name": "kubernetes", + "description": "Synthetic Kubernetes target", + "kind": "Kubernetes" + }, + { + "id": "00000000-0000-4000-8000-000000000003", + "name": "mysql", + "description": "Synthetic MySql target", + "kind": "MySql" + }, + { + "id": "00000000-0000-4000-8000-000000000004", + "name": "db", + "description": "Synthetic Ssh target", + "kind": "Ssh", + "group": { + "id": "00000000-0000-4000-8000-000000000100", + "name": "Servers", + "color": "Primary" + } + }, + { + "id": "00000000-0000-4000-8000-000000000005", + "name": "postgres", + "description": "Synthetic Postgres target", + "kind": "Postgres" + }, + { + "id": "00000000-0000-4000-8000-000000000006", + "name": "vnc", + "description": "Synthetic Vnc target", + "kind": "Vnc" + }, + { + "id": "00000000-0000-4000-8000-000000000007", + "name": "rdp", + "description": "Synthetic Rdp target", + "kind": "Rdp" + } +] diff --git a/crates/warpgatesh-runtime/tests/fixtures/warpgate-0.28.6/info.json b/crates/warpgatesh-runtime/tests/fixtures/warpgate-0.28.6/info.json new file mode 100644 index 0000000..b651799 --- /dev/null +++ b/crates/warpgatesh-runtime/tests/fixtures/warpgate-0.28.6/info.json @@ -0,0 +1,28 @@ +{ + "ports": { + "ssh": 2222, + "http": 443 + }, + "password_login_mode": "Enabled", + "minimize_password_login": false, + "authorized_via_ticket": false, + "authorized_via_sso_with_single_logout": false, + "own_credential_management_allowed": true, + "ticket_self_service_enabled": false, + "ticket_require_description": false, + "ticket_request_show_all_targets": false, + "target_click_action": "Connect", + "open_targets_in_new_tab": "DefaultOn", + "web_clients_enabled": false, + "has_ldap": false, + "should_prompt_analytics": false, + "banner": "", + "show_session_menu": true, + "version": "0.28.6", + "username": "alice", + "external_host": "gateway.example.test", + "external_hosts": { + "ssh": "ssh.example.test", + "http": "gateway.example.test" + } +} diff --git a/crates/warpgatesh-runtime/tests/fixtures/warpgate-0.28.6/targets.json b/crates/warpgatesh-runtime/tests/fixtures/warpgate-0.28.6/targets.json new file mode 100644 index 0000000..6d8805e --- /dev/null +++ b/crates/warpgatesh-runtime/tests/fixtures/warpgate-0.28.6/targets.json @@ -0,0 +1,49 @@ +[ + { + "id": "00000000-0000-4000-8000-000000000001", + "name": "http", + "description": "Synthetic Http target", + "kind": "Http" + }, + { + "id": "00000000-0000-4000-8000-000000000002", + "name": "kubernetes", + "description": "Synthetic Kubernetes target", + "kind": "Kubernetes" + }, + { + "id": "00000000-0000-4000-8000-000000000003", + "name": "mysql", + "description": "Synthetic MySql target", + "kind": "MySql" + }, + { + "id": "00000000-0000-4000-8000-000000000004", + "name": "db", + "description": "Synthetic Ssh target", + "kind": "Ssh", + "group": { + "id": "00000000-0000-4000-8000-000000000100", + "name": "Servers", + "color": "Primary" + } + }, + { + "id": "00000000-0000-4000-8000-000000000005", + "name": "postgres", + "description": "Synthetic Postgres target", + "kind": "Postgres" + }, + { + "id": "00000000-0000-4000-8000-000000000006", + "name": "vnc", + "description": "Synthetic Vnc target", + "kind": "Vnc" + }, + { + "id": "00000000-0000-4000-8000-000000000007", + "name": "rdp", + "description": "Synthetic Rdp target", + "kind": "Rdp" + } +] diff --git a/crates/warpgatesh-runtime/tests/fixtures/warpgate-0.29.1/info.json b/crates/warpgatesh-runtime/tests/fixtures/warpgate-0.29.1/info.json new file mode 100644 index 0000000..09b0328 --- /dev/null +++ b/crates/warpgatesh-runtime/tests/fixtures/warpgate-0.29.1/info.json @@ -0,0 +1,29 @@ +{ + "ports": { + "ssh": 2222, + "http": 443 + }, + "password_login_mode": "Enabled", + "authorized_via_ticket": false, + "authorized_via_sso_with_single_logout": false, + "own_credential_management_allowed": true, + "ticket_self_service_enabled": false, + "ticket_require_description": false, + "ticket_request_show_all_targets": false, + "target_click_action": "Connect", + "open_targets_in_new_tab": "DefaultOn", + "web_clients_enabled": false, + "has_ldap": false, + "needs_mfa_setup": false, + "otp_setup_enforced": false, + "should_prompt_analytics": false, + "banner": "", + "show_session_menu": true, + "version": "0.29.1", + "username": "alice", + "external_host": "gateway.example.test", + "external_hosts": { + "ssh": "ssh.example.test", + "http": "gateway.example.test" + } +} diff --git a/crates/warpgatesh-runtime/tests/fixtures/warpgate-0.29.1/targets.json b/crates/warpgatesh-runtime/tests/fixtures/warpgate-0.29.1/targets.json new file mode 100644 index 0000000..6d8805e --- /dev/null +++ b/crates/warpgatesh-runtime/tests/fixtures/warpgate-0.29.1/targets.json @@ -0,0 +1,49 @@ +[ + { + "id": "00000000-0000-4000-8000-000000000001", + "name": "http", + "description": "Synthetic Http target", + "kind": "Http" + }, + { + "id": "00000000-0000-4000-8000-000000000002", + "name": "kubernetes", + "description": "Synthetic Kubernetes target", + "kind": "Kubernetes" + }, + { + "id": "00000000-0000-4000-8000-000000000003", + "name": "mysql", + "description": "Synthetic MySql target", + "kind": "MySql" + }, + { + "id": "00000000-0000-4000-8000-000000000004", + "name": "db", + "description": "Synthetic Ssh target", + "kind": "Ssh", + "group": { + "id": "00000000-0000-4000-8000-000000000100", + "name": "Servers", + "color": "Primary" + } + }, + { + "id": "00000000-0000-4000-8000-000000000005", + "name": "postgres", + "description": "Synthetic Postgres target", + "kind": "Postgres" + }, + { + "id": "00000000-0000-4000-8000-000000000006", + "name": "vnc", + "description": "Synthetic Vnc target", + "kind": "Vnc" + }, + { + "id": "00000000-0000-4000-8000-000000000007", + "name": "rdp", + "description": "Synthetic Rdp target", + "kind": "Rdp" + } +] diff --git a/crates/warpgatesh-runtime/tests/warpgate_api_contract.rs b/crates/warpgatesh-runtime/tests/warpgate_api_contract.rs new file mode 100644 index 0000000..c3b4a77 --- /dev/null +++ b/crates/warpgatesh-runtime/tests/warpgate_api_contract.rs @@ -0,0 +1,212 @@ +//! Synthetic responses derived from upstream `OpenAPI` schemas; no live credentials. + +use std::fs; +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::sync::{Arc, Mutex}; +use std::thread; + +use tempfile::TempDir; +use warpgatesh_core::paths::WarpgatePaths; +use warpgatesh_core::profiles::{Profile, ProfileCatalog, SshAuthentication}; +use warpgatesh_runtime::RuntimeError; +use warpgatesh_runtime::api::ApiClient; +use warpgatesh_runtime::keychain::TokenStore; +use warpgatesh_runtime::storage::{LocalStore, atomic_write}; +use warpgatesh_runtime::sync::synchronize_all; + +const CONTRACTS: [(&str, &str, &str); 3] = [ + ( + "0.27.5", + include_str!("fixtures/warpgate-0.27.5/info.json"), + include_str!("fixtures/warpgate-0.27.5/targets.json"), + ), + ( + "0.28.6", + include_str!("fixtures/warpgate-0.28.6/info.json"), + include_str!("fixtures/warpgate-0.28.6/targets.json"), + ), + ( + "0.29.1", + include_str!("fixtures/warpgate-0.29.1/info.json"), + include_str!("fixtures/warpgate-0.29.1/targets.json"), + ), +]; + +const SSH_TARGET_ID: &str = "00000000-0000-4000-8000-000000000004"; + +struct Server { + url: String, + requests: Arc>>, + handle: thread::JoinHandle<()>, +} + +impl Server { + fn start(responses: Vec<(u16, String)>) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("mock listener"); + let url = format!("http://{}/", listener.local_addr().expect("mock address")); + let requests = Arc::new(Mutex::new(Vec::new())); + let captured = Arc::clone(&requests); + let handle = thread::spawn(move || { + for (status, body) in responses { + let (mut stream, _) = listener.accept().expect("accept request"); + let mut request = Vec::new(); + while !request.ends_with(b"\r\n\r\n") { + let mut byte = [0_u8]; + stream.read_exact(&mut byte).expect("read request header"); + request.push(byte[0]); + } + captured + .lock() + .expect("requests") + .push(String::from_utf8(request).expect("HTTP request")); + write!( + stream, + "HTTP/1.1 {status} Mock\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .expect("write response"); + } + }); + Self { + url, + requests, + handle, + } + } + + fn finish(self) -> Vec { + self.handle.join().expect("mock server"); + Arc::try_unwrap(self.requests) + .expect("sole request owner") + .into_inner() + .expect("requests") + } +} + +struct MemoryToken; + +impl TokenStore for MemoryToken { + fn set(&self, _profile: &str, _token: &str) -> Result<(), RuntimeError> { + Ok(()) + } + + fn get(&self, _profile: &str) -> Result { + Ok("synthetic-token".to_owned()) + } + + fn delete(&self, _profile: &str) -> Result<(), RuntimeError> { + Ok(()) + } +} + +fn configure_store(home: &TempDir, url: String) -> LocalStore { + let store = LocalStore::new(WarpgatePaths::for_home(home.path())); + let mut catalog = ProfileCatalog::default(); + catalog + .upsert(Profile { + name: "lab".to_owned(), + base_url: url, + username: "alice".to_owned(), + warpgate_version: Some("0.27.5".to_owned()), + ssh_host: "ssh.example.test".to_owned(), + ssh_port: 2222, + ssh_authentication: SshAuthentication::InBrowser, + }) + .expect("profile"); + store.save_profiles(&catalog).expect("save profiles"); + atomic_write( + &store.paths().known_hosts_directory.join("lab"), + b"synthetic-pinned-key\n", + ) + .expect("pinned key"); + store +} + +#[test] +fn reads_user_api_contracts_before_and_after_029() { + for (version, info, targets) in CONTRACTS { + let server = Server::start(vec![(200, info.to_owned()), (200, targets.to_owned())]); + let client = ApiClient::new(&server.url).expect("client"); + let metadata = client.validate("synthetic-token").expect("metadata"); + let targets = client.ssh_targets("synthetic-token").expect("targets"); + let requests = server.finish(); + + assert_eq!(metadata.version.as_deref(), Some(version)); + assert_eq!(metadata.username, "alice"); + assert_eq!(metadata.ssh_host, "ssh.example.test"); + assert_eq!(metadata.ssh_port, 2222); + assert_eq!(targets.len(), 1, "only SSH targets for {version}"); + assert_eq!(targets[0].id, SSH_TARGET_ID); + assert_eq!(targets[0].name, "db"); + for (request, path) in requests.iter().zip(["info", "targets"]) { + assert!(request.starts_with(&format!("GET /@warpgate/api/{path} HTTP/1.1\r\n"))); + assert!( + request + .to_ascii_lowercase() + .contains("\r\nx-warpgate-token: synthetic-token\r\n") + ); + } + } +} + +#[test] +fn synchronizes_each_contract_without_changing_ssh_browser_authentication() { + for (version, info, targets) in CONTRACTS { + let server = Server::start(vec![(200, info.to_owned()), (200, targets.to_owned())]); + let home = TempDir::new().expect("temporary home"); + let store = configure_store(&home, server.url.clone()); + let report = synchronize_all(&store, &MemoryToken).expect("synchronize"); + server.finish(); + + assert_eq!(report.target_count, 1); + let profile = store.load_profiles().expect("profiles").profiles.remove(0); + assert_eq!(profile.warpgate_version.as_deref(), Some(version)); + assert_eq!(profile.ssh_authentication, SshAuthentication::InBrowser); + let config = fs::read_to_string(&store.paths().ssh_config).expect("SSH config"); + assert!(config.contains("Host db db.lab")); + assert!(config.contains("User \"alice:db\"")); + assert!(config.contains("HostName \"ssh.example.test\"")); + assert!(config.contains("PreferredAuthentications keyboard-interactive")); + assert!(!config.contains("synthetic-token")); + let snapshot = store.load_snapshot().expect("snapshot").expect("exists"); + assert_eq!(snapshot.targets[0].target_id, SSH_TARGET_ID); + } +} + +#[test] +fn preserves_saved_state_when_the_upgraded_api_fails() { + let (_, info, targets) = CONTRACTS[2]; + for (status, body) in [ + (200, "{}"), + (200, r#"[{"id":"incomplete","kind":"Ssh"}]"#), + (401, r#""Unauthenticated""#), + (403, r#""Forbidden""#), + (500, r#""Internal error (reference: synthetic-reference)""#), + ] { + // A successful baseline followed by an incompatible or rejected response. + let server = Server::start(vec![ + (200, info.to_owned()), + (200, targets.to_owned()), + (200, info.to_owned()), + (status, body.to_owned()), + ]); + let home = TempDir::new().expect("temporary home"); + let store = configure_store(&home, server.url.clone()); + synchronize_all(&store, &MemoryToken).expect("baseline"); + let paths = [ + &store.paths().ssh_config, + &store.paths().snapshot, + &store.paths().profiles, + ]; + let before: Vec<_> = paths.iter().map(|p| fs::read(p).expect("state")).collect(); + + let error = synchronize_all(&store, &MemoryToken).expect_err("reject failed response"); + server.finish(); + if matches!(status, 401 | 403) { + assert!(matches!(error, RuntimeError::Unauthorized)); + } + let after: Vec<_> = paths.iter().map(|p| fs::read(p).expect("state")).collect(); + assert_eq!(before, after, "preserve all saved state for HTTP {status}"); + } +} diff --git a/docs/README.md b/docs/README.md index 626eff7..b5a1147 100644 --- a/docs/README.md +++ b/docs/README.md @@ -18,6 +18,7 @@ reference. These guides provide the complete public onboarding path. - [Contributing](../CONTRIBUTING.md) - [Changelog](../CHANGELOG.md) +- [Warpgate API compatibility and upgrade checks](warpgate-api-compatibility.md) - [macOS release procedure](releasing-macos.md) - [Architecture decisions](adr/) diff --git a/docs/warpgate-api-compatibility.md b/docs/warpgate-api-compatibility.md new file mode 100644 index 0000000..b353bd9 --- /dev/null +++ b/docs/warpgate-api-compatibility.md @@ -0,0 +1,119 @@ +# Warpgate API compatibility + +Reviewed on 2026-10-02 against Warpgate **0.27.5**, **0.28.6** and **0.29.1**. +The 0.29.0 release warns about breaking API changes. The user API contracts +consumed by WarpgateSH remain compatible in these tags, so this review does +not require a client endpoint or authentication change. + +The source review is backed by synthetic contract tests and an operational +spot check on a deployed 0.29.1 instance on 2026-10-02/03: authenticated +synchronization, unchanged RSA/Ed25519 host keys and a browser-approved +OpenSSH connection succeeded. This does not certify every server configuration, +a fresh SSO login, MFA enrollment or the new administrator approval policy. +Compatibility remains capability-based, as described in +[ADR 0025](adr/0025-detect-warpgate-api-capabilities.md). + +## Contracts used by the client + +| Contract | What WarpgateSH needs | 0.29.1 assessment | +| --- | --- | --- | +| Personal token | `X-Warpgate-Token` request header | Unchanged | +| `GET /@warpgate/api/info` | `username`, optional `version`, `external_hosts.ssh` or `external_host`, `ports.ssh` | Fields and types retained | +| `GET /@warpgate/api/targets` | JSON array with `id`, `name`, `kind`; SSH kind is `Ssh` | Shape and SSH kind retained | +| Personal token browser page | `/@warpgate/#/profile/api-tokens` | Route retained | + +The deprecated `minimize_password_login` field is removed in 0.29. +WarpgateSH never used it. Added MFA fields and admin permissions do not affect +the small subset of `/info` the client reads. Extra target descriptions, +groups and database fields are ignored. The user target list continues to be +filtered by server-side access rules. + +WarpgateSH does not consume the admin session or recording APIs affected by +the user/target session split. It checks HTTP status before decoding successful +responses, so authentication failures and server errors do not depend on the +error body's wording or serialization. + +## Regression coverage + +The [contract tests](../crates/warpgatesh-runtime/tests/warpgate_api_contract.rs) +use synthetic fixtures built from the OpenAPI schemas committed in each +upstream tag. [Fixture provenance](../crates/warpgatesh-runtime/tests/fixtures/README.md) +records their source commits and limits. + +They verify: + +- both user API paths and the personal token header; +- username, version and external SSH endpoint extraction; +- SSH filtering with all seven currently declared protocol kinds; +- synchronization, stable target IDs and browser authentication configuration; +- preservation of the snapshot, managed SSH config and saved profiles when a + subsequent target response is malformed, incomplete, or returns 401, 403 or + 500. + +Run them with: + +```sh +cargo test -p warpgatesh-runtime --test warpgate_api_contract +``` + +The normal workspace CI runs these integration tests on macOS and Linux. +These fixtures do not run the upstream server, enforce its authorization +logic, or exercise database migrations, MFA, session approval or SSH host-key +import. + +## SSH scan comments during an upgrade + +System `ssh-keyscan` can include the server banner in `known_hosts` comments. +Warpgate 0.29.1 changes the SSH library banner. Earlier WarpgateSH clients +incorrectly compared those comments as key material and could report changed +host keys even though the approved keys were identical. The comparison now +ignores comment lines while still rejecting changed key material. Regression +tests cover both cases. + +For an installed client without this fix, compare the actual approved and +presented key fingerprints through a trusted administrative path before +refreshing the managed scan metadata. A banner change alone never authorizes +a new key. Do not discard pins or disable host-key verification. + +## Before upgrading a server to 0.29 + +1. Back up the database, configuration and data directory, including existing + SSH host keys. Keep a tested restoration path: switching an image back is + not sufficient to undo database migrations. +2. Review every admin API integration separately. The 0.29.0 release names + Terraform provider **1.2.0** and Kubernetes operator **0.4.11** as compatible + versions. Update provider constraints and lock files deliberately and + inspect a plan against the upgraded test server before applying changes. +3. Exercise `warpgatesh sync` with an existing non-admin personal token on a + test instance. Compare its visible SSH targets, target IDs, username and + advertised SSH endpoint with the pre-upgrade snapshot. +4. Verify that existing pinned SSH host keys still match. In 0.29, Warpgate + imports the existing key files into its database. Investigate unexpected + fingerprint changes before approving replacement keys. +5. Connect with the system OpenSSH client using the configured authentication + method. Test browser/SSO authentication, MFA enrollment policy and any new + admin session approval requirements independently. User web authentication + and administrator session approval are separate server-controlled steps. +6. Revalidate locally maintained server patches against the new version, + especially patches to browser approval behavior. + +## Reviewing the next release + +Compare the user OpenAPI schema and handler source for `/info` and `/targets`, +the token security scheme, HTTP route mounting and the personal token page. +Add a new synthetic fixture only after confirming the upstream contract and +record its immutable source commit. Unknown extra fields are tolerated; +missing required client fields must fail without replacing saved state. + +For a live test instance, the user schema is exposed at +`/@warpgate/api/openapi.json`. Inspect admin API consumers independently; +compatibility of the user API does not establish admin API compatibility. + +## Upstream references + +- [Warpgate 0.29.0 release notes](https://github.com/warp-tech/warpgate/releases/tag/v0.29.0) +- [Warpgate 0.29.1 release notes](https://github.com/warp-tech/warpgate/releases/tag/v0.29.1) +- [0.29.1 user OpenAPI schema](https://github.com/warp-tech/warpgate/blob/54f93c807be2c161a94c0df764242849125161a8/warpgate-web/src/gateway/lib/openapi-schema.json) +- [0.29.1 info handler](https://github.com/warp-tech/warpgate/blob/54f93c807be2c161a94c0df764242849125161a8/warpgate-protocol-http/src/api/info.rs) +- [0.29.1 targets handler](https://github.com/warp-tech/warpgate/blob/54f93c807be2c161a94c0df764242849125161a8/warpgate-protocol-http/src/api/targets_list.rs) +- [0.29.1 token security scheme](https://github.com/warp-tech/warpgate/blob/54f93c807be2c161a94c0df764242849125161a8/warpgate-protocol-http/src/api/auth_scheme.rs)