manta_server/server/common/
vault.rs

1//! Vault client used by handlers that need backend-specific secrets
2//! (Gitea token for `create_session`, Kubernetes credentials for the
3//! console and log-streaming handlers).
4
5/// Thin Vault HTTP client. Authenticates via OIDC/JWT against a
6/// per-site `jwt-manta-<site>` role, then reads K/V v2 secrets under
7/// `manta/data/<...>`.
8pub mod http_client {
9
10  use std::sync::LazyLock;
11
12  use manta_backend_dispatcher::error::Error;
13  use serde_json::{Value, json};
14
15  /// Vault API version prefix.
16  const VAULT_API_PREFIX: &str = "/v1";
17
18  /// Vault KV secret path prefix for manta.
19  const VAULT_SECRET_PATH_PREFIX: &str = "manta/data";
20
21  /// Vault role name used for JWT authentication.
22  const VAULT_ROLE: &str = "manta";
23
24  /// Process-wide `reqwest::Client` reused for every Vault call.
25  ///
26  /// Each call previously built a fresh client (re-doing the TLS
27  /// handshake + connection-pool setup) — every console attach, log
28  /// stream, and SAT-file apply did two such builds. `LazyLock` lets
29  /// us share one `Client` (which is itself an `Arc` internally, so
30  /// it's cheap to share across handlers) and keep keep-alive working.
31  ///
32  /// `Client::builder().build()` only fails on invalid TLS / proxy
33  /// configuration; with all defaults it can't, so the unwrap here is
34  /// safe — see the reqwest::ClientBuilder source for the conditions.
35  static VAULT_HTTP_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
36    reqwest::Client::builder()
37      .build()
38      .expect("default reqwest::ClientBuilder build cannot fail")
39  });
40
41  /// Authenticate to Vault using a JWT token and return
42  /// a Vault client token.
43  pub async fn auth_oidc_jwt(
44    vault_base_url: &str,
45    shasta_token: &str,
46    site_name: &str,
47  ) -> Result<String, Error> {
48    let role = VAULT_ROLE;
49
50    let api_url = format!(
51      "{vault_base_url}{VAULT_API_PREFIX}/auth/jwt-manta-{site_name}/login"
52    );
53
54    tracing::debug!("Accessing/login to {}", api_url);
55
56    let request_payload = json!({ "jwt": shasta_token, "role": role });
57
58    let resp = VAULT_HTTP_CLIENT
59      .post(api_url)
60      .header("X-Vault-Request", "true")
61      .json(&request_payload)
62      .send()
63      .await?
64      .error_for_status()?;
65
66    let resp_value = resp.json::<Value>().await?;
67    let client_token = resp_value["auth"]
68      .get("client_token")
69      .and_then(Value::as_str)
70      .ok_or_else(|| {
71        Error::MissingField(
72          "Vault auth response missing 'client_token' field".to_string(),
73        )
74      })?;
75    Ok(client_token.to_string())
76  }
77
78  /// Get a secret from Vault's KV store at `secret_path`.
79  pub async fn get_secret(
80    vault_auth_token: &str,
81    vault_base_url: &str,
82    secret_path: &str,
83  ) -> Result<Value, Error> {
84    let api_url = vault_base_url.to_owned() + secret_path;
85
86    tracing::debug!("Vault url to fetch VCS secrets is '{}'", api_url);
87
88    let resp = VAULT_HTTP_CLIENT
89      .get(api_url)
90      .header("X-Vault-Token", vault_auth_token)
91      .send()
92      .await?
93      .error_for_status()?;
94
95    let secret_value: Value = resp.json().await?;
96    Ok(secret_value["data"].clone())
97  }
98
99  /// Retrieve the Gitea VCS token from Vault.
100  pub async fn get_shasta_vcs_token(
101    shasta_token: &str,
102    vault_base_url: &str,
103    site_name: &str,
104  ) -> Result<String, Error> {
105    let vault_token =
106      auth_oidc_jwt(vault_base_url, shasta_token, site_name).await?;
107
108    let vault_secret_path = format!("{VAULT_SECRET_PATH_PREFIX}/{site_name}");
109
110    let vault_secret = get_secret(
111      &vault_token,
112      vault_base_url,
113      &format!("{VAULT_API_PREFIX}/{vault_secret_path}/vcs"),
114    )
115    .await?;
116
117    let vcs_token = vault_secret["data"]
118      .get("token")
119      .and_then(Value::as_str)
120      .ok_or_else(|| {
121      Error::MissingField(
122        "Vault secret response missing 'token' field".to_string(),
123      )
124    })?;
125
126    Ok(vcs_token.to_string())
127  }
128}