├── .gitignore ├── .goreleaser.yaml ├── README.md ├── chown.go ├── completions.go ├── config.go ├── context.go ├── decrypt.go ├── encrypt.go ├── examples ├── .agec.yaml └── james.agekey ├── go.mod ├── go.sum ├── groupadd.go ├── groupdel.go ├── groupmod.go ├── init.go ├── justfile ├── ls.go ├── main.go ├── main_test.go ├── rm.go ├── root.go ├── testdata ├── basic.txt ├── decrypt_failure.txt └── subdirectory.txt ├── useradd.go ├── userdel.go ├── usermod.go └── utils.go /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go test -v ./... 4 | - go mod tidy 5 | builds: 6 | - env: 7 | - CGO_ENABLED=0 8 | goos: 9 | - linux 10 | - darwin 11 | goarch: 12 | - amd64 13 | - arm64 14 | goarm: 15 | - "7" 16 | flags: 17 | - -trimpath 18 | ldflags: 19 | - "-s -w -X main.version={{.Version}}" 20 | universal_binaries: 21 | - replace: false 22 | checksum: 23 | name_template: "checksums.txt" 24 | snapshot: 25 | name_template: "{{ incpatch .Version }}-next" 26 | archives: 27 | - format: binary 28 | brews: 29 | - name: "agec" 30 | tap: 31 | owner: aca 32 | name: homebrew-tap 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # agec 2 | 3 | **age** en**c**rypt. Yet another attempt to store, manage and share secrets in git repository based on [age](https://github.com/FiloSottile/age). 4 | 5 | ## Background 6 | 7 | There's plenty of mature solutions for this, [sops](https://github.com/mozilla/sops), [git-crypt](https://github.com/AGWA/git-crypt), [blackbox](https://github.com/StackExchange/blackbox), [agebox](https://github.com/slok/agebox), [git-agecrypt](https://github.com/vlaci/git-agecrypt). 8 | I was frustrated with the way it all worked. I wanted something with 9 | 10 | - Simple workflow, simple encryption with just SSH keys 11 | - Better shell experience 12 | - Shell completions (bash, zsh, fish) 13 | - Invoke command from any subdirectory 14 | - Mechanism to share secrets to limited users/groups in repository. 15 | 16 | agec is basically just a small wrapper around [age](https://github.com/FiloSottile/age). 17 | 18 | ## Installation 19 | 20 | Download binary from [releases](https://github.com/aca/agec/releases) 21 | 22 | Linux 23 | ``` 24 | curl -L -o agec "https://github.com/aca/agec/releases/download/v0.2.0/agec_0.2.0_linux_amd64" 25 | chmod +x ./agec 26 | sudo mv ./agec /usr/local/bin 27 | ``` 28 | 29 | Darwin 30 | ``` 31 | curl -L -o agec "https://github.com/aca/agec/releases/download/v0.2.0/agec_0.2.0_darwin_all" 32 | chmod +x ./agec 33 | sudo mv ./agec /usr/local/bin 34 | ``` 35 | 36 | or build from source, agec requires go >= 1.18 37 | ``` 38 | go install github.com/aca/agec@main 39 | ``` 40 | 41 | Shell completions require additional setup, supports bash/zsh/fish 42 | ``` 43 | agec completion [SHELL] --help 44 | ``` 45 | 46 | ## Example workflow 47 | Change "aca" with your github id. This example will use public keys registered in github for encryption. 48 | 49 | Setup test directory 50 | ``` 51 | mkdir testdir && cd testdir && git init && agec init && echo "secret txt" > secret.txt 52 | ``` 53 | 54 | Add group "admin" and register "aca" and yourself as a member of group `admin`, with public keys from github 55 | ``` 56 | agec groupadd admin 57 | curl "https://github.com/aca.keys" | agec useradd aca -g admin -R - 58 | curl "https://github.com/{{ your github id }}.keys" | agec useradd {{ your github id }} -g admin -R - 59 | ``` 60 | 61 | Agec have concept of 'user', 'group'. You can check it in root configuration. 62 | ``` 63 | cat .agec.yaml 64 | ``` 65 | 66 | Create encrypted file that can be decrypted by members of group `admin` 67 | ``` 68 | agec encrypt secret.txt -g admin 69 | ``` 70 | 71 | decrypt file, it will try to decrypt file with keys in ~/.ssh by default. 72 | ``` 73 | agec decrypt secret.txt.age 74 | ``` 75 | 76 | edit files 77 | 78 | chown updates owner of the secret, this will change owner of secret.txt from "group:admin" to "user:aca" 79 | ``` 80 | agec chown -u aca -g '' secret.txt 81 | ``` 82 | 83 | Re-encrypt it, but you won't be able to decrypt the secret as you are not the owner of secret anymore. 84 | ``` 85 | agec encrypt secret.txt 86 | agec decrypt secret.txt.age # fail 87 | ``` 88 | 89 | List of available commands, and detailed usage. 90 | ``` 91 | agec --help 92 | agec [command] --help 93 | ``` 94 | -------------------------------------------------------------------------------- /chown.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | type chownOpt struct { 12 | Users []string 13 | Groups []string 14 | Files []string 15 | 16 | ctx *Context 17 | } 18 | 19 | func runChown(opts *chownOpt) error { 20 | ctx := opts.ctx 21 | wd := ctx.WorkingDir 22 | rootDir := ctx.RootDir 23 | 24 | for _, u := range opts.Users { 25 | if _, err := ctx.Config.GetUser(u); err != nil { 26 | return err 27 | } 28 | } 29 | 30 | for _, g := range opts.Groups { 31 | if _, err := ctx.Config.GetGroup(g); err != nil { 32 | return err 33 | } 34 | } 35 | 36 | for _, file := range opts.Files { 37 | var relpath string 38 | var err error 39 | if filepath.IsAbs(file) { 40 | relpath, err = filepath.Rel(rootDir, file) 41 | if err != nil { 42 | return err 43 | } 44 | } else { 45 | relpath, err = filepath.Rel(rootDir, filepath.Join(wd, file)) 46 | if err != nil { 47 | return err 48 | } 49 | } 50 | 51 | secret, err := ctx.Config.GetSecret(relpath) 52 | if err != nil { 53 | return fmt.Errorf("%q is not tracked by agec", file) 54 | } 55 | 56 | if opts.Users != nil { 57 | secret.Users = opts.Users 58 | } 59 | 60 | if opts.Groups != nil { 61 | secret.Groups = opts.Groups 62 | } 63 | 64 | ctx.Config.SaveSecret(secret) 65 | 66 | fmt.Printf("ownership of %q updated\n", file) 67 | ctx.Config.SaveSecret(secret) 68 | 69 | // Remove encrypted file as it's owner has changed 70 | err = os.Remove(file + ".age") 71 | if err != nil && !os.IsNotExist(err) { 72 | return err 73 | } 74 | } 75 | 76 | return ctx.WriteConfigFile() 77 | } 78 | 79 | func newChownCmd(ctx *Context) *cobra.Command { 80 | opts := &chownOpt{ 81 | ctx: ctx, 82 | } 83 | 84 | cmd := &cobra.Command{ 85 | Use: "chown", 86 | Short: "change owner user, group of secrets", 87 | Example: ` # secret.txt will be encrypted using john's public keys 88 | agec chown -g devops secret.txt`, 89 | SilenceUsage: true, 90 | SilenceErrors: true, 91 | Args: cobra.MinimumNArgs(1), 92 | ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { 93 | return nil, cobra.ShellCompDirectiveDefault 94 | }, 95 | PreRunE: func(cmd *cobra.Command, args []string) error { 96 | if ctx == nil { 97 | return ErrConfigurationNotFound 98 | } 99 | 100 | if opts.Users == nil { 101 | opts.Users = ctx.DefaultUsers 102 | } 103 | 104 | if opts.Groups == nil { 105 | opts.Groups = ctx.DefaultGroups 106 | } 107 | 108 | var err error 109 | opts.Files, err = sanitizeChownArgs(args) 110 | if err != nil { 111 | return err 112 | } 113 | return nil 114 | }, 115 | RunE: func(cmd *cobra.Command, args []string) error { 116 | return runChown(opts) 117 | }, 118 | } 119 | 120 | f := cmd.Flags() 121 | f.StringSliceVarP(&opts.Users, "user", "u", nil, "List of users") 122 | f.StringSliceVarP(&opts.Groups, "group", "g", nil, "List of groups") 123 | 124 | cmd.RegisterFlagCompletionFunc("user", UserCompletion(ctx)) 125 | cmd.RegisterFlagCompletionFunc("group", GroupCompletion(ctx)) 126 | 127 | return cmd 128 | } 129 | 130 | func sanitizeChownArgs(paths []string) ([]string, error) { 131 | files := []string{} 132 | 133 | for _, p := range paths { 134 | _, err := os.Lstat(p) 135 | if err != nil { 136 | return nil, err 137 | } 138 | 139 | files = append(files, p) 140 | } 141 | 142 | return files, nil 143 | } 144 | -------------------------------------------------------------------------------- /completions.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io/fs" 5 | "path/filepath" 6 | "strings" 7 | 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func UserCompletion(ctx *Context) func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { 12 | return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { 13 | users := []string{} 14 | for _, user := range ctx.Config.Users { 15 | users = append(users, user.Name) 16 | } 17 | return users, cobra.ShellCompDirectiveNoFileComp 18 | } 19 | } 20 | 21 | func GroupCompletion(ctx *Context) func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { 22 | return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { 23 | groups := []string{} 24 | for _, group := range ctx.Config.Groups { 25 | groups = append(groups, group.Name) 26 | } 27 | return groups, cobra.ShellCompDirectiveNoFileComp 28 | } 29 | } 30 | 31 | func SecretCompletion(ctx *Context) func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { 32 | return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { 33 | var completions []string 34 | 35 | for _, secret := range ctx.Config.Secrets { 36 | abs := filepath.Join(ctx.RootDir, secret.Path) 37 | if strings.HasPrefix(abs, ctx.WorkingDir) { 38 | relpath, err := filepath.Rel(ctx.WorkingDir, abs) 39 | if err != nil { 40 | continue 41 | } 42 | completions = append(completions, relpath) 43 | } 44 | } 45 | return completions, cobra.ShellCompDirectiveDefault 46 | } 47 | } 48 | 49 | func EncryptedFileCompletion(ctx *Context) func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { 50 | return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { 51 | var candidates []string 52 | 53 | filepath.WalkDir(".", func(path string, d fs.DirEntry, err error) error { 54 | if err != nil { 55 | return err 56 | } 57 | 58 | if !d.Type().IsRegular() { 59 | return nil 60 | } 61 | 62 | if filepath.Ext(path) != ".age" { 63 | return nil 64 | } 65 | 66 | candidates = append(candidates, path) 67 | 68 | return nil 69 | }) 70 | 71 | var completions []string 72 | for _, comp := range candidates { 73 | if strings.HasPrefix(comp, toComplete) { 74 | completions = append(completions, comp) 75 | } 76 | } 77 | 78 | return completions, cobra.ShellCompDirectiveDefault 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "filippo.io/age" 8 | "github.com/samber/lo" 9 | ) 10 | 11 | const defaultConfigFilename = ".agec.yaml" 12 | 13 | type Config struct { 14 | Version string `json:"version"` 15 | Users []User `json:"users"` 16 | Groups []Group `json:"groups"` 17 | Secrets []Secret `json:"secret"` 18 | } 19 | 20 | type Group struct { 21 | Name string `json:"name"` 22 | Members []string `json:"members"` 23 | } 24 | 25 | type User struct { 26 | Name string `json:"name"` 27 | Recipients []string `json:"recipients"` 28 | } 29 | 30 | type Secret struct { 31 | Path string `json:"path"` 32 | Groups []string `yaml:"groups"` 33 | Users []string `json:"users"` 34 | } 35 | 36 | func (cfg *Config) AddUser(name string, recipients []string) error { 37 | if cfg.UserExists(name) { 38 | return fmt.Errorf("user %q already exists", name) 39 | } 40 | 41 | cfg.Users = append(cfg.Users, User{ 42 | Name: name, 43 | Recipients: recipients, 44 | }) 45 | 46 | return nil 47 | } 48 | 49 | func (cfg *Config) AddGroup(name string, members []string) error { 50 | if cfg.GroupExists(name) { 51 | return fmt.Errorf("group %q already exists", name) 52 | } 53 | 54 | cfg.Groups = append(cfg.Groups, Group{ 55 | Members: members, 56 | Name: name, 57 | }) 58 | 59 | return nil 60 | } 61 | 62 | func (cfg *Config) AddGroupMember(group string, user string) error { 63 | if !cfg.UserExists(user) { 64 | return fmt.Errorf("user %q not exists", user) 65 | } 66 | 67 | for gi, g := range cfg.Groups { 68 | if g.Name == group { 69 | if lo.Contains(g.Members, user) { 70 | return fmt.Errorf("%q is already member of group %q", user, group) 71 | } 72 | 73 | cfg.Groups[gi].Members = append(cfg.Groups[gi].Members, user) 74 | return nil 75 | } 76 | } 77 | 78 | return fmt.Errorf("group %q not exists", group) 79 | } 80 | 81 | func (cfg *Config) RemoveGroup(name string) error { 82 | groups := lo.Filter(cfg.Groups, func(v Group, _ int) bool { 83 | return v.Name != name 84 | }) 85 | 86 | if len(groups) == len(cfg.Groups) { 87 | return fmt.Errorf("group %q not exists", name) 88 | } 89 | 90 | cfg.Groups = groups 91 | return nil 92 | } 93 | 94 | func (cfg *Config) RemoveUser(name string) error { 95 | users := lo.Filter(cfg.Users, func(v User, _ int) bool { 96 | return v.Name != name 97 | }) 98 | 99 | if len(users) == len(cfg.Users) { 100 | return fmt.Errorf("user %q not exists", name) 101 | } 102 | 103 | cfg.Users = users 104 | 105 | for gi := range cfg.Groups { 106 | cfg.Groups[gi].Members = lo.Filter(cfg.Groups[gi].Members, func(v string, _ int) bool { 107 | return v != name 108 | }) 109 | } 110 | 111 | for si := range cfg.Secrets { 112 | cfg.Secrets[si].Users = lo.Filter(cfg.Secrets[si].Users, func(v string, _ int) bool { 113 | return v != name 114 | }) 115 | } 116 | 117 | return nil 118 | } 119 | 120 | func (cfg *Config) RemoveSecret(path string) error { 121 | secrets := lo.Filter(cfg.Secrets, func(v Secret, _ int) bool { 122 | return v.Path != path 123 | }) 124 | 125 | if len(secrets) == len(cfg.Secrets) { 126 | return fmt.Errorf("secret %q not exists", path) 127 | } 128 | 129 | cfg.Secrets = secrets 130 | return nil 131 | } 132 | 133 | func (cfg *Config) GetGroup(name string) (Group, error) { 134 | for _, group := range cfg.Groups { 135 | if group.Name == name { 136 | return group, nil 137 | } 138 | } 139 | 140 | return Group{}, fmt.Errorf("group %q not exists", name) 141 | } 142 | 143 | func (cfg *Config) GetUser(name string) (User, error) { 144 | for _, user := range cfg.Users { 145 | if user.Name == name { 146 | return user, nil 147 | } 148 | } 149 | 150 | return User{}, fmt.Errorf("user %q not exists", name) 151 | } 152 | 153 | func (cfg *Config) GetSecret(path string) (Secret, error) { 154 | for _, secret := range cfg.Secrets { 155 | if secret.Path == path { 156 | return secret, nil 157 | } 158 | } 159 | 160 | return Secret{}, fmt.Errorf("secret %q not exists", path) 161 | } 162 | 163 | func (cfg *Config) SaveUser(u User) { 164 | for i := range cfg.Users { 165 | if cfg.Users[i].Name == u.Name { 166 | cfg.Users[i] = u 167 | return 168 | } 169 | } 170 | 171 | cfg.Users = append(cfg.Users, u) 172 | } 173 | 174 | func (cfg *Config) SaveGroup(g Group) { 175 | for i := range cfg.Groups { 176 | if cfg.Groups[i].Name == g.Name { 177 | cfg.Groups[i] = g 178 | return 179 | } 180 | } 181 | 182 | cfg.Groups = append(cfg.Groups, g) 183 | } 184 | 185 | func (cfg *Config) SaveSecret(s Secret) { 186 | for i := range cfg.Secrets { 187 | if cfg.Secrets[i].Path == s.Path { 188 | cfg.Secrets[i] = s 189 | return 190 | } 191 | } 192 | 193 | cfg.Secrets = append(cfg.Secrets, s) 194 | } 195 | 196 | func (cfg *Config) GroupExists(name string) bool { 197 | if _, err := cfg.GetGroup(name); err != nil { 198 | return false 199 | } 200 | return true 201 | } 202 | 203 | func (cfg *Config) UserExists(name string) bool { 204 | if _, err := cfg.GetUser(name); err != nil { 205 | return false 206 | } 207 | return true 208 | } 209 | 210 | func (cfg *Config) GetRecipients(users []string, groups []string) ([]age.Recipient, error) { 211 | keys := []string{} 212 | recs := []age.Recipient{} 213 | 214 | for _, group := range groups { 215 | g, err := cfg.GetGroup(group) 216 | if err != nil { 217 | return nil, err 218 | } 219 | users = append(users, g.Members...) 220 | } 221 | 222 | for _, user := range users { 223 | u, err := cfg.GetUser(user) 224 | if err != nil { 225 | return nil, err 226 | } 227 | keys = append(keys, u.Recipients...) 228 | } 229 | 230 | keys = lo.Uniq(keys) 231 | 232 | for _, key := range keys { 233 | rec, err := parseRecipient(key) 234 | if err != nil { 235 | return nil, err 236 | } 237 | recs = append(recs, rec) 238 | } 239 | 240 | if len(recs) == 0 { 241 | return nil, errors.New("no recipients found, specify valid users or groups") 242 | } 243 | 244 | return recs, nil 245 | } 246 | -------------------------------------------------------------------------------- /context.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | 9 | "gopkg.in/yaml.v3" 10 | ) 11 | 12 | type Context struct { 13 | Debug bool 14 | IdentityFile string 15 | DefaultUsers []string 16 | DefaultGroups []string 17 | RootDir string 18 | RootConfig string 19 | WorkingDir string 20 | SecretStorePath string 21 | 22 | Config *Config 23 | } 24 | 25 | func initContext() (*Context, error) { 26 | var err error 27 | 28 | ctx := &Context{ 29 | DefaultUsers: []string{}, 30 | DefaultGroups: []string{}, 31 | } 32 | 33 | ctx.WorkingDir, err = os.Getwd() 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | rootConfig, err := ctx.getRootConfig() 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | b, err := os.ReadFile(rootConfig) 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | err = yaml.Unmarshal(b, &ctx.Config) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | if os.Getenv("AGEC_USERS") != "" { 54 | ctx.DefaultUsers = strings.Split(os.Getenv("AGEC_USERS"), ",") 55 | } 56 | if os.Getenv("AGEC_GROUPS") != "" { 57 | ctx.DefaultGroups = strings.Split(os.Getenv("AGEC_GROUPS"), ",") 58 | } 59 | 60 | ctx.RootDir = filepath.Dir(rootConfig) 61 | ctx.SecretStorePath = filepath.Join(ctx.RootDir, "/.git/agec/objects") 62 | ctx.RootConfig = rootConfig 63 | return ctx, nil 64 | } 65 | 66 | func (ctx *Context) WriteConfigFile() error { 67 | b, err := yaml.Marshal(ctx.Config) 68 | if err != nil { 69 | return err 70 | } 71 | 72 | return os.WriteFile(ctx.RootConfig, b, 0o600) 73 | } 74 | 75 | var ErrConfigurationNotFound = errors.New("failed to find root config, reached max depth") 76 | 77 | func (ctx *Context) getRootConfig() (string, error) { 78 | d := ctx.WorkingDir 79 | for i := 0; i < 50; i++ { 80 | fpath := filepath.Join(d, defaultConfigFilename) 81 | _, err := os.Lstat(fpath) 82 | if err == nil { 83 | return fpath, nil 84 | } else if !errors.Is(err, os.ErrNotExist) { 85 | return "", err 86 | } 87 | 88 | parent := filepath.Dir(d) 89 | if d == parent { 90 | return "", ErrConfigurationNotFound 91 | } 92 | d = parent 93 | } 94 | return "", ErrConfigurationNotFound 95 | } 96 | -------------------------------------------------------------------------------- /decrypt.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "io/ioutil" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | 13 | "filippo.io/age" 14 | "filippo.io/age/agessh" 15 | "github.com/spf13/cobra" 16 | ) 17 | 18 | type decryptOpt struct { 19 | IdentityFile string 20 | Identities []age.Identity 21 | FileArg string 22 | Force bool 23 | ctx *Context 24 | } 25 | 26 | func sanitizeDecryptArg(arg string, workingDir string) (string, error) { 27 | if filepath.Ext(arg) != ".age" { 28 | return "", fmt.Errorf("%q is not valid encrypted file", arg) 29 | } 30 | 31 | fileInfo, err := os.Lstat(arg) 32 | if err != nil { 33 | return "", err 34 | } 35 | 36 | if !fileInfo.Mode().IsRegular() { 37 | return "", fmt.Errorf("%q is not a regular file", arg) 38 | } 39 | 40 | if !filepath.IsAbs(arg) { 41 | arg = filepath.Join(workingDir, arg) 42 | } 43 | 44 | relpath, err := filepath.Rel(workingDir, arg) 45 | if err != nil { 46 | return "", fmt.Errorf("fail to get relpath of %q: %v", arg, err) 47 | } 48 | return relpath, nil 49 | } 50 | 51 | func runDecrypt(opts *decryptOpt) error { 52 | file := opts.FileArg 53 | 54 | decryptedFileLinkPath := filepath.Join(opts.ctx.WorkingDir, strings.TrimSuffix(file, ".age")) 55 | if !opts.Force && fileExists(decryptedFileLinkPath) { 56 | return fmt.Errorf("skipping %q. Use --force to re-decrypt file", decryptedFileLinkPath) 57 | } 58 | 59 | relPath, err := filepath.Rel(opts.ctx.RootDir, decryptedFileLinkPath) 60 | if err != nil { 61 | return err 62 | } 63 | decryptedFilePath := filepath.Join(opts.ctx.SecretStorePath, relPath) 64 | 65 | f, err := os.Open(file) 66 | if err != nil { 67 | return err 68 | } 69 | defer f.Close() 70 | 71 | ager, err := age.Decrypt(f, opts.Identities...) 72 | if err != nil { 73 | return fmt.Errorf("failed to decrypt %q: %v", file, err) 74 | } 75 | 76 | err = os.MkdirAll(filepath.Dir(decryptedFilePath), 0o777) 77 | if err != nil && os.IsNotExist(err) { 78 | return err 79 | } 80 | 81 | dstFile, err := os.Create(decryptedFilePath) 82 | if err != nil { 83 | return err 84 | } 85 | defer dstFile.Close() 86 | 87 | _, err = io.Copy(dstFile, ager) 88 | if err != nil { 89 | return fmt.Errorf("failed to copy %q: %v", file, err) 90 | } 91 | 92 | 93 | // relp, err := filepath.Rel(opts.ctx.WorkingDir, decryptedFilePath) 94 | relp, err := filepath.Rel(filepath.Dir(filepath.Join(opts.ctx.WorkingDir, file)), decryptedFilePath) 95 | if err != nil { 96 | return err 97 | } 98 | 99 | err = os.Symlink(relp, decryptedFileLinkPath) 100 | if err != nil && !os.IsExist(err) { 101 | return err 102 | } 103 | 104 | fmt.Printf("decrypted %q\n", decryptedFileLinkPath) 105 | return nil 106 | } 107 | 108 | func newDecryptCmd(ctx *Context) *cobra.Command { 109 | opts := &decryptOpt{ 110 | ctx: ctx, 111 | } 112 | cmd := &cobra.Command{ 113 | Use: "decrypt", 114 | Short: "Decrypt secrets tracked by agec", 115 | Example: ` # By default, agec will try with key found in '~/.ssh/*' 116 | agec decrypt secret.txt 117 | 118 | # Specify identity file to decrypt 119 | agec decrypt secret.txt -i ~/.ssh/id_rsa 120 | 121 | # or pass private key to stdin 122 | cat ~/.ssh/id_rsa | agec decrypt secret.txt -i - 123 | 124 | # or set env AGEC_IDENTITY_FILE 125 | AGEC_IDENTITY_FILE=~/.ssh/id_rsa agec decrypt secret.txt 126 | 127 | # decrypt all in current directory 128 | fd --extension age | xargs agec decrypt`, 129 | SilenceUsage: true, 130 | SilenceErrors: true, 131 | Args: cobra.ExactArgs(1), 132 | PreRunE: func(cmd *cobra.Command, args []string) error { 133 | if ctx == nil { 134 | return ErrConfigurationNotFound 135 | } 136 | 137 | if opts.IdentityFile == "" { 138 | homedir, err := os.UserHomeDir() 139 | if err != nil { 140 | return err 141 | } 142 | 143 | sshdir := filepath.Join(homedir, ".ssh") 144 | 145 | keyfiles, err := ioutil.ReadDir(sshdir) 146 | if err != nil { 147 | return err 148 | } 149 | 150 | for _, keyfile := range keyfiles { 151 | if keyfile.IsDir() { 152 | continue 153 | } 154 | ident, err := parseIdentitiesFile(filepath.Join(sshdir, keyfile.Name())) 155 | if err != nil { 156 | // TODO: debug log 157 | continue 158 | } 159 | opts.Identities = append(opts.Identities, ident...) 160 | } 161 | 162 | if len(opts.Identities) == 0 { 163 | return errors.New("failed to load identities from ~/.ssh, specify identity file") 164 | } 165 | 166 | } else { 167 | ident, err := parseIdentitiesFile(opts.IdentityFile) 168 | if err != nil { 169 | return err 170 | } 171 | opts.Identities = append(opts.Identities, ident...) 172 | } 173 | 174 | var err error 175 | opts.FileArg, err = sanitizeDecryptArg(args[0], ctx.WorkingDir) 176 | if err != nil { 177 | return err 178 | } 179 | return nil 180 | }, 181 | RunE: func(cmd *cobra.Command, args []string) error { 182 | return runDecrypt(opts) 183 | }, 184 | ValidArgsFunction: EncryptedFileCompletion(ctx), 185 | } 186 | 187 | f := cmd.Flags() 188 | f.StringVarP(&opts.IdentityFile, "identity", "i", os.Getenv("AGEC_IDENTITY"), "Specify identity file to decrypt file") 189 | f.BoolVarP(&opts.Force, "force", "f", false, "Overwrite an existing encrypted file") 190 | 191 | return cmd 192 | } 193 | 194 | // https://github.com/FiloSottile/age/blob/cff70cffe2f665ef67cf243daafb064f0feb61a9/cmd/age/parse.go#L126 195 | func parseIdentitiesFile(name string) ([]age.Identity, error) { 196 | var f *os.File 197 | if name == "-" { 198 | f = os.Stdin 199 | } else { 200 | var err error 201 | f, err = os.Open(name) 202 | if err != nil { 203 | return nil, fmt.Errorf("failed to open file: %v", err) 204 | } 205 | defer f.Close() 206 | } 207 | 208 | b := bufio.NewReader(f) 209 | p, _ := b.Peek(14) // length of "age-encryption" and "-----BEGIN AGE" 210 | peeked := string(p) 211 | 212 | switch { 213 | case strings.HasPrefix(peeked, "-----BEGIN"): 214 | const privateKeySizeLimit = 1 << 14 // 16 KiB 215 | contents, err := io.ReadAll(io.LimitReader(b, privateKeySizeLimit)) 216 | if err != nil { 217 | return nil, fmt.Errorf("failed to read %q: %v", name, err) 218 | } 219 | if len(contents) == privateKeySizeLimit { 220 | return nil, fmt.Errorf("failed to read %q: file too long", name) 221 | } 222 | return parseSSHIdentity(name, contents) 223 | 224 | // An unencrypted age identity file. 225 | default: 226 | ids, err := age.ParseIdentities(b) 227 | if err != nil { 228 | return nil, fmt.Errorf("failed to read %q: %v", name, err) 229 | } 230 | return ids, nil 231 | } 232 | } 233 | 234 | func parseSSHIdentity(name string, pemBytes []byte) ([]age.Identity, error) { 235 | id, err := agessh.ParseIdentity(pemBytes) 236 | if err != nil { 237 | return nil, fmt.Errorf("malformed SSH identity in %q: %v", name, err) 238 | } 239 | 240 | return []age.Identity{id}, nil 241 | } 242 | -------------------------------------------------------------------------------- /encrypt.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "log" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | 12 | "filippo.io/age" 13 | "github.com/google/go-cmp/cmp" 14 | "github.com/spf13/cobra" 15 | ) 16 | 17 | type SecretFile struct { 18 | Input string 19 | RelFromRoot string 20 | RelFromWD string 21 | ABS string 22 | RealPath string 23 | DecryptedPath string 24 | } 25 | 26 | type encryptOpt struct { 27 | Users []string 28 | Groups []string 29 | FileArg SecretFile 30 | Force bool 31 | 32 | ctx *Context 33 | } 34 | 35 | func runEncrypt(opts *encryptOpt) (rerr error) { 36 | ctx := opts.ctx 37 | 38 | f := opts.FileArg 39 | encryptedFile := f.Input + ".age" 40 | 41 | if fileExists(encryptedFile) && !opts.Force { 42 | log.Printf("skipping %q. Use --force to re-encrypt file", f) 43 | return nil 44 | } 45 | 46 | var recs []age.Recipient 47 | 48 | secret, err := ctx.Config.GetSecret(f.RelFromRoot) 49 | if err != nil { 50 | // new secret 51 | secret = Secret{ 52 | Users: opts.Users, 53 | Groups: opts.Groups, 54 | Path: f.RelFromRoot, 55 | } 56 | 57 | recs, err = ctx.Config.GetRecipients(opts.Users, opts.Groups) 58 | if err != nil { 59 | return err 60 | } 61 | } else { 62 | // existing secret 63 | // NOTES: Add message to run chown first to chown for secret 64 | // Fail if user,group has changed 65 | 66 | if len(opts.Users) != 0 || len(opts.Groups) != 0 { 67 | if !cmp.Equal(opts.Users, secret.Users) { 68 | return fmt.Errorf("owner of secret has changed, `agec chown -u %v %v` first", strings.Join(opts.Users, ","), f.Input) 69 | } 70 | if !cmp.Equal(opts.Groups, secret.Groups) { 71 | return fmt.Errorf("owner of secret has changed, `agec chown -g %v %v` first", strings.Join(opts.Groups, ","), f.Input) 72 | } 73 | } 74 | 75 | recs, err = ctx.Config.GetRecipients(secret.Users, secret.Groups) 76 | if err != nil { 77 | return err 78 | } 79 | } 80 | 81 | out := &bytes.Buffer{} 82 | agew, err := age.Encrypt(out, recs...) 83 | if err != nil { 84 | return err 85 | } 86 | defer agew.Close() 87 | 88 | plainFile, err := os.Open(f.ABS) 89 | if err != nil { 90 | return err 91 | } 92 | 93 | _, err = io.Copy(agew, plainFile) 94 | if err != nil { 95 | return err 96 | } 97 | 98 | if agew.Close() != nil { 99 | return err 100 | } 101 | 102 | if plainFile.Close() != nil { 103 | return err 104 | } 105 | 106 | err = WriteFile(encryptedFile, out.Bytes()) 107 | if err != nil { 108 | return err 109 | } 110 | 111 | fmt.Printf("encrypted %q\n", encryptedFile) 112 | 113 | err = os.MkdirAll(filepath.Dir(f.DecryptedPath), 0o777) 114 | if err != nil { 115 | return err 116 | } 117 | 118 | if err != nil && !os.IsExist(err) { 119 | return err 120 | } 121 | 122 | err = os.Rename(f.RealPath, f.DecryptedPath) 123 | if err != nil { 124 | return err 125 | } 126 | 127 | relativelinkpath, err := filepath.Rel(filepath.Dir(f.ABS), f.DecryptedPath) 128 | if err != nil { 129 | return err 130 | } 131 | 132 | err = os.Remove(f.Input) 133 | if err != nil && !os.IsNotExist(err) { 134 | return err 135 | } 136 | 137 | err = os.Symlink(relativelinkpath, f.Input) 138 | if err != nil { 139 | return err 140 | } 141 | 142 | ctx.Config.SaveSecret(secret) 143 | return ctx.WriteConfigFile() 144 | } 145 | 146 | func newEncryptCmd(ctx *Context) *cobra.Command { 147 | opts := &encryptOpt{ 148 | ctx: ctx, 149 | } 150 | 151 | cmd := &cobra.Command{ 152 | Use: "encrypt", 153 | Short: "Encrypt and track secrets", 154 | Example: ` # john, merry can decrypt secret 155 | agec encrypt -u john,merry secret.txt 156 | 157 | # members of "admin" group or john can decrypt secret 158 | agec encrypt -g admin -u john secret.txt 159 | 160 | # set default user/group arguments by setting environment variable 161 | # AGEC_USER, AGRC_GROUP. 162 | AGEC_GROUP=admin,devops agec encrypt secret.txt 163 | 164 | # re-encrypt all secrets under current directory 165 | agec ls | xargs agec encrypt --force`, 166 | 167 | SilenceUsage: true, 168 | SilenceErrors: true, 169 | Args: cobra.ExactArgs(1), 170 | PreRunE: func(cmd *cobra.Command, args []string) error { 171 | if ctx == nil { 172 | return ErrConfigurationNotFound 173 | } 174 | 175 | if opts.Users == nil { 176 | opts.Users = ctx.DefaultUsers 177 | } 178 | 179 | if opts.Groups == nil { 180 | opts.Groups = ctx.DefaultGroups 181 | } 182 | 183 | var err error 184 | opts.FileArg, err = sanitizeEncryptArg(ctx, args[0], ctx.WorkingDir) 185 | if err != nil { 186 | return err 187 | } 188 | return nil 189 | }, 190 | RunE: func(cmd *cobra.Command, args []string) error { 191 | return runEncrypt(opts) 192 | }, 193 | } 194 | 195 | f := cmd.Flags() 196 | f.StringSliceVarP(&opts.Users, "user", "u", nil, "List of users") 197 | f.StringSliceVarP(&opts.Groups, "group", "g", nil, "List of groups") 198 | f.BoolVarP(&opts.Force, "force", "f", false, "Overwrite an existing decrypted file") 199 | 200 | cmd.RegisterFlagCompletionFunc("user", UserCompletion(ctx)) 201 | cmd.RegisterFlagCompletionFunc("group", GroupCompletion(ctx)) 202 | 203 | return cmd 204 | } 205 | 206 | func sanitizeEncryptArg(ctx *Context, arg string, workingDir string) (SecretFile, error) { 207 | tf := SecretFile{ 208 | Input: arg, 209 | } 210 | 211 | fileInfo, err := os.Lstat(tf.Input) 212 | if err != nil { 213 | return tf, err 214 | } 215 | 216 | if fileInfo.Mode().IsDir() { 217 | return tf, fmt.Errorf("%q is not a regular file", arg) 218 | } 219 | 220 | if fileInfo.Mode()&os.ModeSymlink != 0 { 221 | tf.RealPath, err = os.Readlink(tf.Input) 222 | if err != nil { 223 | return tf, err 224 | } 225 | } else { 226 | tf.RealPath = tf.Input 227 | } 228 | 229 | tf.ABS, err = filepath.Abs(tf.Input) 230 | if err != nil { 231 | return tf, err 232 | } 233 | 234 | tf.RelFromWD, err = filepath.Rel(ctx.WorkingDir, tf.ABS) 235 | if err != nil { 236 | return tf, err 237 | } 238 | 239 | tf.RelFromRoot, err = filepath.Rel(ctx.RootDir, tf.ABS) 240 | if err != nil { 241 | return tf, err 242 | } 243 | 244 | tf.DecryptedPath = filepath.Join(ctx.SecretStorePath, tf.RelFromRoot) 245 | return tf, nil 246 | } 247 | -------------------------------------------------------------------------------- /examples/.agec.yaml: -------------------------------------------------------------------------------- 1 | version: v1 2 | users: 3 | - name: james 4 | recipients: 5 | - age18j08cfw85a6fhycpeq6cm5zrm2pnft8qk3vtfyvx6jm5dx0w437qne84l2 6 | groups: 7 | - name: admin 8 | members: 9 | - james 10 | secrets: [] 11 | -------------------------------------------------------------------------------- /examples/james.agekey: -------------------------------------------------------------------------------- 1 | # created: 2022-07-02T14:34:44+09:00 2 | # public key: age18j08cfw85a6fhycpeq6cm5zrm2pnft8qk3vtfyvx6jm5dx0w437qne84l2 3 | AGE-SECRET-KEY-1KZRE3GWPFC2SWW8D43U2F98DS0K70V2KG36SHMF33TAVHEZU5YCS3QPC37 4 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/aca/agec 2 | 3 | go 1.18 4 | 5 | require ( 6 | filippo.io/age v1.1.0-rc.1 7 | github.com/google/go-cmp v0.5.9 8 | github.com/rogpeppe/go-internal v1.8.1 9 | github.com/samber/lo v1.21.0 10 | github.com/spf13/cobra v1.5.0 11 | gopkg.in/yaml.v3 v3.0.1 12 | ) 13 | 14 | require ( 15 | filippo.io/edwards25519 v1.0.0-rc.1 // indirect 16 | github.com/inconshreveable/mousetrap v1.0.0 // indirect 17 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e // indirect 18 | github.com/spf13/pflag v1.0.5 // indirect 19 | golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 // indirect 20 | golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect 21 | golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec // indirect 22 | gopkg.in/errgo.v2 v2.1.0 // indirect 23 | ) 24 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | filippo.io/age v1.1.0-rc.1 h1:C9offoa1LZM3QLViEIS8uHJgdNPW+ledF9xWHoRFOPc= 2 | filippo.io/age v1.1.0-rc.1/go.mod h1:qdpDblw5JSmMCu0O6HbNJ0kknOj1okv9kK8aAxpb3e8= 3 | filippo.io/edwards25519 v1.0.0-rc.1 h1:m0VOOB23frXZvAOK44usCgLWvtsxIoMCTBGJZlpmGfU= 4 | filippo.io/edwards25519 v1.0.0-rc.1/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns= 5 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 6 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 7 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 8 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 9 | github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= 10 | github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= 11 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 12 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 13 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 14 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 15 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= 16 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e h1:aoZm08cpOy4WuID//EZDgcC4zIxODThtZNPirFr42+A= 17 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 18 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 19 | github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg= 20 | github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= 21 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 22 | github.com/samber/lo v1.21.0 h1:FSby8pJQtX4KmyddTCCGhc3JvnnIVrDA+NW37rG+7G8= 23 | github.com/samber/lo v1.21.0/go.mod h1:2I7tgIv8Q1SG2xEIkRq0F2i2zgxVpnyPOP0d3Gj2r+A= 24 | github.com/spf13/cobra v1.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU= 25 | github.com/spf13/cobra v1.5.0/go.mod h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJoUM= 26 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 27 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 28 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 29 | github.com/thoas/go-funk v0.9.1 h1:O549iLZqPpTUQ10ykd26sZhzD+rmR5pWhuElrhbC20M= 30 | golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 h1:HWj/xjIHfjYU5nVXpTM0s39J9CbLn7Cc5a7IC5rwsMQ= 31 | golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 32 | golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 h1:3MTrJm4PyNL9NBqvYDSj3DHl46qQakyfqfWo4jgfaEM= 33 | golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE= 34 | golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec h1:BkDtF2Ih9xZ7le9ndzTA7KJow28VbQW3odyk/8drmuI= 35 | golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 36 | golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b h1:9zKuko04nR4gjZ4+DNjHqRlAJqbJETHwiNKDqTfOjfE= 37 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 38 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 39 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= 40 | gopkg.in/errgo.v2 v2.1.0 h1:0vLT13EuvQ0hNvakwLuFZ/jYrLp5F3kcWHXdRggjCE8= 41 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 42 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 43 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 44 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 45 | -------------------------------------------------------------------------------- /groupadd.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | type groupaddOpt struct { 8 | Group string 9 | Users []string 10 | 11 | ctx *Context 12 | } 13 | 14 | func runGroupadd(opts *groupaddOpt) error { 15 | err := opts.ctx.Config.AddGroup(opts.Group, opts.Users) 16 | if err != nil { 17 | return err 18 | } 19 | return opts.ctx.WriteConfigFile() 20 | } 21 | 22 | func newGroupAddCmd(ctx *Context) *cobra.Command { 23 | opts := &groupaddOpt{ 24 | ctx: ctx, 25 | } 26 | cmd := &cobra.Command{ 27 | Use: "groupadd", 28 | Short: "Creates agec group", 29 | Example: ` # add group devops with user john, merry 30 | agec groupadd devops -u john,merry`, 31 | SilenceUsage: true, 32 | SilenceErrors: true, 33 | PreRunE: func(cmd *cobra.Command, args []string) error { 34 | if ctx == nil { 35 | return ErrConfigurationNotFound 36 | } 37 | opts.Group = args[0] 38 | return nil 39 | }, 40 | RunE: func(cmd *cobra.Command, args []string) error { 41 | return runGroupadd(opts) 42 | }, 43 | Args: cobra.ExactArgs(1), 44 | ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { 45 | return nil, cobra.ShellCompDirectiveNoFileComp 46 | }, 47 | } 48 | 49 | f := cmd.Flags() 50 | f.StringSliceVarP(&opts.Users, "user", "u", nil, "List of users") 51 | cmd.RegisterFlagCompletionFunc("user", UserCompletion(ctx)) 52 | 53 | return cmd 54 | } 55 | -------------------------------------------------------------------------------- /groupdel.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | type groupDelOpt struct { 8 | Group string 9 | 10 | ctx *Context 11 | } 12 | 13 | func runGroupDel(opts *groupDelOpt) error { 14 | cfg := opts.ctx.Config 15 | group := opts.Group 16 | 17 | err := cfg.RemoveGroup(group) 18 | if err != nil { 19 | return err 20 | } 21 | return opts.ctx.WriteConfigFile() 22 | } 23 | 24 | func newGroupDelCmd(ctx *Context) *cobra.Command { 25 | opts := &groupDelOpt{ 26 | ctx: ctx, 27 | } 28 | cmd := &cobra.Command{ 29 | Use: "groupdel", 30 | Short: "Deletes agec group", 31 | SilenceUsage: true, 32 | SilenceErrors: true, 33 | PreRunE: func(cmd *cobra.Command, args []string) error { 34 | if ctx == nil { 35 | return ErrConfigurationNotFound 36 | } 37 | 38 | opts.Group = args[0] 39 | 40 | return nil 41 | }, 42 | RunE: func(cmd *cobra.Command, args []string) error { 43 | return runGroupDel(opts) 44 | }, 45 | Args: cobra.ExactArgs(1), 46 | ValidArgsFunction: GroupCompletion(ctx), 47 | } 48 | 49 | return cmd 50 | } 51 | -------------------------------------------------------------------------------- /groupmod.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/samber/lo" 5 | "github.com/spf13/cobra" 6 | ) 7 | 8 | type groupModOpt struct { 9 | Group string 10 | Users []string 11 | Append bool 12 | 13 | ctx *Context 14 | } 15 | 16 | func runGroupMod(opts *groupModOpt) error { 17 | ctx := opts.ctx 18 | g, err := ctx.Config.GetGroup(opts.Group) 19 | if err != nil { 20 | return err 21 | } 22 | 23 | if opts.Append { 24 | g.Members = append(g.Members, opts.Users...) 25 | g.Members = lo.Uniq(g.Members) 26 | } else { 27 | g.Members = opts.Users 28 | } 29 | 30 | return opts.ctx.WriteConfigFile() 31 | } 32 | 33 | func newGroupModCmd(ctx *Context) *cobra.Command { 34 | opts := &groupModOpt{ 35 | ctx: ctx, 36 | } 37 | cmd := &cobra.Command{ 38 | Use: "groupmod", 39 | Short: "Modify a group definition", 40 | Example: ` # change "devops" group member to "john","james" 41 | agec groupmod -u john devops 42 | 43 | # append member "john" to "devops" group 44 | agec groupmod -u john -a devops`, 45 | SilenceUsage: true, 46 | SilenceErrors: true, 47 | PreRunE: func(cmd *cobra.Command, args []string) error { 48 | if ctx == nil { 49 | return ErrConfigurationNotFound 50 | } 51 | opts.Group = args[0] 52 | return nil 53 | }, 54 | RunE: func(cmd *cobra.Command, args []string) error { 55 | return runGroupMod(opts) 56 | }, 57 | Args: cobra.ExactArgs(1), 58 | ValidArgsFunction: GroupCompletion(ctx), 59 | } 60 | 61 | f := cmd.Flags() 62 | f.StringSliceVarP(&opts.Users, "user", "u", nil, "List of users") 63 | cmd.RegisterFlagCompletionFunc("user", UserCompletion(ctx)) 64 | 65 | f.BoolVarP(&opts.Append, "append", "a", false, "Append users to the existing member list, rather than replacing it") 66 | return cmd 67 | } 68 | -------------------------------------------------------------------------------- /init.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "path/filepath" 8 | "time" 9 | 10 | "filippo.io/age" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | type initOpt struct { 15 | out io.Writer 16 | 17 | ctx *Context 18 | } 19 | 20 | func runInit(opts *initOpt) error { 21 | wd, err := os.Getwd() 22 | if err != nil { 23 | return err 24 | } 25 | 26 | rootConfig := filepath.Join(wd, defaultConfigFilename) 27 | 28 | if opts.ctx != nil { 29 | if opts.ctx.RootConfig == rootConfig { 30 | return fmt.Errorf("%q already exists", opts.ctx.RootConfig) 31 | } 32 | } 33 | 34 | k, err := age.GenerateX25519Identity() 35 | if err != nil { 36 | return err 37 | } 38 | 39 | root := User{ 40 | Name: "root", 41 | Recipients: []string{ 42 | k.Recipient().String(), 43 | }, 44 | } 45 | 46 | ctx := &Context{} 47 | ctx.RootDir = wd 48 | ctx.RootConfig = rootConfig 49 | ctx.Config = &Config{ 50 | Version: "v1", 51 | Users: []User{ 52 | root, 53 | }, 54 | Secrets: []Secret{}, 55 | Groups: []Group{ 56 | { 57 | Name: "root", 58 | Members: []string{ 59 | "root", 60 | }, 61 | }, 62 | }, 63 | } 64 | 65 | fmt.Fprintf(os.Stderr, "Initalized agec in %q\n", rootConfig) 66 | fmt.Fprintf(os.Stderr, "\nGenerated user:root, group:root\n") 67 | fmt.Fprintf(os.Stdout, "# created: %s\n", time.Now().Format(time.RFC3339)) 68 | fmt.Fprintf(os.Stdout, "# public key: %s\n", k.Recipient()) 69 | fmt.Fprintf(os.Stdout, "%s\n", k) 70 | return ctx.WriteConfigFile() 71 | } 72 | 73 | func newInitCmd(ctx *Context) *cobra.Command { 74 | opts := &initOpt{ 75 | ctx: ctx, 76 | } 77 | cmd := &cobra.Command{ 78 | Use: "init", 79 | Short: "Initalizes agec under the current directory", 80 | SilenceUsage: true, 81 | SilenceErrors: false, 82 | RunE: func(cmd *cobra.Command, args []string) error { 83 | return runInit(opts) 84 | }, 85 | ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { 86 | return nil, cobra.ShellCompDirectiveNoFileComp 87 | }, 88 | } 89 | return cmd 90 | } 91 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | test: 2 | go test -v ./... 3 | 4 | dev: 5 | go install . 6 | agec completion zsh > ~/.zsh/zsh-completions/src/_agec 7 | agec completion bash | sudo tee /usr/share/bash-completion/completions/agec >/dev/null || true 8 | agec completion bash | sudo tee /usr/local/share/bash-completion/completions/agec >/dev/null || true 9 | agec completion fish > ~/.config/fish/completions/agec.fish 10 | -------------------------------------------------------------------------------- /ls.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | "strings" 7 | 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | type lsOpt struct { 12 | ctx *Context 13 | } 14 | 15 | func runLs(opts *lsOpt) error { 16 | for _, secret := range opts.ctx.Config.Secrets { 17 | abs := filepath.Join(opts.ctx.RootDir, secret.Path) 18 | if strings.HasPrefix(abs, opts.ctx.WorkingDir) { 19 | relpath, err := filepath.Rel(opts.ctx.WorkingDir, abs) 20 | if err != nil { 21 | return err 22 | } 23 | fmt.Println(relpath) 24 | } 25 | } 26 | return nil 27 | } 28 | 29 | func newLsCmd(ctx *Context) *cobra.Command { 30 | opts := &lsOpt{ 31 | ctx: ctx, 32 | } 33 | 34 | cmd := &cobra.Command{ 35 | Use: "ls", 36 | Short: "List encrypted files under the current directory", 37 | Example: ` # re-encrypt all secrets under current directory 38 | agec ls | xargs agec encrypt --force 39 | 40 | # decrypt all secrets under current directory 41 | agec ls | xargs -I{} agec decrypt {}.age`, 42 | SilenceUsage: true, 43 | SilenceErrors: true, 44 | PreRunE: func(cmd *cobra.Command, args []string) error { 45 | if ctx == nil { 46 | return ErrConfigurationNotFound 47 | } 48 | return nil 49 | }, 50 | RunE: func(cmd *cobra.Command, args []string) error { 51 | return runLs(opts) 52 | }, 53 | ValidArgsFunction: SecretCompletion(ctx), 54 | } 55 | 56 | return cmd 57 | } 58 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | ) 6 | 7 | var version = "devel" 8 | 9 | func main() { 10 | log.SetFlags(0) 11 | err := cmdMain() 12 | if err != nil { 13 | log.Fatal(err) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/rogpeppe/go-internal/testscript" 8 | ) 9 | 10 | func TestMain(m *testing.M) { 11 | version = "test" 12 | os.Exit(testscript.RunMain(m, map[string]func() int{ 13 | "agec": func() int { 14 | err := cmdMain() 15 | if err == nil { 16 | return 0 17 | } 18 | return 1 19 | }, 20 | })) 21 | } 22 | 23 | func TestScript(t *testing.T) { 24 | testscript.Run(t, testscript.Params{ 25 | Dir: "testdata", 26 | }) 27 | } 28 | -------------------------------------------------------------------------------- /rm.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "path/filepath" 6 | 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | type rmOpt struct { 11 | Files []string 12 | 13 | ctx *Context 14 | } 15 | 16 | func runRm(opts *rmOpt) error { 17 | for _, f := range opts.Files { 18 | abs := filepath.Join(opts.ctx.WorkingDir, f) 19 | rel, err := filepath.Rel(opts.ctx.RootDir, abs) 20 | if err != nil { 21 | return err 22 | } 23 | 24 | err = opts.ctx.Config.RemoveSecret(rel) 25 | if err != nil { 26 | return err 27 | } else { 28 | log.Printf("removed secret %q", rel) 29 | } 30 | } 31 | 32 | return opts.ctx.WriteConfigFile() 33 | } 34 | 35 | func newRmCmd(ctx *Context) *cobra.Command { 36 | opts := &rmOpt{ 37 | ctx: ctx, 38 | } 39 | cmd := &cobra.Command{ 40 | Use: "rm", 41 | Short: "Remove secret file and untrack from agec", 42 | SilenceUsage: true, 43 | SilenceErrors: true, 44 | PreRunE: func(cmd *cobra.Command, args []string) error { 45 | if ctx == nil { 46 | return ErrConfigurationNotFound 47 | } 48 | return nil 49 | }, 50 | RunE: func(cmd *cobra.Command, args []string) error { 51 | opts.Files = args 52 | return runRm(opts) 53 | }, 54 | ValidArgsFunction: SecretCompletion(ctx), 55 | } 56 | return cmd 57 | } 58 | -------------------------------------------------------------------------------- /root.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "os" 9 | "strings" 10 | 11 | "filippo.io/age" 12 | "filippo.io/age/agessh" 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | func cmdMain() error { 17 | ctx, err := initContext() 18 | if err != nil { 19 | if !errors.Is(err, os.ErrNotExist) && !errors.Is(err, ErrConfigurationNotFound) { 20 | return err 21 | } 22 | } 23 | 24 | rootCmd, err := newRootCmd(ctx, os.Stdout, os.Args[1:]) 25 | if err != nil { 26 | return err 27 | } 28 | 29 | err = rootCmd.Execute() 30 | return err 31 | } 32 | 33 | func newRootCmd(ctx *Context, out io.Writer, args []string) (*cobra.Command, error) { 34 | versionFlag := false 35 | cmd := &cobra.Command{ 36 | Use: "agec", 37 | SilenceUsage: true, 38 | SilenceErrors: true, 39 | Run: func(cmd *cobra.Command, args []string) { 40 | if versionFlag { 41 | fmt.Println(version) 42 | } else { 43 | cmd.Help() 44 | } 45 | }, 46 | } 47 | 48 | f := cmd.PersistentFlags() 49 | f.BoolP("verbose", "v", false, "verbose output for debugging purposes") 50 | f.BoolVar(&versionFlag, "version", false, "print version") 51 | f.Parse(args) 52 | 53 | cmd.AddCommand( 54 | newInitCmd(ctx), 55 | newLsCmd(ctx), 56 | newRmCmd(ctx), 57 | 58 | newEncryptCmd(ctx), 59 | newDecryptCmd(ctx), 60 | 61 | newUserAddCmd(ctx), 62 | newUserModCmd(ctx), 63 | newUserDelCmd(ctx), 64 | 65 | newGroupAddCmd(ctx), 66 | newGroupModCmd(ctx), 67 | newGroupDelCmd(ctx), 68 | 69 | newChownCmd(ctx), 70 | 71 | newGroupsCmd(ctx), 72 | ) 73 | 74 | return cmd, nil 75 | } 76 | 77 | func parseRecipient(arg string) (age.Recipient, error) { 78 | switch { 79 | case strings.HasPrefix(arg, "age1"): 80 | return age.ParseX25519Recipient(arg) 81 | case strings.HasPrefix(arg, "ssh-"): 82 | return agessh.ParseRecipient(arg) 83 | } 84 | return nil, fmt.Errorf("unknown recipient type: %q", arg) 85 | } 86 | 87 | func ParseRecipients(r io.Reader) ([]age.Recipient, error) { 88 | scanner := bufio.NewScanner(r) 89 | 90 | var recs []age.Recipient 91 | 92 | var n int 93 | for scanner.Scan() { 94 | n++ 95 | line := scanner.Text() 96 | if strings.HasPrefix(line, "#") || line == "" { 97 | continue 98 | } 99 | r, err := parseRecipient(line) 100 | if err != nil { 101 | return nil, fmt.Errorf("malformed recipient at line %d", n) 102 | } 103 | recs = append(recs, r) 104 | } 105 | if err := scanner.Err(); err != nil { 106 | return nil, fmt.Errorf("failed to read recipients file: %v", err) 107 | } 108 | if len(recs) == 0 { 109 | return nil, fmt.Errorf("no recipients found") 110 | } 111 | return recs, nil 112 | } 113 | -------------------------------------------------------------------------------- /testdata/basic.txt: -------------------------------------------------------------------------------- 1 | # basic 2 | exec git init -q 3 | agec encrypt secret.txt -u aca 4 | stdout 'encrypted "secret.txt.age"\n' 5 | agec decrypt secret.txt.age -i key --force 6 | # stdout 'decrypted "secret.txt"\n' 7 | exec cat secret.txt 8 | stdout 'this is secret file' 9 | agec ls 10 | stdout 'secret.txt\n' 11 | 12 | agec chown -u john secret.txt 13 | agec encrypt secret.txt --force 14 | ! agec decrypt secret.txt.age -i key --force 15 | 16 | exec ls -al .git/agec/objects 17 | agec decrypt secret.txt.age -i john.key --force 18 | # stdout 'decrypted "secret.txt"\n' 19 | 20 | agec groups john 21 | stdout 'john\n' 22 | 23 | agec usermod john -g devops 24 | agec groups john 25 | stdout 'devops\n' 26 | 27 | agec usermod john -g devops,john -a 28 | agec groups john 29 | stdout 'devops\njohn' 30 | agec chown -g devops secret.txt 31 | agec encrypt secret.txt 32 | agec decrypt secret.txt.age -i key --force 33 | 34 | agec ls 35 | stdout 'secret.txt\n' 36 | agec rm secret.txt 37 | agec ls 38 | stdout '' 39 | 40 | -- secret.txt -- 41 | this is secret file 42 | 43 | -- .agec.yaml -- 44 | version: v1 45 | users: 46 | - name: aca 47 | recipients: 48 | - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHcOxFPXarxSbKwGUj6bTlxVec6LoPJQKPnVd/p7Zljq tmp 49 | - name: john 50 | recipients: 51 | - age1jk9tea2v9aknanqkvvy2rpfmst7mqu80wezacevd28acpfezagjqrkwnsd 52 | secrets: [] 53 | groups: 54 | - name: devops 55 | members: 56 | - aca 57 | - name: john 58 | members: 59 | - john 60 | 61 | -- key -- 62 | -----BEGIN OPENSSH PRIVATE KEY----- 63 | b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW 64 | QyNTUxOQAAACB3DsRT12q8UmysBlI+m05cVXnOi6DyUCj51Xf6e2ZY6gAAAKDbkxHc25MR 65 | 3AAAAAtzc2gtZWQyNTUxOQAAACB3DsRT12q8UmysBlI+m05cVXnOi6DyUCj51Xf6e2ZY6g 66 | AAAECmy9OuLW/r4tt3+zVz+Cjsooih1CEUcc7ek+Q5yd950XcOxFPXarxSbKwGUj6bTlxV 67 | ec6LoPJQKPnVd/p7ZljqAAAAF2t5dW5ncm9rLmNodW5nQHJvay10b3NzAQIDBAUG 68 | -----END OPENSSH PRIVATE KEY----- 69 | 70 | -- john.key -- 71 | AGE-SECRET-KEY-1AD2S0SCCN762GM6M38VEG2N9D0TEF62EGDPC350VHUWHEXJ52WKQ2R9ST6 72 | -------------------------------------------------------------------------------- /testdata/decrypt_failure.txt: -------------------------------------------------------------------------------- 1 | exec git init -q 2 | # decrypt_failure 3 | agec encrypt secret.txt -g devops 4 | stdout 'encrypted "secret.txt.age"\n' 5 | ! agec decrypt secret.txt.age -i aca.key 6 | 7 | -- secret.txt -- 8 | this is secret file 9 | 10 | -- .agec.yaml -- 11 | version: v1 12 | users: 13 | - name: aca 14 | recipients: 15 | - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDrfyRz3qqKAiBKziZl3aoLkZaaDxF6H/MTRtloKYLVW john 16 | - name: john 17 | recipients: 18 | - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHcOxFPXarxSbKwGUj6bTlxVec6LoPJQKPnVd/p7Zljq tmp 19 | secrets: [] 20 | groups: 21 | - name: devops 22 | members: 23 | - john 24 | 25 | -- aca.key -- 26 | -----BEGIN OPENSSH PRIVATE KEY----- 27 | b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW 28 | QyNTUxOQAAACA638kc96qigIgSs4mZd2qC5GWmg8Reh/zE0bZaCmC1VgAAAKBAPOg/QDzo 29 | PwAAAAtzc2gtZWQyNTUxOQAAACA638kc96qigIgSs4mZd2qC5GWmg8Reh/zE0bZaCmC1Vg 30 | AAAEBY5JvxjGEuyc7Uy0hMBvg4c65h3iC1NzbphMznt9ZJLDrfyRz3qqKAiBKziZl3aoLk 31 | ZaaDxF6H/MTRtloKYLVWAAAAF2t5dW5ncm9rLmNodW5nQHJvay10b3NzAQIDBAUG 32 | -----END OPENSSH PRIVATE KEY----- 33 | 34 | -- john.key -- 35 | -----BEGIN OPENSSH PRIVATE KEY----- 36 | b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW 37 | QyNTUxOQAAACB3DsRT12q8UmysBlI+m05cVXnOi6DyUCj51Xf6e2ZY6gAAAKDbkxHc25MR 38 | 3AAAAAtzc2gtZWQyNTUxOQAAACB3DsRT12q8UmysBlI+m05cVXnOi6DyUCj51Xf6e2ZY6g 39 | AAAECmy9OuLW/r4tt3+zVz+Cjsooih1CEUcc7ek+Q5yd950XcOxFPXarxSbKwGUj6bTlxV 40 | ec6LoPJQKPnVd/p7ZljqAAAAF2t5dW5ncm9rLmNodW5nQHJvay10b3NzAQIDBAUG 41 | -----END OPENSSH PRIVATE KEY----- 42 | -------------------------------------------------------------------------------- /testdata/subdirectory.txt: -------------------------------------------------------------------------------- 1 | # subdirectory 2 | exec git init -q 3 | cd sub 4 | agec encrypt secret.txt -u aca 5 | stdout 'encrypted "secret.txt.age"\n' 6 | agec decrypt secret.txt.age -i ../key --force 7 | exec cat secret.txt 8 | stdout 'this is secret file' 9 | agec ls 10 | stdout 'secret.txt\n' 11 | rm secret.txt 12 | cd .. 13 | agec decrypt sub/secret.txt.age -i ./key --force 14 | exec cat sub/secret.txt 15 | stdout 'this is secret file' 16 | 17 | -- sub/secret.txt -- 18 | this is secret file 19 | 20 | -- .agec.yaml -- 21 | version: v1 22 | users: 23 | - name: aca 24 | recipients: 25 | - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHcOxFPXarxSbKwGUj6bTlxVec6LoPJQKPnVd/p7Zljq tmp 26 | secrets: [] 27 | groups: 28 | - name: devops 29 | members: 30 | - aca 31 | 32 | -- key -- 33 | -----BEGIN OPENSSH PRIVATE KEY----- 34 | b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW 35 | QyNTUxOQAAACB3DsRT12q8UmysBlI+m05cVXnOi6DyUCj51Xf6e2ZY6gAAAKDbkxHc25MR 36 | 3AAAAAtzc2gtZWQyNTUxOQAAACB3DsRT12q8UmysBlI+m05cVXnOi6DyUCj51Xf6e2ZY6g 37 | AAAECmy9OuLW/r4tt3+zVz+Cjsooih1CEUcc7ek+Q5yd950XcOxFPXarxSbKwGUj6bTlxV 38 | ec6LoPJQKPnVd/p7ZljqAAAAF2t5dW5ncm9rLmNodW5nQHJvay10b3NzAQIDBAUG 39 | -----END OPENSSH PRIVATE KEY----- 40 | -------------------------------------------------------------------------------- /useradd.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "os" 9 | "strings" 10 | 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | type useraddOpt struct { 15 | User string 16 | Groups []string 17 | Recipients []string 18 | RecipientsFile string 19 | 20 | ctx *Context 21 | } 22 | 23 | func runUseradd(opts *useraddOpt) error { 24 | err := opts.ctx.Config.AddUser(opts.User, opts.Recipients) 25 | if err != nil { 26 | return err 27 | } 28 | 29 | for _, group := range opts.Groups { 30 | err := opts.ctx.Config.AddGroupMember(group, opts.User) 31 | if err != nil { 32 | return err 33 | } 34 | } 35 | 36 | return opts.ctx.WriteConfigFile() 37 | } 38 | 39 | func newUserAddCmd(ctx *Context) *cobra.Command { 40 | opts := &useraddOpt{ 41 | ctx: ctx, 42 | } 43 | cmd := &cobra.Command{ 44 | Use: "useradd", 45 | Short: "Creates a new user with recipients read from stdin", 46 | Example: ` # create user "aca" with public keys from github 47 | curl -s "https://github.com/aca.keys" | agec useradd aca -R -`, 48 | SilenceUsage: true, 49 | SilenceErrors: true, 50 | PreRunE: func(cmd *cobra.Command, args []string) error { 51 | if opts.ctx == nil { 52 | return ErrConfigurationNotFound 53 | } 54 | 55 | var err error 56 | switch opts.RecipientsFile { 57 | case "": 58 | return errors.New("specify recipients-file for the user") 59 | case "-": 60 | opts.Recipients, err = readRecipients(os.Stdin) 61 | if err != nil { 62 | return err 63 | } 64 | default: 65 | f, err := os.Open(opts.RecipientsFile) 66 | if err != nil { 67 | return err 68 | } 69 | opts.Recipients, err = readRecipients(f) 70 | if err != nil { 71 | return err 72 | } 73 | } 74 | 75 | opts.User = args[0] 76 | 77 | return nil 78 | }, 79 | RunE: func(cmd *cobra.Command, args []string) error { 80 | return runUseradd(opts) 81 | }, 82 | Args: cobra.ExactArgs(1), 83 | ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { 84 | return nil, cobra.ShellCompDirectiveNoFileComp 85 | }, 86 | } 87 | 88 | f := cmd.Flags() 89 | f.StringSliceVarP(&opts.Groups, "group", "g", nil, "Groups which the user is a member of") 90 | f.StringVarP(&opts.RecipientsFile, "recipients-file", "R", "", "User's recipients, if set to -, the recipients are read from standard input.") 91 | cmd.RegisterFlagCompletionFunc("group", GroupCompletion(ctx)) 92 | 93 | return cmd 94 | } 95 | 96 | func readRecipients(r io.Reader) (recs []string, err error) { 97 | scanner := bufio.NewScanner(r) 98 | for scanner.Scan() { 99 | line := scanner.Text() 100 | if strings.HasPrefix(line, "#") || line == "" { 101 | continue 102 | } 103 | recs = append(recs, line) 104 | } 105 | 106 | if err := scanner.Err(); err != nil { 107 | return nil, fmt.Errorf("failed to read recipients: %v", err) 108 | } 109 | 110 | return recs, nil 111 | } 112 | -------------------------------------------------------------------------------- /userdel.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | type userDelOpt struct { 10 | User string 11 | 12 | ctx *Context 13 | } 14 | 15 | func runUserDel(opts *userDelOpt) error { 16 | cfg := opts.ctx.Config 17 | user := opts.User 18 | 19 | err := cfg.RemoveUser(user) 20 | if err != nil { 21 | return err 22 | } 23 | 24 | fmt.Printf("removed user %q\n", user) 25 | 26 | return opts.ctx.WriteConfigFile() 27 | } 28 | 29 | func newUserDelCmd(ctx *Context) *cobra.Command { 30 | opts := &userDelOpt{ 31 | ctx: ctx, 32 | } 33 | cmd := &cobra.Command{ 34 | Use: "userdel", 35 | Short: "Deletes agec user", 36 | SilenceUsage: true, 37 | SilenceErrors: true, 38 | PreRunE: func(cmd *cobra.Command, args []string) error { 39 | if ctx == nil { 40 | return ErrConfigurationNotFound 41 | } 42 | 43 | opts.User = args[0] 44 | 45 | return nil 46 | }, 47 | RunE: func(cmd *cobra.Command, args []string) error { 48 | return runUserDel(opts) 49 | }, 50 | Args: cobra.ExactArgs(1), 51 | ValidArgsFunction: UserCompletion(ctx), 52 | } 53 | 54 | return cmd 55 | } 56 | -------------------------------------------------------------------------------- /usermod.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/samber/lo" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | type userModOpt struct { 11 | Groups []string 12 | Append bool 13 | User string 14 | Recipients []string 15 | RecipientsFile string 16 | 17 | ctx *Context 18 | } 19 | 20 | func runUserMod(opts *userModOpt) error { 21 | ctx := opts.ctx 22 | config := ctx.Config 23 | u, err := config.GetUser(opts.User) 24 | if err != nil { 25 | return err 26 | } 27 | 28 | if len(opts.Recipients) != 0 { 29 | u.Recipients = opts.Recipients 30 | } 31 | 32 | if len(opts.Groups) != 0 { 33 | config.Groups = lo.Map(config.Groups, func(g Group, _ int) Group { 34 | if lo.Contains(opts.Groups, g.Name) { 35 | g.Members = lo.Uniq(append(g.Members, opts.User)) 36 | } else { 37 | if !opts.Append { 38 | g.Members = lo.Filter(g.Members, func(m string, _ int) bool { 39 | return m != opts.User 40 | }) 41 | } 42 | } 43 | return g 44 | }) 45 | } 46 | 47 | ctx.Config.SaveUser(u) 48 | return opts.ctx.WriteConfigFile() 49 | } 50 | 51 | func newUserModCmd(ctx *Context) *cobra.Command { 52 | opts := &userModOpt{ 53 | ctx: ctx, 54 | } 55 | cmd := &cobra.Command{ 56 | Use: "usermod", 57 | Short: "Modify a user account", 58 | Example: ` # Set "john" member of group "devops", remove all other memberships. 59 | agec usermod john -g devops 60 | 61 | # Add "john" to "devops", "admin" groups 62 | agec usermod john --append -g devops,admin 63 | 64 | # Update recipients of user "aca" 65 | curl -s "https://github.com/aca.keys" | agec usermod aca -R -`, 66 | SilenceUsage: true, 67 | SilenceErrors: true, 68 | PreRunE: func(cmd *cobra.Command, args []string) error { 69 | if ctx == nil { 70 | return ErrConfigurationNotFound 71 | } 72 | opts.User = args[0] 73 | 74 | switch opts.RecipientsFile { 75 | case "": 76 | break 77 | case "-": 78 | var err error 79 | opts.Recipients, err = readRecipients(os.Stdin) 80 | if err != nil { 81 | return err 82 | } 83 | default: 84 | f, err := os.Open(opts.RecipientsFile) 85 | if err != nil { 86 | return err 87 | } 88 | opts.Recipients, err = readRecipients(f) 89 | if err != nil { 90 | return err 91 | } 92 | } 93 | 94 | return nil 95 | }, 96 | RunE: func(cmd *cobra.Command, args []string) error { 97 | return runUserMod(opts) 98 | }, 99 | Args: cobra.ExactArgs(1), 100 | ValidArgsFunction: UserCompletion(ctx), 101 | } 102 | 103 | f := cmd.Flags() 104 | f.StringSliceVarP(&opts.Groups, "group", "g", nil, "List of groups") 105 | cmd.RegisterFlagCompletionFunc("group", GroupCompletion(ctx)) 106 | 107 | f.BoolVarP(&opts.Append, "append", "a", false, "Append memberships to given group lists, rather than replacing it") 108 | f.StringVarP(&opts.RecipientsFile, "recipients-file", "R", "", "User's recipients, if set to -, the recipients are read from standard input.") 109 | return cmd 110 | } 111 | -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "runtime" 9 | 10 | "github.com/samber/lo" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | func fileExists(path string) bool { 15 | _, err := os.Lstat(path) 16 | return !errors.Is(err, os.ErrNotExist) 17 | } 18 | 19 | func WriteFile(filename string, data []byte) (err error) { 20 | f, err := os.CreateTemp(filepath.Dir(filename), filepath.Base(filename)+".tmp") 21 | if err != nil { 22 | return err 23 | } 24 | tmpName := f.Name() 25 | defer func() { 26 | if err != nil { 27 | f.Close() 28 | os.Remove(tmpName) 29 | } 30 | }() 31 | if _, err := f.Write(data); err != nil { 32 | return err 33 | } 34 | if runtime.GOOS != "windows" { 35 | if err := f.Chmod(0o644); err != nil { 36 | return err 37 | } 38 | } 39 | if err := f.Sync(); err != nil { 40 | return err 41 | } 42 | if err := f.Close(); err != nil { 43 | return err 44 | } 45 | return os.Rename(tmpName, filename) 46 | } 47 | 48 | func newGroupsCmd(ctx *Context) *cobra.Command { 49 | cmd := &cobra.Command{ 50 | Use: "groups", 51 | Hidden: true, 52 | SilenceUsage: true, 53 | SilenceErrors: true, 54 | PreRunE: func(cmd *cobra.Command, args []string) error { 55 | if ctx == nil { 56 | return ErrConfigurationNotFound 57 | } 58 | return nil 59 | }, 60 | Args: cobra.MaximumNArgs(1), 61 | RunE: func(cmd *cobra.Command, args []string) error { 62 | if len(args) == 0 { 63 | for _, g := range ctx.Config.Groups { 64 | fmt.Println(g.Name) 65 | } 66 | } else { 67 | user := args[0] 68 | for _, g := range ctx.Config.Groups { 69 | if lo.Contains(g.Members, user) { 70 | fmt.Println(g.Name) 71 | } 72 | } 73 | } 74 | return nil 75 | }, 76 | ValidArgsFunction: UserCompletion(ctx), 77 | } 78 | 79 | return cmd 80 | } 81 | --------------------------------------------------------------------------------