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
2 changes: 1 addition & 1 deletion crates/vault-agent/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
2 changes: 1 addition & 1 deletion crates/vault-agent/src/unlock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ async fn online_unlock(
two_factor: Option<&TwoFactorCode>,
) -> Result<Vault, IpcError> {
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
Expand Down
49 changes: 47 additions & 2 deletions crates/vault-api/src/urls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<Self> {
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`.
///
Expand Down
43 changes: 43 additions & 0 deletions crates/vault-api/tests/parsing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
66 changes: 58 additions & 8 deletions crates/vault-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -909,8 +909,8 @@ fn resolve_account(server: Option<String>, email: Option<String>) -> Result<Acco
/// light `http(s)://` check is all the validation done here; a real server
/// error surfaces on the first `login`.
fn cmd_register(server: Option<String>, email: Option<String>, 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);
Expand Down Expand Up @@ -1831,16 +1831,66 @@ fn unexpected(other: &Response) -> Result<(), u8> {
Err(9)
}

fn resolve_arg(cli: Option<String>, env_key: &str, flag: &str) -> Result<String, u8> {
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<String>, env_key: &str) -> Option<String> {
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<String>) -> Result<String, u8> {
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<String, u8> {
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<String>) -> Result<String, u8> {
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)
}

Expand Down
Loading