mirror of
https://github.com/sol1/rustguac.git
synced 2026-09-10 01:26:06 +00:00
feat(vault): route address-book scopes to per-scope backends
Add optional [vault_shared] and [vault_local] config blocks so the shared and instance scopes can each be served by a dedicated Vault, with its own connection, retry loop, and token renewal. A bare [vault] is unchanged: shared and local alias the default cell, so every scope resolves to the one connection exactly as before. VaultBackends gains scope-dispatching wrappers (get_entry, list_entries, resolve_folder_access, ...) that route by the scope argument, plus a fan-out list_all_folders across scopes tolerant of a down backend. Folder listing is split into list_folders_in_scope; a new VaultError::Unavailable marks a configured-but-unconnected backend. Per-user credential variables and the LUKS key stay on the default backend for now. Secret IDs: VAULT_SECRET_ID (default), VAULT_SHARED_SECRET_ID, VAULT_LOCAL_SECRET_ID.
This commit is contained in:
+185
-106
@@ -1505,7 +1505,7 @@ pub async fn me(
|
||||
) -> impl IntoResponse {
|
||||
match identity {
|
||||
Some(Extension(id)) => {
|
||||
let vault_available = vault.default.read().await.is_some();
|
||||
let vault_available = vault.any_connected().await;
|
||||
Json(json!({
|
||||
"name": id.display_name(),
|
||||
"role": id.role(),
|
||||
@@ -1877,25 +1877,186 @@ pub struct VaultBackends {
|
||||
}
|
||||
|
||||
impl VaultBackends {
|
||||
/// Single-backend construction: every scope shares one cell (today's
|
||||
/// behaviour). Multi-backend construction lands with the config wiring.
|
||||
pub fn single(cell: VaultCell) -> Self {
|
||||
Self {
|
||||
default: cell.clone(),
|
||||
shared: cell.clone(),
|
||||
local: cell,
|
||||
}
|
||||
}
|
||||
|
||||
/// The backend cell serving a given address-book scope (`"shared"` or
|
||||
/// `"instance"`). Anything else falls back to the shared backend.
|
||||
#[allow(dead_code)] // wired in once scope-aware routing lands (step 2)
|
||||
pub fn cell_for_scope(&self, scope: &str) -> &VaultCell {
|
||||
match scope {
|
||||
"instance" => &self.local,
|
||||
_ => &self.shared,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the connected client for `scope`, or `Unavailable` if that
|
||||
/// backend is down / not yet connected.
|
||||
async fn scoped(&self, scope: &str) -> Result<Arc<VaultClient>, VaultError> {
|
||||
self.cell_for_scope(scope)
|
||||
.read()
|
||||
.await
|
||||
.clone()
|
||||
.ok_or(VaultError::Unavailable)
|
||||
}
|
||||
|
||||
/// Resolve the connected default (`[vault]`) client — home of unscoped
|
||||
/// secrets and, for now, per-user credential variables.
|
||||
async fn default_client(&self) -> Result<Arc<VaultClient>, VaultError> {
|
||||
self.default
|
||||
.read()
|
||||
.await
|
||||
.clone()
|
||||
.ok_or(VaultError::Unavailable)
|
||||
}
|
||||
|
||||
/// True if at least one configured backend is currently connected.
|
||||
pub async fn any_connected(&self) -> bool {
|
||||
self.default.read().await.is_some()
|
||||
|| self.shared.read().await.is_some()
|
||||
|| self.local.read().await.is_some()
|
||||
}
|
||||
|
||||
// ── Scope-routed address-book operations (dispatch to the scope's backend) ──
|
||||
|
||||
pub async fn list_subfolders(
|
||||
&self,
|
||||
scope: &str,
|
||||
parent: &str,
|
||||
) -> Result<Vec<crate::vault::FolderInfo>, VaultError> {
|
||||
self.scoped(scope)
|
||||
.await?
|
||||
.list_subfolders(scope, parent)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_entries(&self, scope: &str, folder: &str) -> Result<Vec<String>, VaultError> {
|
||||
self.scoped(scope).await?.list_entries(scope, folder).await
|
||||
}
|
||||
|
||||
pub async fn get_entry(
|
||||
&self,
|
||||
scope: &str,
|
||||
folder: &str,
|
||||
entry: &str,
|
||||
) -> Result<AddressBookEntry, VaultError> {
|
||||
self.scoped(scope)
|
||||
.await?
|
||||
.get_entry(scope, folder, entry)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn put_entry(
|
||||
&self,
|
||||
scope: &str,
|
||||
folder: &str,
|
||||
entry: &str,
|
||||
data: &AddressBookEntry,
|
||||
) -> Result<(), VaultError> {
|
||||
self.scoped(scope)
|
||||
.await?
|
||||
.put_entry(scope, folder, entry, data)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_entry(
|
||||
&self,
|
||||
scope: &str,
|
||||
folder: &str,
|
||||
entry: &str,
|
||||
) -> Result<(), VaultError> {
|
||||
self.scoped(scope)
|
||||
.await?
|
||||
.delete_entry(scope, folder, entry)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_folder_config(
|
||||
&self,
|
||||
scope: &str,
|
||||
folder: &str,
|
||||
) -> Result<FolderConfig, VaultError> {
|
||||
self.scoped(scope)
|
||||
.await?
|
||||
.get_folder_config(scope, folder)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn put_folder_config(
|
||||
&self,
|
||||
scope: &str,
|
||||
folder: &str,
|
||||
config: &FolderConfig,
|
||||
) -> Result<(), VaultError> {
|
||||
self.scoped(scope)
|
||||
.await?
|
||||
.put_folder_config(scope, folder, config)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_folder(
|
||||
&self,
|
||||
scope: &str,
|
||||
folder: &str,
|
||||
) -> Result<(usize, usize), VaultError> {
|
||||
self.scoped(scope).await?.delete_folder(scope, folder).await
|
||||
}
|
||||
|
||||
pub async fn resolve_folder_access(
|
||||
&self,
|
||||
scope: &str,
|
||||
folder: &str,
|
||||
user_groups: &[String],
|
||||
) -> Result<bool, VaultError> {
|
||||
self.scoped(scope)
|
||||
.await?
|
||||
.resolve_folder_access(scope, folder, user_groups)
|
||||
.await
|
||||
}
|
||||
|
||||
// ── Unscoped operations (default backend) ──
|
||||
|
||||
pub async fn get_user_credentials(
|
||||
&self,
|
||||
email: &str,
|
||||
) -> Result<std::collections::HashMap<String, String>, VaultError> {
|
||||
self.default_client()
|
||||
.await?
|
||||
.get_user_credentials(email)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn put_user_credentials(
|
||||
&self,
|
||||
email: &str,
|
||||
creds: &std::collections::HashMap<String, String>,
|
||||
) -> Result<(), VaultError> {
|
||||
self.default_client()
|
||||
.await?
|
||||
.put_user_credentials(email, creds)
|
||||
.await
|
||||
}
|
||||
|
||||
// ── Fan-out across scopes ──
|
||||
|
||||
/// List top-level folders across every scope, routing each to its backend.
|
||||
/// Tolerant of a down backend: a scope whose backend is unavailable simply
|
||||
/// contributes nothing, unless NO backend is connected at all, in which
|
||||
/// case `Unavailable` is returned so callers surface the outage.
|
||||
pub async fn list_all_folders(&self) -> Result<Vec<crate::vault::FolderInfo>, VaultError> {
|
||||
let mut folders = Vec::new();
|
||||
let mut any = false;
|
||||
for scope in ["shared", "instance"] {
|
||||
if let Some(client) = self.cell_for_scope(scope).read().await.clone() {
|
||||
any = true;
|
||||
match client.list_folders_in_scope(scope).await {
|
||||
Ok(fs) => folders.extend(fs),
|
||||
Err(VaultError::NotFound) => {}
|
||||
Err(e) => tracing::warn!(scope, error = %e, "listing folders for scope failed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
if !any {
|
||||
return Err(VaultError::Unavailable);
|
||||
}
|
||||
Ok(folders)
|
||||
}
|
||||
}
|
||||
|
||||
pub type VaultState = Arc<VaultBackends>;
|
||||
@@ -1913,24 +2074,12 @@ pub async fn get_docs() -> impl IntoResponse {
|
||||
Json(json!(sections))
|
||||
}
|
||||
|
||||
/// Helper: require Vault to be available, or return an appropriate error.
|
||||
async fn require_vault(vault: &VaultState) -> Result<Arc<VaultClient>, Response> {
|
||||
let guard = vault.default.read().await;
|
||||
guard.clone().ok_or_else(|| {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(json!({"error": "Vault is not available — address book is temporarily offline"})),
|
||||
)
|
||||
.into_response()
|
||||
})
|
||||
}
|
||||
|
||||
/// Helper: check if the identity has group access to a folder, honouring
|
||||
/// `inherit_from_parent` on the folder's config (a subfolder may inherit
|
||||
/// access from any ancestor whose `allowed_groups` matches the caller's
|
||||
/// OIDC groups). Admin role bypasses all checks.
|
||||
async fn check_folder_access(
|
||||
vault: &VaultClient,
|
||||
vault: &VaultBackends,
|
||||
scope: &str,
|
||||
folder: &str,
|
||||
identity: &AuthIdentity,
|
||||
@@ -1973,7 +2122,7 @@ async fn check_folder_access(
|
||||
/// practice; folders the user CAN access return immediately without
|
||||
/// descending.
|
||||
fn folder_or_descendant_accessible<'a>(
|
||||
vault: &'a VaultClient,
|
||||
vault: &'a VaultBackends,
|
||||
scope: &'a str,
|
||||
path: &'a str,
|
||||
user_groups: &'a [String],
|
||||
@@ -2004,10 +2153,6 @@ pub async fn ab_list_folders(
|
||||
identity: Option<Extension<AuthIdentity>>,
|
||||
Extension(vault): Extension<VaultState>,
|
||||
) -> impl IntoResponse {
|
||||
let vault = match require_vault(&vault).await {
|
||||
Ok(v) => v,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
let id = match identity {
|
||||
Some(Extension(ref id)) if id.has_role("operator") => id,
|
||||
_ => {
|
||||
@@ -2019,7 +2164,7 @@ pub async fn ab_list_folders(
|
||||
}
|
||||
};
|
||||
|
||||
let folders = match vault.list_folders().await {
|
||||
let folders = match vault.list_all_folders().await {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
return (
|
||||
@@ -2056,10 +2201,6 @@ pub async fn ab_list_subfolders(
|
||||
Extension(vault): Extension<VaultState>,
|
||||
Path((scope, folder)): Path<(String, String)>,
|
||||
) -> impl IntoResponse {
|
||||
let vault = match require_vault(&vault).await {
|
||||
Ok(v) => v,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
let id = match identity {
|
||||
Some(Extension(ref id)) if id.has_role("operator") => id,
|
||||
_ => {
|
||||
@@ -2111,10 +2252,6 @@ pub async fn ab_list_all(
|
||||
identity: Option<Extension<AuthIdentity>>,
|
||||
Extension(vault): Extension<VaultState>,
|
||||
) -> impl IntoResponse {
|
||||
let vault = match require_vault(&vault).await {
|
||||
Ok(v) => v,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
let id = match identity {
|
||||
Some(Extension(ref id)) if id.has_role("operator") => id,
|
||||
_ => {
|
||||
@@ -2126,7 +2263,7 @@ pub async fn ab_list_all(
|
||||
}
|
||||
};
|
||||
|
||||
let folders = match vault.list_folders().await {
|
||||
let folders = match vault.list_all_folders().await {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
return (
|
||||
@@ -2194,10 +2331,6 @@ pub async fn ab_search_index(
|
||||
identity: Option<Extension<AuthIdentity>>,
|
||||
Extension(vault): Extension<VaultState>,
|
||||
) -> impl IntoResponse {
|
||||
let vault = match require_vault(&vault).await {
|
||||
Ok(v) => v,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
let id = match identity {
|
||||
Some(Extension(ref id)) if id.has_role("operator") => id,
|
||||
_ => {
|
||||
@@ -2212,7 +2345,7 @@ pub async fn ab_search_index(
|
||||
let user_groups = id.groups();
|
||||
let is_admin = id.has_role("admin");
|
||||
|
||||
let top = match vault.list_folders().await {
|
||||
let top = match vault.list_all_folders().await {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
return (
|
||||
@@ -2270,10 +2403,6 @@ pub async fn ab_list_entries(
|
||||
Extension(vault): Extension<VaultState>,
|
||||
Path((scope, folder)): Path<(String, String)>,
|
||||
) -> impl IntoResponse {
|
||||
let vault = match require_vault(&vault).await {
|
||||
Ok(v) => v,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
let id = match identity {
|
||||
Some(Extension(ref id)) if id.has_role("operator") => id,
|
||||
_ => {
|
||||
@@ -2394,10 +2523,6 @@ pub async fn ab_connect_entry(
|
||||
Path((scope, folder, entry)): Path<(String, String, String)>,
|
||||
Json(req): Json<ConnectRequest>,
|
||||
) -> impl IntoResponse {
|
||||
let vault = match require_vault(&vault).await {
|
||||
Ok(v) => v,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
let id = match identity {
|
||||
Some(Extension(ref id)) if id.has_role("operator") => id.clone(),
|
||||
_ => {
|
||||
@@ -2673,10 +2798,6 @@ pub async fn ab_create_folder(
|
||||
Extension(vault): Extension<VaultState>,
|
||||
Json(req): Json<CreateFolderRequest>,
|
||||
) -> impl IntoResponse {
|
||||
let vault = match require_vault(&vault).await {
|
||||
Ok(v) => v,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
let admin_email = match identity.as_ref() {
|
||||
Some(Extension(id)) if id.has_role("admin") => id.display_name().to_string(),
|
||||
_ => {
|
||||
@@ -2749,10 +2870,6 @@ pub async fn ab_update_folder(
|
||||
Path((scope, folder)): Path<(String, String)>,
|
||||
Json(req): Json<UpdateFolderRequest>,
|
||||
) -> impl IntoResponse {
|
||||
let vault = match require_vault(&vault).await {
|
||||
Ok(v) => v,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
let admin_email = match identity.as_ref() {
|
||||
Some(Extension(id)) if id.has_role("admin") => id.display_name().to_string(),
|
||||
_ => {
|
||||
@@ -2809,10 +2926,6 @@ pub async fn ab_get_folder_config(
|
||||
Extension(vault): Extension<VaultState>,
|
||||
Path((scope, folder)): Path<(String, String)>,
|
||||
) -> impl IntoResponse {
|
||||
let vault = match require_vault(&vault).await {
|
||||
Ok(v) => v,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
if !identity
|
||||
.as_ref()
|
||||
.map(|Extension(id)| id.has_role("admin"))
|
||||
@@ -2856,10 +2969,6 @@ pub async fn ab_delete_folder(
|
||||
Extension(vault): Extension<VaultState>,
|
||||
Path((scope, folder)): Path<(String, String)>,
|
||||
) -> impl IntoResponse {
|
||||
let vault = match require_vault(&vault).await {
|
||||
Ok(v) => v,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
let admin_email = match identity.as_ref() {
|
||||
Some(Extension(id)) if id.has_role("admin") => id.display_name().to_string(),
|
||||
_ => {
|
||||
@@ -2924,10 +3033,6 @@ pub async fn ab_create_entry(
|
||||
Path((scope, folder)): Path<(String, String)>,
|
||||
Json(req): Json<CreateEntryRequest>,
|
||||
) -> impl IntoResponse {
|
||||
let vault = match require_vault(&vault).await {
|
||||
Ok(v) => v,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
let admin_email = match identity.as_ref() {
|
||||
Some(Extension(id)) if id.has_role("admin") => id.display_name().to_string(),
|
||||
_ => {
|
||||
@@ -2983,10 +3088,6 @@ pub async fn ab_update_entry(
|
||||
Path((scope, folder, entry)): Path<(String, String, String)>,
|
||||
Json(data): Json<AddressBookEntry>,
|
||||
) -> impl IntoResponse {
|
||||
let vault = match require_vault(&vault).await {
|
||||
Ok(v) => v,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
let admin_email = match identity.as_ref() {
|
||||
Some(Extension(id)) if id.has_role("admin") => id.display_name().to_string(),
|
||||
_ => {
|
||||
@@ -3085,10 +3186,6 @@ pub async fn ab_delete_entry(
|
||||
Extension(vault): Extension<VaultState>,
|
||||
Path((scope, folder, entry)): Path<(String, String, String)>,
|
||||
) -> impl IntoResponse {
|
||||
let vault = match require_vault(&vault).await {
|
||||
Ok(v) => v,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
let admin_email = match identity.as_ref() {
|
||||
Some(Extension(id)) if id.has_role("admin") => id.display_name().to_string(),
|
||||
_ => {
|
||||
@@ -3444,11 +3541,6 @@ pub async fn get_my_credentials(
|
||||
}
|
||||
};
|
||||
|
||||
let vault = match require_vault(&vault).await {
|
||||
Ok(v) => v,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
|
||||
match vault.get_user_credentials(&email).await {
|
||||
Ok(creds) => {
|
||||
// Return variable names with masked values (indicate set vs unset)
|
||||
@@ -3506,11 +3598,6 @@ pub async fn put_my_credentials(
|
||||
}
|
||||
};
|
||||
|
||||
let vault = match require_vault(&vault).await {
|
||||
Ok(v) => v,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
|
||||
// Expect { "credentials": { "corp_user": "alice", "corp_password": "secret", ... } }
|
||||
let creds_val = match body.get("credentials") {
|
||||
Some(v) => v,
|
||||
@@ -3599,14 +3686,9 @@ pub async fn list_credential_variables(
|
||||
}
|
||||
};
|
||||
|
||||
let vault = match require_vault(&vault).await {
|
||||
Ok(v) => v,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
|
||||
// Scan all accessible folders and entries for variable references,
|
||||
// recursing into subfolders so vars inside nested trees surface too.
|
||||
let folders = match vault.list_folders().await {
|
||||
let folders = match vault.list_all_folders().await {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
return (
|
||||
@@ -4062,15 +4144,12 @@ pub async fn quick_connect(
|
||||
);
|
||||
}
|
||||
|
||||
let vault = match require_vault(&vault).await {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
return quick_connect_error(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Address book is temporarily unavailable (Vault offline).",
|
||||
);
|
||||
}
|
||||
};
|
||||
if !vault.any_connected().await {
|
||||
return quick_connect_error(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Address book is temporarily unavailable (Vault offline).",
|
||||
);
|
||||
}
|
||||
|
||||
if check_folder_access(&vault, scope, folder, &id)
|
||||
.await
|
||||
|
||||
@@ -419,7 +419,20 @@ pub struct Config {
|
||||
|
||||
pub tls: Option<TlsConfig>,
|
||||
pub oidc: Option<OidcConfig>,
|
||||
/// Primary/default Vault backend. Serves any address-book scope that does
|
||||
/// not have a dedicated backend below, and is the home of unscoped secrets
|
||||
/// (the LUKS key). A bare `[vault]` with no overrides behaves exactly as a
|
||||
/// single-Vault deployment always has.
|
||||
pub vault: Option<VaultConfig>,
|
||||
/// Optional dedicated backend for the `shared` scope (e.g. a central,
|
||||
/// fleet-wide Vault). When set, shared-scope folders/entries route here
|
||||
/// instead of `[vault]`. Secret ID via `VAULT_SHARED_SECRET_ID`.
|
||||
pub vault_shared: Option<VaultConfig>,
|
||||
/// Optional dedicated backend for the `instance` (local) scope (e.g. a
|
||||
/// per-host Vault that stays reachable during a central outage). When set,
|
||||
/// instance-scope folders/entries route here. Secret ID via
|
||||
/// `VAULT_LOCAL_SECRET_ID`.
|
||||
pub vault_local: Option<VaultConfig>,
|
||||
pub drive: Option<DriveConfig>,
|
||||
pub theme: Option<ThemeConfig>,
|
||||
pub recording: Option<RecordingConfig>,
|
||||
@@ -1146,6 +1159,8 @@ impl Default for Config {
|
||||
tls: None,
|
||||
oidc: None,
|
||||
vault: None,
|
||||
vault_shared: None,
|
||||
vault_local: None,
|
||||
drive: None,
|
||||
theme: None,
|
||||
recording: None,
|
||||
|
||||
+132
-84
@@ -455,6 +455,83 @@ async fn security_headers(
|
||||
response
|
||||
}
|
||||
|
||||
/// Connect a single Vault backend into `cell`. On a failed initial connect,
|
||||
/// spawns a background 30s retry loop; the cell stays `None` (and that scope's
|
||||
/// address book stays unavailable) until a connect succeeds. `luks_drive` is
|
||||
/// `Some` only for the default backend, so the LUKS volume mounts as soon as
|
||||
/// that backend comes up on a retry (the initial-boot mount happens in the
|
||||
/// drive-init block once the awaited connect below has populated the cell).
|
||||
async fn connect_vault_backend(
|
||||
label: &'static str,
|
||||
cell: VaultCell,
|
||||
config: crate::config::VaultConfig,
|
||||
secret_id: String,
|
||||
luks_drive: Option<crate::config::DriveConfig>,
|
||||
) {
|
||||
match vault::VaultClient::new(&config, &secret_id).await {
|
||||
Ok(client) => {
|
||||
let client = Arc::new(client);
|
||||
client.spawn_renewal_task();
|
||||
tracing::info!("Vault backend '{}' initialized: {}", label, config.addr);
|
||||
*cell.write().await = Some(client);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"Vault backend '{}' connect to {} failed: {} \
|
||||
— that scope's address book is unavailable; retrying every 30s",
|
||||
label,
|
||||
config.addr,
|
||||
e
|
||||
);
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(30));
|
||||
interval.tick().await; // skip immediate tick
|
||||
loop {
|
||||
interval.tick().await;
|
||||
tracing::debug!(
|
||||
"Retrying Vault backend '{}' connect to {}...",
|
||||
label,
|
||||
config.addr
|
||||
);
|
||||
match vault::VaultClient::new(&config, &secret_id).await {
|
||||
Ok(client) => {
|
||||
let client = Arc::new(client);
|
||||
client.spawn_renewal_task();
|
||||
tracing::info!(
|
||||
"Vault backend '{}' connected (retry succeeded): {}",
|
||||
label,
|
||||
config.addr
|
||||
);
|
||||
*cell.write().await = Some(client.clone());
|
||||
|
||||
// Mount LUKS now that the default backend is available.
|
||||
if let Some(ref dc) = luks_drive {
|
||||
if dc.enabled && drive::luks_configured(dc) {
|
||||
match drive::mount_luks(dc, &client).await {
|
||||
Ok(_) => {
|
||||
tracing::info!("LUKS drive volume mounted (deferred)")
|
||||
}
|
||||
Err(e) => tracing::error!(
|
||||
"Failed to mount LUKS drive volume: {}",
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
Err(e) => tracing::warn!(
|
||||
"Vault backend '{}' retry failed: {} — will retry in 30s",
|
||||
label,
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_server(config: Config, database: Db) {
|
||||
// Initialize logging
|
||||
tracing_subscriber::fmt()
|
||||
@@ -484,93 +561,61 @@ async fn run_server(config: Config, database: Db) {
|
||||
None
|
||||
};
|
||||
|
||||
// Initialize Vault client if configured.
|
||||
// Initialize Vault backend(s) if configured.
|
||||
//
|
||||
// `vault_cell` is the single backend connection cell used throughout setup
|
||||
// (initial connect, background retry, LUKS mount). It is wrapped into the
|
||||
// shared `VaultBackends` state after drive init below; in the single-Vault
|
||||
// configuration every address-book scope aliases this one cell.
|
||||
let vault_cell: VaultCell = Arc::new(tokio::sync::RwLock::new(None));
|
||||
// `[vault]` is the default/primary backend and the home of unscoped secrets
|
||||
// (the LUKS key). Optional `[vault_shared]` / `[vault_local]` route the
|
||||
// shared / instance address-book scopes to dedicated Vaults so one being
|
||||
// down cannot take the others with it. Each backend gets its own connection
|
||||
// cell, background retry, and token renewal. A bare `[vault]` behaves
|
||||
// exactly as a single-Vault deployment: shared and local alias the default
|
||||
// cell, so every scope resolves to the one connection.
|
||||
let default_cell: VaultCell = Arc::new(tokio::sync::RwLock::new(None));
|
||||
let mut shared_cell = default_cell.clone();
|
||||
let mut local_cell = default_cell.clone();
|
||||
|
||||
if let Some(ref vault_config) = config.vault {
|
||||
let secret_id = match std::env::var("VAULT_SECRET_ID") {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
match std::env::var("VAULT_SECRET_ID") {
|
||||
Ok(sid) if !sid.is_empty() => {
|
||||
connect_vault_backend(
|
||||
"default",
|
||||
default_cell.clone(),
|
||||
vault_config.clone(),
|
||||
sid,
|
||||
config.drive.clone(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
_ => {
|
||||
tracing::error!("VAULT_SECRET_ID env var required when [vault] is configured");
|
||||
tracing::error!("Address book and drive features will be unavailable");
|
||||
String::new()
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if !secret_id.is_empty() {
|
||||
match vault::VaultClient::new(vault_config, &secret_id).await {
|
||||
Ok(client) => {
|
||||
let client = Arc::new(client);
|
||||
client.spawn_renewal_task();
|
||||
tracing::info!("Vault client initialized: {}", vault_config.addr);
|
||||
*vault_cell.write().await = Some(client);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("=============================================");
|
||||
tracing::error!("VAULT CONNECTION FAILED");
|
||||
tracing::error!(" Address: {}", vault_config.addr);
|
||||
tracing::error!(" Error: {}", e);
|
||||
tracing::error!(" Address book and drive features are UNAVAILABLE");
|
||||
tracing::error!(" Sessions (SSH/RDP/VNC) will still work normally");
|
||||
tracing::error!(" Retrying Vault connection every 30s in background");
|
||||
tracing::error!("=============================================");
|
||||
|
||||
// Spawn background retry task
|
||||
let retry_vault_config = vault_config.clone();
|
||||
let retry_secret_id = secret_id.clone();
|
||||
let retry_vault_state = vault_cell.clone();
|
||||
let retry_drive_config = config.drive.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval =
|
||||
tokio::time::interval(std::time::Duration::from_secs(30));
|
||||
interval.tick().await; // skip immediate tick
|
||||
loop {
|
||||
interval.tick().await;
|
||||
tracing::debug!(
|
||||
"Retrying Vault connection to {}...",
|
||||
retry_vault_config.addr
|
||||
);
|
||||
match vault::VaultClient::new(&retry_vault_config, &retry_secret_id)
|
||||
.await
|
||||
{
|
||||
Ok(client) => {
|
||||
let client = Arc::new(client);
|
||||
client.spawn_renewal_task();
|
||||
tracing::info!(
|
||||
"Vault client connected (retry succeeded): {}",
|
||||
retry_vault_config.addr
|
||||
);
|
||||
*retry_vault_state.write().await = Some(client.clone());
|
||||
|
||||
// Mount LUKS now that Vault is available
|
||||
if let Some(ref dc) = retry_drive_config {
|
||||
if dc.enabled && drive::luks_configured(dc) {
|
||||
match drive::mount_luks(dc, &client).await {
|
||||
Ok(_) => tracing::info!(
|
||||
"LUKS drive volume mounted (deferred)"
|
||||
),
|
||||
Err(e) => tracing::error!(
|
||||
"Failed to mount LUKS drive volume: {}",
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Vault retry failed: {} — will retry in 30s", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(ref vc) = config.vault_shared {
|
||||
match std::env::var("VAULT_SHARED_SECRET_ID") {
|
||||
Ok(sid) if !sid.is_empty() => {
|
||||
let cell: VaultCell = Arc::new(tokio::sync::RwLock::new(None));
|
||||
connect_vault_backend("shared", cell.clone(), vc.clone(), sid, None).await;
|
||||
shared_cell = cell;
|
||||
}
|
||||
_ => tracing::error!(
|
||||
"VAULT_SHARED_SECRET_ID required for [vault_shared]; shared-scope connections unavailable"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref vc) = config.vault_local {
|
||||
match std::env::var("VAULT_LOCAL_SECRET_ID") {
|
||||
Ok(sid) if !sid.is_empty() => {
|
||||
let cell: VaultCell = Arc::new(tokio::sync::RwLock::new(None));
|
||||
connect_vault_backend("local", cell.clone(), vc.clone(), sid, None).await;
|
||||
local_cell = cell;
|
||||
}
|
||||
_ => tracing::error!(
|
||||
"VAULT_LOCAL_SECRET_ID required for [vault_local]; instance-scope connections unavailable"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -579,7 +624,7 @@ async fn run_server(config: Config, database: Db) {
|
||||
if drive_config.enabled {
|
||||
// Mount LUKS volume if configured and Vault is available now
|
||||
if drive::luks_configured(drive_config) {
|
||||
let vc = vault_cell.read().await;
|
||||
let vc = default_cell.read().await;
|
||||
if let Some(ref client) = *vc {
|
||||
match drive::mount_luks(drive_config, client).await {
|
||||
Ok(_) => tracing::info!("LUKS drive volume mounted"),
|
||||
@@ -598,10 +643,13 @@ async fn run_server(config: Config, database: Db) {
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap the backend cell into the shared VaultBackends state. Single-Vault
|
||||
// for now: `shared` and `local` scopes both alias this one cell. The
|
||||
// multi-backend split adds dedicated cells here without touching handlers.
|
||||
let vault_client: VaultState = Arc::new(VaultBackends::single(vault_cell));
|
||||
// Assemble the shared VaultBackends state. `shared`/`local` alias the
|
||||
// default cell unless a dedicated backend was configured above.
|
||||
let vault_client: VaultState = Arc::new(VaultBackends {
|
||||
default: default_cell,
|
||||
shared: shared_cell,
|
||||
local: local_cell,
|
||||
});
|
||||
|
||||
let oidc_enabled = OidcEnabled(oidc_state.is_some());
|
||||
let vault_configured = VaultConfigured(config.vault.is_some());
|
||||
|
||||
+35
-31
@@ -26,6 +26,10 @@ pub enum VaultError {
|
||||
Http(reqwest::Error),
|
||||
Parse(String),
|
||||
BadName(String),
|
||||
/// The backend serving this scope is configured but not currently
|
||||
/// connected (initial connect pending or the Vault is down). Distinct from
|
||||
/// a Vault that returns an error: the request never left rustguac.
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for VaultError {
|
||||
@@ -37,6 +41,7 @@ impl std::fmt::Display for VaultError {
|
||||
Self::Http(e) => write!(f, "vault HTTP error: {}", e),
|
||||
Self::Parse(msg) => write!(f, "vault response parse error: {}", msg),
|
||||
Self::BadName(msg) => write!(f, "invalid name: {}", msg),
|
||||
Self::Unavailable => write!(f, "vault backend not available"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -735,15 +740,6 @@ impl VaultClient {
|
||||
|
||||
// ── Path helpers ──
|
||||
|
||||
/// Returns the path prefixes to scan: ["shared"] and optionally ["instance/<name>"].
|
||||
fn scope_prefixes(&self) -> Vec<(&str, String)> {
|
||||
let mut prefixes = vec![("shared", "shared".to_string())];
|
||||
if let Some(ref name) = self.instance_name {
|
||||
prefixes.push(("instance", format!("instance/{}", name)));
|
||||
}
|
||||
prefixes
|
||||
}
|
||||
|
||||
fn data_path(&self, scope_prefix: &str, rest: &str) -> String {
|
||||
format!(
|
||||
"/v1/{}/data/{}/{}/{}",
|
||||
@@ -760,31 +756,39 @@ impl VaultClient {
|
||||
|
||||
// ── KV v2 operations ──
|
||||
|
||||
/// List top-level folders visible across all scopes (shared + instance).
|
||||
pub async fn list_folders(&self) -> Result<Vec<FolderInfo>, VaultError> {
|
||||
let mut folders = Vec::new();
|
||||
/// List top-level folders for a single scope (`"shared"` or `"instance"`).
|
||||
///
|
||||
/// Returns an empty vec (not an error) when the scope isn't applicable to
|
||||
/// this client — e.g. `"instance"` with no `instance_name` configured — so
|
||||
/// the multi-backend fan-out can call it unconditionally.
|
||||
pub async fn list_folders_in_scope(&self, scope: &str) -> Result<Vec<FolderInfo>, VaultError> {
|
||||
let prefix = match scope {
|
||||
"shared" => "shared".to_string(),
|
||||
"instance" => match &self.instance_name {
|
||||
Some(name) => format!("instance/{}", name),
|
||||
None => return Ok(Vec::new()),
|
||||
},
|
||||
_ => return Err(VaultError::BadName(format!("invalid scope: {}", scope))),
|
||||
};
|
||||
|
||||
for (scope_label, prefix) in self.scope_prefixes() {
|
||||
let path = format!("/v1/{}/metadata/{}/{}/", self.mount, self.base_path, prefix);
|
||||
match self.kv_list(&path).await {
|
||||
Ok(keys) => {
|
||||
let has_subfolders: Vec<&str> =
|
||||
keys.iter().filter_map(|k| k.strip_suffix('/')).collect();
|
||||
for name in &has_subfolders {
|
||||
folders.push(FolderInfo {
|
||||
name: name.to_string(),
|
||||
description: String::new(),
|
||||
scope: scope_label.to_string(),
|
||||
path: Some(name.to_string()),
|
||||
has_children: None, // enriched below
|
||||
});
|
||||
}
|
||||
let mut folders = Vec::new();
|
||||
let path = format!("/v1/{}/metadata/{}/{}/", self.mount, self.base_path, prefix);
|
||||
match self.kv_list(&path).await {
|
||||
Ok(keys) => {
|
||||
for name in keys.iter().filter_map(|k| k.strip_suffix('/')) {
|
||||
folders.push(FolderInfo {
|
||||
name: name.to_string(),
|
||||
description: String::new(),
|
||||
scope: scope.to_string(),
|
||||
path: Some(name.to_string()),
|
||||
has_children: None, // enriched below
|
||||
});
|
||||
}
|
||||
Err(VaultError::NotFound) => {
|
||||
// No folders in this scope — that's fine
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
Err(VaultError::NotFound) => {
|
||||
// No folders in this scope — that's fine
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
|
||||
// Enrich with descriptions and child detection
|
||||
|
||||
Reference in New Issue
Block a user