mirror of
https://github.com/sol1/rustguac.git
synced 2026-09-10 09:35:45 +00:00
feat(vault): add vault-migrate subcommand for the DR split
Copies an address-book scope subtree (entries + every folder's .config, so allowed_groups/inherit_from_parent come along) between two configured backends, resolving each from config + its VAULT_*_SECRET_ID. Because the scope->path layout is identical in every backend, this is a same-identity server-to-server copy, not a path rewrite. Flags: --scope shared|instance, --from/--to (vault|vault_shared| vault_local), --dry-run, skip-existing by default with --overwrite, and --users to bulk-copy users/* (documented as "makes them shared"). Exits non-zero if any copy fails. Adds get/put_user_credentials_by_key and list_user_keys to VaultClient for the --users path.
This commit is contained in:
+39
@@ -6,6 +6,7 @@ mod db;
|
||||
mod drive;
|
||||
mod guacd;
|
||||
mod import;
|
||||
mod migrate;
|
||||
mod oidc;
|
||||
mod protocol;
|
||||
mod pve;
|
||||
@@ -151,6 +152,33 @@ enum Command {
|
||||
#[arg(long)]
|
||||
dry_run: bool,
|
||||
},
|
||||
|
||||
/// Copy an address-book scope subtree between configured Vault backends
|
||||
/// (for the multi-Vault DR split). Copies entries and every folder's
|
||||
/// `.config`; run with --dry-run first.
|
||||
VaultMigrate {
|
||||
/// Scope to migrate: "shared" or "instance"
|
||||
#[arg(long)]
|
||||
scope: String,
|
||||
/// Source backend: "vault", "vault_shared", or "vault_local"
|
||||
#[arg(long)]
|
||||
from: String,
|
||||
/// Destination backend: "vault", "vault_shared", or "vault_local"
|
||||
#[arg(long)]
|
||||
to: String,
|
||||
/// Also copy ALL per-user credential secrets (users/*). This makes
|
||||
/// those credentials shared; normally you toggle per-credential in the
|
||||
/// My Credentials UI instead.
|
||||
#[arg(long)]
|
||||
users: bool,
|
||||
/// Overwrite entries that already exist in the destination
|
||||
/// (default: skip existing).
|
||||
#[arg(long)]
|
||||
overwrite: bool,
|
||||
/// Preview without writing to the destination
|
||||
#[arg(long)]
|
||||
dry_run: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
@@ -204,6 +232,17 @@ async fn main() {
|
||||
import::cmd_import_guacamole(&config, &file, &folder, &scope, &allowed_groups, dry_run)
|
||||
.await;
|
||||
}
|
||||
Some(Command::VaultMigrate {
|
||||
scope,
|
||||
from,
|
||||
to,
|
||||
users,
|
||||
overwrite,
|
||||
dry_run,
|
||||
}) => {
|
||||
migrate::cmd_vault_migrate(&config, &scope, &from, &to, users, overwrite, dry_run)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
//! `vault-migrate` subcommand: copy an address-book scope subtree between
|
||||
//! configured Vault backends, for the multi-Vault DR split.
|
||||
//!
|
||||
//! The scope→path layout is identical in every backend, so migration is a
|
||||
//! server-to-server copy (same scope/folder/entry identity), not a path
|
||||
//! rewrite: reading `scope=shared, folder=Foo, entry=Bar` from the source and
|
||||
//! writing it back with the same coordinates maps `<src_base>/shared/Foo/Bar`
|
||||
//! to `<dst_base>/shared/Foo/Bar`, each client applying its own mount /
|
||||
//! base_path / instance_name. Entries AND the `.config` sentinel at every
|
||||
//! folder level are copied (the latter carries allowed_groups /
|
||||
//! inherit_from_parent — dropping it would silently lose a folder's ACL).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::vault::{VaultClient, VaultError};
|
||||
|
||||
/// Run the `vault-migrate` subcommand.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn cmd_vault_migrate(
|
||||
config: &Config,
|
||||
scope: &str,
|
||||
from: &str,
|
||||
to: &str,
|
||||
include_users: bool,
|
||||
overwrite: bool,
|
||||
dry_run: bool,
|
||||
) {
|
||||
if scope != "shared" && scope != "instance" {
|
||||
eprintln!("Error: --scope must be \"shared\" or \"instance\"");
|
||||
std::process::exit(1);
|
||||
}
|
||||
if from == to {
|
||||
eprintln!("Error: --from and --to must name different backends");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let src = connect_named(config, from).await;
|
||||
let dst = connect_named(config, to).await;
|
||||
|
||||
if dry_run {
|
||||
println!(
|
||||
"[DRY RUN] Copy {} scope: {} -> {} (nothing will be written)\n",
|
||||
scope, from, to
|
||||
);
|
||||
} else {
|
||||
println!("Copying {} scope: {} -> {}\n", scope, from, to);
|
||||
}
|
||||
|
||||
// BFS-collect every folder path in the scope subtree (top-level first, so
|
||||
// ancestors are written before descendants).
|
||||
let top = match src.list_folders_in_scope(scope).await {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"Error listing folders in {} scope on '{}': {}",
|
||||
scope, from, e
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
let mut folder_paths: Vec<String> = top.into_iter().map(|f| f.path.unwrap_or(f.name)).collect();
|
||||
let mut i = 0;
|
||||
while i < folder_paths.len() {
|
||||
let path = folder_paths[i].clone();
|
||||
if let Ok(subs) = src.list_subfolders(scope, &path).await {
|
||||
for s in subs {
|
||||
folder_paths.push(s.path.unwrap_or_else(|| format!("{}/{}", path, s.name)));
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
let mut folders_done = 0usize;
|
||||
let mut entries_copied = 0usize;
|
||||
let mut entries_skipped = 0usize;
|
||||
let mut failures = 0usize;
|
||||
|
||||
for path in &folder_paths {
|
||||
folders_done += 1;
|
||||
|
||||
// Copy the folder's .config sentinel (if any).
|
||||
match src.get_folder_config(scope, path).await {
|
||||
Ok(cfg) => {
|
||||
if dry_run {
|
||||
println!(" [config] {}", path);
|
||||
} else if let Err(e) = dst.put_folder_config(scope, path, &cfg).await {
|
||||
eprintln!(" Warning: write .config for '{}' failed: {}", path, e);
|
||||
failures += 1;
|
||||
}
|
||||
}
|
||||
Err(VaultError::NotFound) => {}
|
||||
Err(e) => eprintln!(" Warning: read .config for '{}' failed: {}", path, e),
|
||||
}
|
||||
|
||||
// Copy entries.
|
||||
let entries = src.list_entries(scope, path).await.unwrap_or_default();
|
||||
for name in &entries {
|
||||
// Skip existing unless --overwrite (the existence probe is only
|
||||
// meaningful for a real write).
|
||||
if !overwrite && !dry_run {
|
||||
match dst.get_entry(scope, path, name).await {
|
||||
Ok(_) => {
|
||||
println!(" skip (exists): {}/{}", path, name);
|
||||
entries_skipped += 1;
|
||||
continue;
|
||||
}
|
||||
Err(VaultError::NotFound) => {}
|
||||
Err(_) => {} // fall through and try the copy
|
||||
}
|
||||
}
|
||||
|
||||
match src.get_entry(scope, path, name).await {
|
||||
Ok(entry) => {
|
||||
if dry_run {
|
||||
println!(" [entry] {}/{}", path, name);
|
||||
entries_copied += 1;
|
||||
} else {
|
||||
match dst.put_entry(scope, path, name, &entry).await {
|
||||
Ok(()) => {
|
||||
println!(" copied: {}/{}", path, name);
|
||||
entries_copied += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(" FAILED: {}/{} — {}", path, name, e);
|
||||
failures += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(" FAILED to read {}/{}: {}", path, name, e);
|
||||
failures += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Optionally copy every per-user credential secret (users/*).
|
||||
let mut users_copied = 0usize;
|
||||
if include_users {
|
||||
match src.list_user_keys().await {
|
||||
Ok(keys) => {
|
||||
for key in &keys {
|
||||
match src.get_user_credentials_by_key(key).await {
|
||||
Ok(creds) => {
|
||||
if dry_run {
|
||||
println!(" [users] {} ({} vars)", key, creds.len());
|
||||
users_copied += 1;
|
||||
} else {
|
||||
match dst.put_user_credentials_by_key(key, &creds).await {
|
||||
Ok(()) => {
|
||||
println!(" copied: users/{}", key);
|
||||
users_copied += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(" FAILED: users/{} — {}", key, e);
|
||||
failures += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!(" FAILED to read users/{}: {}", key, e),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("Warning: could not list users on '{}': {}", from, e),
|
||||
}
|
||||
}
|
||||
|
||||
let verb = if dry_run { "would copy" } else { "copied" };
|
||||
println!(
|
||||
"\n{} {} folders, {} entries {} ({} skipped){}{}.",
|
||||
if dry_run { "[DRY RUN]" } else { "Done:" },
|
||||
folders_done,
|
||||
entries_copied,
|
||||
verb,
|
||||
entries_skipped,
|
||||
if include_users {
|
||||
format!(", {} user credential secrets", users_copied)
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
if failures > 0 {
|
||||
format!(", {} FAILURES", failures)
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
);
|
||||
if dry_run {
|
||||
println!("Re-run without --dry-run to perform the copy.");
|
||||
}
|
||||
if failures > 0 {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a named backend (`vault` / `vault_shared` / `vault_local`) from
|
||||
/// config, read its secret ID from the matching env var, and connect. Exits
|
||||
/// the process with a clear message on any misconfiguration.
|
||||
async fn connect_named(config: &Config, name: &str) -> Arc<VaultClient> {
|
||||
let (vault_config, env_var) = match name {
|
||||
"vault" => (config.vault.as_ref(), "VAULT_SECRET_ID"),
|
||||
"vault_shared" => (config.vault_shared.as_ref(), "VAULT_SHARED_SECRET_ID"),
|
||||
"vault_local" => (config.vault_local.as_ref(), "VAULT_LOCAL_SECRET_ID"),
|
||||
_ => {
|
||||
eprintln!(
|
||||
"Error: unknown backend '{}' (use vault, vault_shared, or vault_local)",
|
||||
name
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let vault_config = match vault_config {
|
||||
Some(vc) => vc,
|
||||
None => {
|
||||
eprintln!("Error: backend '{}' is not configured in config.toml", name);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let secret_id = match std::env::var(env_var) {
|
||||
Ok(s) if !s.is_empty() => s,
|
||||
_ => {
|
||||
eprintln!("Error: {} env var required for backend '{}'", env_var, name);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
match VaultClient::new(vault_config, &secret_id).await {
|
||||
Ok(c) => Arc::new(c),
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"Error connecting to backend '{}' ({}): {}",
|
||||
name, vault_config.addr, e
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
+36
-6
@@ -1158,7 +1158,27 @@ impl VaultClient {
|
||||
&self,
|
||||
email: &str,
|
||||
) -> Result<HashMap<String, String>, VaultError> {
|
||||
let key = sanitize_email_key(email);
|
||||
self.get_user_credentials_by_key(&sanitize_email_key(email))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Write a user's credential variables to Vault (full replace).
|
||||
/// Path: `<base_path>/users/<sanitized_email>`
|
||||
pub async fn put_user_credentials(
|
||||
&self,
|
||||
email: &str,
|
||||
creds: &HashMap<String, String>,
|
||||
) -> Result<(), VaultError> {
|
||||
self.put_user_credentials_by_key(&sanitize_email_key(email), creds)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Read credential variables by the already-sanitised users key. Used by
|
||||
/// `vault-migrate` (which enumerates raw keys and must not re-sanitise).
|
||||
pub async fn get_user_credentials_by_key(
|
||||
&self,
|
||||
key: &str,
|
||||
) -> Result<HashMap<String, String>, VaultError> {
|
||||
let path = format!("/v1/{}/data/{}/users/{}", self.mount, self.base_path, key);
|
||||
let resp = self.request(reqwest::Method::GET, &path, None).await?;
|
||||
|
||||
@@ -1185,14 +1205,13 @@ impl VaultClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a user's credential variables to Vault (full replace).
|
||||
/// Path: `<base_path>/users/<sanitized_email>`
|
||||
pub async fn put_user_credentials(
|
||||
/// Write credential variables by the already-sanitised users key (full
|
||||
/// replace). Companion to [`get_user_credentials_by_key`] for migration.
|
||||
pub async fn put_user_credentials_by_key(
|
||||
&self,
|
||||
email: &str,
|
||||
key: &str,
|
||||
creds: &HashMap<String, String>,
|
||||
) -> Result<(), VaultError> {
|
||||
let key = sanitize_email_key(email);
|
||||
let path = format!("/v1/{}/data/{}/users/{}", self.mount, self.base_path, key);
|
||||
let body = serde_json::json!({ "data": creds });
|
||||
let resp = self
|
||||
@@ -1212,6 +1231,17 @@ impl VaultClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// List the raw key names under `<base_path>/users/` (each a sanitised
|
||||
/// email). Empty when no credentials have been stored yet.
|
||||
pub async fn list_user_keys(&self) -> Result<Vec<String>, VaultError> {
|
||||
let path = format!("/v1/{}/metadata/{}/users/", self.mount, self.base_path);
|
||||
match self.kv_list(&path).await {
|
||||
Ok(keys) => Ok(keys.into_iter().filter(|k| !k.ends_with('/')).collect()),
|
||||
Err(VaultError::NotFound) => Ok(Vec::new()),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a user's credential variables from Vault.
|
||||
#[allow(dead_code)] // Will be used by admin endpoint
|
||||
pub async fn delete_user_credentials(&self, email: &str) -> Result<(), VaultError> {
|
||||
|
||||
Reference in New Issue
Block a user