diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..62b1a19 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +envdo diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0e9bd99 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2017 Anton Lindström + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2322338 --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# envdo + +Manage environment variables with a command. + +envdo came into existance when I realized I had multiple environment +variables set for different work, for home and for side projects. This meant +having one default and then use aliases or manually try to switch the +environment variables. Since this is a hassle I wanted to make it easy to +switch while still maintain some security. + +The goal is to have all environment variables in gpg encrypted files and +import them when needed. + +### Examples + +List profiles: + + envdo ls + +Use project1 environment variables with command `env`: + + envdo project1 env + +Add new environment variable file: + + envdo -r GPGID add profile + +### Installation instructions + +The command can be installed with `go get`: + + go get -v github.com/antonlindstrom/envdo + +## Bugs + +I'm sure there are a lot of bugs or surprises, please file anything you find +and I will try to do my best to solve them. + +Currently the code is very hacky and there are no tests, this was made in an +evening and works for my uses. + +## Acknowledgements + +This program has borrowed most of its ideas from +[Pass](https://www.passwordstore.org/). This software can be managed by Pass +and I suggest linking `~/.password-store/envvars` to `.envdo`. + +## Author + +This is maintained by [Anton Lindstrom](https://www.antonlindstrom.com). + +## License + +See [LICENSE](LICENSE) file in the current directory. diff --git a/bash/auto-complete.sh b/bash/auto-complete.sh new file mode 100644 index 0000000..4da0b4b --- /dev/null +++ b/bash/auto-complete.sh @@ -0,0 +1,25 @@ + +_envdo() { + COMPREPLY=() + local word="${COMP_WORDS[COMP_CWORD]}" + + if [ "$COMP_CWORD" -eq 1 ]; then + COMPREPLY=( $(compgen -W "$(envdo ls --plain) help add ls --gpg-recipient --version --directory" -- "$word") ) + elif [[ $COMP_CWORD -eq 2 ]]; then + local lastarg="${COMP_WORDS[$COMP_CWORD-1]}" + case "${COMP_WORDS[1]}" in + add) + if [[ $COMP_CWORD -le 2 ]]; then + COMPREPLY=($(compgen -W "$(envdo ls --plain)" -- ${word})); + fi + ;; + esac + else + local command="${COMP_WORDS[1]}" + # TODO: load autocompletion for all other commands. + #local completions="$("$command")" + #COMPREPLY=( $(compgen -W "$completions" -- "$word") ) + fi +} + +complete -o default -F _envdo envdo diff --git a/envdo.go b/envdo.go new file mode 100644 index 0000000..d4386bc --- /dev/null +++ b/envdo.go @@ -0,0 +1,165 @@ +package main + +import ( + "bufio" + "bytes" + "fmt" + "io" + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "strings" +) + +var passhroughEnvVars = []string{ + "LANG", + "LC_CTYPE", + "TERM", + "SHELL", + "PATH", + "PWD", + "HOME", + "USER", + "LOGNAME", +} + +func readFiles(path string) ([]string, error) { + files, err := ioutil.ReadDir(path) + if err != nil { + return nil, err + } + + var allFiles []string + + for _, f := range files { + if f.Mode().IsDir() { + if strings.HasPrefix(f.Name(), ".") { + continue + } + + subdirFiles, err := readFiles(path + "/" + f.Name()) + if err != nil { + return nil, err + } + + allFiles = append(allFiles, subdirFiles...) + + continue + } + + allFiles = append(allFiles, path+"/"+f.Name()) + } + + return allFiles, nil +} + +func profileName(base, path string) string { + return strings.TrimPrefix(path, base+"/") +} + +func absProfilePath(path, profileName string) (string, error) { + stat, err := os.Lstat(path) + if err != nil { + return "", err + } + + if stat.Mode() == os.ModeSymlink { + path, err = os.Readlink(path) + if err != nil { + return "", err + } + } + + return fmt.Sprintf("%s/%s", path, profileName), nil +} + +func passtroughEnvWithValues() []string { + var evs []string + + for _, name := range passhroughEnvVars { + evs = append(evs, fmt.Sprintf("%s=%s", name, os.Getenv(name))) + } + + return evs +} + +func fetchEnv(path, profile string, recipient *string) ([]string, error) { + absPath, err := absProfilePath(path, profile) + if err != nil { + return nil, err + } + + var reader io.Reader + + ext := filepath.Ext(absPath) + if ext == ".gpg" { + var b []byte + var gpgArgs []string + + if recipient != nil { + gpgArgs = append(gpgArgs, []string{"-r", *recipient}...) + } + + gpgArgs = append(gpgArgs, []string{"-d", "--quiet", absPath}...) + + buf := bytes.NewBuffer(b) + + err := executeCmdWithWriter("gpg2", gpgArgs, true, nil, buf) + if err != nil { + return nil, err + } + + reader = buf + } else { + f, err := os.Open(absPath) + if err != nil { + return nil, err + } + + reader = f + + defer f.Close() + } + + var lines []string + + scanner := bufio.NewScanner(reader) + for scanner.Scan() { + line := scanner.Text() + + if strings.HasPrefix("#", line) { + continue + } + + lines = append(lines, strings.Replace(line, `"`, ``, -1)) + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return lines, nil +} + +func executeCmd(command string, args []string, preserveEnv bool, env []string) error { + return executeCmdWithWriter(command, args, preserveEnv, env, os.Stdout) +} + +func executeCmdWithWriter(command string, args []string, preserveEnv bool, env []string, writer io.Writer) error { + cmd := exec.Command(command, args...) + cmd.Stdin = os.Stdin + cmd.Stderr = os.Stderr + cmd.Stdout = writer + + // Default variables to pass through. + cmd.Env = passtroughEnvWithValues() + + if preserveEnv { + cmd.Env = os.Environ() + } + + cmd.Env = append(cmd.Env, env...) + + return cmd.Run() +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..afb509d --- /dev/null +++ b/main.go @@ -0,0 +1,149 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/mitchellh/go-homedir" + "gopkg.in/urfave/cli.v1" +) + +func main() { + app := cli.NewApp() + app.Name = "envdo" + app.Usage = "Manage environment variables efficiently." + app.Version = "0.1.0" + app.Author = "Anton Lindstrom" + + homeDir, err := homedir.Dir() + if err != nil { + fmt.Fprintf(os.Stderr, "homedir: %s\n", err) + os.Exit(2) + } + + app.Flags = []cli.Flag{ + cli.StringFlag{ + Name: "directory,d", + Value: homeDir + "/.envdo", + Usage: "path to directory with environment variables", + EnvVar: "ENVDO_DIR", + }, + cli.StringFlag{ + Name: "gpg-recipient,r", + Usage: "GPG recipient to encrypt or decrypt environment variables (email or ID)", + EnvVar: "ENVDO_GPG_RECIPIENT", + }, + cli.BoolFlag{ + Name: "preserve-env,E", + Usage: "preserve user environment when running command", + }, + } + + app.Action = func(c *cli.Context) error { + if c.NArg() == 0 { + return cli.ShowAppHelp(c) + } + + if c.NArg() < 2 { + fmt.Printf("usage: %s \n", os.Args[0]) + fmt.Println("\nExample usage:") + fmt.Println(" envdo Business/cheese-whiz env # Run env with profile Business/cheese-whiz.") + fmt.Println(" envdo Home/backup ls -1 -h # Run ls -1 -h with profile Home/backup.") + fmt.Println("") + return nil + } + + var gpgRecipient *string + if c.GlobalIsSet("gpg-recipient") { + gpgRecipient = func(s string) *string { return &s }(c.GlobalString("gpg-recipient")) + } + + profileEnvVars, err := fetchEnv(c.GlobalString("directory"), c.Args()[0], gpgRecipient) + if err != nil { + return cli.NewExitError(err.Error(), 1) + } + + err = executeCmd(c.Args()[1], c.Args()[2:], c.GlobalBool("preserve-env"), profileEnvVars) + if err != nil { + return cli.NewExitError(err.Error(), 1) + } + + return nil + } + + app.Commands = []cli.Command{ + addCmd, + lsCmd, + } + + err = app.Run(os.Args) + if err != nil { + fmt.Println(err) + } +} + +var lsCmd = cli.Command{ + Name: "ls", + Usage: "List all profiles", + Flags: []cli.Flag{ + cli.BoolFlag{ + Name: "plain", + Usage: "plain text, without colors or indent", + }, + }, + Action: func(c *cli.Context) error { + var extraPath string + if len(c.Args()) > 0 { + extraPath = "/" + c.Args()[0] + } + + if c.Bool("plain") { + files, err := readFiles(c.GlobalString("directory") + extraPath) + if err != nil { + return cli.NewExitError(err.Error(), 1) + } + + for _, f := range files { + fmt.Println(profileName(c.GlobalString("directory")+extraPath, f)) + } + return nil + } + + printTree(c.GlobalString("directory") + extraPath) + + return nil + }, +} + +var addCmd = cli.Command{ + Name: "add", + Usage: "add a GPG2 encrypted environment variable file", + Action: func(c *cli.Context) error { + if c.GlobalString("gpg-recipient") == "" { + return cli.NewExitError("error: gpg recipient is required (-r)", 1) + } + + profileName := c.Args()[0] + ".gpg" + absoluteProfile := fmt.Sprintf("%s/%s", c.GlobalString("directory"), profileName) + file := filepath.Base(profileName) + + directory := strings.TrimSuffix(absoluteProfile, file) + err := os.MkdirAll(directory, 0700) + if err != nil { + return cli.NewExitError(err.Error(), 1) + } + + fmt.Printf("Format for environment variables is `KEY=value`.\n") + fmt.Printf("Enter contents for %s and press Ctrl+D when finished:\n", profileName) + + gpgArgs := []string{"-e", "-r", c.GlobalString("gpg-recipient"), "-o", absoluteProfile} + + err = executeCmd("gpg2", gpgArgs, true, nil) + if err != nil { + return cli.NewExitError(err.Error(), 1) + } + return nil + }, +} diff --git a/printer.go b/printer.go new file mode 100644 index 0000000..08a082c --- /dev/null +++ b/printer.go @@ -0,0 +1,45 @@ +package main + +import ( + "os" + + "github.com/a8m/tree" +) + +type fs struct{} + +func (f *fs) Stat(path string) (os.FileInfo, error) { + return os.Lstat(path) +} +func (f *fs) ReadDir(path string) ([]string, error) { + dir, err := os.Open(path) + if err != nil { + return nil, err + } + names, err := dir.Readdirnames(-1) + dir.Close() + if err != nil { + return nil, err + } + return names, nil +} + +func printTree(path string) { + opts := &tree.Options{ + Fs: new(fs), + FollowLink: true, + OutFile: os.Stdout, + Colorize: false, + } + + var nd, nf int + inf := tree.New(path) + if d, f := inf.Visit(opts); f != 0 { + if d > 0 { + d -= 1 + } + nd, nf = nd+d, nf+f + } + + inf.Print(opts) +}