Updated the README with a more clear example of what the purpose of gman is, and also support fully isolated, multiple local based configurations with isolated git repositories so users can have separate repos for different environments

This commit is contained in:
2025-09-11 16:21:47 -06:00
parent a8d959dac3
commit 9e8e317daf
10 changed files with 228 additions and 84 deletions
Generated
+1
View File
@@ -730,6 +730,7 @@ dependencies = [
"serde",
"serde_json",
"serde_with",
"serde_yaml",
"tempfile",
"thiserror",
"validator",
+1
View File
@@ -40,6 +40,7 @@ dialoguer = "0.12.0"
chrono = "0.4.42"
indoc = "2.0.6"
regex = "1.11.2"
serde_yaml = "0.9.34"
[dev-dependencies]
pretty_assertions = "1.4.1"
+94 -45
View File
@@ -10,49 +10,78 @@ files or sprinkling environment variables everywhere.
certs—with a provider, then either fetch them directly or run your command through `gman` to inject what it needs as
environment variables, flags, or file content.
## Quick Examples: Before vs After
These examples show how `gman` reduces friction when running tools that need secrets. The run profile snippets referenced
here are shown later in this README under [Run Configurations](#run-configurations).
### AWS CLI (env vars)
**Before:**
```shell
export AWS_ACCESS_KEY_ID=...
export AWS_SECRET_ACCESS_KEY=...
aws sts get-caller-identity
```
**After (with a run profile named `aws`):**
```shell
gman aws sts get-caller-identity
````
### Docker (flags)
**Before:**
```shell
docker run -e API_KEY=... -e DB_PASSWORD=... my/image
```
**After (with a run profile named `docker` that uses `-e` flags):**
```shell
gman docker run my/image
```
- Pro Tip: Run `gman --dry-run docker run my/image` to preview the full command with masked values
### Config file injection
**Before:**
```shell
# Place plaintext secrets directly in configuration files (not recommended)
# Or use a tool like `envsubst` to replace placeholders; e.g.
export RADARR_API_KEY=...
export SONARR_API_KEY=...
envsubst < ~/.config/managarr/config.yml.template > ~/.config/managarr/config.yml
managarr radarr list movies
```
**After (with a run profile named `managarr` that injects files):**
```shell
# `gman` injects secret values into the file(s), runs the command, then restores the original content
gman managarr radarr list movies
```
### Example roundtrip of adding, retrieving, and using a secret
```shell
# Add a secret (value read from stdin)
echo "mySuperSecretValue" | gman add my_api_key
# Retrieve a secret
gman get my_api_key
# Use a secret in a wrapped command (with an 'aws' run profile defined)
gman aws sts get-caller-identity
```
## Features
- Secure encryption for stored secrets
- Pluggable providers (local by default; more can be added)
- Git sync for local vaults to move secrets across machines
- Command wrapping to inject secrets for any program
- Customizable run profiles (env, flags, or files)
- Consistent secret naming: input is snake_case; injected as UPPER_SNAKE_CASE
- Direct retrieval via `gman get ...`
- Dry-run to preview wrapped commands and secret injection
## Example Use Cases
### Create/Get/Delete Secrets Securely As You Need From Any Configured Provider
```shell
# Add a secret to the 'local' provider
echo "someApiKey" | gman add my_api_key
# Retrieve a secret from the 'aws_secrets_manager' provider
gman get -p aws_secrets_manager db_password
# Delete a secret from the 'local' provider
gman delete my_api_key
```
### Automatically Inject Secrets Into Any Command
```shell
# Can inject secrets as environment variables into the 'aws' CLI command
gman aws sts get-caller-identity
# Inject secrets into 'docker run' command via '-e' flags
gman docker run --rm --entrypoint env busybox | grep -i 'token'
# Inject secrets into configuration files automatically for the 'managarr' application
gman managarr
```
- **Secure encryption** for stored secrets
- **Pluggable providers** (local by default; more planned)
- **Git sync for local vaults** to move secrets across machines
- **Command wrapping** to inject secrets for any program
- **Customizable run profiles** (env, flags, or files)
- **Consistent secret naming**: input is snake_case; injected as UPPER_SNAKE_CASE
- **Direct secret retrieval** via `gman get ...`
- **Dry-run** to preview wrapped commands and secret injection
## Installation
### Cargo
If you have Cargo installed, then you can install gman from Crates.io:
If you have Cargo installed, then you can install `gman` from Crates.io:
```shell
cargo install gman
@@ -139,7 +168,7 @@ Similar to [Ansible Vault](https://docs.ansible.com/ansible/latest/vault_guide/v
`password_file` configuration option. If you choose to use a password file, ensure that it is secured with appropriate
file permissions (e.g., `chmod 600 ~/.gman_password`). The default file for the password file is `~/.gman_password`.
For use across multiple systems, `gman` can sync with a remote Git repository.
For use across multiple systems, `gman` can sync with a remote Git repository (requires `git` to be installed).
**Important Notes for Git Sync:**
- You **must** create the remote repository on your Git provider (e.g., GitHub) *before* attempting to sync.
@@ -161,6 +190,25 @@ providers:
git_user_email: "your.email@example.com"
```
Repository layout and file tracking
- By default (no sync), secrets are stored in a single file: `~/.config/gman/vault.yml`.
- After configuring a remote and running `gman sync` for the first time:
- A dedicated repository directory is created under the config dir, derived from the remote name, e.g. `~/.config/gman/.vault` or `~/.config/gman/.test-vault`.
- The existing `vault.yml` is moved into that directory as `~/.config/gman/.<repo-name>/vault.yml`.
- Only `vault.yml` is tracked and committed in that repository; other files in the config directory are ignored.
- With multiple `local` providers each pointing at different remotes, each gets its own `.repo-name` directory, so you can switch between isolated sets of secrets.
Security and encryption basics
- Client-side encryption: Secrets are encrypted before being written to disk. The local provider uses Argon2id for key
derivation and XChaCha20-Poly1305 (AEAD) for encryption/authentication.
- Strong defaults: A unique random salt and nonce are generated with the OS RNG for every encryption; Argon2id parameters
are tuned for interactive usage and can evolve in future versions.
- Tamper detection: The AEAD ensures decryption fails if the password is wrong or the ciphertext is modified.
- Envelope format: The stored value encodes header, version, KDF params, and base64-encoded salt, nonce, and ciphertext
to enable robust, portable decryption.
- Memory hygiene: Sensitive buffers are wiped after use (zeroized), and secrets are handled with types (like SecretString)
that reduce accidental exposure through logs and debug prints. No plaintext secrets are logged.
## Run Configurations
Run configurations (or "profiles") tell `gman` how to inject secrets into a command. Three modes of secret injection are
@@ -341,15 +389,16 @@ Example: two AWS Secrets Manager providers named `lab` and `prod`.
default_provider: prod
providers:
- name: lab
provider: aws_secrets_manager
# Additional provider-specific settings (e.g., region, role_arn, profile)
# region: us-east-1
# role_arn: arn:aws:iam::111111111111:role/lab-access
provider: local
password_file: /home/user/.lab_gman_password
git_branch: main
git_remote_url: git@github.com:username/lab-vault.git
- name: prod
provider: aws_secrets_manager
# region: us-east-1
# role_arn: arn:aws:iam::222222222222:role/prod-access
provider: local
password_file: /home/user/.prod_gman_password
git_branch: main
git_remote_url: git@github.com:username/prod-vault.git
run_configs:
- name: aws
+4 -4
View File
@@ -17,7 +17,7 @@ const ARG_FORMAT_PLACEHOLDER_VALUE: &str = "{{value}}";
pub fn wrap_and_run_command(
secrets_provider: Box<dyn SecretProvider>,
config: &Config,
provider_config: &ProviderConfig,
provider_config: &ProviderConfig,
tokens: Vec<OsString>,
profile_name: Option<String>,
dry_run: bool,
@@ -270,7 +270,7 @@ mod tests {
fn set_secret(&self, _config: &ProviderConfig, _key: &str, _value: &str) -> Result<()> {
Ok(())
}
fn delete_secret(&self, _key: &str) -> Result<()> {
fn delete_secret(&self, _config: &ProviderConfig, _key: &str) -> Result<()> {
Ok(())
}
fn sync(&self, _config: &mut ProviderConfig) -> Result<()> {
@@ -345,7 +345,7 @@ mod tests {
#[test]
fn test_wrap_and_run_command_no_profile() {
let cfg = Config::default();
let provider_cfg = ProviderConfig::default();
let provider_cfg = ProviderConfig::default();
let prov: Box<dyn SecretProvider> = Box::new(DummyProvider);
let tokens = vec![OsString::from("echo"), OsString::from("hi")];
let err = wrap_and_run_command(prov, &cfg, &provider_cfg, tokens, None, true).unwrap_err();
@@ -367,7 +367,7 @@ mod tests {
run_configs: Some(vec![run_cfg]),
..Config::default()
};
let provider_cfg = ProviderConfig::default();
let provider_cfg = ProviderConfig::default();
let prov: Box<dyn SecretProvider> = Box::new(DummyProvider);
// Capture stderr for dry_run preview
+11 -9
View File
@@ -41,11 +41,11 @@ enum OutputFormat {
)]
struct Cli {
/// Specify the output format
#[arg(short, long, value_enum)]
#[arg(short, long, global = true, value_enum, env = "GMAN_OUTPUT")]
output: Option<OutputFormat>,
/// Specify the secret provider to use (defaults to 'default_provider' in config (usually 'local'))
#[arg(long, value_enum)]
#[arg(long, value_enum, global = true, env = "GMAN_PROVIDER")]
provider: Option<String>,
/// Specify a run profile to use when wrapping a command
@@ -53,7 +53,7 @@ struct Cli {
profile: Option<String>,
/// Output the command that will be run instead of executing it
#[arg(long)]
#[arg(long, global = true)]
dry_run: bool,
#[command(subcommand)]
@@ -162,14 +162,16 @@ fn main() -> Result<()> {
}
Commands::Delete { name } => {
let snake_case_name = name.to_snake_case().to_uppercase();
secrets_provider.delete_secret(&snake_case_name).map(|_| {
if cli.output.is_none() {
println!("✓ Secret '{snake_case_name}' deleted from the vault.")
}
})?;
secrets_provider
.delete_secret(&provider_config, &snake_case_name)
.map(|_| {
if cli.output.is_none() {
println!("✓ Secret '{snake_case_name}' deleted from the vault.")
}
})?;
}
Commands::List {} => {
let secrets = secrets_provider.list_secrets()?;
let secrets = secrets_provider.list_secrets(&provider_config)?;
if secrets.is_empty() {
match cli.output {
Some(OutputFormat::Json) => {
+2 -2
View File
@@ -218,8 +218,8 @@ impl Config {
///
/// ```no_run
/// # use gman::config::Config;
/// let provider_config = Config::default().extract_provider_config(None);
/// println!("using provider config: {}", provider_config.unwrap().name);
/// let provider_config = Config::default().extract_provider_config(None).unwrap();
/// println!("using provider config: {:?}", provider_config.name);
/// ```
pub fn extract_provider_config(&self, provider_name: Option<String>) -> Result<ProviderConfig> {
let name = provider_name
+41 -8
View File
@@ -4,7 +4,7 @@ use dialoguer::Confirm;
use dialoguer::theme::ColorfulTheme;
use indoc::formatdoc;
use log::debug;
use std::env;
use std::{env, fs};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use validator::Validate;
@@ -25,12 +25,28 @@ pub fn sync_and_push(opts: &SyncOpts<'_>) -> Result<()> {
opts.validate()
.with_context(|| "invalid git sync options")?;
let commit_message = format!("chore: sync @ {}", Utc::now().to_rfc3339());
let repo_dir = confy::get_configuration_file_path("gman", "vault")
let config_dir = confy::get_configuration_file_path("gman", "vault")
.with_context(|| "get config dir")?
.parent()
.map(Path::to_path_buf)
.ok_or_else(|| anyhow!("Failed to determine repo dir"))?;
std::fs::create_dir_all(&repo_dir).with_context(|| format!("create {}", repo_dir.display()))?;
.ok_or_else(|| anyhow!("Failed to determine config dir"))?;
let remote_url = opts.remote_url.as_ref().expect("no remote url defined");
let repo_name = repo_name_from_url(remote_url);
let repo_dir = config_dir.join(format!(".{}", repo_name));
fs::create_dir_all(&repo_dir).with_context(|| format!("create {}", repo_dir.display()))?;
// Move the default vault into the repo dir on first sync so only vault.yml is tracked.
let default_vault = confy::get_configuration_file_path("gman", "vault")
.with_context(|| "get default vault path")?;
let repo_vault = repo_dir.join("vault.yml");
if default_vault.exists() && !repo_vault.exists() {
fs::rename(&default_vault, &repo_vault)
.with_context(|| format!("move {} -> {}", default_vault.display(), repo_vault.display()))?;
} else if !repo_vault.exists() {
// Ensure an empty vault exists to allow initial commits
fs::write(&repo_vault, "{}\n").with_context(|| format!("create {}", repo_vault.display()))?;
}
let git = resolve_git(opts.git_executable.as_ref())?;
ensure_git_available(&git)?;
@@ -42,7 +58,6 @@ pub fn sync_and_push(opts: &SyncOpts<'_>) -> Result<()> {
.trim()
.to_string();
let branch = opts.branch.as_ref().expect("no target branch defined");
let remote_url = opts.remote_url.as_ref().expect("no remote url defined");
debug!(
"{}",
@@ -74,7 +89,7 @@ pub fn sync_and_push(opts: &SyncOpts<'_>) -> Result<()> {
// Stage and commit any subsequent local changes after aligning with remote
// so we don't merge uncommitted local state.
stage_all(&git, &repo_dir)?;
stage_vault_only(&git, &repo_dir)?;
commit_now(&git, &repo_dir, &commit_message)?;
@@ -228,8 +243,8 @@ fn set_origin(git: &Path, repo: &Path, url: &str) -> Result<()> {
Ok(())
}
fn stage_all(git: &Path, repo: &Path) -> Result<()> {
run_git(git, repo, &["add", "-A"])?;
fn stage_vault_only(git: &Path, repo: &Path) -> Result<()> {
run_git(git, repo, &["add", "vault.yml"])?;
Ok(())
}
@@ -321,6 +336,16 @@ fn commit_now(git: &Path, repo: &Path, msg: &str) -> Result<()> {
Ok(())
}
pub fn repo_name_from_url(url: &str) -> String {
let mut s = url;
if let Some(idx) = s.rfind('/') {
s = &s[idx + 1..];
} else if let Some(idx) = s.rfind(':') {
s = &s[idx + 1..];
}
s.trim_end_matches(".git").to_string()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -370,4 +395,12 @@ mod tests {
env::remove_var("GIT_EXECUTABLE");
}
}
#[test]
fn test_repo_name_from_url() {
assert_eq!(repo_name_from_url("git@github.com:user/vault.git"), "vault");
assert_eq!(repo_name_from_url("https://github.com/user/test-vault.git"), "test-vault");
assert_eq!(repo_name_from_url("ssh://git@example.com/x/y/z.git"), "z");
assert_eq!(repo_name_from_url("git@example.com:ns/repo"), "repo");
}
}
+66 -12
View File
@@ -2,10 +2,11 @@ use anyhow::{anyhow, bail, Context};
use secrecy::{ExposeSecret, SecretString};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use zeroize::Zeroize;
use crate::config::ProviderConfig;
use crate::providers::git_sync::{sync_and_push, SyncOpts};
use crate::providers::git_sync::{repo_name_from_url, sync_and_push, SyncOpts};
use crate::providers::SecretProvider;
use crate::{
ARGON_M_COST_KIB, ARGON_P, ARGON_T_COST, HEADER, KDF, KEY_LEN, NONCE_LEN, SALT_LEN, VERSION,
@@ -69,7 +70,8 @@ impl SecretProvider for LocalProvider {
}
fn get_secret(&self, config: &ProviderConfig, key: &str) -> Result<String> {
let vault: HashMap<String, String> = confy::load("gman", "vault").unwrap_or_default();
let vault_path = active_vault_path(config)?;
let vault: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default();
let envelope = vault
.get(key)
.with_context(|| format!("key '{key}' not found in the vault"))?;
@@ -82,7 +84,8 @@ impl SecretProvider for LocalProvider {
}
fn set_secret(&self, config: &ProviderConfig, key: &str, value: &str) -> Result<()> {
let mut vault: HashMap<String, String> = confy::load("gman", "vault").unwrap_or_default();
let vault_path = active_vault_path(config)?;
let mut vault: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default();
if vault.contains_key(key) {
error!(
"Key '{key}' already exists in the vault. Use a different key or delete the existing one first."
@@ -96,11 +99,12 @@ impl SecretProvider for LocalProvider {
vault.insert(key.to_string(), envelope);
confy::store("gman", "vault", vault).with_context(|| "failed to save secret to the vault")
store_vault(&vault_path, &vault).with_context(|| "failed to save secret to the vault")
}
fn update_secret(&self, config: &ProviderConfig, key: &str, value: &str) -> Result<()> {
let mut vault: HashMap<String, String> = confy::load("gman", "vault").unwrap_or_default();
let vault_path = active_vault_path(config)?;
let mut vault: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default();
let password = get_password(config)?;
let envelope = encrypt_string(&password, value)?;
@@ -113,27 +117,29 @@ impl SecretProvider for LocalProvider {
.with_context(|| format!("key '{key}' not found in the vault"))?;
*vault_entry = envelope;
return confy::store("gman", "vault", vault)
return store_vault(&vault_path, &vault)
.with_context(|| "failed to save secret to the vault");
}
vault.insert(key.to_string(), envelope);
confy::store("gman", "vault", vault).with_context(|| "failed to save secret to the vault")
store_vault(&vault_path, &vault).with_context(|| "failed to save secret to the vault")
}
fn delete_secret(&self, key: &str) -> Result<()> {
let mut vault: HashMap<String, String> = confy::load("gman", "vault").unwrap_or_default();
fn delete_secret(&self, config: &ProviderConfig, key: &str) -> Result<()> {
let vault_path = active_vault_path(config)?;
let mut vault: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default();
if !vault.contains_key(key) {
error!("Key '{key}' does not exist in the vault.");
bail!("key '{key}' does not exist");
}
vault.remove(key);
confy::store("gman", "vault", vault).with_context(|| "failed to save secret to the vault")
store_vault(&vault_path, &vault).with_context(|| "failed to save secret to the vault")
}
fn list_secrets(&self) -> Result<Vec<String>> {
let vault: HashMap<String, String> = confy::load("gman", "vault").unwrap_or_default();
fn list_secrets(&self, config: &ProviderConfig) -> Result<Vec<String>> {
let vault_path = active_vault_path(config)?;
let vault: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default();
let keys: Vec<String> = vault.keys().cloned().collect();
Ok(keys)
@@ -190,6 +196,54 @@ impl SecretProvider for LocalProvider {
}
}
fn default_vault_path() -> Result<PathBuf> {
confy::get_configuration_file_path("gman", "vault").with_context(|| "get config dir")
}
fn base_config_dir() -> Result<PathBuf> {
default_vault_path()?
.parent()
.map(Path::to_path_buf)
.ok_or_else(|| anyhow!("Failed to determine config dir"))
}
fn repo_dir_for_config(config: &ProviderConfig) -> Result<Option<PathBuf>> {
if let Some(remote) = &config.git_remote_url {
let name = repo_name_from_url(remote);
let dir = base_config_dir()?.join(format!(".{}", name));
Ok(Some(dir))
} else {
Ok(None)
}
}
fn active_vault_path(config: &ProviderConfig) -> Result<PathBuf> {
if let Some(dir) = repo_dir_for_config(config)?
&& dir.exists()
{
return Ok(dir.join("vault.yml"));
}
default_vault_path()
}
fn load_vault(path: &Path) -> Result<HashMap<String, String>> {
if !path.exists() {
return Ok(HashMap::new());
}
let s = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
let map: HashMap<String, String> = serde_yaml::from_str(&s).unwrap_or_default();
Ok(map)
}
fn store_vault(path: &Path, map: &HashMap<String, String>) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
let s = serde_yaml::to_string(map).with_context(|| "serialize vault")?;
fs::write(path, s).with_context(|| format!("write {}", path.display()))
}
fn encrypt_string(password: &SecretString, plaintext: &str) -> Result<String> {
let mut salt = [0u8; SALT_LEN];
OsRng.fill_bytes(&mut salt);
+2 -2
View File
@@ -34,8 +34,8 @@ pub trait SecretProvider {
self.name()
))
}
fn delete_secret(&self, key: &str) -> Result<()>;
fn list_secrets(&self) -> Result<Vec<String>> {
fn delete_secret(&self, config: &ProviderConfig, key: &str) -> Result<()>;
fn list_secrets(&self, _config: &ProviderConfig) -> Result<Vec<String>> {
Err(anyhow!(
"list secrets is not supported for the provider {}",
self.name()
+6 -2
View File
@@ -1,11 +1,15 @@
use base64::Engine;
use gman::{decrypt_string, encrypt_string};
use proptest::prelude::*;
proptest! {
#![proptest_config(ProptestConfig::with_cases(64))]
}
use secrecy::SecretString;
proptest! {
#[test]
fn prop_encrypt_decrypt_roundtrip(password in ".{0,64}", msg in ".{0,2048}") {
fn prop_encrypt_decrypt_roundtrip(password in ".{0,64}", msg in ".{0,512}") {
let pw = SecretString::new(password.into());
let env = encrypt_string(pw.clone(), &msg).unwrap();
let out = decrypt_string(pw, &env).unwrap();
@@ -14,7 +18,7 @@ proptest! {
}
#[test]
fn prop_tamper_ciphertext_detected(password in ".{0,32}", msg in ".{1,256}") {
fn prop_tamper_ciphertext_detected(password in ".{0,32}", msg in ".{1,128}") {
let pw = SecretString::new(password.into());
let env = encrypt_string(pw.clone(), &msg).unwrap();
// Flip a bit in the ct payload segment