add lock subcommand; add --local flag to rm and exec
All checks were successful
CI / build (push) Successful in 23s

This commit is contained in:
Jonathan Apodaca 2025-05-12 09:55:35 -06:00
parent dac1a06e1a
commit 1922138133
4 changed files with 444 additions and 42 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
/build
/envvault
.aider*
.envvault.json.enc

View File

@ -6,16 +6,18 @@ import (
"encoding/json"
"fmt"
"log"
"maps"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"time"
"github.com/alecthomas/kong"
"golang.org/x/crypto/nacl/secretbox"
"golang.org/x/crypto/scrypt"
"golang.org/x/term"
"slices"
"git.jrop.me/jonathan/envvault/internal"
"git.jrop.me/jonathan/envvault/internal/config"
@ -24,9 +26,14 @@ import (
var cachedPassword []byte
const (
EnvStoreCurrentVersion = 1
)
var (
keyFilePath string
dbFilePath string
keyFilePath string
dbFilePath string
localDbFilePath string
)
func init() {
@ -36,10 +43,79 @@ func init() {
}
keyFilePath = filepath.Join(homeDir, ".local/cache/envvault/key")
dbFilePath = filepath.Join(homeDir, ".local/cache/envvault/db.json.enc")
localDbFilePath = ".envvault.json.enc"
}
type EnvStore struct {
Vars map[string]string `json:"vars"`
Vars map[string]string `json:"vars"`
Tags map[string][]string `json:"tags,omitempty"`
}
// GetTags returns all tags for a given variable name
func (s *EnvStore) GetTags(varName string) []string {
if s.Tags == nil {
return []string{}
}
return s.Tags[varName]
}
// SetTags sets the tags for a given variable name
func (s *EnvStore) SetTags(varName string, tags []string) {
if s.Tags == nil {
s.Tags = make(map[string][]string)
}
// Create a union of existing and new tags
existingTags := s.Tags[varName]
unionTags := make([]string, 0, len(existingTags)+len(tags))
// Add existing tags to the union
for _, tag := range existingTags {
unionTags = append(unionTags, tag)
}
// Add new tags if they don't already exist
for _, tag := range tags {
if !slices.Contains(unionTags, tag) {
unionTags = append(unionTags, tag)
}
}
s.Tags[varName] = unionTags
}
// RemoveTags removes the specified tags from a variable
func (s *EnvStore) RemoveTags(varName string, tagsToRemove []string) {
if s.Tags == nil || len(s.Tags[varName]) == 0 {
return
}
existingTags := s.Tags[varName]
remainingTags := make([]string, 0, len(existingTags))
// Keep only tags that are not in tagsToRemove
for _, tag := range existingTags {
if !slices.Contains(tagsToRemove, tag) {
remainingTags = append(remainingTags, tag)
}
}
s.Tags[varName] = remainingTags
}
// HasAnyTag returns true if the variable has any of the specified tags
func (s *EnvStore) HasAnyTag(varName string, tags []string) bool {
if len(tags) == 0 {
return true // No tags specified, include all variables
}
varTags := s.GetTags(varName)
for _, tag := range tags {
if slices.Contains(varTags, tag) {
return true
}
}
return false
}
type CLI struct {
@ -47,31 +123,47 @@ type CLI struct {
List ListCmd `cmd:"" help:"List all environment variables."`
Add AddCmd `cmd:"" help:"Add an environment variable."`
Rm RmCmd `cmd:"" help:"Remove an environment variable."`
Exec ExecCmd `cmd:"" help:"Execute a command with environment variables." alias:"x"`
Tag TagCmd `cmd:"" help:"Add or remove tags from an environment variable."`
Exec ExecCmd `cmd:"" help:"Execute a command with environment variables." alias:"x"`
Rekey RekeyCmd `cmd:"" help:"Change the master password."`
Daemon DaemonCmd `cmd:"" help:"Start the password caching daemon."`
Lock LockCmd `cmd:"" help:"Lock the vault by stopping the password caching daemon."`
}
type InitCmd struct{}
type ListCmd struct {
Values bool `short:"v" help:"Show values of environment variables." default:"false"`
Values bool `short:"v" help:"Show values of environment variables." default:"false"`
Local bool `short:"l" help:"List only from local vault." default:"false"`
Tags []string `short:"t" help:"Filter by tags (can be specified multiple times)."`
}
type AddCmd struct {
Name string `arg:"" help:"Name of the environment variable."`
Value string `arg:"" optional:"" help:"Value of the environment variable."`
Value string `arg:"" help:"Value of the environment variable." optional:""`
Local bool ` help:"Add to local vault instead of global." short:"l" default:"false"`
}
type RmCmd struct {
Name string `arg:"" help:"Name of the environment variable to remove."`
Name string `arg:"" help:"Name of the environment variable to remove."`
Local bool ` help:"Remove from local vault only." short:"l" default:"false"`
}
type TagCmd struct {
Name string `arg:"" help:"Name of the environment variable."`
Tags []string `arg:"" help:"Tags to add or remove." optional:""`
Remove bool ` help:"Remove tags instead of adding them." short:"r" default:"false"`
Local bool ` help:"Operate on local vault only." short:"l" default:"false"`
List bool ` help:"List tags for the variable." short:"L" default:"false"`
}
type ExecCmd struct {
Env []string `short:"e" help:"Environment variables to set."`
Cmd string `arg:"" help:"Command to execute." passthrough:"all"`
Args []string `arg:"" optional:"" help:"Arguments for the command."`
Env []string `short:"e" help:"Environment variables to set."`
Local bool `short:"l" help:"Use only local vault." default:"false"`
Tags []string `short:"t" help:"Filter by tags (can be specified multiple times)."`
Cmd string ` help:"Command to execute." arg:"" passthrough:"all"`
Args []string ` help:"Arguments for the command." arg:"" optional:""`
}
type RekeyCmd struct{}
type DaemonCmd struct {
Timeout int `help:"Password cache timeout in minutes." default:"5"`
}
type LockCmd struct{}
func main() {
internal.Log.Println("Starting envvault")
@ -86,11 +178,11 @@ func main() {
case "init":
subcommandInitVault()
case "add <name>":
subcommandAddEnvVar(cli.Add.Name, cli.Add.Value)
subcommandAddEnvVar(cli.Add)
case "add <name> <value>":
subcommandAddEnvVar(cli.Add.Name, cli.Add.Value)
subcommandAddEnvVar(cli.Add)
case "rm <name>":
subcommandRmEnvVar(cli.Rm.Name)
subcommandRmEnvVar(cli.Rm.Name, cli.Rm.Local)
case "list":
subcommandListEnvVars(cli.List)
case "exec <cmd>":
@ -101,6 +193,12 @@ func main() {
subcommandRekeyVault()
case "daemon":
subcommandStartDaemon(cli.Daemon.Timeout)
case "lock":
subcommandLockVault()
case "tag <name>":
subcommandTagEnvVar(cli.Tag)
case "tag <name> <tags>":
subcommandTagEnvVar(cli.Tag)
default:
internal.Log.Printf("Unknown command: %s", command)
log.Fatal("Unknown command")
@ -165,7 +263,7 @@ func subcommandInitVault() { // {{{
// Initialize the db file
internal.Log.Println("Initializing empty environment store")
emptyStore := &EnvStore{Vars: make(map[string]string)}
saveEnvStore(emptyStore)
saveGlobalEnvStore(emptyStore)
internal.Log.Println("Vault initialized successfully")
fmt.Fprintln(os.Stderr, "Vault initialized successfully.")
@ -177,47 +275,205 @@ func subcommandInitVault() { // {{{
// }}}
func subcommandAddEnvVar(name, value string) { // {{{
store := loadEnvStore()
if value == "" {
fmt.Fprintf(os.Stderr, "Enter value for %s: ", name)
func subcommandAddEnvVar(cmd AddCmd) { // {{{
var store *EnvStore
if cmd.Local {
store = loadLocalEnvStore()
internal.Log.Printf("Adding environment variable '%s' to local vault", cmd.Name)
} else {
store = loadGlobalEnvStore()
internal.Log.Printf("Adding environment variable '%s' to global vault", cmd.Name)
}
if cmd.Value == "" {
fmt.Fprintf(os.Stderr, "Enter value for %s: ", cmd.Name)
inputValue, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr)
if err != nil {
log.Fatal(err)
}
value = string(inputValue)
cmd.Value = string(inputValue)
}
store.Vars[cmd.Name] = cmd.Value
if cmd.Local {
saveLocalEnvStore(store)
fmt.Fprintf(os.Stderr, "Environment variable '%s' added to local vault.\n", cmd.Name)
} else {
saveGlobalEnvStore(store)
fmt.Fprintf(os.Stderr, "Environment variable '%s' added to global vault.\n", cmd.Name)
}
store.Vars[name] = value
saveEnvStore(store)
} // }}}
func subcommandRmEnvVar(name string) { // {{{
store := loadEnvStore()
if _, exists := store.Vars[name]; exists {
delete(store.Vars, name)
saveEnvStore(store)
fmt.Fprintf(os.Stderr, "Environment variable '%s' removed.\n", name)
func subcommandRmEnvVar(name string, localOnly bool) { // {{{
localExists := false
// Check local store if it exists
if _, err := os.Stat(localDbFilePath); err == nil {
localStore := loadLocalEnvStore()
if _, exists := localStore.Vars[name]; exists {
delete(localStore.Vars, name)
saveLocalEnvStore(localStore)
localExists = true
fmt.Fprintf(os.Stderr, "Environment variable '%s' removed from local vault.\n", name)
} else if localOnly {
fmt.Fprintf(os.Stderr, "Environment variable '%s' not found in local vault.\n", name)
}
} else if localOnly {
fmt.Fprintf(os.Stderr, "Local vault does not exist.\n")
}
// Check global store if not local-only
if !localOnly {
globalStore := loadGlobalEnvStore()
if _, exists := globalStore.Vars[name]; exists {
delete(globalStore.Vars, name)
saveGlobalEnvStore(globalStore)
fmt.Fprintf(os.Stderr, "Environment variable '%s' removed from global vault.\n", name)
} else if !localExists {
fmt.Fprintf(os.Stderr, "Environment variable '%s' not found in global vault.\n", name)
}
}
} // }}}
func subcommandTagEnvVar(cmd TagCmd) { // {{{
var store *EnvStore
// Determine which store to use
if cmd.Local {
if _, err := os.Stat(localDbFilePath); os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "Local vault does not exist.\n")
os.Exit(1)
}
store = loadLocalEnvStore()
internal.Log.Printf("Using local vault")
} else {
fmt.Fprintf(os.Stderr, "Environment variable '%s' not found.\n", name)
store = loadGlobalEnvStore()
internal.Log.Printf("Using global vault")
}
// Check if the variable exists
if _, exists := store.Vars[cmd.Name]; !exists {
vaultType := "global"
if cmd.Local {
vaultType = "local"
}
fmt.Fprintf(os.Stderr, "Environment variable '%s' not found in %s vault.\n",
cmd.Name,
vaultType)
os.Exit(1)
}
// If list flag is set, just list the tags and return
if cmd.List {
tags := store.GetTags(cmd.Name)
if len(tags) == 0 {
fmt.Fprintf(os.Stderr, "No tags for '%s'\n", cmd.Name)
return
}
for _, tag := range tags {
fmt.Println(tag)
}
return
}
// If no tags provided and not in list mode, show error
if len(cmd.Tags) == 0 {
fmt.Fprintf(os.Stderr, "No tags provided. Use --list to view tags.\n")
os.Exit(1)
}
// Process tags
if cmd.Remove {
internal.Log.Printf("Removing tags %v from variable '%s'", cmd.Tags, cmd.Name)
store.RemoveTags(cmd.Name, cmd.Tags)
fmt.Fprintf(
os.Stderr,
"Removed tags from '%s': %s\n",
cmd.Name,
strings.Join(cmd.Tags, ", "),
)
} else {
internal.Log.Printf("Adding tags %v to variable '%s'", cmd.Tags, cmd.Name)
store.SetTags(cmd.Name, cmd.Tags)
fmt.Fprintf(os.Stderr, "Added tags to '%s': %s\n", cmd.Name, strings.Join(cmd.Tags, ", "))
}
// Save the store
if cmd.Local {
saveLocalEnvStore(store)
} else {
saveGlobalEnvStore(store)
}
} // }}}
func subcommandListEnvVars(cmdArgs ListCmd) { // {{{
store := loadEnvStore()
for k, v := range store.Vars {
var store *EnvStore
// Determine which store to use
if cmdArgs.Local {
if _, err := os.Stat(localDbFilePath); os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "Local vault does not exist.\n")
return
}
store = loadLocalEnvStore()
} else {
// Default: load and merge stores
store = loadEnvStore()
}
// Sort keys for consistent output
var keys []string
for k := range store.Vars {
// Filter by tags if specified
if store.HasAnyTag(k, cmdArgs.Tags) {
keys = append(keys, k)
}
}
slices.Sort(keys)
for _, k := range keys {
v := store.Vars[k]
tags := store.GetTags(k)
tagStr := ""
if len(tags) > 0 {
tagStr = fmt.Sprintf(" (tags: %s)", strings.Join(tags, ", "))
}
if cmdArgs.Values {
fmt.Printf("%s=%s\n", k, v)
fmt.Printf("%s=%s", k, v)
fmt.Fprintf(os.Stderr, "%s", tagStr)
fmt.Println()
} else {
fmt.Printf("%s\n", k)
fmt.Printf("%s", k)
fmt.Fprintf(os.Stderr, "%s", tagStr)
fmt.Println()
}
}
} // }}}
func subcommandExecCommand(cmdArgs ExecCmd) { // {{{
store := loadEnvStore()
var store *EnvStore
// Determine which store to use
if cmdArgs.Local {
if _, err := os.Stat(localDbFilePath); os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "Local vault does not exist.\n")
os.Exit(1)
}
store = loadLocalEnvStore()
internal.Log.Println("Using local vault only for execution")
} else {
// Default: load and merge stores
store = loadEnvStore()
internal.Log.Println("Using merged vaults for execution")
}
envVars := os.Environ()
for candidateEnvName, candidateEnvValue := range store.Vars {
// Skip if not matching tag filter
if !store.HasAnyTag(candidateEnvName, cmdArgs.Tags) {
continue
}
if len(cmdArgs.Env) == 0 {
// If no env vars are specified, add all env vars
envVars = append(envVars, fmt.Sprintf("%s=%s", candidateEnvName, candidateEnvValue))
@ -266,6 +522,29 @@ func subcommandStartDaemon(timeoutMinutes int) { // {{{
}
} // }}}
func subcommandLockVault() { // {{{
internal.Log.Println("Locking vault by stopping daemon")
socketPath := daemon.GetSocketPath()
client := daemon.NewClient(socketPath)
if !client.IsRunning() {
internal.Log.Println("Daemon is not running, nothing to lock")
fmt.Println("Vault is already locked (daemon not running)")
return
}
internal.Log.Println("Sending kill command to daemon")
err := client.KillDaemon()
if err != nil {
internal.Log.Printf("Failed to kill daemon: %v", err)
log.Fatalf("Failed to lock vault: %v", err)
}
internal.Log.Println("Daemon stopped successfully")
fmt.Println("Vault locked successfully")
} // }}}
func subcommandRekeyVault() { // {{{
// Ask for current master password
fmt.Fprint(os.Stderr, "Enter current master password: ")
@ -307,6 +586,21 @@ func subcommandRekeyVault() { // {{{
log.Fatal("Passwords do not match")
}
// Check if daemon is running and kill it
socketPath := daemon.GetSocketPath()
client := daemon.NewClient(socketPath)
if client.IsRunning() {
internal.Log.Println("Daemon is running, stopping it before rekey")
fmt.Println("Stopping password daemon before changing master password...")
err := client.KillDaemon()
if err != nil {
internal.Log.Printf("Failed to kill daemon: %v", err)
log.Fatalf("Failed to stop daemon: %v", err)
}
internal.Log.Println("Daemon stopped successfully")
}
// Encrypt the master key with the new password
newEncryptedKey := encryptKeyWithPassword(masterKey, newPassword)
@ -460,15 +754,16 @@ func decryptKeyWithPassword(encryptedKey, password []byte) ([]byte, error) { //
return decrypted, nil
} // }}}
func loadEnvStore() *EnvStore { // {{{
func loadStoreFile(filePath string) *EnvStore { // {{{
key := loadKey()
if err := os.MkdirAll(filepath.Dir(dbFilePath), 0700); err != nil {
log.Fatal(err)
}
data, err := os.ReadFile(dbFilePath)
data, err := os.ReadFile(filePath)
if err != nil {
if os.IsNotExist(err) {
return &EnvStore{Vars: make(map[string]string)}
return &EnvStore{
Vars: make(map[string]string),
Tags: make(map[string][]string),
}
}
log.Fatal(err)
}
@ -484,13 +779,61 @@ func loadEnvStore() *EnvStore { // {{{
if err := json.Unmarshal(decrypted, &store); err != nil {
log.Fatal(err)
}
// Initialize Tags map if it's nil
if store.Tags == nil {
store.Tags = make(map[string][]string)
}
} else {
return &EnvStore{Vars: make(map[string]string)}
return &EnvStore{
Vars: make(map[string]string),
Tags: make(map[string][]string),
}
}
return &store
} // }}}
func saveEnvStore(store *EnvStore) { // {{{
func loadGlobalEnvStore() *EnvStore { // {{{
if err := os.MkdirAll(filepath.Dir(dbFilePath), 0700); err != nil {
log.Fatal(err)
}
return loadStoreFile(dbFilePath)
} // }}}
func loadLocalEnvStore() *EnvStore { // {{{
return loadStoreFile(localDbFilePath)
} // }}}
func loadEnvStore() *EnvStore { // {{{
// Load global store first
globalStore := loadGlobalEnvStore()
// Check if local store exists
if _, err := os.Stat(localDbFilePath); os.IsNotExist(err) {
// No local store, just return global
return globalStore
}
// Load local store
localStore := loadLocalEnvStore()
// Merge stores (local takes precedence)
mergedStore := &EnvStore{
Vars: make(map[string]string),
Tags: make(map[string][]string),
}
// Copy all global vars and tags
maps.Copy(mergedStore.Vars, globalStore.Vars)
maps.Copy(mergedStore.Tags, globalStore.Tags)
// Override/add local vars and tags
maps.Copy(mergedStore.Vars, localStore.Vars)
maps.Copy(mergedStore.Tags, localStore.Tags)
return mergedStore
} // }}}
func saveGlobalEnvStore(store *EnvStore) { // {{{
key := loadKey()
data, err := json.Marshal(store)
if err != nil {
@ -507,3 +850,21 @@ func saveEnvStore(store *EnvStore) { // {{{
log.Fatal(err)
}
} // }}}
func saveLocalEnvStore(store *EnvStore) { // {{{
key := loadKey()
data, err := json.Marshal(store)
if err != nil {
log.Fatal(err)
}
var nonce [24]byte
if _, err := rand.Read(nonce[:]); err != nil {
log.Fatal(err)
}
encrypted := secretbox.Seal(nonce[:], data, &nonce, &key)
if err := os.WriteFile(localDbFilePath, encrypted, 0600); err != nil {
log.Fatal(err)
}
} // }}}

View File

@ -111,6 +111,29 @@ func (c *Client) RetrievePassword() ([]byte, error) {
return []byte(resp.Data), nil
}
// KillDaemon sends a kill command to the daemon to stop it
func (c *Client) KillDaemon() error {
internal.DaemonLog.Printf("Sending kill command to daemon")
msg := Message{
Command: "kill",
}
internal.DaemonLog.Printf("Sending kill message to daemon")
resp, err := c.sendMessage(msg)
if err != nil {
internal.DaemonLog.Printf("Failed to send kill message: %v", err)
return err
}
if !resp.Success {
internal.DaemonLog.Printf("Daemon reported error: %s", resp.Error)
return fmt.Errorf("failed to kill daemon: %s", resp.Error)
}
internal.DaemonLog.Printf("Kill command sent successfully")
return nil
}
// sendMessage sends a message to the daemon and returns the response
func (c *Client) sendMessage(msg Message) (*Response, error) {
internal.DaemonLog.Printf("Connecting to daemon socket: %s", c.socketPath)

View File

@ -217,6 +217,23 @@ func handleConnection(conn net.Conn, cache *PasswordCache) {
internal.DaemonLog.Println("Password retrieved, sending success response")
encoder.Encode(Response{Success: true, Data: string(password)})
case "kill":
internal.DaemonLog.Println("Kill command received, shutting down daemon")
encoder.Encode(Response{Success: true})
// Close the connection before exiting
conn.Close()
// Clean up the socket file
os.Remove(conn.LocalAddr().String())
internal.DaemonLog.Printf("Removed socket file: %s", conn.LocalAddr().String())
// Exit with success status
internal.DaemonLog.Println("Daemon shutting down gracefully")
// Use goroutine to allow response to be sent before exit
go func() {
time.Sleep(100 * time.Millisecond)
os.Exit(0)
}()
return
default:
internal.DaemonLog.Printf("Unknown command received: %s", msg.Command)
encoder.Encode(Response{Success: false, Error: "Unknown command"})