From c2e30f7375196a0f08bff84aac79abea2f112e94 Mon Sep 17 00:00:00 2001 From: UnbreakableMJ Date: Mon, 22 Jun 2026 18:23:15 +0300 Subject: [PATCH] feat(cli): interactive register server picker + Bitwarden-cloud routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `vault register` with no --server on a TTY now shows a server picker (Bitwarden cloud US / EU / self-hosted) instead of erroring. Choosing a cloud option only works because this also wires split-domain routing into the agent — previously the live unlock path always used the single-origin self_hosted shape, so bitwarden.com/.eu could never connect. vault-api: - add BaseUrls::bitwarden_eu() (api/identity.bitwarden.eu), mirroring bitwarden_hosted(). - add BaseUrls::infer_from(server): route *.bitwarden.com -> US split, *.bitwarden.eu -> EU split, else self_hosted. Implements the method the orphaned error.rs doc comment already referenced. Suffix match is apex-anchored (lookalike hosts fall through to self_hosted). - 6 infer_from tests in tests/parsing.rs. vault-agent: - ensure_online (state.rs) + online_unlock (unlock.rs) now call infer_from instead of self_hosted. The stored server string and cache-dir scheme are untouched, so existing caches don't orphan. vault-cli: - cmd_register resolves server/email via new resolve_register_server/ _email: flag -> env -> (TTY only) prompt. prompt_server renders the menu and maps choices to canonical origins; self-hosted prompts for a URL that the existing http(s):// check validates. Off a TTY with neither flag nor env, it still prints the same "missing --server" error and exits 2 — the non-interactive contract is preserved (piped register never hangs). - replace single-use resolve_arg with the non-erroring arg_or_env helper. Advances the still-open PRD §11.1 end-to-end-against-bitwarden.com gate. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/vault-agent/src/state.rs | 2 +- crates/vault-agent/src/unlock.rs | 2 +- crates/vault-api/src/urls.rs | 49 ++++++++++++++++++++++- crates/vault-api/tests/parsing.rs | 43 ++++++++++++++++++++ crates/vault-cli/src/main.rs | 66 +++++++++++++++++++++++++++---- 5 files changed, 150 insertions(+), 12 deletions(-) diff --git a/crates/vault-agent/src/state.rs b/crates/vault-agent/src/state.rs index 2fa5e69..b4e4da6 100644 --- a/crates/vault-agent/src/state.rs +++ b/crates/vault-agent/src/state.rs @@ -104,7 +104,7 @@ impl Vault { if self.client.is_some() { return Ok(()); } - let urls = vault_api::BaseUrls::self_hosted(&self.server) + let urls = vault_api::BaseUrls::infer_from(&self.server) .map_err(|e| IpcError::Internal(e.to_string()))?; let device = uuid::Uuid::parse_str(&self.device_id).unwrap_or_else(|_| uuid::Uuid::new_v4()); diff --git a/crates/vault-agent/src/unlock.rs b/crates/vault-agent/src/unlock.rs index b742281..72f345e 100644 --- a/crates/vault-agent/src/unlock.rs +++ b/crates/vault-agent/src/unlock.rs @@ -54,7 +54,7 @@ async fn online_unlock( two_factor: Option<&TwoFactorCode>, ) -> Result { let email_lower = email.trim().to_lowercase(); - let urls = BaseUrls::self_hosted(server).map_err(|e| IpcError::Internal(e.to_string()))?; + let urls = BaseUrls::infer_from(server).map_err(|e| IpcError::Internal(e.to_string()))?; // Prefer the account profile's stable device id; fall back to a fresh one // so an unregistered unlock still works (it just registers a new device). let device = device_id diff --git a/crates/vault-api/src/urls.rs b/crates/vault-api/src/urls.rs index 26676aa..13bb075 100644 --- a/crates/vault-api/src/urls.rs +++ b/crates/vault-api/src/urls.rs @@ -3,9 +3,11 @@ //! Base URLs for a Bitwarden / Vaultwarden deployment. //! //! Bitwarden's hosted service splits the API and identity endpoints across -//! two hostnames (`api.bitwarden.com`, `identity.bitwarden.com`). +//! two hostnames — `api.bitwarden.com` + `identity.bitwarden.com` for the US +//! cloud, `api.bitwarden.eu` + `identity.bitwarden.eu` for the EU cloud. //! Self-hosted Vaultwarden serves both from the same origin under -//! `/api` and `/identity` path prefixes. `BaseUrls` accommodates both. +//! `/api` and `/identity` path prefixes. `BaseUrls` accommodates all three; +//! [`BaseUrls::infer_from`] routes a configured server origin to the right one. use url::Url; @@ -39,6 +41,49 @@ impl BaseUrls { } } + /// Hosted Bitwarden, EU region — `api.bitwarden.eu` + `identity.bitwarden.eu`. + /// + /// # Panics + /// + /// Never: both URLs are compile-time string literals known to parse. + #[must_use] + #[allow(clippy::expect_used)] // the two literals are valid URLs; the Err arm is unreachable + pub fn bitwarden_eu() -> Self { + Self { + api: "https://api.bitwarden.eu" + .parse() + .expect("static URL parses"), + identity: "https://identity.bitwarden.eu" + .parse() + .expect("static URL parses"), + } + } + + /// Route a configured server origin to the right endpoint pair: the hosted + /// Bitwarden split for the `bitwarden.com` (US) and `bitwarden.eu` (EU) + /// clouds, or the single-origin [`self_hosted`](Self::self_hosted) shape for + /// anything else (Vaultwarden / other self-hosted). Subdomains of the cloud + /// apexes (e.g. `vault.bitwarden.com`) route to the matching cloud; no real + /// self-host lives under those apexes, so the suffix match is safe. + /// + /// # Errors + /// + /// Returns [`Error::BaseUrl`] if `server` is not a valid URL, or — for the + /// self-hosted path — if the `/api/` and `/identity/` joins fail. + pub fn infer_from(server: &str) -> Result { + let url: Url = server + .parse() + .map_err(|_| Error::BaseUrl("server is not a valid URL"))?; + let host = url.host_str().unwrap_or_default(); + if host == "bitwarden.com" || host.ends_with(".bitwarden.com") { + Ok(Self::bitwarden_hosted()) + } else if host == "bitwarden.eu" || host.ends_with(".bitwarden.eu") { + Ok(Self::bitwarden_eu()) + } else { + Self::self_hosted(server) + } + } + /// Vaultwarden / self-hosted — both halves are served from one origin /// under `/api` and `/identity`. /// diff --git a/crates/vault-api/tests/parsing.rs b/crates/vault-api/tests/parsing.rs index 991813c..edce716 100644 --- a/crates/vault-api/tests/parsing.rs +++ b/crates/vault-api/tests/parsing.rs @@ -79,3 +79,46 @@ fn base_urls_rejects_garbage() { .then_some(()) .expect("garbage URL must be rejected"); } + +#[test] +fn infer_from_routes_bitwarden_com_to_us_split() { + let u = BaseUrls::infer_from("https://bitwarden.com").unwrap(); + assert_eq!(u.api.as_str(), "https://api.bitwarden.com/"); + assert_eq!(u.identity.as_str(), "https://identity.bitwarden.com/"); +} + +#[test] +fn infer_from_routes_bitwarden_eu_to_eu_split() { + let u = BaseUrls::infer_from("https://bitwarden.eu").unwrap(); + assert_eq!(u.api.as_str(), "https://api.bitwarden.eu/"); + assert_eq!(u.identity.as_str(), "https://identity.bitwarden.eu/"); +} + +#[test] +fn infer_from_routes_cloud_subdomain_to_split() { + // The web-vault host (a subdomain of the apex) routes to the same cloud. + let u = BaseUrls::infer_from("https://vault.bitwarden.com").unwrap(); + assert_eq!(u.api.as_str(), "https://api.bitwarden.com/"); + assert_eq!(u.identity.as_str(), "https://identity.bitwarden.com/"); +} + +#[test] +fn infer_from_routes_self_hosted_to_single_origin() { + let u = BaseUrls::infer_from("https://vault.example.org").unwrap(); + assert_eq!(u.api.as_str(), "https://vault.example.org/api/"); + assert_eq!(u.identity.as_str(), "https://vault.example.org/identity/"); +} + +#[test] +fn infer_from_does_not_match_lookalike_host() { + // A host that merely *contains* the apex is not a cloud server. + let u = BaseUrls::infer_from("https://bitwarden.com.evil.example").unwrap(); + assert_eq!(u.api.as_str(), "https://bitwarden.com.evil.example/api/"); +} + +#[test] +fn infer_from_rejects_garbage() { + matches!(BaseUrls::infer_from("not a url"), Err(Error::BaseUrl(_))) + .then_some(()) + .expect("garbage URL must be rejected"); +} diff --git a/crates/vault-cli/src/main.rs b/crates/vault-cli/src/main.rs index e583abb..03c8753 100644 --- a/crates/vault-cli/src/main.rs +++ b/crates/vault-cli/src/main.rs @@ -909,8 +909,8 @@ fn resolve_account(server: Option, email: Option) -> Result, email: Option, json: bool) -> Result<(), u8> { - let server = resolve_arg(server, "VAULT_SERVER", "--server")?; - let email = resolve_arg(email, "VAULT_EMAIL", "--email")?; + let server = resolve_register_server(server)?; + let email = resolve_register_email(email)?; if !(server.starts_with("https://") || server.starts_with("http://")) { eprintln!("vault: server must be an http(s):// origin, got '{server}'"); return Err(2); @@ -1831,16 +1831,66 @@ fn unexpected(other: &Response) -> Result<(), u8> { Err(9) } -fn resolve_arg(cli: Option, env_key: &str, flag: &str) -> Result { - if let Some(v) = cli { +/// Explicit flag, else `$env_key` (ignoring an empty value). `None` if neither +/// is set — the caller decides whether that's an error or a prompt. +fn arg_or_env(cli: Option, env_key: &str) -> Option { + cli.or_else(|| std::env::var(env_key).ok().filter(|v| !v.is_empty())) +} + +/// Resolve `register`'s server: `--server`, then `$VAULT_SERVER`, then — only on +/// an interactive terminal — a server picker. Off a TTY with neither set, fail +/// with the same message/exit code as before, so piped or scripted `register` +/// still errors fast and never blocks waiting for input. +fn resolve_register_server(cli: Option) -> Result { + if let Some(v) = arg_or_env(cli, "VAULT_SERVER") { return Ok(v); } - if let Ok(v) = std::env::var(env_key) - && !v.is_empty() - { + if io::stdin().is_terminal() { + return prompt_server(); + } + eprintln!("vault: missing --server (or $VAULT_SERVER)"); + Err(2) +} + +/// Interactive server picker (TTY only). Cloud choices map to their canonical +/// origin; the self-hosted choice prompts for a URL that `cmd_register`'s +/// existing `http(s)://` check then validates. +fn prompt_server() -> Result { + eprintln!("Select a server:"); + eprintln!(" 1) Bitwarden cloud — US (bitwarden.com)"); + eprintln!(" 2) Bitwarden cloud — EU (bitwarden.eu)"); + eprintln!(" 3) Vaultwarden / other self-hosted (enter URL)"); + let choice = read_tty_line("Choice [1-3]: ").ok_or_else(|| { + eprintln!("vault: no choice entered"); + 2u8 + })?; + match choice.trim() { + "1" => Ok("https://bitwarden.com".to_owned()), + "2" => Ok("https://bitwarden.eu".to_owned()), + "3" => read_tty_line("Server URL: ").ok_or_else(|| { + eprintln!("vault: no server URL entered"); + 2u8 + }), + other => { + eprintln!("vault: invalid choice '{other}' (expected 1, 2, or 3)"); + Err(2) + } + } +} + +/// Resolve `register`'s email: `--email`, then `$VAULT_EMAIL`, then — only on an +/// interactive terminal — a prompt. Off a TTY, fail as before. +fn resolve_register_email(cli: Option) -> Result { + if let Some(v) = arg_or_env(cli, "VAULT_EMAIL") { return Ok(v); } - eprintln!("vault: missing {flag} (or ${env_key})"); + if io::stdin().is_terminal() { + return read_tty_line("Account email: ").ok_or_else(|| { + eprintln!("vault: no email entered"); + 2u8 + }); + } + eprintln!("vault: missing --email (or $VAULT_EMAIL)"); Err(2) }