vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
|
0c40a1d3
Keenan Nemetz
7d ago
|
1 | package ssh |
| 2 | ||
| 3 | // A repository's secrets over SSH. | |
| 4 | // | |
| 5 | // vole-secret list <repository> | |
| 6 | // vole-secret get <repository> <name> | |
| 7 | // vole-secret set <repository> <name> (the value is the whole of stdin) | |
| 8 | // vole-secret rm <repository> <name> | |
| 9 | // | |
| 10 | // The value travels on stdin and never in the command line: an exec line is | |
| 11 | // visible to every process on both hosts, and the instance records the command it | |
| 12 | // ran. What comes back is the value and nothing else, so `vole secret get` can be | |
| 13 | // redirected without stripping a banner first. | |
| 14 | // | |
| 15 | // The capability is the maintainer one: the accounts that can approve work, | |
| 16 | // manage releases and allow CI are the accounts that own the credentials the | |
| 17 | // repository's jobs use. | |
| 18 | ||
| 19 | import ( | |
| 20 | "errors" | |
| 21 | "fmt" | |
| 22 | "io" | |
| 23 | "strings" | |
| 24 | "time" | |
| 25 | ||
| 26 | xssh "golang.org/x/crypto/ssh" | |
| 27 | ||
|
d37d301f
Keenan Nemetz
15h ago
|
28 | "rodent.software/vole/ci/runs" |
| 29 | "rodent.software/vole/forge/secret" | |
|
0c40a1d3
Keenan Nemetz
7d ago
|
30 | ) |
| 31 | ||
| 32 | // maxSecretInput bounds what `set` will read from the client. It is one | |
| 33 | // NAME=value entry plus room for a name, which is the most a value can be; a | |
| 34 | // larger body is refused before it reaches the store. | |
| 35 | const maxSecretInput = secret.MaxEntryBytes + 64 | |
| 36 | ||
| 37 | func (s *Server) serveSecret(ch xssh.Channel, argv []string, user string) int { | |
| 38 | if len(argv) < 3 { | |
| 39 | fmt.Fprint(ch, "vole: usage: vole-secret list <repository>\n"+ | |
| 40 | "vole: vole-secret get|set|rm <repository> <name>\n") | |
| 41 | return 129 | |
| 42 | } | |
| 43 | op := argv[1] | |
| 44 | name, ok := repoName(argv[2]) | |
| 45 | if !ok { | |
| 46 | fmt.Fprint(ch, "vole: invalid repository name\n") | |
| 47 | return 129 | |
| 48 | } | |
| 49 | if s.cfg.Instance == nil || s.cfg.Instance.Secrets == nil { | |
| 50 | fmt.Fprint(ch, "vole: this instance has no secret store\n") | |
| 51 | return 1 | |
| 52 | } | |
| 53 | m, err := s.cfg.Repos.Open(name) | |
| 54 | if err != nil { | |
| 55 | fmt.Fprintf(ch, "vole: %v\n", err) | |
| 56 | return 1 | |
| 57 | } | |
| 58 | acl, err := m.ACL() | |
| 59 | if err != nil || acl == nil { | |
| 60 | fmt.Fprintf(ch, "vole: cannot read access rules for %s\n", name) | |
| 61 | return 1 | |
| 62 | } | |
| 63 | if !acl.CanTriage(user) { | |
| 64 | fmt.Fprintf(ch, "vole: %s may not manage secrets in %s\n", user, name) | |
| 65 | return 1 | |
| 66 | } | |
| 67 | store := s.cfg.Instance.Secrets | |
| 68 | ||
| 69 | if op == "list" { | |
| 70 | infos, err := store.List(m.ID) | |
| 71 | if err != nil { | |
| 72 | fmt.Fprintf(ch, "vole: %v\n", err) | |
| 73 | return 1 | |
| 74 | } | |
| 75 | if len(infos) == 0 { | |
| 76 | fmt.Fprintf(ch, "no secrets are stored for %s\n", name) | |
| 77 | return 0 | |
| 78 | } | |
| 79 | for _, info := range infos { | |
| 80 | fmt.Fprintf(ch, "%-32s v%-3d %s\n", info.Name, info.Version, secretDetail(info)) | |
| 81 | } | |
| 82 | return 0 | |
| 83 | } | |
| 84 | ||
| 85 | if len(argv) < 4 { | |
| 86 | fmt.Fprintf(ch, "vole: usage: vole-secret %s <repository> <name>\n", op) | |
| 87 | return 129 | |
| 88 | } | |
| 89 | secretName := strings.TrimSpace(argv[3]) | |
| 90 | if err := secret.ValidateName(secretName); err != nil { | |
| 91 | fmt.Fprintf(ch, "vole: %v\n", err) | |
| 92 | return 1 | |
| 93 | } | |
| 94 | ||
| 95 | switch op { | |
| 96 | case "get": | |
| 97 | value, _, err := store.Read(m.ID, secretName, user, "cli") | |
| 98 | if err != nil { | |
| 99 | fmt.Fprintf(ch, "vole: %v\n", secretProblem(err, secretName, name)) | |
| 100 | return 1 | |
| 101 | } | |
| 102 | // The value is the whole answer, with nothing appended: a caller that | |
| 103 | // redirects it to a file must get the bytes that were stored. | |
| 104 | fmt.Fprint(ch, value) | |
| 105 | return 0 | |
| 106 | ||
| 107 | case "set": | |
| 108 | body, err := io.ReadAll(io.LimitReader(ch, maxSecretInput+1)) | |
| 109 | if err != nil { | |
| 110 | fmt.Fprintf(ch, "vole: %v\n", err) | |
| 111 | return 1 | |
| 112 | } | |
| 113 | if len(body) > maxSecretInput { | |
| 114 | fmt.Fprintf(ch, "vole: %s is larger than one environment variable can hold (%d bytes)\n", | |
| 115 | secretName, secret.MaxEntryBytes) | |
| 116 | return 1 | |
| 117 | } | |
| 118 | version, err := store.Set(m.ID, secretName, string(body), user) | |
| 119 | if err != nil { | |
| 120 | fmt.Fprintf(ch, "vole: %v\n", err) | |
| 121 | return 1 | |
| 122 | } | |
| 123 | fmt.Fprintf(ch, "saved %s v%d for %s\n", secretName, version, name) | |
| 124 | return 0 | |
| 125 | ||
| 126 | case "rm": | |
| 127 | if err := store.Delete(m.ID, secretName, user); err != nil { | |
| 128 | fmt.Fprintf(ch, "vole: %v\n", secretProblem(err, secretName, name)) | |
| 129 | return 1 | |
| 130 | } | |
| 131 | fmt.Fprintf(ch, "deleted %s from %s\n", secretName, name) | |
| 132 | return 0 | |
| 133 | } | |
| 134 | fmt.Fprintf(ch, "vole: %s is not a secret command\n", op) | |
| 135 | return 129 | |
| 136 | } | |
| 137 | ||
| 138 | // secretDetail says what a list line is about: which version this is, who wrote | |
| 139 | // it, and whether anybody has read it. | |
| 140 | func secretDetail(info secret.Info) string { | |
| 141 | read := "never read" | |
| 142 | if !info.LastRead.IsZero() { | |
| 143 | read = "last read " + info.LastRead.Format(time.RFC3339) | |
| 144 | } | |
| 145 | return fmt.Sprintf("updated %s by %s, %s", info.Updated.Format("2006-01-02 15:04"), info.UpdatedBy, read) | |
| 146 | } | |
| 147 | ||
| 148 | // secretProblem turns the store's two refusals into the sentence a person needed: | |
| 149 | // a missing name is about this repository, and everything else is passed through. | |
| 150 | func secretProblem(err error, secretName, repoName string) string { | |
| 151 | if errors.Is(err, secret.ErrNotFound) { | |
| 152 | return fmt.Sprintf("%s has no secret %s", repoName, secretName) | |
| 153 | } | |
| 154 | return err.Error() | |
| 155 | } | |
|
4ac42680
Keenan Nemetz
7d ago
|
156 | |
| 157 | // leaseSecrets resolves what a leased job declared. The queue row holds names, so | |
| 158 | // this is the one place a value leaves the store on its way to a job, and the store | |
| 159 | // records the read against the account holding the lease. | |
| 160 | // | |
| 161 | // A name the repository has no value for is not a failure. The job runs without | |
| 162 | // that variable and the runner says which one it went without, because a workflow | |
| 163 | // is allowed to have an optional secret and a required one is the step's | |
| 164 | // ${NAME:?}. A value that cannot be read for another reason — a database restored | |
| 165 | // beside a new key — is reported the same way, with the reason, so the job is not | |
| 166 | // stopped by an instance problem it cannot fix. | |
| 167 | func (s *Server) leaseSecrets(run *runs.Run, actor string) (map[string]runnerSecret, []runnerSecretIssue) { | |
| 168 | if len(run.Secrets) == 0 { | |
| 169 | return nil, nil | |
| 170 | } | |
| 171 | var store *secret.Store | |
| 172 | if s.cfg.Instance != nil { | |
| 173 | store = s.cfg.Instance.Secrets | |
| 174 | } | |
| 175 | meta, metaErr := s.cfg.Repos.Meta(run.Repo) | |
| 176 | delivered := make(map[string]runnerSecret, len(run.Secrets)) | |
| 177 | var missing []runnerSecretIssue | |
| 178 | for _, name := range run.Secrets { | |
| 179 | switch { | |
| 180 | case metaErr != nil: | |
| 181 | missing = append(missing, runnerSecretIssue{Name: name, Reason: metaErr.Error()}) | |
| 182 | case store == nil: | |
| 183 | missing = append(missing, runnerSecretIssue{Name: name, Reason: "this instance has no secret store"}) | |
| 184 | default: | |
| 185 | value, version, err := store.Read(meta.ID, name, actor, "run="+run.ID) | |
| 186 | switch { | |
| 187 | case errors.Is(err, secret.ErrNotFound): | |
| 188 | missing = append(missing, runnerSecretIssue{Name: name}) | |
| 189 | case err != nil: | |
| 190 | missing = append(missing, runnerSecretIssue{Name: name, Reason: err.Error()}) | |
| 191 | default: | |
| 192 | delivered[name] = runnerSecret{Version: version, Value: value} | |
| 193 | } | |
| 194 | } | |
| 195 | } | |
| 196 | return delivered, missing | |
| 197 | } |