6 Commits

12 changed files with 483 additions and 46 deletions
+9
View File
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.0.1] - 2025-09-10 ## [0.0.1] - 2025-09-10
## v0.2.0 (2025-09-29)
### Feat
- gopass support
- Added command aliases to make the CLI more universal
- Added dynamic tab completions for the profile, providers, and the secrets in any given secret manager
- Users can now specify a default provider to use with each run config, so they don't need to explicitly specify which to use when wanting to run different applications.
## v0.1.0 (2025-09-17) ## v0.1.0 (2025-09-17)
### Feat ### Feat
Generated
+38 -1
View File
@@ -926,6 +926,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75bf0b32ad2e152de789bb635ea4d3078f6b838ad7974143e99b99f45a04af4a" checksum = "75bf0b32ad2e152de789bb635ea4d3078f6b838ad7974143e99b99f45a04af4a"
dependencies = [ dependencies = [
"clap", "clap",
"clap_lex",
"is_executable",
"shlex",
] ]
[[package]] [[package]]
@@ -1306,6 +1309,12 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]]
name = "env_home"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe"
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@@ -1576,7 +1585,7 @@ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]] [[package]]
name = "gman" name = "gman"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",
@@ -1604,6 +1613,7 @@ dependencies = [
"indoc", "indoc",
"log", "log",
"log4rs", "log4rs",
"once_cell",
"openssl", "openssl",
"predicates", "predicates",
"pretty_assertions", "pretty_assertions",
@@ -1618,6 +1628,7 @@ dependencies = [
"tempfile", "tempfile",
"tokio", "tokio",
"validator", "validator",
"which",
"zeroize", "zeroize",
] ]
@@ -2117,6 +2128,15 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "is_executable"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baabb8b4867b26294d818bf3f651a454b6901431711abb96e296245888d6e8c4"
dependencies = [
"windows-sys 0.60.2",
]
[[package]] [[package]]
name = "is_terminal_polyfill" name = "is_terminal_polyfill"
version = "1.70.1" version = "1.70.1"
@@ -4258,6 +4278,17 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "which"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fabb953106c3c8eea8306e4393700d7657561cb43122571b172bbfb7c7ba1d"
dependencies = [
"env_home",
"rustix",
"winsafe",
]
[[package]] [[package]]
name = "winapi" name = "winapi"
version = "0.3.9" version = "0.3.9"
@@ -4510,6 +4541,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"
[[package]]
name = "winsafe"
version = "0.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904"
[[package]] [[package]]
name = "wit-bindgen" name = "wit-bindgen"
version = "0.46.0" version = "0.46.0"
+4 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "gman" name = "gman"
version = "0.1.0" version = "0.2.0"
edition = "2024" edition = "2024"
authors = ["Alex Clarke <alex.j.tusa@gmail.com>"] authors = ["Alex Clarke <alex.j.tusa@gmail.com>"]
description = "Universal command line secret management and injection tool" description = "Universal command line secret management and injection tool"
@@ -25,7 +25,7 @@ clap = { version = "4.5.47", features = [
"env", "env",
"wrap_help", "wrap_help",
] } ] }
clap_complete = "4.5.57" clap_complete = { version = "4.5.57", features = ["unstable-dynamic"] }
confy = { version = "1.0.0", default-features = false, features = [ confy = { version = "1.0.0", default-features = false, features = [
"yaml_conf", "yaml_conf",
] } ] }
@@ -59,6 +59,8 @@ crc32c = "0.6.8"
azure_identity = "0.27.0" azure_identity = "0.27.0"
azure_security_keyvault_secrets = "0.6.0" azure_security_keyvault_secrets = "0.6.0"
aws-lc-sys = { version = "0.31.0", features = ["bindgen"] } aws-lc-sys = { version = "0.31.0", features = ["bindgen"] }
which = "8.0.0"
once_cell = "1.21.3"
[target.'cfg(all(target_os="linux", target_env="musl"))'.dependencies] [target.'cfg(all(target_os="linux", target_env="musl"))'.dependencies]
openssl = { version = "0.10", features = ["vendored"] } openssl = { version = "0.10", features = ["vendored"] }
+77
View File
@@ -95,6 +95,7 @@ gman aws sts get-caller-identity
- [GCP Secret Manager](#provider-gcp_secret_manager) - [GCP Secret Manager](#provider-gcp_secret_manager)
- [Azure Key Vault](#provider-azure_key_vault) - [Azure Key Vault](#provider-azure_key_vault)
- [Run Configurations](#run-configurations) - [Run Configurations](#run-configurations)
- [Specifying a Default Provider per Run Config](#specifying-a-default-provider-per-run-config)
- [Environment Variable Secret Injection](#environment-variable-secret-injection) - [Environment Variable Secret Injection](#environment-variable-secret-injection)
- [Inject Secrets via Command-Line Flags](#inject-secrets-via-command-line-flags) - [Inject Secrets via Command-Line Flags](#inject-secrets-via-command-line-flags)
- [Inject Secrets into Files](#inject-secrets-into-files) - [Inject Secrets into Files](#inject-secrets-into-files)
@@ -174,6 +175,22 @@ To use a binary from the releases page on Linux/MacOS, do the following:
3. Extract the binary with `tar -C /usr/local/bin -xzf gman-<arch>.tar.gz` (Note: This may require `sudo`) 3. Extract the binary with `tar -C /usr/local/bin -xzf gman-<arch>.tar.gz` (Note: This may require `sudo`)
4. Now you can run `gman`! 4. Now you can run `gman`!
### Enable Tab Completion
`gman` supports shell tab completion for `bash`, `zsh`, and `fish`. To enable it, run the following command for your
shell:
```shell
# Bash
echo 'source <(COMPLETE=bash gman)' >> ~/.bashrc
# Zsh
echo 'source <(COMPLETE=zsh gman)' >> ~/.zshrc
# Fish
echo 'COMPLETE=fish gman | source' >> ~/.config/fish/config.fish
```
Then restart your shell or `source` the appropriate config file.
## Configuration ## Configuration
`gman` reads a YAML configuration file located at an OS-specific path: `gman` reads a YAML configuration file located at an OS-specific path:
@@ -245,6 +262,7 @@ documented and added without breaking existing setups. The following table shows
| [`hashicorp_vault`](https://www.hashicorp.com/en/products/vault) | 🕒 | | | | [`hashicorp_vault`](https://www.hashicorp.com/en/products/vault) | 🕒 | | |
| [`azure_key_vault`](https://azure.microsoft.com/en-us/products/key-vault/) | ✅ | [Azure Key Vault](#provider-azure_key_vault) | | | [`azure_key_vault`](https://azure.microsoft.com/en-us/products/key-vault/) | ✅ | [Azure Key Vault](#provider-azure_key_vault) | |
| [`gcp_secret_manager`](https://cloud.google.com/security/products/secret-manager?hl=en) | ✅ | [GCP Secret Manager](#provider-gcp_secret_manager) | | | [`gcp_secret_manager`](https://cloud.google.com/security/products/secret-manager?hl=en) | ✅ | [GCP Secret Manager](#provider-gcp_secret_manager) | |
| [`gopass`](https://www.gopass.pw/) | ✅ | | |
| [`1password`](https://1password.com/) | 🕒 | | | | [`1password`](https://1password.com/) | 🕒 | | |
| [`bitwarden`](https://bitwarden.com/) | 🕒 | | | | [`bitwarden`](https://bitwarden.com/) | 🕒 | | |
| [`dashlane`](https://www.dashlane.com/) | 🕒 | | Waiting for CLI support for adding secrets | | [`dashlane`](https://www.dashlane.com/) | 🕒 | | Waiting for CLI support for adding secrets |
@@ -388,6 +406,26 @@ Important notes:
- Ensure your identity has the necessary Key Vault permissions (RBAC such as `Key Vault Secrets User`/`Administrator`, - Ensure your identity has the necessary Key Vault permissions (RBAC such as `Key Vault Secrets User`/`Administrator`,
or appropriate access policies) for get/set/list/delete. or appropriate access policies) for get/set/list/delete.
### Provider: `gopass`
The `gopass` provider uses [gopass](https://www.gopass.pw/) as the backing storage location for secrets.
- Optional: `store` (string) to specify a particular gopass store if you have multiple.
Configuration example:
```yaml
default_provider: gopass
providers:
- name: gopass
type: gopass
store: my-store # Optional; if omitted, uses the default configured gopass store
```
Important notes:
- Ensure `gopass` is installed and initialized on your system.
- Secrets are managed using gopass's native commands; `gman` acts as a wrapper to interface with gopass.
- Updates overwrite existing secrets
- If no store is specified, the default gopass store is used and `gman sync` will sync with all configured stores.
## Run Configurations ## Run Configurations
Run configurations (or "profiles") tell `gman` how to inject secrets into a command. Three modes of secret injection are Run configurations (or "profiles") tell `gman` how to inject secrets into a command. Three modes of secret injection are
@@ -404,6 +442,45 @@ will error out and report that it could not find the run config with that name.
You can manually specify which run configuration to use with the `--profile` flag. Again, if no profile is found with You can manually specify which run configuration to use with the `--profile` flag. Again, if no profile is found with
that name, `gman` will error out. that name, `gman` will error out.
### Specifying a Default Provider per Run Config
All run configs also support the `provider` field, which lets you override the default provider for that specific
profile. This is useful if you have multiple providers configured and want to use a different one for a specific command
, but that provider may not be the `default_provider`, and you don't want to have to specify `--provider` on the command
line every time.
For Example:
```yaml
default_provider: local
run_configs:
# `gman aws ...` uses the `aws` provider instead of `local` if no
# `--provider` is given.
- name: aws
# Can be overridden by explicitly specifying a `--provider`
provider: aws
secrets:
- DB_USERNAME
- DB_PASSWORD
# `gman docker ...` uses the default_provider `local` because no
# `provider` is specified.
- name: docker
secrets:
- MY_APP_API_KEY
- MY_APP_DB_PASSWORD
# `gman managarr ...` uses the `local` provider; This is useful
# if you change the default provider to something else.
- name: managarr
provider: local
secrets:
- RADARR_API_KEY
- SONARR_API_KEY
files:
- /home/user/.config/managarr/config.yml
```
**Important Note:** Any run config with a `provider` field can be overridden by specifying `--provider` on the command
line.
### Environment Variable Secret Injection ### Environment Variable Secret Injection
By default, secrets are injected as environment variables. The two required fields are `name` and `secrets`. By default, secrets are injected as environment variables. The two required fields are `name` and `secrets`.
+63 -35
View File
@@ -1,21 +1,22 @@
use crate::command::preview_command; use crate::command::preview_command;
use anyhow::{Context, Result, anyhow}; use anyhow::{Context, Result, anyhow};
use clap_complete::CompletionCandidate;
use futures::future::join_all; use futures::future::join_all;
use gman::config::{Config, RunConfig}; use gman::config::{Config, RunConfig, load_config};
use gman::providers::SecretProvider;
use log::{debug, error}; use log::{debug, error};
use regex::Regex; use regex::Regex;
use std::collections::HashMap; use std::collections::HashMap;
use std::ffi::OsString; use std::ffi::{OsStr, OsString};
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::Command; use std::process::Command;
use tokio::runtime::Handle;
const ARG_FORMAT_PLACEHOLDER_KEY: &str = "{{key}}"; const ARG_FORMAT_PLACEHOLDER_KEY: &str = "{{key}}";
const ARG_FORMAT_PLACEHOLDER_VALUE: &str = "{{value}}"; const ARG_FORMAT_PLACEHOLDER_VALUE: &str = "{{value}}";
pub async fn wrap_and_run_command( pub async fn wrap_and_run_command(
secrets_provider: &mut dyn SecretProvider, provider: Option<String>,
config: &Config, config: &Config,
tokens: Vec<OsString>, tokens: Vec<OsString>,
profile_name: Option<String>, profile_name: Option<String>,
@@ -36,6 +37,9 @@ pub async fn wrap_and_run_command(
.find(|c| c.name.as_deref() == Some(run_config_profile_name)) .find(|c| c.name.as_deref() == Some(run_config_profile_name))
}); });
if let Some(run_cfg) = run_config_opt { if let Some(run_cfg) = run_config_opt {
let mut provider_config =
config.extract_provider_config(provider.or(run_cfg.provider.clone()))?;
let secrets_provider = provider_config.extract_provider();
let secrets_result_futures = run_cfg let secrets_result_futures = run_cfg
.secrets .secrets
.as_ref() .as_ref()
@@ -163,7 +167,7 @@ fn generate_files_secret_injections(
secrets: HashMap<&str, String>, secrets: HashMap<&str, String>,
run_config: &RunConfig, run_config: &RunConfig,
) -> Result<Vec<(PathBuf, String, String)>> { ) -> Result<Vec<(PathBuf, String, String)>> {
let re = Regex::new(r"\{\{(.+)\}\}")?; let re = Regex::new(r"\{\{(.+)}}")?;
let mut results = Vec::new(); let mut results = Vec::new();
for file in run_config for file in run_config
.files .files
@@ -251,6 +255,52 @@ pub fn parse_args(
Ok(args) Ok(args)
} }
pub fn run_config_completer(current: &OsStr) -> Vec<CompletionCandidate> {
let cur = current.to_string_lossy();
match load_config() {
Ok(config) => {
if let Some(run_configs) = config.run_configs {
run_configs
.iter()
.filter(|rc| {
rc.name
.as_ref()
.expect("run config has no name")
.starts_with(&*cur)
})
.map(|rc| {
CompletionCandidate::new(rc.name.as_ref().expect("run config has no name"))
})
.collect()
} else {
vec![]
}
}
Err(_) => vec![],
}
}
pub fn secrets_completer(current: &OsStr) -> Vec<CompletionCandidate> {
let cur = current.to_string_lossy();
match load_config() {
Ok(config) => {
let mut provider_config = match config.extract_provider_config(None) {
Ok(pc) => pc,
Err(_) => return vec![],
};
let secrets_provider = provider_config.extract_provider();
let h = Handle::current();
tokio::task::block_in_place(|| h.block_on(secrets_provider.list_secrets()))
.unwrap_or_default()
.into_iter()
.filter(|s| s.starts_with(&*cur))
.map(CompletionCandidate::new)
.collect()
}
Err(_) => vec![],
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -260,26 +310,6 @@ mod tests {
use std::collections::HashMap; use std::collections::HashMap;
use std::ffi::OsString; use std::ffi::OsString;
struct DummyProvider;
#[async_trait::async_trait]
impl SecretProvider for DummyProvider {
fn name(&self) -> &'static str {
"Dummy"
}
async fn get_secret(&self, key: &str) -> Result<String> {
Ok(format!("{}_VAL", key))
}
async fn set_secret(&self, _key: &str, _value: &str) -> Result<()> {
Ok(())
}
async fn delete_secret(&self, _key: &str) -> Result<()> {
Ok(())
}
async fn sync(&mut self) -> Result<()> {
Ok(())
}
}
#[test] #[test]
fn test_generate_files_secret_injections() { fn test_generate_files_secret_injections() {
let mut secrets = HashMap::new(); let mut secrets = HashMap::new();
@@ -290,6 +320,7 @@ mod tests {
let run_config = RunConfig { let run_config = RunConfig {
name: Some("test".to_string()), name: Some("test".to_string()),
provider: None,
secrets: Some(vec!["testing/SOME-secret".to_string()]), secrets: Some(vec!["testing/SOME-secret".to_string()]),
files: Some(vec![file_path.clone()]), files: Some(vec![file_path.clone()]),
flag: None, flag: None,
@@ -309,6 +340,7 @@ mod tests {
fn test_parse_args_insert_and_append() { fn test_parse_args_insert_and_append() {
let run_config = RunConfig { let run_config = RunConfig {
name: Some("docker".into()), name: Some("docker".into()),
provider: None,
secrets: Some(vec!["api_key".into()]), secrets: Some(vec!["api_key".into()]),
files: None, files: None,
flag: Some("-e".into()), flag: Some("-e".into()),
@@ -347,10 +379,8 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_wrap_and_run_command_no_profile() { async fn test_wrap_and_run_command_no_profile() {
let cfg = Config::default(); let cfg = Config::default();
let mut dummy = DummyProvider;
let prov: &mut dyn SecretProvider = &mut dummy;
let tokens = vec![OsString::from("echo"), OsString::from("hi")]; let tokens = vec![OsString::from("echo"), OsString::from("hi")];
let err = wrap_and_run_command(prov, &cfg, tokens, None, true) let err = wrap_and_run_command(None, &cfg, tokens, None, true)
.await .await
.unwrap_err(); .unwrap_err();
assert!(err.to_string().contains("No run profile found")); assert!(err.to_string().contains("No run profile found"));
@@ -361,6 +391,7 @@ mod tests {
// Create a config with a matching run profile for command "echo" // Create a config with a matching run profile for command "echo"
let run_cfg = RunConfig { let run_cfg = RunConfig {
name: Some("echo".into()), name: Some("echo".into()),
provider: None,
secrets: Some(vec!["api_key".into()]), secrets: Some(vec!["api_key".into()]),
files: None, files: None,
flag: None, flag: None,
@@ -371,14 +402,11 @@ mod tests {
run_configs: Some(vec![run_cfg]), run_configs: Some(vec![run_cfg]),
..Config::default() ..Config::default()
}; };
let mut dummy = DummyProvider;
let prov: &mut dyn SecretProvider = &mut dummy;
// Capture stderr for dry_run preview
let tokens = vec![OsString::from("echo"), OsString::from("hello")]; let tokens = vec![OsString::from("echo"), OsString::from("hello")];
// Best-effort: ensure function does not error under dry_run let err = wrap_and_run_command(None, &cfg, tokens, None, true)
let res = wrap_and_run_command(prov, &cfg, tokens, None, true).await; .await
assert!(res.is_ok()); .expect_err("expected failed secret resolution in dry_run");
// Not asserting output text to keep test platform-agnostic assert!(err.to_string().contains("Failed to fetch"));
} }
} }
+14 -4
View File
@@ -1,8 +1,11 @@
use crate::cli::run_config_completer;
use crate::cli::secrets_completer;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use clap::Subcommand; use clap::Subcommand;
use clap::{ use clap::{
CommandFactory, Parser, ValueEnum, crate_authors, crate_description, crate_name, crate_version, CommandFactory, Parser, ValueEnum, crate_authors, crate_description, crate_name, crate_version,
}; };
use clap_complete::{ArgValueCompleter, CompleteEnv};
use crossterm::execute; use crossterm::execute;
use crossterm::terminal::{LeaveAlternateScreen, disable_raw_mode}; use crossterm::terminal::{LeaveAlternateScreen, disable_raw_mode};
use gman::config::{Config, get_config_file_path, load_config}; use gman::config::{Config, get_config_file_path, load_config};
@@ -48,11 +51,11 @@ struct Cli {
output: Option<OutputFormat>, output: Option<OutputFormat>,
/// Specify the secret provider to use (defaults to 'default_provider' in config (usually 'local')) /// Specify the secret provider to use (defaults to 'default_provider' in config (usually 'local'))
#[arg(long, value_enum, global = true, env = "GMAN_PROVIDER")] #[arg(long, global = true, env = "GMAN_PROVIDER", value_parser = ["local", "aws_secrets_manager", "azure_key_vault", "gcp_secret_manager", "gopass"])]
provider: Option<String>, provider: Option<String>,
/// Specify a run profile to use when wrapping a command /// Specify a run profile to use when wrapping a command
#[arg(long, short)] #[arg(long, short, add = ArgValueCompleter::new(run_config_completer))]
profile: Option<String>, profile: Option<String>,
/// Output the command that will be run instead of executing it /// Output the command that will be run instead of executing it
@@ -74,14 +77,17 @@ struct Cli {
#[derive(Subcommand, Clone, Debug)] #[derive(Subcommand, Clone, Debug)]
enum Commands { enum Commands {
/// Add a secret to the configured secret provider /// Add a secret to the configured secret provider
#[clap(aliases = &["set", "create"])]
Add { Add {
/// Name of the secret to store /// Name of the secret to store
name: String, name: String,
}, },
/// Decrypt a secret and print the plaintext /// Decrypt a secret and print the plaintext
#[clap(alias = "show")]
Get { Get {
/// Name of the secret to retrieve /// Name of the secret to retrieve
#[arg(add = ArgValueCompleter::new(secrets_completer))]
name: String, name: String,
}, },
@@ -89,17 +95,21 @@ enum Commands {
/// If a provider does not support updating secrets, this command will return an error. /// If a provider does not support updating secrets, this command will return an error.
Update { Update {
/// Name of the secret to update /// Name of the secret to update
#[arg(add = ArgValueCompleter::new(secrets_completer))]
name: String, name: String,
}, },
/// Delete a secret from the configured secret provider /// Delete a secret from the configured secret provider
#[clap(aliases = &["remove", "rm"])]
Delete { Delete {
/// Name of the secret to delete /// Name of the secret to delete
#[arg(add = ArgValueCompleter::new(secrets_completer))]
name: String, name: String,
}, },
/// List all secrets stored in the configured secret provider (if supported by the provider) /// List all secrets stored in the configured secret provider (if supported by the provider)
/// If a provider does not support listing secrets, this command will return an error. /// If a provider does not support listing secrets, this command will return an error.
#[clap(alias = "ls")]
List {}, List {},
/// Sync secrets with remote storage (if supported by the provider) /// Sync secrets with remote storage (if supported by the provider)
@@ -129,6 +139,7 @@ async fn main() -> Result<()> {
panic::set_hook(Box::new(|info| { panic::set_hook(Box::new(|info| {
panic_hook(info); panic_hook(info);
})); }));
CompleteEnv::with_factory(Cli::command).complete();
let cli = Cli::parse(); let cli = Cli::parse();
if cli.show_log_path { if cli.show_log_path {
@@ -253,8 +264,7 @@ async fn main() -> Result<()> {
})?; })?;
} }
Commands::External(tokens) => { Commands::External(tokens) => {
wrap_and_run_command(secrets_provider, &config, tokens, cli.profile, cli.dry_run) wrap_and_run_command(cli.provider, &config, tokens, cli.profile, cli.dry_run).await?;
.await?;
} }
Commands::Completions { shell } => { Commands::Completions { shell } => {
let mut cmd = Cli::command(); let mut cmd = Cli::command();
+6
View File
@@ -11,6 +11,7 @@
//! //!
//! let rc = RunConfig{ //! let rc = RunConfig{
//! name: Some("echo".into()), //! name: Some("echo".into()),
//! provider: None,
//! secrets: Some(vec!["api_key".into()]), //! secrets: Some(vec!["api_key".into()]),
//! files: None, //! files: None,
//! flag: None, //! flag: None,
@@ -45,6 +46,7 @@ use validator::{Validate, ValidationError};
pub struct RunConfig { pub struct RunConfig {
#[validate(required)] #[validate(required)]
pub name: Option<String>, pub name: Option<String>,
pub provider: Option<String>,
#[validate(required)] #[validate(required)]
pub secrets: Option<Vec<String>>, pub secrets: Option<Vec<String>>,
pub files: Option<Vec<PathBuf>>, pub files: Option<Vec<PathBuf>>,
@@ -161,6 +163,10 @@ impl ProviderConfig {
debug!("Using Azure Key Vault provider"); debug!("Using Azure Key Vault provider");
provider_def provider_def
} }
SupportedProvider::Gopass { provider_def } => {
debug!("Using Gopass provider");
provider_def
}
} }
} }
} }
+190
View File
@@ -0,0 +1,190 @@
use crate::providers::{ENV_PATH, SecretProvider};
use anyhow::{Context, Result, anyhow};
use serde::{Deserialize, Serialize};
use serde_with::skip_serializing_none;
use std::io::{Read, Write};
use std::process::{Command, Stdio};
use validator::Validate;
#[skip_serializing_none]
/// Gopass-based secret provider
/// See [Gopass](https://gopass.pw/) for more information.
///
/// You must already have gopass installed and configured on your system.
///
/// This provider stores secrets in a gopass store. It requires
/// an optional store name to be specified. If no store name is
/// specified, the default store will be used.
///
/// Example
/// ```no_run
/// use gman::providers::gopass::GopassProvider;
/// use gman::providers::{SecretProvider, SupportedProvider};
/// use gman::config::Config;
///
/// let provider = GopassProvider::default();
/// let _ = provider.set_secret("MY_SECRET", "value");
/// ```
#[derive(Debug, Default, Clone, Validate, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct GopassProvider {
pub store: Option<String>,
}
#[async_trait::async_trait]
impl SecretProvider for GopassProvider {
fn name(&self) -> &'static str {
"GopassProvider"
}
async fn get_secret(&self, key: &str) -> Result<String> {
ensure_gopass_installed()?;
let mut child = Command::new("gopass")
.args(["show", "-yfon", key])
.env("PATH", ENV_PATH.as_ref().expect("No ENV_PATH set"))
.stdin(Stdio::inherit())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()
.context("Failed to spawn gopass command")?;
let mut output = String::new();
child
.stdout
.as_mut()
.expect("Failed to open gopass stdout")
.read_to_string(&mut output)
.context("Failed to read gopass output")?;
let status = child.wait().context("Failed to wait on gopass process")?;
if !status.success() {
return Err(anyhow!("gopass command failed with status: {}", status));
}
Ok(output.trim_end_matches(&['\r', '\n'][..]).to_string())
}
async fn set_secret(&self, key: &str, value: &str) -> Result<()> {
ensure_gopass_installed()?;
let mut child = Command::new("gopass")
.args(["insert", "-f", key])
.env("PATH", ENV_PATH.as_ref().expect("No ENV_PATH set"))
.stdin(Stdio::piped())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.spawn()
.context("Failed to spawn gopass command")?;
{
let stdin = child.stdin.as_mut().expect("Failed to open gopass stdin");
stdin
.write_all(value.as_bytes())
.context("Failed to write to gopass stdin")?;
}
let status = child.wait().context("Failed to wait on gopass process")?;
if !status.success() {
return Err(anyhow!("gopass command failed with status: {}", status));
}
Ok(())
}
async fn update_secret(&self, key: &str, value: &str) -> Result<()> {
ensure_gopass_installed()?;
self.set_secret(key, value).await
}
async fn delete_secret(&self, key: &str) -> Result<()> {
ensure_gopass_installed()?;
let mut child = Command::new("gopass")
.args(["rm", "-f", key])
.env("PATH", ENV_PATH.as_ref().expect("No ENV_PATH set"))
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.spawn()
.context("Failed to spawn gopass command")?;
let status = child.wait().context("Failed to wait on gopass process")?;
if !status.success() {
return Err(anyhow!("gopass command failed with status: {}", status));
}
Ok(())
}
async fn list_secrets(&self) -> Result<Vec<String>> {
ensure_gopass_installed()?;
let mut child = Command::new("gopass")
.args(["ls", "-f"])
.env("PATH", ENV_PATH.as_ref().expect("No ENV_PATH set"))
.stdin(Stdio::inherit())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()
.context("Failed to spawn gopass command")?;
let mut output = String::new();
child
.stdout
.as_mut()
.expect("Failed to open gopass stdout")
.read_to_string(&mut output)
.context("Failed to read gopass output")?;
let status = child.wait().context("Failed to wait on gopass process")?;
if !status.success() {
return Err(anyhow!("gopass command failed with status: {}", status));
}
let secrets: Vec<String> = output
.lines()
.map(|line| line.trim().to_string())
.filter(|line| !line.is_empty())
.collect();
Ok(secrets)
}
async fn sync(&mut self) -> Result<()> {
ensure_gopass_installed()?;
let mut child = Command::new("gopass");
child.arg("sync");
if let Some(store) = &self.store {
child.args(["-s", store]);
}
let status = child
.env("PATH", ENV_PATH.as_ref().expect("No ENV_PATH set"))
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.spawn()
.context("Failed to spawn gopass command")?
.wait()
.context("Failed to wait on gopass process")?;
if !status.success() {
return Err(anyhow!("gopass command failed with status: {}", status));
}
Ok(())
}
}
fn ensure_gopass_installed() -> Result<()> {
if which::which("gopass").is_err() {
Err(anyhow!(
"Gopass is not installed or not found in PATH. Please install Gopass from https://gopass.pw/"
))
} else {
Ok(())
}
}
+16 -3
View File
@@ -6,17 +6,24 @@ pub mod aws_secrets_manager;
pub mod azure_key_vault; pub mod azure_key_vault;
pub mod gcp_secret_manager; pub mod gcp_secret_manager;
mod git_sync; mod git_sync;
pub mod gopass;
pub mod local; pub mod local;
use crate::providers::gopass::GopassProvider;
use crate::providers::local::LocalProvider; use crate::providers::local::LocalProvider;
use anyhow::{Result, anyhow}; use anyhow::{Context, Result, anyhow};
use aws_secrets_manager::AwsSecretsManagerProvider; use aws_secrets_manager::AwsSecretsManagerProvider;
use azure_key_vault::AzureKeyVaultProvider;
use gcp_secret_manager::GcpSecretManagerProvider; use gcp_secret_manager::GcpSecretManagerProvider;
use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fmt;
use std::fmt::{Display, Formatter}; use std::fmt::{Display, Formatter};
use std::{env, fmt};
use validator::{Validate, ValidationErrors}; use validator::{Validate, ValidationErrors};
pub(in crate::providers) static ENV_PATH: Lazy<Result<String>> =
Lazy::new(|| env::var("PATH").context("No PATH environment variable"));
/// A secret storage backend capable of CRUD, with optional /// A secret storage backend capable of CRUD, with optional
/// update, listing, and sync support. /// update, listing, and sync support.
#[async_trait::async_trait] #[async_trait::async_trait]
@@ -63,7 +70,11 @@ pub enum SupportedProvider {
}, },
AzureKeyVault { AzureKeyVault {
#[serde(flatten)] #[serde(flatten)]
provider_def: azure_key_vault::AzureKeyVaultProvider, provider_def: AzureKeyVaultProvider,
},
Gopass {
#[serde(flatten)]
provider_def: GopassProvider,
}, },
} }
@@ -74,6 +85,7 @@ impl Validate for SupportedProvider {
SupportedProvider::AwsSecretsManager { provider_def } => provider_def.validate(), SupportedProvider::AwsSecretsManager { provider_def } => provider_def.validate(),
SupportedProvider::GcpSecretManager { provider_def } => provider_def.validate(), SupportedProvider::GcpSecretManager { provider_def } => provider_def.validate(),
SupportedProvider::AzureKeyVault { provider_def } => provider_def.validate(), SupportedProvider::AzureKeyVault { provider_def } => provider_def.validate(),
SupportedProvider::Gopass { provider_def } => provider_def.validate(),
} }
} }
} }
@@ -93,6 +105,7 @@ impl Display for SupportedProvider {
SupportedProvider::AwsSecretsManager { .. } => write!(f, "aws_secrets_manager"), SupportedProvider::AwsSecretsManager { .. } => write!(f, "aws_secrets_manager"),
SupportedProvider::GcpSecretManager { .. } => write!(f, "gcp_secret_manager"), SupportedProvider::GcpSecretManager { .. } => write!(f, "gcp_secret_manager"),
SupportedProvider::AzureKeyVault { .. } => write!(f, "azure_key_vault"), SupportedProvider::AzureKeyVault { .. } => write!(f, "azure_key_vault"),
SupportedProvider::Gopass { .. } => write!(f, "gopass"),
} }
} }
} }
+11
View File
@@ -9,6 +9,7 @@ mod tests {
fn test_run_config_valid() { fn test_run_config_valid() {
let run_config = RunConfig { let run_config = RunConfig {
name: Some("test".to_string()), name: Some("test".to_string()),
provider: None,
secrets: Some(vec!["secret1".to_string()]), secrets: Some(vec!["secret1".to_string()]),
flag: None, flag: None,
flag_position: None, flag_position: None,
@@ -23,6 +24,7 @@ mod tests {
fn test_run_config_missing_name() { fn test_run_config_missing_name() {
let run_config = RunConfig { let run_config = RunConfig {
name: None, name: None,
provider: None,
secrets: Some(vec!["secret1".to_string()]), secrets: Some(vec!["secret1".to_string()]),
flag: None, flag: None,
flag_position: None, flag_position: None,
@@ -37,6 +39,7 @@ mod tests {
fn test_run_config_missing_secrets() { fn test_run_config_missing_secrets() {
let run_config = RunConfig { let run_config = RunConfig {
name: Some("test".to_string()), name: Some("test".to_string()),
provider: None,
secrets: None, secrets: None,
flag: None, flag: None,
flag_position: None, flag_position: None,
@@ -51,6 +54,7 @@ mod tests {
fn test_run_config_invalid_flag_position() { fn test_run_config_invalid_flag_position() {
let run_config = RunConfig { let run_config = RunConfig {
name: Some("test".to_string()), name: Some("test".to_string()),
provider: None,
secrets: Some(vec!["secret1".to_string()]), secrets: Some(vec!["secret1".to_string()]),
flag: Some("--test-flag".to_string()), flag: Some("--test-flag".to_string()),
flag_position: Some(0), flag_position: Some(0),
@@ -65,6 +69,7 @@ mod tests {
fn test_run_config_flags_or_none_all_some() { fn test_run_config_flags_or_none_all_some() {
let run_config = RunConfig { let run_config = RunConfig {
name: Some("test".to_string()), name: Some("test".to_string()),
provider: None,
secrets: Some(vec!["secret1".to_string()]), secrets: Some(vec!["secret1".to_string()]),
flag: Some("--test-flag".to_string()), flag: Some("--test-flag".to_string()),
flag_position: Some(1), flag_position: Some(1),
@@ -79,6 +84,7 @@ mod tests {
fn test_run_config_flags_or_none_all_none() { fn test_run_config_flags_or_none_all_none() {
let run_config = RunConfig { let run_config = RunConfig {
name: Some("test".to_string()), name: Some("test".to_string()),
provider: None,
secrets: Some(vec!["secret1".to_string()]), secrets: Some(vec!["secret1".to_string()]),
flag: None, flag: None,
flag_position: None, flag_position: None,
@@ -93,6 +99,7 @@ mod tests {
fn test_run_config_flags_or_none_partial_some() { fn test_run_config_flags_or_none_partial_some() {
let run_config = RunConfig { let run_config = RunConfig {
name: Some("test".to_string()), name: Some("test".to_string()),
provider: None,
secrets: Some(vec!["secret1".to_string()]), secrets: Some(vec!["secret1".to_string()]),
flag: Some("--test-flag".to_string()), flag: Some("--test-flag".to_string()),
flag_position: None, flag_position: None,
@@ -107,6 +114,7 @@ mod tests {
fn test_run_config_flags_or_none_missing_placeholder() { fn test_run_config_flags_or_none_missing_placeholder() {
let run_config = RunConfig { let run_config = RunConfig {
name: Some("test".to_string()), name: Some("test".to_string()),
provider: None,
secrets: Some(vec!["secret1".to_string()]), secrets: Some(vec!["secret1".to_string()]),
flag: Some("--test-flag".to_string()), flag: Some("--test-flag".to_string()),
flag_position: Some(1), flag_position: Some(1),
@@ -121,6 +129,7 @@ mod tests {
fn test_run_config_flags_or_files_all_none() { fn test_run_config_flags_or_files_all_none() {
let run_config = RunConfig { let run_config = RunConfig {
name: Some("test".to_string()), name: Some("test".to_string()),
provider: None,
secrets: Some(vec!["secret1".to_string()]), secrets: Some(vec!["secret1".to_string()]),
flag: None, flag: None,
flag_position: None, flag_position: None,
@@ -135,6 +144,7 @@ mod tests {
fn test_run_config_flags_or_files_files_is_some() { fn test_run_config_flags_or_files_files_is_some() {
let run_config = RunConfig { let run_config = RunConfig {
name: Some("test".to_string()), name: Some("test".to_string()),
provider: None,
secrets: Some(vec!["secret1".to_string()]), secrets: Some(vec!["secret1".to_string()]),
flag: None, flag: None,
flag_position: None, flag_position: None,
@@ -149,6 +159,7 @@ mod tests {
fn test_run_config_flags_or_files_all_some() { fn test_run_config_flags_or_files_all_some() {
let run_config = RunConfig { let run_config = RunConfig {
name: Some("test".to_string()), name: Some("test".to_string()),
provider: None,
secrets: Some(vec!["secret1".to_string()]), secrets: Some(vec!["secret1".to_string()]),
flag: Some("--test-flag".to_string()), flag: Some("--test-flag".to_string()),
flag_position: Some(1), flag_position: Some(1),
+53
View File
@@ -0,0 +1,53 @@
use gman::config::{Config, ProviderConfig};
use gman::providers::{SecretProvider, SupportedProvider};
use pretty_assertions::{assert_eq, assert_str_eq};
use validator::Validate;
#[test]
fn test_gopass_supported_provider_display_and_validate_from_yaml() {
// Build a SupportedProvider via YAML to avoid direct type import
let yaml = r#"---
type: gopass
store: personal
"#;
let sp: SupportedProvider = serde_yaml::from_str(yaml).expect("valid supported provider yaml");
// Validate delegates to inner provider (no required fields)
assert!(sp.validate().is_ok());
// Display formatting for the enum variant
assert_eq!(sp.to_string(), "gopass");
}
#[test]
fn test_provider_config_with_gopass_deserialize_and_extract() {
// Minimal ProviderConfig YAML using the gopass variant
let yaml = r#"---
name: gopass
type: gopass
"#;
let pc: ProviderConfig = serde_yaml::from_str(yaml).expect("valid provider config yaml");
// Gopass has no required fields, so validation should pass
assert!(pc.validate().is_ok());
// Extract the provider and inspect its name via the trait
let mut pc_owned = pc.clone();
let provider: &mut dyn SecretProvider = pc_owned.extract_provider();
assert_str_eq!(provider.name(), "GopassProvider");
// Round-trip through Config with default_provider
let cfg_yaml = r#"---
default_provider: gopass
providers:
- name: gopass
type: gopass
store: personal
"#;
let cfg: Config = serde_yaml::from_str(cfg_yaml).expect("valid config yaml");
assert!(cfg.validate().is_ok());
let extracted = cfg
.extract_provider_config(None)
.expect("should find default provider");
assert_eq!(extracted.name.as_deref(), Some("gopass"));
}
+1
View File
@@ -1,5 +1,6 @@
mod aws_secrets_manager_tests; mod aws_secrets_manager_tests;
mod azure_key_vault_tests; mod azure_key_vault_tests;
mod gcp_secret_manager_tests; mod gcp_secret_manager_tests;
mod gopass_tests;
mod local_tests; mod local_tests;
mod provider_tests; mod provider_tests;