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.
Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys
keenan · 25m ago
4b8c9e1d
| 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 | |
| 28 | "rodent.software/vole/ci/runs" |
| 29 | "rodent.software/vole/forge/secret" |
| 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 | } |
| 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 | } |
| 198 |